diff --git a/vscode/src/codeReader/callTree/query.ts b/vscode/src/codeReader/callTree/query.ts index 0be7b1a..5c6a7fe 100644 --- a/vscode/src/codeReader/callTree/query.ts +++ b/vscode/src/codeReader/callTree/query.ts @@ -3,10 +3,11 @@ * 语言扩展不提供、或者这一轮已经作废,就当没有。 */ -import type { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, TextDocument } from 'vscode' +import type { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, DocumentSymbol, Location, SymbolInformation, TextDocument } from 'vscode' import type { RuleLocation } from '../contract' import type { IndexedEntry, IndexFind } from '../workspaceIndex' import type { CallEdge, CallRange, CallSymbol } from './model' +import type { OutlineSymbol } from './referenceCalls' import type { RequestEntry } from './requests' import type { CallTreeBlock, LoadedCalls } from './session' import type { IncomingHit } from './walk' @@ -14,14 +15,16 @@ import * as path from 'node:path' import { commands, Position, SymbolKind, Uri, workspace } from 'vscode' import { endpointCalls } from './endpoint' import { symbolKey } from './model' +import { referenceCalls } from './referenceCalls' +import { nameOffset } from './session' import { collectCallEdges } from './walk' /** - * 问一块的调用关系。接口卡片(callTree.endpoint)先到契约里找前端发这个请求的地方(见 endpoint.ts),并进起点的调用者; - * 契约里对不上时带上 contractMiss,Python 里的调用者照常问。 + * 问一块的调用关系,按它的调用树查的那个名字(见 [nameOffset])。接口卡片(callTree.endpoint)先到契约里找前端发这个请求的地方(见 endpoint.ts), + * 并进起点的调用者;契约里对不上时带上 contractMiss,Python 里的调用者照常问。 */ export async function loadBlockCalls(document: TextDocument, block: CallTreeBlock, find: IndexFind, cancelled: () => boolean): Promise { - const offset = block.defines?.startIndex + const offset = nameOffset(block) const endpoint = block.callTree?.endpoint if (offset === undefined) return undefined @@ -42,6 +45,8 @@ export async function loadBlockCalls(document: TextDocument, block: CallTreeBloc * 先准备符号,再往上问谁调用了它;路径上的调用者再问它还调用了谁,用来收「另外 N 处」。 * 给了 requests(接口卡片,可以是空的)时,它们并进起点的调用者,从它们往上照常问编辑器; * 这时编辑器认不出起点也照样收前端这一路,起点按名字起点自己写一个。 + * 没给 `requests`、编辑器又认不出起点(数组解构出来的 `setOpen` 这类,见 [referenceCalls])时,起点的调用者改查引用, + * 起点同样按名字起点自己写一个,从查到的调用者往上照常问编辑器。 */ export async function loadCallEdges( document: TextDocument, @@ -53,8 +58,9 @@ export async function loadCallEdges( return undefined const position = document.positionAt(offset) const items = await prepare(document.uri, position) - if (cancelled() || (items.length === 0 && !requests)) + if (cancelled()) return undefined + const byReferences = items.length === 0 && !requests const vscodeItems = new Map() const remember = (item: CallHierarchyItem): CallSymbol => { @@ -87,10 +93,27 @@ export async function loadCallEdges( } return cancelled() ? undefined : { symbol, edges } - /** 起点的调用者里并上前端发请求的地方;别的节点照常问编辑器。 */ + /** 起点的调用者里并上前端发请求的地方,编辑器认不出起点时改查引用;别的节点照常问编辑器。 */ async function withRequests(node: CallSymbol): Promise { - const calls = await incoming(node) - return symbolKey(node) === symbolKey(symbol) ? [...calls, ...sent] : calls + if (symbolKey(node) !== symbolKey(symbol)) + return incoming(node) + const calls = byReferences ? await referenced() : await incoming(node) + return [...calls, ...sent] + } + + /** 起点这个名字的各处引用里的调用处,调用者是包着它、编辑器的调用关系认得的最内层符号(见 [referenceCalls])。 */ + async function referenced(): Promise { + const locations = await run('vscode.executeReferenceProvider', document.uri, position) ?? [] + return referenceCalls(locations.map(location => ({ uri: location.uri.toString(), range: toRange(location.range) })), { + after: site => textAfter(site.uri, site.range), + outline: async uri => outlineOf(await run<(DocumentSymbol | SymbolInformation)[]>('vscode.executeDocumentSymbolProvider', Uri.parse(uri)) ?? []), + prepare: async (uri, line, character) => { + const [item] = await prepare(Uri.parse(uri), new Position(line, character)) + return item && remember(item) + }, + file: fileSymbol, + key: symbolKey, + }, cancelled) } async function incoming(node: CallSymbol) { @@ -129,25 +152,48 @@ export async function loadCallEdges( const item = vscodeItems.get(symbolKey(node)) if (!item || cancelled()) return [] - try { - const calls = await commands.executeCommand(command, item) - return (calls ?? []).map(map) - } - catch { - return [] - } + const calls = await run(command, item) + return (calls ?? []).map(map) } } +/** 执行一个编辑器命令;语言扩展不提供、出错时没有。 */ +async function run(command: string, ...args: unknown[]): Promise { + try { + return await commands.executeCommand(command, ...args) + } + catch { + return undefined + } +} + +/** 编辑器给的文件大纲换成 [referenceCalls] 要的样子:[DocumentSymbol] 带名字的范围和子符号,老式的 [SymbolInformation] 只有整个范围。 */ +function outlineOf(symbols: readonly (DocumentSymbol | SymbolInformation)[]): OutlineSymbol[] { + return symbols.map(item => 'selectionRange' in item + ? { range: toRange(item.range), selection: toRange(item.selectionRange), children: outlineOf(item.children) } + : { range: toRange(item.location.range), selection: toRange(item.location.range), children: [] }) +} + +/** 这个范围所在那一行里,范围后面的文字;文件打不开时是空的。 */ +async function textAfter(uri: string, range: CallRange): Promise { + try { + return (await workspace.openTextDocument(Uri.parse(uri))).lineAt(range.endLine).text.slice(range.endCharacter) + } + catch { + return '' + } +} + +/** 调用写在文件顶层、不在哪个函数里时,按文件算的调用者。 */ +function fileSymbol(uri: string): CallSymbol { + const parsed = Uri.parse(uri) + return { uri, line: 0, character: 0, name: path.posix.basename(parsed.path), kind: 'module', path: workspace.asRelativePath(parsed) } +} + /** 编辑器在这个位置准备出的调用关系符号;语言扩展不提供时是空的。 */ async function prepare(uri: Uri, position: Position): Promise { - try { - const prepared = await commands.executeCommand('vscode.prepareCallHierarchy', uri, position) - return prepared == null ? [] : (Array.isArray(prepared) ? prepared : [prepared]).filter(item => item) - } - catch { - return [] - } + const prepared = await run('vscode.prepareCallHierarchy', uri, position) + return prepared == null ? [] : (Array.isArray(prepared) ? prepared : [prepared]).filter(item => item) } /** 编辑器认不出起点时按名字起点自己写的符号。 */ diff --git a/vscode/src/codeReader/callTree/referenceCalls.test.ts b/vscode/src/codeReader/callTree/referenceCalls.test.ts new file mode 100644 index 0000000..fa6311f --- /dev/null +++ b/vscode/src/codeReader/callTree/referenceCalls.test.ts @@ -0,0 +1,78 @@ +import type { CallRange } from './model' +import type { OutlineSymbol, ReferenceLookup, ReferenceSite } from './referenceCalls' +import { describe, expect, it } from 'vitest' +import { isCallSite, referenceCalls } from './referenceCalls' + +function range(line: number, character: number, length: number): CallRange { + return { line, character, endLine: line, endCharacter: character + length } +} + +function symbol(from: number, to: number, nameAt: [number, number], children: OutlineSymbol[] = []): OutlineSymbol { + return { range: { line: from, character: 0, endLine: to, endCharacter: 1 }, selection: range(nameAt[0], nameAt[1], 4), children } +} + +const URI = 'file:///Panel.tsx' + +/** + * 原型 Panel.tsx 的一段(行从 0 起):Panel 在 27–45 行,里面 toggle 的 useCallback 回调在 33–36 行、useEffect 回调在 37–40 行、handleClick 在 41–43 行。 + * 调用关系只认得 Panel 和 handleClick(大纲里的子符号不按位置排)。 + */ +const OUTLINE: OutlineSymbol[] = [ + symbol(27, 45, [27, 16], [ + symbol(41, 43, [41, 11], [symbol(42, 42, [42, 20])]), + symbol(33, 36, [33, 8], [symbol(33, 36, [33, 29])]), + symbol(37, 40, [37, 12], [symbol(38, 39, [38, 10])]), + ]), +] + +const KNOWN: Record = { '27:16': 'Panel', '41:11': 'handleClick' } + +function lookup(after: Record, asked: string[] = []): ReferenceLookup { + return { + after: async site => after[`${site.range.line}:${site.range.character}`] ?? '', + outline: async (uri) => { + asked.push(uri) + return uri === URI ? OUTLINE : [] + }, + prepare: async (_uri, line, character) => KNOWN[`${line}:${character}`], + file: uri => `文件 ${uri}`, + key: caller => caller, + } +} + +describe('调用关系认不出的名字改查引用', () => { + it('只留调用处:名字后面紧跟实参的括号,中间可以隔着空白、?.、泛型实参', () => { + expect(['(next)', ' (x)', '?.(x)', '(x)', '] = useState(false)', ' = useCallback(() => {', ', setOpen] =', ')', '.current', '} />'].map(isCallSite)) + .toEqual([true, true, true, true, false, false, false, false, false, false]) + }) + + it('每处调用往外找第一个调用关系认得的符号当调用者,同一个调用者的几处并成一项;写在文件顶层的按文件算', async () => { + const sites: ReferenceSite[] = [ + { uri: URI, range: range(30, 15, 7) }, + { uri: URI, range: range(34, 4, 7) }, + { uri: URI, range: range(38, 42, 7) }, + { uri: URI, range: range(42, 4, 15) }, + { uri: 'file:///main.tsx', range: range(3, 0, 7) }, + ] + const asked: string[] = [] + const calls = await referenceCalls(sites, lookup({ '30:15': ', setOpen] =', '34:4': '(next)', '38:42': '(false), 1000)', '42:4': '(() => toggle(!open))', '3:0': '(true)' }, asked), () => false) + expect(calls).toEqual([ + { from: 'Panel', ranges: [range(34, 4, 7), range(38, 42, 7)] }, + { from: 'handleClick', ranges: [range(42, 4, 15)] }, + { from: '文件 file:///main.tsx', ranges: [range(3, 0, 7)] }, + ]) + expect(asked).toEqual([URI, 'file:///main.tsx']) + }) + + it('作废了就不要已经收到的', async () => { + let done = false + const calls = await referenceCalls([{ uri: URI, range: range(34, 4, 7) }], { + ...lookup({ '34:4': '(next)' }), + outline: async () => { + done = true + return OUTLINE + }, + }, () => done) + expect(calls).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/callTree/referenceCalls.ts b/vscode/src/codeReader/callTree/referenceCalls.ts new file mode 100644 index 0000000..5937c29 --- /dev/null +++ b/vscode/src/codeReader/callTree/referenceCalls.ts @@ -0,0 +1,102 @@ +/** + * 编辑器的调用关系认不出的名字改查引用:TypeScript 的调用关系只认函数、类、方法这些声明, + * 数组解构出来的 `setOpen`(`const [open, setOpen] = useState()`)、`const toggle = useCallback(…)` 的 toggle 都准备不出符号。 + * 这时查这个名字的各处引用,只留调用处,每处往外找第一个调用关系认得的符号当调用者,再从它往上照常问编辑器。 + * 问编辑器的动作由外面传入:测试喂假的,宿主去问编辑器命令(见 [查调用关系](vscode/src/codeReader/callTree/query.ts#loadCallEdges))。 + */ + +import type { CallRange } from './model' + +/** 名字的一处引用:文件(uri 字符串)和名字的范围,行和列从 0 起。 */ +export interface ReferenceSite { + uri: string + range: CallRange +} + +/** 文件大纲里的一个符号:整个范围、名字的范围、里面的符号(编辑器给的子符号不一定按位置排)。 */ +export interface OutlineSymbol { + range: CallRange + selection: CallRange + children: readonly OutlineSymbol[] +} + +/** 改查引用时要问编辑器的几样;Caller 是认出来的调用者。 */ +export interface ReferenceLookup { + /** 引用那一行里,名字后面的文字 */ + after: (site: ReferenceSite) => Promise + /** 文件的大纲 */ + outline: (uri: string) => Promise + /** 在这个位置准备调用关系的符号;调用关系认不出时没有 */ + prepare: (uri: string, line: number, character: number) => Promise + /** 往外都找不到调用关系认得的符号时(调用写在文件顶层),按文件算的调用者 */ + file: (uri: string) => Caller + /** 调用者的身份:同一个调用者的几处调用并成一项 */ + key: (caller: Caller) => string +} + +/** 一个调用者,和它里面每一处调用的范围(名字的范围,同编辑器调用关系的 fromRanges)。 */ +export interface ReferenceCall { + from: Caller + ranges: CallRange[] +} + +/** 名字后面紧跟着实参的括号,这处引用就是在调用它;中间可以隔着空白、可选调用的 `?.`、泛型实参 ``。 */ +export function isCallSite(after: string): boolean { + return /^\s*(?:\?\.\s*)?(?:<[^<>()]*>\s*)?\(/.test(after) +} + +/** + * 各处引用里的调用处,按调用者并起来,照第一次出现的顺序:调用者是包着这处调用、调用关系认得的最内层符号, + * 都不认得时按文件算。cancelled 变成真就停,已经收到的也不要了。 + */ +export async function referenceCalls(sites: readonly ReferenceSite[], lookup: ReferenceLookup, cancelled: () => boolean): Promise[]> { + const outlines = new Map>() + const calls = new Map>() + for (const site of sites) { + if (cancelled()) + return [] + if (!isCallSite(await lookup.after(site))) + continue + const outline = outlines.get(site.uri) ?? lookup.outline(site.uri) + outlines.set(site.uri, outline) + const caller = await callerOf(site, enclosingSymbols(await outline, site.range), lookup) + if (cancelled()) + return [] + const key = lookup.key(caller) + const known = calls.get(key) + if (known) + known.ranges.push(site.range) + else + calls.set(key, { from: caller, ranges: [site.range] }) + } + return cancelled() ? [] : [...calls.values()] +} + +/** 包着这处引用的符号,从里往外。 */ +function enclosingSymbols(outline: readonly OutlineSymbol[], at: CallRange): OutlineSymbol[] { + const chain: OutlineSymbol[] = [] + for (let level = outline; ;) { + const inner = level.find(symbol => contains(symbol.range, at)) + if (!inner) + return chain + chain.unshift(inner) + level = inner.children + } +} + +/** 从里往外第一个调用关系认得的符号;都不认得时按文件算。 */ +async function callerOf(site: ReferenceSite, chain: readonly OutlineSymbol[], lookup: ReferenceLookup): Promise { + for (const symbol of chain) { + const caller = await lookup.prepare(site.uri, symbol.selection.line, symbol.selection.character) + if (caller !== undefined) + return caller + } + return lookup.file(site.uri) +} + +/** outer 盖住 inner 的开头。 */ +function contains(outer: CallRange, inner: CallRange): boolean { + const afterStart = inner.line > outer.line || (inner.line === outer.line && inner.character >= outer.character) + const beforeEnd = inner.line < outer.endLine || (inner.line === outer.endLine && inner.character < outer.endCharacter) + return afterStart && beforeEnd +} diff --git a/vscode/src/codeReader/callTree/session.test.ts b/vscode/src/codeReader/callTree/session.test.ts index b0f666c..1612c94 100644 --- a/vscode/src/codeReader/callTree/session.test.ts +++ b/vscode/src/codeReader/callTree/session.test.ts @@ -1,7 +1,7 @@ import type { CallEdge, CallSymbol } from './model' import type { CallTreeBlock, CallTreeSource } from './session' import { describe, expect, it } from 'vitest' -import { CallTreeSession } from './session' +import { CallTreeSession, nameOffset } from './session' function symbol(name: string): CallSymbol { return { uri: `file:///${name}`, line: 0, character: 0, name, kind: 'function', path: `${name}.py` } @@ -54,6 +54,30 @@ describe('调用树棵数在文档发出去之后算', () => { session.dispose() }) + it('卡片查的名字不是自己的名字时(钩子卡片查返回的 setOpen)按 callTree.name 问,没有 defines 也数,先数离视口近的', async () => { + const asked: (number | undefined)[] = [] + const source: CallTreeSource = { + version: 7, + blocks: [ + { anchor: 'far', kind: 'function', defines: { startIndex: 900 }, callTree: {} }, + { anchor: 'state', kind: 'react.hook', defines: { startIndex: 500 }, callTree: { name: { startIndex: 120, endIndex: 127 } } }, + { anchor: 'tuple', kind: 'react.hook', callTree: { name: { startIndex: 300, endIndex: 308 } } }, + { anchor: 'none', kind: 'react.hook', defines: { startIndex: 50 } }, + ], + load: async (block) => { + asked.push(nameOffset(block)) + return { symbol: symbol('setOpen'), edges: [{ caller: symbol('Panel'), callee: symbol('setOpen'), at: { line: 1, character: 0, endLine: 1, endCharacter: 7 } }] } + }, + } + const posted: unknown[] = [] + const session = new CallTreeSession(message => posted.push(message), () => source, offset => offset) + session.documentSent() + await viFlush() + expect(asked).toEqual([120, 300, 900]) + expect(posted).toEqual([{ type: 'callTreeCounts', version: 7, counts: { far: 1, state: 1, tuple: 1 } }]) + session.dispose() + }) + it('文档版本变了就丢掉还在跑的查询,不把旧结果发出去', async () => { let release: (() => void) | undefined const gate = new Promise((resolve) => { diff --git a/vscode/src/codeReader/callTree/session.ts b/vscode/src/codeReader/callTree/session.ts index 4d7514b..4768af7 100644 --- a/vscode/src/codeReader/callTree/session.ts +++ b/vscode/src/codeReader/callTree/session.ts @@ -1,5 +1,5 @@ /** - * 文档发出去之后,在后台数每个能看调用树的块(函数、类、接口卡片)落在几棵调用树里,问到的都记下(没人调用的也记),数完把棵数发给页面。 + * 文档发出去之后,在后台数每个能看调用树的块(函数、类、接口卡片、返回里有函数的 React 钩子卡片)落在几棵调用树里,问到的都记下(没人调用的也记),数完把棵数发给页面。 * 页面点开某一块时,问到过有调用者就把树发回去;没问过、或上回没人调用就当场问这一块,问到了补发棵数再发树,没问到发一棵空的; * 接口在契约文件里没对上时,发出去的树带着 contractMiss,页面照它说明,不当成没人调用。 * 问到的结果跟着文档版本走:同一版文档又发一遍(页面重新加载、按新规则重发)时接着用,已知的棵数马上补发, @@ -78,7 +78,7 @@ export class CallTreeSession { const src = this.source() if (!src || src.version !== version) return - const block = callBlocks(src.blocks).find(item => item.anchor === anchor) + const block = callBlocks(src.blocks).find(item => item.block.anchor === anchor)?.block const known = block && this.answerOf(block) if (known?.calls && known.count > 0) { this.postTree(version, anchor, known.calls) @@ -118,15 +118,15 @@ export class CallTreeSession { const src = this.source() if (!src || this.stale(version)) return - let next: NamedCallBlock | undefined + let next: CallBlock | undefined for (const block of callBlocks(src.blocks)) { - if (!this.answerOf(block) && (!next || this.distance(block.defines.startIndex) < this.distance(next.defines.startIndex))) + if (!this.answerOf(block.block) && (!next || this.distance(block.offset) < this.distance(next.offset))) next = block } if (!next) break asked = true - await this.collect(version, src, next) + await this.collect(version, src, next.block) } if (asked) this.postCounts(version) @@ -181,7 +181,7 @@ export class CallTreeSession { /** 眼下这份块树里有调用者的块各落在几棵树里。 */ private postCounts(version: number): void { const counts: Record = {} - for (const block of callBlocks(this.source()?.blocks ?? [])) { + for (const { block } of callBlocks(this.source()?.blocks ?? [])) { const count = this.answerOf(block)?.count ?? 0 if (count > 0) counts[block.anchor] = count @@ -207,13 +207,28 @@ export class CallTreeSession { } } +/** + * 一块的调用树查哪个名字:名字起点在源码里的下标。卡片查的不是自己的名字时(钩子卡片查返回的 `setOpen`)是那个名字, + * 否则是块自己的名字;没标能看调用树、没有名字的块没有。 + */ +export function nameOffset(block: CallTreeBlock): number | undefined { + return block.callTree ? block.callTree.name?.startIndex ?? block.defines?.startIndex : undefined +} + +/** 能看调用树、有名字起点的一块,和它的名字起点(见 [nameOffset])。 */ +interface CallBlock { + block: CallTreeBlock + offset: number +} + /** 宿主标了能看调用树(callTree)、有名字起点的块,含嵌在别的块里面的。 */ -function callBlocks(blocks: readonly CallTreeBlock[]): NamedCallBlock[] { - const found: NamedCallBlock[] = [] +function callBlocks(blocks: readonly CallTreeBlock[]): CallBlock[] { + const found: CallBlock[] = [] const walk = (list: readonly CallTreeBlock[]) => { for (const block of list) { - if (isNamedCallBlock(block)) - found.push(block) + const offset = nameOffset(block) + if (offset !== undefined) + found.push({ block, offset }) if (block.children?.length) walk(block.children) } @@ -222,14 +237,7 @@ function callBlocks(blocks: readonly CallTreeBlock[]): NamedCallBlock[] { return found } -/** 能看调用树、有名字起点的块。 */ -type NamedCallBlock = CallTreeBlock & Required> - -function isNamedCallBlock(block: CallTreeBlock): block is NamedCallBlock { - return !!block.callTree && !!block.defines -} - /** 一块怎么问:名字起点和接口。同一版文档按新规则重发时接口可能变了(查到了挂载前缀),变了就得重问。 */ function targetOf(block: CallTreeBlock): string { - return JSON.stringify([block.defines?.startIndex, block.callTree?.endpoint]) + return JSON.stringify([nameOffset(block), block.callTree?.endpoint]) }