From 659afe66f5e3728227cce763a02fcb3fcb4eb05f Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Tue, 3 Nov 2026 20:31:01 +0800 Subject: [PATCH] fix(code-insight): stop call/type hierarchy from nesting recursive symbols forever (#1016) A recursive symbol got a fresh id on every level (ids carry the parent chain), so sibling dedupe never matched and each expansion requested the same level again, forever. Identify items by their position key instead: a child whose key repeats an ancestor is kept a leaf, marked recursive, and never expanded. --- src/features/code-insight/HierarchyView.tsx | 5 +++ src/features/code-insight/hierarchy.test.ts | 25 ++++++++++- src/features/code-insight/hierarchy.ts | 48 ++++++++++++++++++--- src/platform/locales/text/zh.json | 3 +- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/features/code-insight/HierarchyView.tsx b/src/features/code-insight/HierarchyView.tsx index 6e39047c..5f6ce0fc 100644 --- a/src/features/code-insight/HierarchyView.tsx +++ b/src/features/code-insight/HierarchyView.tsx @@ -183,6 +183,11 @@ export function HierarchyView() { {basename(item.path)}:{item.selectionRange.start.line + 1} {calls > 1 && {tx`(${calls} calls)`}} + {n.node.recursion && ( + + recursive + + )} {notes.has(n.id) && ( {notes.get(n.id)} diff --git a/src/features/code-insight/hierarchy.test.ts b/src/features/code-insight/hierarchy.test.ts index 46663183..8c2120ee 100644 --- a/src/features/code-insight/hierarchy.test.ts +++ b/src/features/code-insight/hierarchy.test.ts @@ -54,6 +54,8 @@ const callHierarchy = vi.fn((): { items: HierarchyItem[]; incomplete: References incomplete: null, })); const cancelRequest = vi.fn(async () => undefined); +// The positions a hierarchy request is answered at; anything else is "no symbol". +let servedAt = (pos: { line: number; col: number }) => pos.line === 2 && pos.col === 7; vi.mock("@/platform/ipc", async (orig) => { const real = await orig(); @@ -66,7 +68,7 @@ vi.mock("@/platform/ipc", async (orig) => { cancelRequest, lspCallHierarchy: async (_request: number, _doc: number, pos: { line: number; col: number }) => { // The server prepares a hierarchy only on the method name. - if (pos.line !== 2 || pos.col !== 7) throw new real.IpcError({ kind: "notFound", message: "No symbol" }); + if (!servedAt(pos)) throw new real.IpcError({ kind: "notFound", message: "No symbol" }); return callHierarchy(); }, }, @@ -83,6 +85,7 @@ vi.mock("./ipc", async (orig) => ({ const { enclosingDeclaration, hierarchyTarget, + loadChildren, refreshHierarchy, resetHierarchy, showHierarchy, @@ -143,4 +146,24 @@ describe("hierarchy", () => { expect(cancelRequest).toHaveBeenCalledTimes(1); answer({ items: [item("stale")], incomplete: null }); }); + + it("keeps a caller that repeats an ancestor a recursion leaf", async () => { + servedAt = () => true; // `main` calls itself: every level answers with the same item. + try { + useHierarchy.setState({ mode: "call", direction: "incoming" }); + await showHierarchy("call"); + const [top] = useHierarchy.getState().children.get(ROOT_ID) ?? []; + expect(top?.item.name).toBe("main"); + expect(top?.recursion).toBeUndefined(); + await loadChildren(top!); + const nested = useHierarchy.getState().children.get(top!.id)?.[0]; + expect(nested?.item.name).toBe("main"); + expect(nested?.recursion).toBe(true); + // The leaf carries no expandable children, so the tree never requests it again. + expect(useHierarchy.getState().children.get(nested!.id)).toEqual([]); + expect(useHierarchy.getState().failed.size).toBe(0); + } finally { + servedAt = (pos) => pos.line === 2 && pos.col === 7; + } + }); }); diff --git a/src/features/code-insight/hierarchy.ts b/src/features/code-insight/hierarchy.ts index cfd3f2f4..ccbaca6f 100644 --- a/src/features/code-insight/hierarchy.ts +++ b/src/features/code-insight/hierarchy.ts @@ -13,6 +13,8 @@ export type HierarchyMode = "call" | "type"; export interface HierarchyNode { id: string; item: HierarchyItem; + /** Set when the node repeats an ancestor: it is kept a leaf and marked "recursive". */ + recursion?: boolean; } export interface HierarchyRoot { @@ -107,19 +109,49 @@ export function childId(parent: string, item: HierarchyItem): string { return `${parent}/${item.path}:${p.line}:${p.col}:${item.name}`; } -function setChildren(generation: number, parent: string, items: HierarchyItem[], note: string | null) { +/** Position-unique key of a hierarchy item: the same symbol always has the same one. */ +function itemKey(item: HierarchyItem): string { + const p = item.selectionRange.start; + return `${item.path}:${p.line}:${p.col}:${item.name}`; +} + +/** The root's key, matching itemKey of the item its position resolves to. */ +function rootKey(root: HierarchyRoot): string { + return `${root.path}:${root.line}:${root.col}:${root.name}`; +} + +/** Each node's position key plus its ancestors', so a child repeating one is a cycle. */ +const nodeKeys = new WeakMap(); + +function setChildren( + generation: number, + parent: string, + parentKeys: readonly string[], + items: HierarchyItem[], + note: string | null, +) { const s = useHierarchy.getState(); if (s.generation !== generation) return; + const ancestors = new Set(parentKeys); const seen = new Set(); const nodes: HierarchyNode[] = []; + const children = new Map(s.children); + children.set(parent, nodes); for (const item of items) { const id = childId(parent, item); if (seen.has(id)) continue; seen.add(id); - nodes.push({ id, item }); + const key = itemKey(item); + // A recursive symbol arrives with a fresh id on every level (the id carries the + // parent chain); only its position key reveals the cycle. Keep such a node a + // leaf — expanding it again would request the same level forever. + const node: HierarchyNode = ancestors.has(key) ? { id, item, recursion: true } : { id, item }; + if (node.recursion) children.set(id, []); + nodeKeys.set(node, [...parentKeys, key]); + nodes.push(node); } useHierarchy.setState({ - children: new Map(s.children).set(parent, nodes), + children, ...(note ? { notes: new Map(s.notes).set(parent, note) } : {}), }); } @@ -138,7 +170,7 @@ async function loadRoot(quiet = false): Promise { const { items, incomplete } = await withDocument(root.path, (doc) => request(s.mode, doc, root.line, root.col, s.direction), ); - setChildren(generation, ROOT_ID, items, incomplete && incompleteNote(incomplete, root.name)); + setChildren(generation, ROOT_ID, [rootKey(root)], items, incomplete && incompleteNote(incomplete, root.name)); return "loaded"; } catch (e) { if (useHierarchy.getState().generation !== generation) return "stale"; @@ -189,7 +221,13 @@ export async function loadChildren(node: HierarchyNode) { const { items, incomplete } = await withDocument(node.item.path, (doc) => request(mode, doc, at.line, at.col, direction), ); - setChildren(generation, node.id, items, incomplete && incompleteNote(incomplete, node.item.name)); + setChildren( + generation, + node.id, + nodeKeys.get(node) ?? [], + items, + incomplete && incompleteNote(incomplete, node.item.name), + ); } catch (e) { if (useHierarchy.getState().generation !== generation) return; useHierarchy.setState((st) => ({ diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d7b36d69..28f805a2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5551,5 +5551,6 @@ "e.g. --keepdb --parallel": "例如 --keepdb --parallel", "select files in a git repository to shelve": "请选择 Git 仓库中的文件以搁置更改", "select files from a single git repository to shelve": "请一次只选择同一个 Git 仓库中的文件以搁置更改", - "External file changes sync may be slow": "外部文件更改同步可能较慢" + "External file changes sync may be slow": "外部文件更改同步可能较慢", + "recursive": "递归" }