/** * 把调用边收成调用树。边从测试里喂,不碰编辑器。 * 身份是文件加名字起点;同一次查询里种类会变,种类只用来画图标。 */ /** 往上走到第 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 /** 前端发请求调到接口的才有:方法和前端写的路径(`GET /api/v1/agents`),树上在调用方和接口之间多一个「请求」节点 */ request?: string } /** 点节点跳到哪:文件和位置,行和列从 0 起。 */ export interface CallJump { uri: string line: number character: number } /** 树上的一个节点。当前块可以在同一棵里出现多次,每次一个节点。 */ export interface CallTreeNode { name: string /** 块种类;「请求」节点是 request */ kind: string /** 第二行。根是「入口」(Next.js 页面是「页面 网址」),新建类是「进入这个类」,其余是符号自己的说明(没有就不写) */ detail?: string /** 文件:行,行从 1 起 */ where: string /** 符号是它的名字起点;「新建」「请求」节点是发生调用的那一处 */ jump: CallJump current: boolean children: CallTreeNode[] /** 不在通向当前块的路径上的调用次数;没有就不画那一行 */ otherCalls?: number } /** 一棵调用树:根是没有更上层调用者的入口。 */ export interface CallTree { id: string /** 入口的名字 */ name: string /** 这一棵里当前块出现几次 */ hits: number root: CallTreeNode } /** 文件加名字起点。种类不放进来。 */ export function symbolKey(symbol: Pick): 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}` } /** 调用方里发生这次调用的那一处:「新建」「请求」节点写它、点了跳到它。 */ function callSite(caller: CallSymbol, at: CallRange): Pick { return { where: `${caller.path}:${at.line + 1}`, jump: { uri: caller.uri, line: at.line, character: at.character } } } /** * Next.js 页面的网址:文件在某个 `app/` 目录下、名字是 `page.(tsx|ts|jsx|js)` 时按目录算,路由分组 `(xxx)`、并行路由 `@slot` 去掉, * 动态段 `[id]`、`[[...section]]` 原样留;`app/page.tsx` 是 `/`。不是页面文件时没有。 * @param path `/` 分隔的路径,取第一层叫 app 的目录 */ export function nextPageUrl(path: string): string | undefined { const segments = path.split('/') const app = segments.indexOf('app') if (app < 0 || !/^page\.[jt]sx?$/.test(segments.at(-1)!)) return undefined const route = segments.slice(app + 1, -1).filter(segment => !/^\(.*\)$/.test(segment) && !segment.startsWith('@')) return `/${route.join('/')}` } /** * 从目标往上收到根,再顺着走回目标。 * 不通向目标的调用不展开,记在父节点的 otherCalls 上。 * 选中的是类时,直接调用它的那一层写成「新建 类名」;前端发请求调到接口的那一下写成「请求 方法 路径」。 * 根的第二行是「入口」,Next.js 页面写成「页面 网址」。 */ export function buildCallTrees(edges: readonly CallEdge[], target: CallSymbol, options?: { classTarget?: boolean }): CallTree[] { const symbols = new Map() 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() for (const edge of edges) { const normalized: CallEdge = { ...edge, caller: remember(edge.caller), callee: remember(edge.callee) } const key = edgeKey(normalized) if (seen.has(key)) continue seen.add(key) unique.push(normalized) } const byCallee = new Map() const byCaller = new Map() 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() const roots: CallSymbol[] = [] const rootSeen = new Set() 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) => { 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() 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): 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 children.push(calleeKey === targetKey ? targetChild(node, edge, nextPath) : render(edge.callee, nextPath)) } } return { name: node.name, kind: node.kind, ...(node.detail && { detail: node.detail }), where: whereOf(node), jump: { uri: node.uri, line: node.line, character: node.character }, current, children, ...(otherCalls > 0 && { otherCalls }), } } /** 直接调到目标的那一下:前端发的请求、新建类,各在中间多一个节点写调用的那一处;其余直接是目标。 */ function targetChild(caller: CallSymbol, edge: CallEdge, path: ReadonlySet): CallTreeNode { const target = render(edge.callee, path) if (edge.request) return { name: `请求 ${edge.request}`, kind: 'request', ...callSite(caller, edge.at), current: false, children: [target] } if (classTarget) return { name: `新建 ${targetSymbol.name}`, kind: 'class', detail: '进入这个类', ...callSite(caller, edge.at), current: false, children: [target] } return target } return roots.map((root) => { const rendered = render(root, new Set()) const page = nextPageUrl(root.path) rendered.detail = page === undefined ? '入口' : `页面 ${page}` return { id: symbolKey(root), name: root.name, hits: countCurrent(rendered), root: rendered } }) } function pushTo(map: Map, 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) }