✨ feat(vscode): 调用树认不出的名字改查引用只留调用处,钩子卡片按返回的函数名查调用树

This commit is contained in:
cruldra committed 2026-10-16 20:19:01 +11:00
1 parent e7059bcc73
commit 6e63b7ba6a
5 files changed
+299 -41

No files matched your search

+68 -22
View File
@@ -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<LoadedCalls | undefined> {
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<string, CallHierarchyItem>()
const remember = (item: CallHierarchyItem): CallSymbol => {
@@ -87,10 +93,27 @@ export async function loadCallEdges(
}
return cancelled() ? undefined : { symbol, edges }
/** 起点的调用者里并上前端发请求的地方;别的节点照常问编辑器。 */
/** 起点的调用者里并上前端发请求的地方,编辑器认不出起点时改查引用;别的节点照常问编辑器。 */
async function withRequests(node: CallSymbol): Promise<IncomingHit[]> {
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<IncomingHit[]> {
const locations = await run<Location[]>('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<T[]>(command, item)
return (calls ?? []).map(map)
}
catch {
return []
}
const calls = await run<T[]>(command, item)
return (calls ?? []).map(map)
}
}
/** 执行一个编辑器命令;语言扩展不提供、出错时没有。 */
async function run<T>(command: string, ...args: unknown[]): Promise<T | undefined> {
try {
return await commands.executeCommand<T>(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<string> {
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<CallHierarchyItem[]> {
try {
const prepared = await commands.executeCommand<CallHierarchyItem | CallHierarchyItem[] | null | undefined>('vscode.prepareCallHierarchy', uri, position)
return prepared == null ? [] : (Array.isArray(prepared) ? prepared : [prepared]).filter(item => item)
}
catch {
return []
}
const prepared = await run<CallHierarchyItem | CallHierarchyItem[] | null>('vscode.prepareCallHierarchy', uri, position)
return prepared == null ? [] : (Array.isArray(prepared) ? prepared : [prepared]).filter(item => item)
}
/** 编辑器认不出起点时按名字起点自己写的符号。 */
@@ -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<string, string> = { '27:16': 'Panel', '41:11': 'handleClick' }
function lookup(after: Record<string, string>, asked: string[] = []): ReferenceLookup<string> {
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)', '<number>(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([])
})
})
@@ -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<Caller> {
/** 引用那一行里,名字后面的文字 */
after: (site: ReferenceSite) => Promise<string>
/** 文件的大纲 */
outline: (uri: string) => Promise<readonly OutlineSymbol[]>
/** 在这个位置准备调用关系的符号;调用关系认不出时没有 */
prepare: (uri: string, line: number, character: number) => Promise<Caller | undefined>
/** 往外都找不到调用关系认得的符号时(调用写在文件顶层),按文件算的调用者 */
file: (uri: string) => Caller
/** 调用者的身份:同一个调用者的几处调用并成一项 */
key: (caller: Caller) => string
}
/** 一个调用者,和它里面每一处调用的范围(名字的范围,同编辑器调用关系的 fromRanges)。 */
export interface ReferenceCall<Caller> {
from: Caller
ranges: CallRange[]
}
/** 名字后面紧跟着实参的括号,这处引用就是在调用它;中间可以隔着空白、可选调用的 `?.`、泛型实参 `<T>`。 */
export function isCallSite(after: string): boolean {
return /^\s*(?:\?\.\s*)?(?:<[^<>()]*>\s*)?\(/.test(after)
}
/**
* 各处引用里的调用处,按调用者并起来,照第一次出现的顺序:调用者是包着这处调用、调用关系认得的最内层符号,
* 都不认得时按文件算。cancelled 变成真就停,已经收到的也不要了。
*/
export async function referenceCalls<Caller>(sites: readonly ReferenceSite[], lookup: ReferenceLookup<Caller>, cancelled: () => boolean): Promise<ReferenceCall<Caller>[]> {
const outlines = new Map<string, Promise<readonly OutlineSymbol[]>>()
const calls = new Map<string, ReferenceCall<Caller>>()
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<Caller>(site: ReferenceSite, chain: readonly OutlineSymbol[], lookup: ReferenceLookup<Caller>): Promise<Caller> {
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
}
+25 -1
View File
@@ -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<void>((resolve) => {
+26 -18
View File
@@ -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<string, number> = {}
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<Pick<CallTreeBlock, 'defines'>>
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])
}