@nudojs/lsp 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/agent-skill/SKILL.md +83 -0
- package/dist/server.d.ts +8 -1
- package/dist/server.js +44156 -76
- package/package.json +25 -3
- package/src/__tests__/definition.test.ts +32 -0
- package/src/__tests__/lsp-integration.test.ts +822 -0
- package/src/agent-tools.ts +325 -0
- package/src/semantic-tokens.ts +45 -0
- package/src/server.ts +437 -67
- package/src/symbols.ts +109 -0
- package/src/validation.ts +197 -0
package/src/server.ts
CHANGED
|
@@ -5,26 +5,53 @@ import {
|
|
|
5
5
|
TextDocumentSyncKind,
|
|
6
6
|
type InitializeParams,
|
|
7
7
|
type InitializeResult,
|
|
8
|
-
type Diagnostic as LspDiagnostic,
|
|
9
|
-
DiagnosticSeverity,
|
|
10
|
-
DiagnosticTag,
|
|
11
8
|
type CompletionItem as LspCompletionItem,
|
|
12
9
|
CompletionItemKind,
|
|
13
10
|
MarkupKind,
|
|
14
11
|
type CodeLens,
|
|
15
12
|
CodeLensRefreshRequest,
|
|
13
|
+
FileChangeType,
|
|
14
|
+
type FileEvent,
|
|
16
15
|
type InlayHint,
|
|
17
16
|
InlayHintKind,
|
|
18
17
|
} from "vscode-languageserver/node";
|
|
19
18
|
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
20
19
|
import { typeValueToString } from "@nudojs/core";
|
|
21
20
|
import {
|
|
22
|
-
analyzeFile,
|
|
23
21
|
getTypeAtPosition,
|
|
24
22
|
getCompletionsAtPosition,
|
|
25
23
|
getCasesForFile,
|
|
26
|
-
type DiagnosticSeverity as JsDiagSeverity,
|
|
27
24
|
} from "@nudojs/service";
|
|
25
|
+
import { parse } from "@nudojs/parser";
|
|
26
|
+
import { buildSymbolTable, findDefinition, findReferences, findIdentifierAtPosition } from "./symbols.ts";
|
|
27
|
+
import { TOKEN_TYPES, TOKEN_MODIFIERS } from "./semantic-tokens.ts";
|
|
28
|
+
import {
|
|
29
|
+
analysisCache,
|
|
30
|
+
evictModuleGraphCacheEntries,
|
|
31
|
+
forgetValidatedFile,
|
|
32
|
+
getCachedOrAnalyze,
|
|
33
|
+
hasNudoDirectives,
|
|
34
|
+
toLspDiagnostic,
|
|
35
|
+
uriToFilePath,
|
|
36
|
+
validateText,
|
|
37
|
+
type ValidateTextDeps,
|
|
38
|
+
} from "./validation.ts";
|
|
39
|
+
import {
|
|
40
|
+
normalizeFilePath,
|
|
41
|
+
suggestCase,
|
|
42
|
+
trace,
|
|
43
|
+
whatIf,
|
|
44
|
+
type AgentToolDeps,
|
|
45
|
+
type AgentToolResult,
|
|
46
|
+
} from "./agent-tools.ts";
|
|
47
|
+
|
|
48
|
+
const NUDO_COMMANDS = [
|
|
49
|
+
"nudo.whatIf",
|
|
50
|
+
"nudo.suggestCase",
|
|
51
|
+
"nudo.trace",
|
|
52
|
+
"nudo.selectCase",
|
|
53
|
+
"nudo.getActiveCases",
|
|
54
|
+
] as const;
|
|
28
55
|
|
|
29
56
|
const connection = createConnection(ProposedFeatures.all);
|
|
30
57
|
const documents = new TextDocuments(TextDocument);
|
|
@@ -39,12 +66,6 @@ function getActiveCasesForUri(uri: string): Map<string, number> {
|
|
|
39
66
|
return map;
|
|
40
67
|
}
|
|
41
68
|
|
|
42
|
-
const severityMap: Record<JsDiagSeverity, DiagnosticSeverity> = {
|
|
43
|
-
error: DiagnosticSeverity.Error,
|
|
44
|
-
warning: DiagnosticSeverity.Warning,
|
|
45
|
-
info: DiagnosticSeverity.Information,
|
|
46
|
-
};
|
|
47
|
-
|
|
48
69
|
connection.onInitialize((_params: InitializeParams): InitializeResult => ({
|
|
49
70
|
capabilities: {
|
|
50
71
|
textDocumentSync: TextDocumentSyncKind.Full,
|
|
@@ -57,11 +78,42 @@ connection.onInitialize((_params: InitializeParams): InitializeResult => ({
|
|
|
57
78
|
resolveProvider: false,
|
|
58
79
|
},
|
|
59
80
|
inlayHintProvider: true,
|
|
81
|
+
definitionProvider: true,
|
|
82
|
+
referencesProvider: true,
|
|
83
|
+
renameProvider: true,
|
|
84
|
+
codeActionProvider: {
|
|
85
|
+
codeActionKinds: ["quickfix"],
|
|
86
|
+
},
|
|
87
|
+
signatureHelpProvider: {
|
|
88
|
+
triggerCharacters: ["(", ","],
|
|
89
|
+
},
|
|
90
|
+
semanticTokensProvider: {
|
|
91
|
+
full: true,
|
|
92
|
+
legend: {
|
|
93
|
+
tokenTypes: [...TOKEN_TYPES],
|
|
94
|
+
tokenModifiers: [...TOKEN_MODIFIERS],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
executeCommandProvider: {
|
|
98
|
+
commands: [...NUDO_COMMANDS],
|
|
99
|
+
},
|
|
100
|
+
diagnosticProvider: {
|
|
101
|
+
interFileDependencies: false,
|
|
102
|
+
workspaceDiagnostics: false,
|
|
103
|
+
},
|
|
60
104
|
},
|
|
61
105
|
}));
|
|
62
106
|
|
|
63
107
|
let debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
64
108
|
|
|
109
|
+
// 打开即验证:didOpen 不会触发 onDidChangeContent,若不在此主动验证,
|
|
110
|
+
// 新打开的文件要等到首次编辑(300ms 防抖后)或客户端 pull 诊断才有结果。
|
|
111
|
+
// propagate=true 与编辑防抖路径同语义(含对打开依赖项的一次脏传播)。
|
|
112
|
+
documents.onDidOpen((event) => {
|
|
113
|
+
nudoFileCache.delete(event.document.uri);
|
|
114
|
+
validateDocument(event.document, true).catch(() => {});
|
|
115
|
+
});
|
|
116
|
+
|
|
65
117
|
documents.onDidChangeContent((change) => {
|
|
66
118
|
const uri = change.document.uri;
|
|
67
119
|
nudoFileCache.delete(uri);
|
|
@@ -72,7 +124,7 @@ documents.onDidChangeContent((change) => {
|
|
|
72
124
|
uri,
|
|
73
125
|
setTimeout(() => {
|
|
74
126
|
debounceTimers.delete(uri);
|
|
75
|
-
validateDocument(change.document);
|
|
127
|
+
validateDocument(change.document, true).catch(() => {});
|
|
76
128
|
}, 300),
|
|
77
129
|
);
|
|
78
130
|
});
|
|
@@ -82,51 +134,82 @@ documents.onDidClose((event) => {
|
|
|
82
134
|
if (timer) clearTimeout(timer);
|
|
83
135
|
debounceTimers.delete(event.document.uri);
|
|
84
136
|
nudoFileCache.delete(event.document.uri);
|
|
137
|
+
analysisCache.delete(uriToFilePath(event.document.uri));
|
|
85
138
|
connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] });
|
|
86
139
|
});
|
|
87
140
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
141
|
+
/**
|
|
142
|
+
* watched-files 删除事件监听器:接收「被删除且不在打开集」的 uri 列表。
|
|
143
|
+
* 缓存逐出等后续逻辑通过 registerWatchedFilesListener 挂到这里。
|
|
144
|
+
*/
|
|
145
|
+
export const watchedFilesListeners: Array<(uris: string[]) => void> = [];
|
|
146
|
+
|
|
147
|
+
/** 注册 watched-files 监听器,返回注销函数。 */
|
|
148
|
+
export function registerWatchedFilesListener(listener: (uris: string[]) => void): () => void {
|
|
149
|
+
watchedFilesListeners.push(listener);
|
|
150
|
+
return () => {
|
|
151
|
+
const idx = watchedFilesListeners.indexOf(listener);
|
|
152
|
+
if (idx >= 0) watchedFilesListeners.splice(idx, 1);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
98
155
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
156
|
+
/**
|
|
157
|
+
* watched-files 事件核心:对 Deleted 且不在打开集的文件清理会话登记项
|
|
158
|
+
* (knownFiles/analysisCache 走 forgetValidatedFile,activeCases/nudoFileCache 按 uri),
|
|
159
|
+
* 清空其已发布诊断,并把被清理的 uri 列表广播给监听器。
|
|
160
|
+
* 打开中的文件跳过——其内容由编辑流负责,外部删除会被编辑器以 didOpen/didChange 覆盖。
|
|
161
|
+
*/
|
|
162
|
+
function handleWatchedFilesChanges(changes: readonly FileEvent[], isOpen: (uri: string) => boolean): string[] {
|
|
163
|
+
const gone: string[] = [];
|
|
164
|
+
for (const change of changes) {
|
|
165
|
+
if (change.type !== FileChangeType.Deleted) continue;
|
|
166
|
+
if (isOpen(change.uri)) continue;
|
|
167
|
+
gone.push(change.uri);
|
|
168
|
+
forgetValidatedFile(uriToFilePath(change.uri));
|
|
169
|
+
activeCases.delete(change.uri);
|
|
170
|
+
nudoFileCache.delete(change.uri);
|
|
171
|
+
connection.sendDiagnostics({ uri: change.uri, diagnostics: [] });
|
|
172
|
+
}
|
|
173
|
+
if (gone.length > 0) {
|
|
174
|
+
// 拷贝后再遍历:监听器内注销自身不应影响本轮广播
|
|
175
|
+
for (const listener of [...watchedFilesListeners]) {
|
|
176
|
+
try {
|
|
177
|
+
listener(gone);
|
|
178
|
+
} catch {
|
|
179
|
+
// 单个监听器异常不阻断其余监听器的缓存逐出
|
|
113
180
|
}
|
|
114
|
-
|
|
115
|
-
});
|
|
116
|
-
connection.sendDiagnostics({ uri, diagnostics });
|
|
117
|
-
} catch (err) {
|
|
118
|
-
connection.sendDiagnostics({
|
|
119
|
-
uri,
|
|
120
|
-
diagnostics: [
|
|
121
|
-
{
|
|
122
|
-
severity: DiagnosticSeverity.Error,
|
|
123
|
-
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
|
|
124
|
-
message: `Analysis error: ${(err as Error).message}`,
|
|
125
|
-
source: "nudo",
|
|
126
|
-
},
|
|
127
|
-
],
|
|
128
|
-
});
|
|
181
|
+
}
|
|
129
182
|
}
|
|
183
|
+
return gone;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
connection.onDidChangeWatchedFiles((event) =>
|
|
187
|
+
handleWatchedFilesChanges(event.changes, (uri) => documents.get(uri) !== undefined),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// 模块图边缓存逐出:收到被清理 uri 时逐出 moduleGraphCache 对应 filePath 的条目。
|
|
191
|
+
// 会话级常驻注册,无需持有注销函数。
|
|
192
|
+
registerWatchedFilesListener(evictModuleGraphCacheEntries);
|
|
193
|
+
|
|
194
|
+
function validationDeps(): ValidateTextDeps {
|
|
195
|
+
return {
|
|
196
|
+
sendDiagnostics: (params) => connection.sendDiagnostics(params),
|
|
197
|
+
isNudoUri: (uri) => isNudoFile(uri),
|
|
198
|
+
getActiveCases: (uri) => getActiveCasesForUri(uri),
|
|
199
|
+
getOpenDocumentByPath: (filePath) =>
|
|
200
|
+
documents.all().find((doc) => uriToFilePath(doc.uri) === filePath),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function validateDocument(document: TextDocument, propagate = false): Promise<void> {
|
|
205
|
+
return validateText(
|
|
206
|
+
uriToFilePath(document.uri),
|
|
207
|
+
document.uri,
|
|
208
|
+
document.getText(),
|
|
209
|
+
document.version,
|
|
210
|
+
validationDeps(),
|
|
211
|
+
propagate,
|
|
212
|
+
);
|
|
130
213
|
}
|
|
131
214
|
|
|
132
215
|
connection.onHover((params) => {
|
|
@@ -232,7 +315,7 @@ connection.languages.inlayHint.on((params) => {
|
|
|
232
315
|
const lines = source.split("\n");
|
|
233
316
|
|
|
234
317
|
try {
|
|
235
|
-
const result =
|
|
318
|
+
const result = getCachedOrAnalyze(filePath, source, document.version, cases);
|
|
236
319
|
const hints: InlayHint[] = [];
|
|
237
320
|
|
|
238
321
|
for (const hint of result.caseHints) {
|
|
@@ -254,27 +337,322 @@ connection.languages.inlayHint.on((params) => {
|
|
|
254
337
|
}
|
|
255
338
|
});
|
|
256
339
|
|
|
257
|
-
connection.
|
|
258
|
-
const
|
|
340
|
+
connection.onDefinition((params) => {
|
|
341
|
+
const document = documents.get(params.textDocument.uri);
|
|
342
|
+
if (!document) return null;
|
|
343
|
+
if (!isNudoFile(params.textDocument.uri)) return null;
|
|
344
|
+
|
|
345
|
+
const source = document.getText();
|
|
346
|
+
const ast = parse(source);
|
|
347
|
+
const table = buildSymbolTable(ast, params.textDocument.uri);
|
|
348
|
+
|
|
349
|
+
const line = params.position.line + 1;
|
|
350
|
+
const column = params.position.character;
|
|
351
|
+
const identAtPos = findIdentifierAtPosition(ast, line, column);
|
|
352
|
+
if (!identAtPos) return null;
|
|
353
|
+
|
|
354
|
+
const def = findDefinition(table, identAtPos);
|
|
355
|
+
if (!def) return null;
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
uri: params.textDocument.uri,
|
|
359
|
+
range: {
|
|
360
|
+
start: { line: def.loc.start.line - 1, character: def.loc.start.column },
|
|
361
|
+
end: { line: def.loc.end.line - 1, character: def.loc.end.column },
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
connection.onReferences((params) => {
|
|
367
|
+
const document = documents.get(params.textDocument.uri);
|
|
368
|
+
if (!document) return [];
|
|
369
|
+
if (!isNudoFile(params.textDocument.uri)) return [];
|
|
370
|
+
|
|
371
|
+
const source = document.getText();
|
|
372
|
+
const ast = parse(source);
|
|
373
|
+
const table = buildSymbolTable(ast, params.textDocument.uri);
|
|
374
|
+
|
|
375
|
+
const line = params.position.line + 1;
|
|
376
|
+
const column = params.position.character;
|
|
377
|
+
const identAtPos = findIdentifierAtPosition(ast, line, column);
|
|
378
|
+
if (!identAtPos) return [];
|
|
379
|
+
|
|
380
|
+
const refs = findReferences(table, identAtPos);
|
|
381
|
+
return refs.map((ref) => ({
|
|
382
|
+
uri: params.textDocument.uri,
|
|
383
|
+
range: {
|
|
384
|
+
start: { line: ref.loc.start.line - 1, character: ref.loc.start.column },
|
|
385
|
+
end: { line: ref.loc.end.line - 1, character: ref.loc.end.column },
|
|
386
|
+
},
|
|
387
|
+
}));
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
connection.onRenameRequest((params) => {
|
|
391
|
+
const document = documents.get(params.textDocument.uri);
|
|
392
|
+
if (!document) return null;
|
|
393
|
+
if (!isNudoFile(params.textDocument.uri)) return null;
|
|
394
|
+
|
|
395
|
+
const source = document.getText();
|
|
396
|
+
const ast = parse(source);
|
|
397
|
+
const table = buildSymbolTable(ast, params.textDocument.uri);
|
|
398
|
+
|
|
399
|
+
const line = params.position.line + 1;
|
|
400
|
+
const column = params.position.character;
|
|
401
|
+
const identAtPos = findIdentifierAtPosition(ast, line, column);
|
|
402
|
+
if (!identAtPos) return null;
|
|
403
|
+
|
|
404
|
+
const def = findDefinition(table, identAtPos);
|
|
405
|
+
const refs = findReferences(table, identAtPos);
|
|
406
|
+
|
|
407
|
+
const edits = [];
|
|
408
|
+
|
|
409
|
+
if (def) {
|
|
410
|
+
edits.push({
|
|
411
|
+
range: {
|
|
412
|
+
start: { line: def.loc.start.line - 1, character: def.loc.start.column },
|
|
413
|
+
end: { line: def.loc.end.line - 1, character: def.loc.end.column },
|
|
414
|
+
},
|
|
415
|
+
newText: params.newName,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const ref of refs) {
|
|
420
|
+
edits.push({
|
|
421
|
+
range: {
|
|
422
|
+
start: { line: ref.loc.start.line - 1, character: ref.loc.start.column },
|
|
423
|
+
end: { line: ref.loc.end.line - 1, character: ref.loc.end.column },
|
|
424
|
+
},
|
|
425
|
+
newText: params.newName,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
changes: {
|
|
431
|
+
[params.textDocument.uri]: edits,
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
connection.onCodeAction((params) => {
|
|
437
|
+
const document = documents.get(params.textDocument.uri);
|
|
438
|
+
if (!document) return [];
|
|
439
|
+
if (!isNudoFile(params.textDocument.uri)) return [];
|
|
440
|
+
|
|
441
|
+
const actions = [];
|
|
442
|
+
|
|
443
|
+
for (const diag of params.context.diagnostics) {
|
|
444
|
+
if (diag.code === "nudo-unreachable") {
|
|
445
|
+
actions.push({
|
|
446
|
+
title: "Remove unreachable code",
|
|
447
|
+
kind: "quickfix",
|
|
448
|
+
diagnostics: [diag],
|
|
449
|
+
edit: {
|
|
450
|
+
changes: {
|
|
451
|
+
[params.textDocument.uri]: [{
|
|
452
|
+
range: diag.range,
|
|
453
|
+
newText: "",
|
|
454
|
+
}],
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (diag.code === "nudo-assertion-failed") {
|
|
461
|
+
actions.push({
|
|
462
|
+
title: "Update @nudo:returns to match inferred type",
|
|
463
|
+
kind: "quickfix",
|
|
464
|
+
diagnostics: [diag],
|
|
465
|
+
isPreferred: false,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return actions;
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
connection.onSignatureHelp((params) => {
|
|
474
|
+
const document = documents.get(params.textDocument.uri);
|
|
475
|
+
if (!document) return null;
|
|
476
|
+
if (!isNudoFile(params.textDocument.uri)) return null;
|
|
477
|
+
|
|
478
|
+
const filePath = uriToFilePath(params.textDocument.uri);
|
|
479
|
+
const source = document.getText();
|
|
480
|
+
const line = params.position.line + 1;
|
|
481
|
+
const column = params.position.character;
|
|
482
|
+
const cases = getActiveCasesForUri(params.textDocument.uri);
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const ast = parse(source);
|
|
486
|
+
const callInfo = findEnclosingCall(ast, line, column);
|
|
487
|
+
if (!callInfo) return null;
|
|
488
|
+
|
|
489
|
+
const fnType = getTypeAtPosition(filePath, source, callInfo.calleeLine, callInfo.calleeCol, cases);
|
|
490
|
+
if (!fnType || fnType.kind !== "function") return null;
|
|
491
|
+
|
|
492
|
+
const paramLabels = fnType.params.map((p) => `${p}: unknown`);
|
|
493
|
+
const activeParam = callInfo.currentParamIndex;
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
signatures: [{
|
|
497
|
+
label: `(${paramLabels.join(", ")}) => unknown`,
|
|
498
|
+
parameters: paramLabels.map((label) => ({ label })),
|
|
499
|
+
activeParameter: activeParam,
|
|
500
|
+
}],
|
|
501
|
+
activeSignature: 0,
|
|
502
|
+
activeParameter: activeParam,
|
|
503
|
+
};
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
function findEnclosingCall(ast: any, line: number, column: number): { calleeLine: number; calleeCol: number; currentParamIndex: number } | null {
|
|
510
|
+
let result: any = null;
|
|
511
|
+
|
|
512
|
+
function visit(node: any): void {
|
|
513
|
+
if (!node || result) return;
|
|
514
|
+
|
|
515
|
+
if (node.type === "CallExpression") {
|
|
516
|
+
const loc = node.loc;
|
|
517
|
+
if (loc && loc.start.line <= line && loc.end.line >= line) {
|
|
518
|
+
const calleeLoc = node.callee.loc;
|
|
519
|
+
if (calleeLoc) {
|
|
520
|
+
let paramIndex = 0;
|
|
521
|
+
for (let i = 0; i < node.arguments.length; i++) {
|
|
522
|
+
const argLoc = node.arguments[i].loc;
|
|
523
|
+
if (argLoc) {
|
|
524
|
+
if (argLoc.start.line < line || (argLoc.start.line === line && argLoc.start.column <= column)) {
|
|
525
|
+
paramIndex = i + 1;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
result = {
|
|
530
|
+
calleeLine: calleeLoc.start.line,
|
|
531
|
+
calleeCol: calleeLoc.start.column,
|
|
532
|
+
currentParamIndex: Math.min(paramIndex, node.arguments.length),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
for (const key of Object.keys(node)) {
|
|
539
|
+
if (key === "type" || key === "loc" || key === "start" || key === "end") continue;
|
|
540
|
+
const child = node[key];
|
|
541
|
+
if (Array.isArray(child)) {
|
|
542
|
+
for (const item of child) {
|
|
543
|
+
if (item && typeof item === "object" && item.type) visit(item);
|
|
544
|
+
}
|
|
545
|
+
} else if (child && typeof child === "object" && child.type) {
|
|
546
|
+
visit(child);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
visit(ast);
|
|
552
|
+
return result;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
connection.languages.semanticTokens.on((params) => {
|
|
556
|
+
const document = documents.get(params.textDocument.uri);
|
|
557
|
+
if (!document) return { data: [] };
|
|
558
|
+
if (!isNudoFile(params.textDocument.uri)) return { data: [] };
|
|
559
|
+
|
|
560
|
+
// For now, return empty tokens - can be enhanced later
|
|
561
|
+
return { data: [] };
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
/** Resolve a `uri`- or `file`-identified target to the uri key used by activeCases/documents. */
|
|
565
|
+
function uriForFileOrUri(params: { uri?: string; file?: string }): string {
|
|
566
|
+
if (params.uri) return params.uri;
|
|
567
|
+
const filePath = normalizeFilePath(params.file ?? "");
|
|
568
|
+
const doc = documents.all().find((d) => uriToFilePath(d.uri) === filePath);
|
|
569
|
+
return doc ? doc.uri : `file://${filePath}`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function handleSelectCase(params: { uri?: string; file?: string; functionName: string; caseIndex: number }) {
|
|
573
|
+
const uri = uriForFileOrUri(params);
|
|
574
|
+
const cases = getActiveCasesForUri(uri);
|
|
259
575
|
cases.set(params.functionName, params.caseIndex);
|
|
260
576
|
|
|
261
|
-
const document = documents.get(
|
|
577
|
+
const document = documents.get(uri);
|
|
262
578
|
if (document) {
|
|
263
|
-
validateDocument(document);
|
|
579
|
+
await validateDocument(document);
|
|
264
580
|
}
|
|
265
581
|
|
|
266
582
|
connection.sendRequest(CodeLensRefreshRequest.type).catch(() => {});
|
|
267
583
|
|
|
268
584
|
return { success: true };
|
|
269
|
-
}
|
|
585
|
+
}
|
|
270
586
|
|
|
271
|
-
|
|
272
|
-
const cases = getActiveCasesForUri(params
|
|
587
|
+
function handleGetActiveCases(params: { uri?: string; file?: string }) {
|
|
588
|
+
const cases = getActiveCasesForUri(uriForFileOrUri(params));
|
|
273
589
|
const result: Record<string, number> = {};
|
|
274
590
|
for (const [fn, idx] of cases) {
|
|
275
591
|
result[fn] = idx;
|
|
276
592
|
}
|
|
277
593
|
return result;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const agentToolDeps: AgentToolDeps = {
|
|
597
|
+
getOpenText: (filePath) => {
|
|
598
|
+
const doc = documents.all().find((d) => uriToFilePath(d.uri) === filePath);
|
|
599
|
+
return doc ? { text: doc.getText() } : undefined;
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* One dispatch table shared by the executeCommand commands (`nudo.*`) and the
|
|
605
|
+
* custom request aliases (`nudo/…`) — both channels run the same handlers.
|
|
606
|
+
*/
|
|
607
|
+
function dispatchNudoCommand(command: string, arg: Record<string, unknown>) {
|
|
608
|
+
switch (command) {
|
|
609
|
+
case "nudo.whatIf":
|
|
610
|
+
return whatIf(arg as Parameters<typeof whatIf>[0], agentToolDeps);
|
|
611
|
+
case "nudo.suggestCase":
|
|
612
|
+
return suggestCase(arg as Parameters<typeof suggestCase>[0], agentToolDeps);
|
|
613
|
+
case "nudo.trace":
|
|
614
|
+
return trace(arg as Parameters<typeof trace>[0], agentToolDeps);
|
|
615
|
+
case "nudo.selectCase":
|
|
616
|
+
return handleSelectCase(arg as Parameters<typeof handleSelectCase>[0]);
|
|
617
|
+
case "nudo.getActiveCases":
|
|
618
|
+
return handleGetActiveCases(arg as Parameters<typeof handleGetActiveCases>[0]);
|
|
619
|
+
default:
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
connection.onExecuteCommand((params) =>
|
|
625
|
+
dispatchNudoCommand(params.command, (params.arguments?.[0] as Record<string, unknown>) ?? {}),
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
connection.onRequest("nudo/selectCase", handleSelectCase);
|
|
629
|
+
|
|
630
|
+
connection.onRequest("nudo/getActiveCases", handleGetActiveCases);
|
|
631
|
+
|
|
632
|
+
/** Request aliases share the command handlers; the pinned return type keeps onRequest overload inference happy. */
|
|
633
|
+
function dispatchAgentRequest(command: string, params: Record<string, unknown>): AgentToolResult {
|
|
634
|
+
return dispatchNudoCommand(command, params) as AgentToolResult;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
connection.onRequest("nudo/whatIf", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.whatIf", params));
|
|
638
|
+
connection.onRequest("nudo/suggestCase", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.suggestCase", params));
|
|
639
|
+
connection.onRequest("nudo/trace", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.trace", params));
|
|
640
|
+
|
|
641
|
+
connection.languages.diagnostics.on((params) => {
|
|
642
|
+
const document = documents.get(params.textDocument.uri);
|
|
643
|
+
if (!document) return { kind: "full", items: [] };
|
|
644
|
+
if (!isNudoFile(params.textDocument.uri)) return { kind: "full", items: [] };
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
const filePath = uriToFilePath(document.uri);
|
|
648
|
+
const result = getCachedOrAnalyze(filePath, document.getText(), document.version, getActiveCasesForUri(document.uri));
|
|
649
|
+
return {
|
|
650
|
+
kind: "full",
|
|
651
|
+
items: result.diagnostics.map((d) => toLspDiagnostic(d, document.uri)),
|
|
652
|
+
};
|
|
653
|
+
} catch {
|
|
654
|
+
return { kind: "full", items: [] };
|
|
655
|
+
}
|
|
278
656
|
});
|
|
279
657
|
|
|
280
658
|
const nudoFileCache = new Map<string, boolean>();
|
|
@@ -290,13 +668,5 @@ function isNudoFile(uri: string): boolean {
|
|
|
290
668
|
return result;
|
|
291
669
|
}
|
|
292
670
|
|
|
293
|
-
function hasNudoDirectives(source: string): boolean {
|
|
294
|
-
return /@nudo:(case|mock|pure|skip|sample|returns)\b/.test(source);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function uriToFilePath(uri: string): string {
|
|
298
|
-
return uri.startsWith("file://") ? decodeURIComponent(uri.slice(7)) : uri;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
671
|
documents.listen(connection);
|
|
302
672
|
connection.listen();
|
package/src/symbols.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Node } from "@babel/types";
|
|
2
|
+
import traverse from "@babel/traverse";
|
|
3
|
+
import type { SymbolInfo, ReferenceInfo, SymbolTable, SourceLocation } from "@nudojs/service";
|
|
4
|
+
|
|
5
|
+
function locFromNode(node: Node): SourceLocation {
|
|
6
|
+
return {
|
|
7
|
+
start: { line: node.loc?.start.line ?? 1, column: node.loc?.start.column ?? 0 },
|
|
8
|
+
end: { line: node.loc?.end.line ?? 1, column: node.loc?.end.column ?? 0 },
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildSymbolTable(ast: Node, uri: string): SymbolTable {
|
|
13
|
+
const definitions = new Map<string, SymbolInfo>();
|
|
14
|
+
const references: ReferenceInfo[] = [];
|
|
15
|
+
|
|
16
|
+
const traverseFn = (typeof traverse === "function" ? traverse : (traverse as any).default) as typeof traverse;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
traverseFn(ast, {
|
|
20
|
+
// Collect definitions
|
|
21
|
+
FunctionDeclaration(path) {
|
|
22
|
+
if (path.node.id) {
|
|
23
|
+
definitions.set(path.node.id.name, {
|
|
24
|
+
name: path.node.id.name,
|
|
25
|
+
kind: "function",
|
|
26
|
+
loc: locFromNode(path.node.id),
|
|
27
|
+
uri,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
VariableDeclarator(path) {
|
|
32
|
+
if (path.node.id.type === "Identifier") {
|
|
33
|
+
definitions.set(path.node.id.name, {
|
|
34
|
+
name: path.node.id.name,
|
|
35
|
+
kind: "variable",
|
|
36
|
+
loc: locFromNode(path.node.id),
|
|
37
|
+
uri,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
ClassDeclaration(path) {
|
|
42
|
+
if (path.node.id) {
|
|
43
|
+
definitions.set(path.node.id.name, {
|
|
44
|
+
name: path.node.id.name,
|
|
45
|
+
kind: "class",
|
|
46
|
+
loc: locFromNode(path.node.id),
|
|
47
|
+
uri,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
// Collect references
|
|
52
|
+
Identifier(path) {
|
|
53
|
+
// Skip definition sites
|
|
54
|
+
if (path.parentPath?.node.type === "FunctionDeclaration" && path.parentPath.node.id === path.node) return;
|
|
55
|
+
if (path.parentPath?.node.type === "VariableDeclarator" && path.parentPath.node.id === path.node) return;
|
|
56
|
+
if (path.parentPath?.node.type === "ClassDeclaration" && path.parentPath.node.id === path.node) return;
|
|
57
|
+
|
|
58
|
+
references.push({
|
|
59
|
+
name: path.node.name,
|
|
60
|
+
loc: locFromNode(path.node),
|
|
61
|
+
uri,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
} catch {
|
|
66
|
+
// traverse may fail on partial ASTs
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { definitions, references };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function findDefinition(
|
|
73
|
+
symbolTable: SymbolTable,
|
|
74
|
+
name: string,
|
|
75
|
+
): SymbolInfo | null {
|
|
76
|
+
return symbolTable.definitions.get(name) ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function findReferences(
|
|
80
|
+
symbolTable: SymbolTable,
|
|
81
|
+
name: string,
|
|
82
|
+
): ReferenceInfo[] {
|
|
83
|
+
return symbolTable.references.filter((r) => r.name === name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function findIdentifierAtPosition(ast: Node, line: number, column: number): string | null {
|
|
87
|
+
let found: string | null = null;
|
|
88
|
+
const traverseFn = (typeof traverse === "function" ? traverse : (traverse as any).default) as typeof traverse;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
traverseFn(ast, {
|
|
92
|
+
Identifier(path) {
|
|
93
|
+
const loc = path.node.loc;
|
|
94
|
+
if (!loc) return;
|
|
95
|
+
if (
|
|
96
|
+
loc.start.line === line &&
|
|
97
|
+
loc.start.column <= column &&
|
|
98
|
+
loc.end.column >= column
|
|
99
|
+
) {
|
|
100
|
+
found = path.node.name;
|
|
101
|
+
path.stop();
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
} catch {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
109
|
+
}
|