✨ feat(vscode): 代码阅读的函数和类可以看调用树

This commit is contained in:
cruldra committed 2026-10-11 00:37:24 -04:00
1 parent 8dc572bf94
commit 645542eb5c
17 files changed
+1679 -5

No files matched your search

@@ -0,0 +1,173 @@
import type { CallEdge, CallRange, CallSymbol, CallTreeNode } from './model'
import { describe, expect, it } from 'vitest'
import { buildCallTrees } from './model'
function symbol(name: string, line = 0, extra: Partial<CallSymbol> = {}): CallSymbol {
return {
uri: `file:///${name}`,
line,
character: 0,
name,
kind: 'function',
path: `${name}.py`,
...extra,
}
}
function at(line: number, character = 0): CallRange {
return { line, character, endLine: line, endCharacter: character + 4 }
}
function edge(caller: CallSymbol, callee: CallSymbol, line: number, character = 0): CallEdge {
return { caller, callee, at: at(line, character) }
}
function currents(node: CallTreeNode): CallTreeNode[] {
return [...(node.current ? [node] : []), ...node.children.flatMap(currents)]
}
describe('调用边收成调用树', () => {
const mask = symbol('mask_phone', 27, { path: 'utils/phone.py' })
it('两个没有更上层调用者的根是两棵树,每棵记下当前块出现几次', () => {
const read = symbol('读取用户资料', 0, { path: 'routes/user.py' })
const profile = symbol('get_profile', 10, { path: 'services/user.py' })
const serialize = symbol('serialize_user', 20, { path: 'services/user.py' })
const load = symbol('load_user', 30, { path: 'services/user.py' })
const exp = symbol('导出通讯录', 0, { path: 'routes/export.py' })
const trees = buildCallTrees([
edge(serialize, mask, 40),
edge(load, mask, 50),
edge(profile, serialize, 12),
edge(profile, load, 14),
edge(read, profile, 3),
edge(exp, mask, 8),
], mask)
expect(trees.map(tree => tree.name)).toEqual(['读取用户资料', '导出通讯录'])
expect(trees.map(tree => tree.hits)).toEqual([2, 1])
expect(trees[0].root.detail).toBe('入口')
expect(trees[0].root.where).toBe('routes/user.py:1')
expect(currents(trees[0].root).map(node => node.where)).toEqual(['utils/phone.py:28', 'utils/phone.py:28'])
expect(currents(trees[1].root)).toHaveLength(1)
})
it('同一个调用者的两段不同范围是当前块的两次出现', () => {
const caller = symbol('serialize_user', 20, { path: 'services/user.py' })
const root = symbol('读取用户资料', 0, { path: 'routes/user.py' })
const trees = buildCallTrees([
edge(caller, mask, 10),
edge(caller, mask, 20),
edge(root, caller, 3),
], mask)
expect(trees).toHaveLength(1)
expect(trees[0].hits).toBe(2)
const parent = trees[0].root.children[0]
expect(parent.name).toBe('serialize_user')
expect(parent.children.map(node => node.current)).toEqual([true, true])
expect(parent.otherCalls).toBeUndefined()
})
it('不在通向当前块的路径上的调用收成一行,不把那些符号画出来', () => {
const caller = symbol('serialize_user', 20, { path: 'services/user.py' })
const root = symbol('读取用户资料', 0, { path: 'routes/user.py' })
const trees = buildCallTrees([
edge(caller, mask, 10),
edge(caller, symbol('format_name'), 11),
edge(caller, symbol('format_name'), 12, 2),
edge(root, caller, 3),
edge(root, symbol('sideways'), 4),
edge(root, symbol('leftover'), 5),
], mask)
expect(trees).toHaveLength(1)
expect(trees[0].root.otherCalls).toBe(2)
expect(trees[0].root.children).toHaveLength(1)
expect(trees[0].root.children[0].otherCalls).toBe(2)
expect(trees[0].root.children[0].children.map(node => node.name)).toEqual(['mask_phone'])
const text = JSON.stringify(trees)
expect(text).not.toContain('format_name')
expect(text).not.toContain('sideways')
expect(text).not.toContain('leftover')
})
it('调用边绕回去就停,不把当前块再画到自己上面', () => {
const caller = symbol('get_profile')
const trees = buildCallTrees([
edge(caller, mask, 1),
edge(mask, caller, 2),
], mask)
expect(trees).toHaveLength(1)
expect(trees[0].name).toBe('get_profile')
expect(trees[0].root.children.map(node => node.name)).toEqual(['mask_phone'])
expect(trees[0].root.children[0].current).toBe(true)
expect(trees[0].root.children[0].children).toEqual([])
expect(trees[0].hits).toBe(1)
})
it('往上最多 8 层,再高的调用者不进这棵树', () => {
const above = Array.from({ length: 9 }, (_, index) => symbol(`n${index}`, index))
const edges = [edge(above[0], mask, 1)]
for (let index = 1; index < above.length; index++)
edges.push(edge(above[index], above[index - 1], index + 1))
const trees = buildCallTrees(edges, mask)
expect(trees).toHaveLength(1)
expect(trees[0].name).toBe('n7')
expect(JSON.stringify(trees)).not.toContain('n8')
})
it('重复的同一段调用范围只算一次', () => {
const caller = symbol('serialize_user')
const trees = buildCallTrees([
edge(caller, mask, 10),
edge(caller, mask, 10),
], mask)
expect(trees).toHaveLength(1)
expect(trees[0].hits).toBe(1)
})
it('同一次查询里种类变了仍是同一个符号,不拆成两棵', () => {
const asFunction = symbol('get_profile', 4, { uri: 'file:///services/user.py', path: 'services/user.py', kind: 'function' })
const asClass = symbol('get_profile', 4, { uri: 'file:///services/user.py', path: 'services/user.py', kind: 'class' })
const trees = buildCallTrees([
edge(asFunction, mask, 8),
edge(asClass, mask, 8),
], mask)
expect(trees).toHaveLength(1)
expect(trees[0].hits).toBe(1)
expect(trees[0].root.kind).toBe('function')
})
it('没有任何调用者就没有树', () => {
expect(buildCallTrees([], mask)).toEqual([])
})
it('选中的是类时,直接调用它的那一层是「新建」,类自己标当前,不编造方法', () => {
const order = symbol('OrderService', 13, { uri: 'file:///billing/order.py', path: 'billing/order.py', kind: 'class' })
const create = symbol('create_order', 20, { path: 'billing/order.py', detail: '创建一笔订单' })
const post = symbol('POST /orders', 0, { path: 'routes/order.py' })
const trees = buildCallTrees([
edge(create, order, 39),
edge(post, create, 5),
edge(create, symbol('charge'), 41),
], order, { classTarget: true })
expect(trees).toHaveLength(1)
expect(trees[0].hits).toBe(1)
const created = trees[0].root.children[0]
expect(created.name).toBe('create_order')
expect(created.detail).toBe('创建一笔订单')
expect(created.otherCalls).toBe(1)
const made = created.children[0]
expect(made.name).toBe('新建 OrderService')
expect(made.detail).toBe('进入这个类')
expect(made.where).toBe('billing/order.py:40')
expect(made.current).toBe(false)
expect(made.kind).toBe('class')
expect(made.children).toEqual([{
name: 'OrderService',
kind: 'class',
where: 'billing/order.py:14',
current: true,
children: [],
}])
expect(JSON.stringify(trees)).not.toContain('方法')
})
})
+215
View File
@@ -0,0 +1,215 @@
/**
* 把调用边收成调用树。边从测试里喂,不碰编辑器。
* 身份是文件加名字起点;同一次查询里种类会变,种类只用来画图标。
*/
/** 往上走到第 8 层就停,这一层当作这棵树的根。 */
export const CALL_TREE_MAX_DEPTH = 8
/** 一个符号:文件、名字起点、给人看的路径和种类。 */
export interface CallSymbol {
uri: string
/** 名字起点,行和列都从 0 起 */
line: number
character: number
name: string
/** 块种类,只决定图标,不参与认人 */
kind: string
/** 从工作区文件夹算起的路径 */
path: string
detail?: string
}
/** 调用出现的范围,在调用方文件里,行和列从 0 起。 */
export interface CallRange {
line: number
character: number
endLine: number
endCharacter: number
}
/** 一次调用:谁在哪一段范围里调用了谁。同一段范围重复出现只算一次。 */
export interface CallEdge {
caller: CallSymbol
callee: CallSymbol
at: CallRange
}
/** 树上的一个节点。当前块可以在同一棵里出现多次,每次一个节点。 */
export interface CallTreeNode {
name: string
kind: string
/** 第二行。根是「入口」,新建类是「进入这个类」,其余是符号自己的说明(没有就不写) */
detail?: string
/** 文件:行,行从 1 起 */
where: string
current: boolean
children: CallTreeNode[]
/** 不在通向当前块的路径上的调用次数;没有就不画那一行 */
otherCalls?: number
}
/** 一棵调用树:根是没有更上层调用者的入口。 */
export interface CallTree {
id: string
/** 入口的名字 */
name: string
/** 这一棵里当前块出现几次 */
hits: number
root: CallTreeNode
}
/** 文件加名字起点。种类不放进来。 */
export function symbolKey(symbol: Pick<CallSymbol, 'uri' | 'line' | 'character'>): string {
return `${symbol.uri}#${symbol.line}:${symbol.character}`
}
function rangeKey(at: CallRange): string {
return `${at.line}:${at.character}-${at.endLine}:${at.endCharacter}`
}
function edgeKey(edge: CallEdge): string {
return `${symbolKey(edge.caller)}>${symbolKey(edge.callee)}@${rangeKey(edge.at)}`
}
function whereOf(symbol: CallSymbol): string {
return `${symbol.path}:${symbol.line + 1}`
}
/**
* 从目标往上收到根,再顺着走回目标。
* 不通向目标的调用不展开,记在父节点的 otherCalls 上。
* 选中的是类时,直接调用它的那一层写成「新建 类名」。
*/
export function buildCallTrees(edges: readonly CallEdge[], target: CallSymbol, options?: { classTarget?: boolean }): CallTree[] {
const symbols = new Map<string, CallSymbol>()
const remember = (symbol: CallSymbol): CallSymbol => {
const key = symbolKey(symbol)
const known = symbols.get(key)
if (known)
return known
symbols.set(key, symbol)
return symbol
}
const targetSymbol = remember(target)
const targetKey = symbolKey(targetSymbol)
const unique: CallEdge[] = []
const seen = new Set<string>()
for (const edge of edges) {
const normalized: CallEdge = { caller: remember(edge.caller), callee: remember(edge.callee), at: edge.at }
const key = edgeKey(normalized)
if (seen.has(key))
continue
seen.add(key)
unique.push(normalized)
}
const byCallee = new Map<string, CallEdge[]>()
const byCaller = new Map<string, CallEdge[]>()
for (const edge of unique) {
pushTo(byCallee, symbolKey(edge.callee), edge)
pushTo(byCaller, symbolKey(edge.caller), edge)
}
if ((byCallee.get(targetKey) ?? []).length === 0)
return []
const depthOf = new Map<string, number>()
const roots: CallSymbol[] = []
const rootSeen = new Set<string>()
const addRoot = (symbol: CallSymbol) => {
const key = symbolKey(symbol)
if (rootSeen.has(key))
return
rootSeen.add(key)
roots.push(symbol)
}
const climb = (node: CallSymbol, depth: number, path: ReadonlySet<string>) => {
const key = symbolKey(node)
const known = depthOf.get(key)
if (known !== undefined && known <= depth)
return
depthOf.set(key, depth)
if (depth >= CALL_TREE_MAX_DEPTH) {
addRoot(node)
return
}
const callers = new Map<string, CallSymbol>()
for (const edge of byCallee.get(key) ?? []) {
const callerKey = symbolKey(edge.caller)
if (!path.has(callerKey) && !callers.has(callerKey))
callers.set(callerKey, edge.caller)
}
if (callers.size === 0) {
addRoot(node)
return
}
const nextPath = new Set(path)
nextPath.add(key)
for (const caller of callers.values())
climb(caller, depth + 1, nextPath)
}
climb(targetSymbol, 0, new Set())
const classTarget = options?.classTarget === true
const render = (node: CallSymbol, path: ReadonlySet<string>): CallTreeNode => {
const key = symbolKey(node)
const current = key === targetKey
const nodeDepth = depthOf.get(key) ?? 0
const children: CallTreeNode[] = []
let otherCalls = 0
if (!current) {
const nextPath = new Set(path)
nextPath.add(key)
for (const edge of byCaller.get(key) ?? []) {
const calleeKey = symbolKey(edge.callee)
if (nextPath.has(calleeKey))
continue
const calleeDepth = depthOf.get(calleeKey)
if (calleeDepth === undefined) {
otherCalls++
continue
}
if (calleeDepth >= nodeDepth)
continue
const child = classTarget && calleeKey === targetKey
? {
name: `新建 ${targetSymbol.name}`,
kind: 'class',
detail: '进入这个类',
where: `${node.path}:${edge.at.line + 1}`,
current: false,
children: [render(edge.callee, nextPath)],
}
: render(edge.callee, nextPath)
children.push(child)
}
}
return {
name: node.name,
kind: node.kind,
...(node.detail && { detail: node.detail }),
where: whereOf(node),
current,
children,
...(otherCalls > 0 && { otherCalls }),
}
}
return roots.map((root) => {
const rendered = render(root, new Set())
rendered.detail = '入口'
return { id: symbolKey(root), name: root.name, hits: countCurrent(rendered), root: rendered }
})
}
function pushTo(map: Map<string, CallEdge[]>, key: string, edge: CallEdge): void {
const list = map.get(key)
if (list)
list.push(edge)
else
map.set(key, [edge])
}
function countCurrent(node: CallTreeNode): number {
return (node.current ? 1 : 0) + node.children.reduce((sum, child) => sum + countCurrent(child), 0)
}
+141
View File
@@ -0,0 +1,141 @@
/**
* 问编辑器的调用关系命令,收成调用边。
* 语言扩展不提供、或者这一轮已经作废,就当没有。
*/
import type { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, TextDocument } from 'vscode'
import type { CallEdge, CallRange, CallSymbol } from './model'
import { commands, SymbolKind, workspace } from 'vscode'
import { symbolKey } from './model'
import { collectCallEdges } from './walk'
/**
* 在 offset 这个名字起点上问调用关系。
* 先准备符号,再往上问谁调用了它;路径上的调用者再问它还调用了谁,用来收「另外 N 处」。
*/
export async function loadCallEdges(
document: TextDocument,
offset: number,
cancelled: () => boolean,
): Promise<{ symbol: CallSymbol, edges: CallEdge[] } | undefined> {
if (cancelled())
return undefined
const position = document.positionAt(offset)
let prepared: CallHierarchyItem | CallHierarchyItem[] | null | undefined
try {
prepared = await commands.executeCommand('vscode.prepareCallHierarchy', document.uri, position)
}
catch {
return undefined
}
if (cancelled() || prepared == null)
return undefined
const items = (Array.isArray(prepared) ? prepared : [prepared]).filter(item => item)
if (items.length === 0)
return undefined
const vscodeItems = new Map<string, CallHierarchyItem>()
const remember = (item: CallHierarchyItem): CallSymbol => {
const symbol = toSymbol(item)
vscodeItems.set(symbolKey(symbol), item)
return symbol
}
const starts: CallSymbol[] = []
const seen = new Set<string>()
for (const item of items) {
const symbol = remember(item)
const key = symbolKey(symbol)
if (seen.has(key))
continue
seen.add(key)
starts.push(symbol)
}
const symbol = starts.find(item => item.line === position.line && item.character === position.character) ?? starts[0]!
const edges: CallEdge[] = []
for (const start of starts) {
if (cancelled())
return undefined
edges.push(...await collectCallEdges(start, incoming, outgoing, cancelled))
}
return cancelled() ? undefined : { symbol, edges }
async function incoming(node: CallSymbol) {
return hits(node, 'vscode.provideIncomingCalls', (call: CallHierarchyIncomingCall) => ({
from: remember(call.from),
ranges: call.fromRanges.map(toRange),
}))
}
async function outgoing(node: CallSymbol) {
return hits(node, 'vscode.provideOutgoingCalls', (call: CallHierarchyOutgoingCall) => ({
to: remember(call.to),
ranges: call.fromRanges.map(toRange),
}))
}
async function hits<T, R>(node: CallSymbol, command: string, map: (call: T) => R): Promise<R[]> {
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 []
}
}
}
function toSymbol(item: CallHierarchyItem): CallSymbol {
const start = item.selectionRange.start
return {
uri: item.uri.toString(),
line: start.line,
character: start.character,
name: item.name,
kind: kindName(item.kind),
path: workspace.asRelativePath(item.uri),
...(item.detail && { detail: item.detail }),
}
}
function toRange(range: CallHierarchyItem['range']): CallRange {
return {
line: range.start.line,
character: range.start.character,
endLine: range.end.line,
endCharacter: range.end.character,
}
}
/** 编辑器的符号种类换成块种类,只给图标用。 */
function kindName(kind: SymbolKind): string {
switch (kind) {
case SymbolKind.Method:
case SymbolKind.Function:
case SymbolKind.Operator:
return 'function'
case SymbolKind.Class:
case SymbolKind.Struct:
case SymbolKind.Constructor:
return 'class'
case SymbolKind.Interface:
case SymbolKind.TypeParameter:
return 'type'
case SymbolKind.Enum:
return 'enum'
case SymbolKind.Field:
return 'field'
case SymbolKind.Variable:
return 'variable'
case SymbolKind.Constant:
return 'const'
case SymbolKind.Module:
case SymbolKind.Namespace:
case SymbolKind.Package:
return 'module'
default:
return 'function'
}
}
@@ -0,0 +1,108 @@
import type { CallEdge, CallSymbol } from './model'
import type { CallTreeBlock, CallTreeSource } from './session'
import { describe, expect, it } from 'vitest'
import { CallTreeSession } from './session'
function symbol(name: string): CallSymbol {
return { uri: `file:///${name}`, line: 0, character: 0, name, kind: 'function', path: `${name}.py` }
}
function block(anchor: string, kind: string, defines?: number, children: CallTreeBlock[] = []): CallTreeBlock {
return { anchor, kind, ...(defines !== undefined && { defines: { startIndex: defines } }), children }
}
describe('调用树棵数在文档发出去之后算', () => {
it('只给有名字起点的函数和类计数,没有调用者的不出现,嵌在里面的也算', async () => {
const mask = symbol('mask_phone')
const caller = symbol('get_profile')
const edges: CallEdge[] = [{
caller,
callee: mask,
at: { line: 1, character: 0, endLine: 1, endCharacter: 4 },
}]
let source: CallTreeSource | undefined = {
version: 3,
blocks: [
block('fn', 'function', 10),
block('no-name', 'function'),
block('stmt', 'statement', 4),
block('cls', 'class', 20, [block('inner', 'function', 30)]),
block('alone', 'function', 40),
],
load: async (offset) => {
if (offset === 10)
return { symbol: mask, edges }
if (offset === 30)
return { symbol: symbol('inner'), edges: [{ caller, callee: symbol('inner'), at: edges[0].at }] }
return { symbol: symbol('nobody'), edges: [] }
},
}
const posted: { type: string, counts?: Record<string, number> }[] = []
const session = new CallTreeSession(message => posted.push(message), () => source)
session.refresh()
await viFlush()
expect(posted).toEqual([{ type: 'callTreeCounts', version: 3, counts: { fn: 1, inner: 1 } }])
source = undefined
session.dispose()
})
it('文档版本变了就丢掉还在跑的查询,不把旧结果发出去', async () => {
let release: (() => void) | undefined
const gate = new Promise<void>((resolve) => {
release = resolve
})
let version = 1
const source: CallTreeSource = {
get version() {
return version
},
blocks: [block('fn', 'function', 1)],
load: async () => {
await gate
return { symbol: symbol('mask_phone'), edges: [] }
},
}
const posted: unknown[] = []
const session = new CallTreeSession(message => posted.push(message), () => source)
session.refresh()
version = 2
session.refresh()
release!()
await viFlush()
expect(posted).toEqual([{ type: 'callTreeCounts', version: 2, counts: {} }])
session.dispose()
})
it('打开某一块时版本对不上不发,对得上就把这棵树发回去', async () => {
const mask = symbol('mask_phone')
const caller = symbol('get_profile')
const edge: CallEdge = { caller, callee: mask, at: { line: 2, character: 0, endLine: 2, endCharacter: 3 } }
const source: CallTreeSource = {
version: 4,
blocks: [block('fn', 'function', 1)],
load: async () => ({ symbol: mask, edges: [edge] }),
}
const posted: { type: string, version?: number, anchor?: string, trees?: { name: string }[] }[] = []
const session = new CallTreeSession(message => posted.push(message), () => source)
session.open(3, 'fn')
expect(posted).toEqual([])
session.refresh()
await viFlush()
posted.length = 0
session.open(4, 'fn')
expect(posted).toEqual([{
type: 'callTree',
version: 4,
anchor: 'fn',
name: 'mask_phone',
where: 'mask_phone.py:1',
trees: [expect.objectContaining({ name: 'get_profile', hits: 1 })],
}])
session.dispose()
})
})
async function viFlush(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
await new Promise(resolve => setTimeout(resolve, 0))
}
+136
View File
@@ -0,0 +1,136 @@
/**
* 文档发出去之后,在后台数每个函数、类落在几棵调用树里。
* 页面点开某一块时,把这一版已经收好的边收成树发回去。版本对不上就丢掉。
*/
import type { ExtensionToWebview } from '../editor/messages'
import type { CallEdge, CallSymbol, CallTree } from './model'
import { buildCallTrees } from './model'
/** 页面上的一块里,数调用树用得到的那几项。 */
export interface CallTreeBlock {
anchor: string
kind: string
defines?: { startIndex: number }
children?: readonly CallTreeBlock[]
}
/** 眼下这份文档,以及按名字起点去问调用关系。问的时候可以取消。 */
export interface CallTreeSource {
version: number
blocks: readonly CallTreeBlock[]
load: (offset: number, cancelled: () => boolean) => Promise<{ symbol: CallSymbol, edges: CallEdge[] } | undefined>
}
interface Cached {
symbol: CallSymbol
edges: CallEdge[]
classTarget: boolean
}
/** 一份文档的调用树:旧的查询作废,不挡住第一次预览。 */
export class CallTreeSession {
private generation = 0
private readonly cache = new Map<string, Cached>()
private pending: { version: number, anchor: string } | undefined
private scannedGeneration = -1
constructor(
private readonly post: (message: ExtensionToWebview) => void,
private readonly source: () => CallTreeSource | undefined,
) {}
/** 停掉还在跑的查询。文档又改了、预览关掉时用。 */
cancel(): void {
this.generation++
this.cache.clear()
}
/** 按眼下这份文档重新数。先停掉上一轮。 */
refresh(): void {
this.cancel()
void this.scan(this.generation)
}
/** 页面要看某一块。不是这一版文档的不理;还没数完就等这一轮数完再发。 */
open(version: number, anchor: string): void {
const src = this.source()
if (!src || src.version !== version)
return
const cached = this.cache.get(anchor)
if (cached) {
this.postTree(version, anchor, cached)
return
}
this.pending = { version, anchor }
if (this.scannedGeneration === this.generation)
this.postEmpty(version, anchor)
}
dispose(): void {
this.cancel()
}
private async scan(generation: number): Promise<void> {
const src = this.source()
if (!src || generation !== this.generation)
return
const version = src.version
const counts: Record<string, number> = {}
for (const block of callBlocks(src.blocks)) {
if (generation !== this.generation || this.source()?.version !== version)
return
const loaded = await src.load(block.defines!.startIndex, () => generation !== this.generation)
if (generation !== this.generation || this.source()?.version !== version)
return
if (!loaded)
continue
const classTarget = block.kind === 'class'
const trees = buildCallTrees(loaded.edges, loaded.symbol, { classTarget })
if (trees.length === 0)
continue
const cached: Cached = { symbol: loaded.symbol, edges: loaded.edges, classTarget }
this.cache.set(block.anchor, cached)
counts[block.anchor] = trees.length
if (this.pending?.anchor === block.anchor && this.pending.version === version)
this.postTree(version, block.anchor, cached)
}
if (generation !== this.generation || this.source()?.version !== version)
return
this.scannedGeneration = generation
this.post({ type: 'callTreeCounts', version, counts })
if (this.pending && this.pending.version === version && !this.cache.has(this.pending.anchor))
this.postEmpty(version, this.pending.anchor)
}
private postTree(version: number, anchor: string, cached: Cached): void {
const trees: CallTree[] = buildCallTrees(cached.edges, cached.symbol, { classTarget: cached.classTarget })
this.post({
type: 'callTree',
version,
anchor,
name: cached.symbol.name,
where: `${cached.symbol.path}:${cached.symbol.line + 1}`,
trees,
})
}
private postEmpty(version: number, anchor: string): void {
this.post({ type: 'callTree', version, anchor, name: '', where: '', trees: [] })
}
}
/** 有名字起点的函数和类,含嵌在别的块里面的。 */
function callBlocks(blocks: readonly CallTreeBlock[]): CallTreeBlock[] {
const found: CallTreeBlock[] = []
const walk = (list: readonly CallTreeBlock[]) => {
for (const block of list) {
if ((block.kind === 'function' || block.kind === 'class') && block.defines)
found.push(block)
if (block.children?.length)
walk(block.children)
}
}
walk(blocks)
return found
}
+101
View File
@@ -0,0 +1,101 @@
import type { CallRange, CallSymbol } from './model'
import { describe, expect, it } from 'vitest'
import { collectCallEdges } from './walk'
function symbol(name: string, kind = 'function'): CallSymbol {
return { uri: `file:///${name}`, line: 0, character: 0, name, kind, path: `${name}.py` }
}
function range(line: number, character = 0): CallRange {
return { line, character, endLine: line, endCharacter: character + 3 }
}
describe('顺着调用者往上收集调用边', () => {
it('有环就停,不再问已经走过的那个', async () => {
const target = symbol('mask_phone')
const caller = symbol('get_profile')
const asked: string[] = []
const edges = await collectCallEdges(target, async (node) => {
asked.push(node.name)
if (node.name === 'mask_phone')
return [{ from: caller, ranges: [range(1)] }]
return [{ from: target, ranges: [range(2)] }]
}, async () => [])
expect(asked).toEqual(['mask_phone', 'get_profile'])
expect(edges).toEqual([{ caller, callee: target, at: range(1) }])
})
it('往上最多问 8 层,第 9 层的调用者不再问', async () => {
const names = ['t', ...Array.from({ length: 9 }, (_, index) => `n${index}`)]
const asked: string[] = []
await collectCallEdges(symbol('t'), async (node) => {
asked.push(node.name)
const index = names.indexOf(node.name)
const next = names[index + 1]
return next ? [{ from: symbol(next), ranges: [range(index + 1)] }] : []
}, async () => [])
expect(asked).toEqual(['t', 'n0', 'n1', 'n2', 'n3', 'n4', 'n5', 'n6'])
})
it('重复的调用范围去掉,同一调用者的两段不同范围都留下', async () => {
const target = symbol('mask_phone')
const caller = symbol('serialize_user')
const edges = await collectCallEdges(target, async () => [{
from: caller,
ranges: [range(10), range(10), range(20)],
}], async () => [])
expect(edges.map(item => item.at.line)).toEqual([10, 20])
})
it('种类不同但名字起点相同的调用者是同一个人,只往上问一次', async () => {
const target = symbol('mask_phone')
const asFunction = symbol('get_profile', 'function')
const asClass = symbol('get_profile', 'class')
const asked: string[] = []
const edges = await collectCallEdges(target, async (node) => {
asked.push(`${node.name}:${node.kind}`)
if (node.name === 'mask_phone')
return [{ from: asFunction, ranges: [range(1)] }, { from: asClass, ranges: [range(1)] }]
return []
}, async () => [])
expect(asked.filter(item => item.startsWith('get_profile'))).toEqual(['get_profile:function'])
expect(edges).toHaveLength(1)
})
it('路径上的调用者还要问它调用了谁,不通向当前块的边也留下;当前块自己不再往下问', async () => {
const target = symbol('mask_phone')
const caller = symbol('serialize_user')
const side = symbol('format_name')
const outgoingAsked: string[] = []
const edges = await collectCallEdges(target, async (node) => {
if (node.name === 'mask_phone')
return [{ from: caller, ranges: [range(4)] }]
return []
}, async (node) => {
outgoingAsked.push(node.name)
return [
{ to: target, ranges: [range(4)] },
{ to: side, ranges: [range(8), range(8)] },
]
})
expect(outgoingAsked).toEqual(['serialize_user'])
expect(edges).toEqual([
{ caller, callee: target, at: range(4) },
{ caller, callee: side, at: range(8) },
])
})
it('取消之后不再问下一个人', async () => {
const target = symbol('mask_phone')
const caller = symbol('get_profile')
let stop = false
const asked: string[] = []
const edges = await collectCallEdges(target, async (node) => {
asked.push(node.name)
stop = true
return [{ from: caller, ranges: [range(1)] }]
}, async () => [], () => stop)
expect(asked).toEqual(['mask_phone'])
expect(edges).toEqual([])
})
})
+108
View File
@@ -0,0 +1,108 @@
/**
* 从当前符号往上问调用者,收到没有更上层的入口为止。
* 问的动作由外面传入:测试喂假的,宿主去问编辑器命令。
*/
import type { CallEdge, CallRange, CallSymbol } from './model'
import { CALL_TREE_MAX_DEPTH, symbolKey } from './model'
/** 一个调用者,以及它里面每一处调用的范围。 */
export interface IncomingHit {
from: CallSymbol
ranges: readonly CallRange[]
}
/** 一个被调用的符号,以及调用它的每一处范围。 */
export interface OutgoingHit {
to: CallSymbol
ranges: readonly CallRange[]
}
/**
* 收集通向 start 的调用边,再加上这些调用者通向别处的边。
* 有环就停。往上最多 8 层。cancelled 变成真就停,已经收到的也不要了。
* start 自己不再往下问:它调用了谁不画在这棵树上。
*/
export async function collectCallEdges(
start: CallSymbol,
incoming: (node: CallSymbol) => Promise<readonly IncomingHit[] | undefined>,
outgoing: (node: CallSymbol) => Promise<readonly OutgoingHit[] | undefined>,
cancelled: () => boolean = () => false,
): Promise<CallEdge[]> {
if (cancelled())
return []
const edges: CallEdge[] = []
const seen = new Set<string>()
const ancestors = new Map<string, CallSymbol>()
const walked = new Map<string, number>()
const startKey = symbolKey(start)
const add = (caller: CallSymbol, callee: CallSymbol, at: CallRange) => {
const edge: CallEdge = { caller, callee, at }
const key = `${symbolKey(caller)}>${symbolKey(callee)}@${at.line}:${at.character}-${at.endLine}:${at.endCharacter}`
if (seen.has(key))
return
seen.add(key)
edges.push(edge)
}
const walk = async (node: CallSymbol, depth: number, path: ReadonlySet<string>): Promise<void> => {
if (cancelled() || depth >= CALL_TREE_MAX_DEPTH)
return
const calls = await incoming(node) ?? []
if (cancelled())
return
const here = new Set(path)
here.add(symbolKey(node))
const next = new Map<string, CallSymbol>()
for (const call of calls) {
const key = symbolKey(call.from)
if (here.has(key))
continue
for (const at of uniqueRanges(call.ranges))
add(call.from, node, at)
if (key !== startKey && !ancestors.has(key))
ancestors.set(key, call.from)
if (!next.has(key))
next.set(key, call.from)
}
for (const [key, caller] of next) {
if (cancelled() || depth + 1 >= CALL_TREE_MAX_DEPTH)
continue
const known = walked.get(key)
if (known !== undefined && known <= depth + 1)
continue
walked.set(key, depth + 1)
await walk(caller, depth + 1, here)
}
}
await walk(start, 0, new Set())
if (cancelled())
return []
for (const ancestor of ancestors.values()) {
if (cancelled())
return []
const calls = await outgoing(ancestor) ?? []
if (cancelled())
return []
for (const call of calls) {
for (const at of uniqueRanges(call.ranges))
add(ancestor, call.to, at)
}
}
return cancelled() ? [] : edges
}
function uniqueRanges(ranges: readonly CallRange[]): CallRange[] {
const seen = new Set<string>()
const unique: CallRange[] = []
for (const range of ranges) {
const key = `${range.line}:${range.character}-${range.endLine}:${range.endCharacter}`
if (seen.has(key))
continue
seen.add(key)
unique.push(range)
}
return unique
}
+7
View File
@@ -5,6 +5,7 @@
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { FileAttribute } from '../attributes'
import type { BlockView } from '../blockView'
import type { CallTree } from '../callTree/model'
import type { DocumentDiff } from '../diff/previewDiff'
import type { ShownName } from '../nameResolution'
import type { ProfileChoice } from '../qa/askProfiles'
@@ -129,6 +130,8 @@ export interface StickyScrollSettings {
* repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到
* repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了
* repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在
* callTreeCounts:version 这份文档里,每个函数、类落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理
* callTree:anchor 那块的调用树。trees 空着是没有调用它的地方。不是页面上这个版本的不理
*/
export type ExtensionToWebview
= | DocumentMessage
@@ -153,6 +156,8 @@ export type ExtensionToWebview
| { type: 'repoLinkOpened', path?: string, symbol?: string }
| { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] }
| { type: 'focusPathAsk' }
| { type: 'callTreeCounts', version: number, counts: Record<string, number> }
| { type: 'callTree', version: number, anchor: string, name: string, where: string, trees: CallTree[] }
/**
* edit:把 startLine..endLine 这几行整行替换成 text(扩展端给每行补回原来的缩进)
@@ -178,6 +183,7 @@ export type ExtensionToWebview
* 没有 path 是不带路径的引用(`[Name]`、`[Class.member]`),按这份文件找 symbol(本文件定义的,再是导入的,见 symbolLinks.ts)
* checkRepoLinks:预览里这批仓库路径问哪些文件或目录没有,这批不带路径的引用(symbols)问哪些找不到。id 标出是哪一次问
* checkRepoImages:预览里这批仓库图片,问哪些文件在,在的要预览里能直接用的地址。id 标出是哪一次问
* openCallTree:打开 anchor 那块的调用树。version 是这块所在的文档版本,对不上就不查
*/
export type WebviewToExtension
= | { type: 'ready' }
@@ -201,3 +207,4 @@ export type WebviewToExtension
| { type: 'openRepoLink', path?: string, symbol?: string }
| { type: 'checkRepoLinks', id: number, paths: string[], symbols: string[] }
| { type: 'checkRepoImages', id: number, paths: string[] }
| { type: 'openCallTree', version: number, anchor: string }
@@ -23,6 +23,8 @@ import { commands, EndOfLine, Position, Range, Uri, window, workspace, Workspace
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
import { DocumentCallees } from '../calleeKinds'
import { loadCallEdges } from '../callTree/query'
import { CallTreeSession } from '../callTree/session'
import { findBlock, rewriteBlockComment } from '../commentRewrite'
import { PreviewDiff } from '../diff/previewDiff'
import { blockReplacement } from '../editText'
@@ -187,6 +189,20 @@ class Preview {
private readonly rewriteStops = new Map<string, AbortController>()
/** 最近一次发出去的文档(版本、块树、源码):问 AI 时按块的记号在里面找那块代码,对比基准变了时拿它重新比 */
private sent: SentDocument | undefined
/** 函数、类落在几棵调用树里。文档发出去之后再数,不挡住第一次预览 */
private readonly callTrees = new CallTreeSession(
message => this.post(message),
() => {
if (!this.sent || this.sent.version !== this.document.version)
return undefined
return {
version: this.sent.version,
blocks: this.sent.blocks,
load: (offset, cancelled) => loadCallEdges(this.document, offset, cancelled),
}
},
)
/** 最近一次发出去的文档里语言插件自己认得出的名字(LanguageAdapter.symbols):语言扩展查不到定义、各处出现时用 */
private symbols: { version: number, list: LocalSymbol[] } | undefined
/** 最近一次发出去的文档里各条导入引进来的名字:注释里不带路径的引用按它找导入的定义 */
@@ -318,6 +334,7 @@ class Preview {
if (!this.ready)
return
const request = ++this.latest
this.callTrees.cancel()
if (pathPreviewOf(this.document.uri)) {
this.sent = undefined
const reveal = this.pendingReveal
@@ -359,6 +376,7 @@ class Preview {
this.names.resolve(version, report => resolveNames(displayNames, message.blocks, this.nameSources(), report))
this.lookups.resolve(version, session.requests(), this.lookupSources())
this.callees.resolve(version, callees.requests(), offset => this.calleeKind(offset))
this.callTrees.refresh()
}
}
@@ -455,6 +473,7 @@ class Preview {
}
dispose(): void {
this.callTrees.dispose()
this.rewriteAbort.abort()
clearTimeout(this.timer)
this.names.dispose()
@@ -608,6 +627,9 @@ class Preview {
case 'checkRepoImages':
await this.checkRepoImages(msg.id, msg.paths)
return
case 'openCallTree':
this.callTrees.open(msg.version, msg.anchor)
return
case 'occurrenceNavigation':
this.owner.navigation.update(this, msg.available)
}