mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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.
This commit is contained in:
@@ -183,6 +183,11 @@ export function HierarchyView() {
|
||||
{basename(item.path)}:{item.selectionRange.start.line + 1}
|
||||
</span>
|
||||
{calls > 1 && <span className={styles.muted}>{tx`(${calls} calls)`}</span>}
|
||||
{n.node.recursion && (
|
||||
<span className={styles.muted}>
|
||||
<T>recursive</T>
|
||||
</span>
|
||||
)}
|
||||
{notes.has(n.id) && (
|
||||
<span className={styles.muted} title={notes.get(n.id)}>
|
||||
{notes.get(n.id)}
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HierarchyNode, readonly string[]>();
|
||||
|
||||
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<string>();
|
||||
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<RootResult> {
|
||||
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) => ({
|
||||
|
||||
@@ -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": "递归"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user