Skip to content

Commit 6332d43

Browse files
nam-hleclaude
andauthored
feat: add textDocument/references support to language server (#527)
## Summary - Adds `textDocument/references` support to the nadle language server, enabling "Find All References" for task names - Handles cursor on both `tasks.register("name")` registration sites and `dependsOn` string references - Collects references across all open documents via new `DocumentStore.getAllAnalyses()` method - Respects `includeDeclaration` context flag per LSP spec Closes #526 ## Test plan - [x] 7 new test cases in `references.test.ts` covering: - References from registration cursor (includeDeclaration true/false) - References from dependsOn cursor - Unreferenced tasks - Workspace-qualified refs (skipped) - Non-task strings (empty result) - [x] All 58 language-server tests pass - [x] Type-check passes - [ ] Verify "Find All References" works in VS Code with the extension 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a9618e8 commit 6332d43

File tree

6 files changed

+210
-1
lines changed

6 files changed

+210
-1
lines changed

packages/language-server/src/definitions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { TextDocument } from "vscode-languageserver-textdocument";
44

55
import type { DocumentAnalysis } from "./analyzer.js";
66

7-
function findDependsOnNameAtPosition(content: string, offset: number): { name: string; isWorkspaceQualified: boolean } | null {
7+
export function findDependsOnNameAtPosition(content: string, offset: number): { name: string; isWorkspaceQualified: boolean } | null {
88
const file = ts.createSourceFile("def.ts", content, ts.ScriptTarget.Latest, true);
99
let result: { name: string; isWorkspaceQualified: boolean } | null = null;
1010

packages/language-server/src/document-store.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export class DocumentStore {
1616
return result;
1717
}
1818

19+
public getAllAnalyses(): DocumentAnalysis[] {
20+
return [...this.cache.values()];
21+
}
22+
1923
public removeDocument(uri: string): void {
2024
this.cache.delete(uri);
2125
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { analyzeDocument } from "./analyzer.js";
2+
export { getReferences } from "./references.js";
23
export { DocumentStore } from "./document-store.js";
34
export type { DependencyRef, DocumentAnalysis, TaskConfigInfo, TaskRegistration } from "./analyzer.js";
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { TextDocument } from "vscode-languageserver-textdocument";
2+
import type { Location, Position, ReferenceContext } from "vscode-languageserver";
3+
4+
import type { DocumentAnalysis } from "./analyzer.js";
5+
import { findDependsOnNameAtPosition } from "./definitions.js";
6+
7+
function findRegistrationNameAtPosition(analysis: DocumentAnalysis, offset: number, document: TextDocument): string | null {
8+
for (const reg of analysis.registrations) {
9+
if (reg.name === null) {
10+
continue;
11+
}
12+
13+
const start = document.offsetAt(reg.nameRange.start);
14+
const end = document.offsetAt(reg.nameRange.end);
15+
16+
if (offset > start && offset < end) {
17+
return reg.name;
18+
}
19+
}
20+
21+
return null;
22+
}
23+
24+
export function getReferences(analyses: DocumentAnalysis[], position: Position, document: TextDocument, context: ReferenceContext): Location[] {
25+
const offset = document.offsetAt(position);
26+
const content = document.getText();
27+
const currentUri = document.uri;
28+
29+
const currentAnalysis = analyses.find((a) => a.uri === currentUri);
30+
let taskName: string | null = null;
31+
32+
const depRef = findDependsOnNameAtPosition(content, offset);
33+
34+
if (depRef && !depRef.isWorkspaceQualified) {
35+
taskName = depRef.name;
36+
}
37+
38+
if (!taskName && currentAnalysis) {
39+
taskName = findRegistrationNameAtPosition(currentAnalysis, offset, document);
40+
}
41+
42+
if (!taskName) {
43+
return [];
44+
}
45+
46+
const locations: Location[] = [];
47+
48+
for (const analysis of analyses) {
49+
if (context.includeDeclaration) {
50+
const entries = analysis.taskNames.get(taskName);
51+
52+
if (entries) {
53+
for (const entry of entries) {
54+
locations.push({ uri: analysis.uri, range: entry.nameRange });
55+
}
56+
}
57+
}
58+
59+
for (const reg of analysis.registrations) {
60+
if (!reg.configuration) {
61+
continue;
62+
}
63+
64+
for (const dep of reg.configuration.dependsOn) {
65+
if (dep.name === taskName && !dep.isWorkspaceQualified) {
66+
locations.push({ range: dep.range, uri: analysis.uri });
67+
}
68+
}
69+
}
70+
}
71+
72+
return locations;
73+
}

packages/language-server/src/server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { TextDocument } from "vscode-languageserver-textdocument";
66
import { TextDocuments, createConnection, ProposedFeatures, TextDocumentSyncKind } from "vscode-languageserver/node";
77

88
import { getHover } from "./hover.js";
9+
import { getReferences } from "./references.js";
910
import { getDefinition } from "./definitions.js";
1011
import { getCompletions } from "./completions.js";
1112
import { DocumentStore } from "./document-store.js";
@@ -24,6 +25,7 @@ connection.onInitialize((): InitializeResult => {
2425
capabilities: {
2526
hoverProvider: true,
2627
definitionProvider: true,
28+
referencesProvider: true,
2729
textDocumentSync: TextDocumentSyncKind.Incremental,
2830
completionProvider: {
2931
resolveProvider: false,
@@ -129,5 +131,15 @@ connection.onDefinition(({ position, textDocument }) => {
129131
return getDefinition(analysis, position, doc);
130132
});
131133

134+
connection.onReferences(({ context, position, textDocument }) => {
135+
const doc = documents.get(textDocument.uri);
136+
137+
if (!doc) {
138+
return [];
139+
}
140+
141+
return getReferences(store.getAllAnalyses(), position, doc, context);
142+
});
143+
132144
documents.listen(connection);
133145
connection.listen();
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import Path from "node:path";
2+
import Fs from "node:fs/promises";
3+
4+
import { it, expect, describe } from "vitest";
5+
import { analyzeDocument } from "src/analyzer.js";
6+
import { getReferences } from "src/references.js";
7+
import { TextDocument } from "vscode-languageserver-textdocument";
8+
9+
const fixturesDir = Path.resolve(import.meta.dirname, "__fixtures__");
10+
11+
async function setupFixture(name: string) {
12+
const filePath = Path.resolve(fixturesDir, name);
13+
const content = await Fs.readFile(filePath, "utf-8");
14+
const analysis = analyzeDocument(content, name);
15+
const uri = `file:///${name}`;
16+
const doc = TextDocument.create(uri, "typescript", 1, content);
17+
18+
return { doc, analysis: { ...analysis, uri } };
19+
}
20+
21+
describe("getReferences", () => {
22+
it("finds dependsOn refs when cursor is on a registration name", async () => {
23+
const { doc, analysis } = await setupFixture("valid.ts");
24+
const content = doc.getText();
25+
const idx = content.indexOf('"compile"');
26+
const offset = idx + 1;
27+
const position = doc.positionAt(offset);
28+
const locations = getReferences([analysis], position, doc, { includeDeclaration: true });
29+
30+
expect(locations.length).toBe(2);
31+
32+
const compileReg = analysis.registrations.find((r) => r.name === "compile");
33+
expect(locations).toContainEqual({ uri: analysis.uri, range: compileReg!.nameRange });
34+
35+
const buildReg = analysis.registrations.find((r) => r.name === "build");
36+
const compileDep = buildReg!.configuration!.dependsOn.find((d) => d.name === "compile");
37+
expect(locations).toContainEqual({ uri: analysis.uri, range: compileDep!.range });
38+
});
39+
40+
it("finds registration + dependsOn refs from a dependsOn cursor", async () => {
41+
const { doc, analysis } = await setupFixture("valid.ts");
42+
const content = doc.getText();
43+
const dependsOnIdx = content.indexOf('dependsOn: ["compile"');
44+
const offset = dependsOnIdx + 'dependsOn: ["'.length + 1;
45+
const position = doc.positionAt(offset);
46+
const locations = getReferences([analysis], position, doc, { includeDeclaration: true });
47+
48+
expect(locations.length).toBe(2);
49+
50+
const compileReg = analysis.registrations.find((r) => r.name === "compile");
51+
expect(locations).toContainEqual({ uri: analysis.uri, range: compileReg!.nameRange });
52+
53+
const buildReg = analysis.registrations.find((r) => r.name === "build");
54+
const compileDep = buildReg!.configuration!.dependsOn.find((d) => d.name === "compile");
55+
expect(locations).toContainEqual({ uri: analysis.uri, range: compileDep!.range });
56+
});
57+
58+
it("excludes declaration when includeDeclaration is false", async () => {
59+
const { doc, analysis } = await setupFixture("valid.ts");
60+
const content = doc.getText();
61+
const idx = content.indexOf('"compile"');
62+
const offset = idx + 1;
63+
const position = doc.positionAt(offset);
64+
const locations = getReferences([analysis], position, doc, { includeDeclaration: false });
65+
66+
expect(locations.length).toBe(1);
67+
68+
const buildReg = analysis.registrations.find((r) => r.name === "build");
69+
const compileDep = buildReg!.configuration!.dependsOn.find((d) => d.name === "compile");
70+
expect(locations).toContainEqual({ uri: analysis.uri, range: compileDep!.range });
71+
});
72+
73+
it("returns only declaration for unreferenced task", async () => {
74+
const { doc, analysis } = await setupFixture("valid.ts");
75+
const content = doc.getText();
76+
const idx = content.indexOf('"clean-cache"');
77+
const offset = idx + 1;
78+
const position = doc.positionAt(offset);
79+
const locations = getReferences([analysis], position, doc, { includeDeclaration: true });
80+
81+
expect(locations.length).toBe(1);
82+
83+
const reg = analysis.registrations.find((r) => r.name === "clean-cache");
84+
expect(locations[0]).toEqual({ uri: analysis.uri, range: reg!.nameRange });
85+
});
86+
87+
it("returns empty for unreferenced task with includeDeclaration false", async () => {
88+
const { doc, analysis } = await setupFixture("valid.ts");
89+
const content = doc.getText();
90+
const idx = content.indexOf('"clean-cache"');
91+
const offset = idx + 1;
92+
const position = doc.positionAt(offset);
93+
const locations = getReferences([analysis], position, doc, { includeDeclaration: false });
94+
95+
expect(locations).toEqual([]);
96+
});
97+
98+
it("returns empty for workspace-qualified references", async () => {
99+
const { doc, analysis } = await setupFixture("unresolved-deps.ts");
100+
const content = doc.getText();
101+
const idx = content.indexOf('"other-pkg:build"');
102+
const offset = idx + 1;
103+
const position = doc.positionAt(offset);
104+
const locations = getReferences([analysis], position, doc, { includeDeclaration: true });
105+
106+
expect(locations).toEqual([]);
107+
});
108+
109+
it("returns empty for non-task strings", async () => {
110+
const { doc, analysis } = await setupFixture("valid.ts");
111+
const content = doc.getText();
112+
const idx = content.indexOf('"Compile TypeScript"');
113+
const offset = idx + 1;
114+
const position = doc.positionAt(offset);
115+
const locations = getReferences([analysis], position, doc, { includeDeclaration: true });
116+
117+
expect(locations).toEqual([]);
118+
});
119+
});

0 commit comments

Comments
 (0)