@nudojs/lsp 0.2.0 → 0.4.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 +34 -0
- package/README.md +35 -0
- package/agent-skill/SKILL.md +82 -0
- package/dist/server.d.ts +8 -1
- package/dist/server.js +44431 -92
- package/package.json +15 -3
- package/src/__tests__/agent-check.test.ts +47 -0
- package/src/__tests__/agent-hover.test.ts +28 -0
- package/src/__tests__/agent-infer.test.ts +38 -0
- package/src/__tests__/check-diagnostics.test.ts +46 -0
- package/src/__tests__/definition.test.ts +32 -0
- package/src/__tests__/lsp-integration.test.ts +921 -0
- package/src/__tests__/nudo-dep-evict.test.ts +103 -0
- package/src/agent-tools.ts +486 -0
- package/src/semantic-tokens.ts +9 -0
- package/src/server.ts +517 -71
- package/src/symbols.ts +109 -0
- package/src/validation.ts +348 -0
package/src/server.ts
CHANGED
|
@@ -5,26 +5,65 @@ 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,
|
|
22
|
+
getHoverAtPosition,
|
|
24
23
|
getCompletionsAtPosition,
|
|
25
24
|
getCasesForFile,
|
|
26
|
-
|
|
25
|
+
buildSemanticTokens,
|
|
26
|
+
isNudoTargetPath,
|
|
27
|
+
collectAbsInlays,
|
|
27
28
|
} from "@nudojs/service";
|
|
29
|
+
import { parse } from "@nudojs/parser";
|
|
30
|
+
import { buildSymbolTable, findDefinition, findReferences, findIdentifierAtPosition } from "./symbols.ts";
|
|
31
|
+
import { TOKEN_TYPES, TOKEN_MODIFIERS } from "./semantic-tokens.ts";
|
|
32
|
+
import {
|
|
33
|
+
analysisCache,
|
|
34
|
+
evictModuleGraphCacheEntries,
|
|
35
|
+
forgetValidatedFile,
|
|
36
|
+
getCachedOrAnalyze,
|
|
37
|
+
handleNudoDepFileChanged,
|
|
38
|
+
hasNudoDirectives,
|
|
39
|
+
lspLoadModule,
|
|
40
|
+
toLspDiagnostic,
|
|
41
|
+
uriToFilePath,
|
|
42
|
+
validateText,
|
|
43
|
+
type ValidateTextDeps,
|
|
44
|
+
} from "./validation.ts";
|
|
45
|
+
import {
|
|
46
|
+
normalizeFilePath,
|
|
47
|
+
suggestCase,
|
|
48
|
+
trace,
|
|
49
|
+
whatIf,
|
|
50
|
+
checkTool,
|
|
51
|
+
hoverTool,
|
|
52
|
+
inferTool,
|
|
53
|
+
type AgentToolDeps,
|
|
54
|
+
type AgentToolResult,
|
|
55
|
+
} from "./agent-tools.ts";
|
|
56
|
+
|
|
57
|
+
const NUDO_COMMANDS = [
|
|
58
|
+
"nudo.whatIf",
|
|
59
|
+
"nudo.suggestCase",
|
|
60
|
+
"nudo.trace",
|
|
61
|
+
"nudo.check",
|
|
62
|
+
"nudo.hover",
|
|
63
|
+
"nudo.infer",
|
|
64
|
+
"nudo.selectCase",
|
|
65
|
+
"nudo.getActiveCases",
|
|
66
|
+
] as const;
|
|
28
67
|
|
|
29
68
|
const connection = createConnection(ProposedFeatures.all);
|
|
30
69
|
const documents = new TextDocuments(TextDocument);
|
|
@@ -39,12 +78,6 @@ function getActiveCasesForUri(uri: string): Map<string, number> {
|
|
|
39
78
|
return map;
|
|
40
79
|
}
|
|
41
80
|
|
|
42
|
-
const severityMap: Record<JsDiagSeverity, DiagnosticSeverity> = {
|
|
43
|
-
error: DiagnosticSeverity.Error,
|
|
44
|
-
warning: DiagnosticSeverity.Warning,
|
|
45
|
-
info: DiagnosticSeverity.Information,
|
|
46
|
-
};
|
|
47
|
-
|
|
48
81
|
connection.onInitialize((_params: InitializeParams): InitializeResult => ({
|
|
49
82
|
capabilities: {
|
|
50
83
|
textDocumentSync: TextDocumentSyncKind.Full,
|
|
@@ -57,11 +90,42 @@ connection.onInitialize((_params: InitializeParams): InitializeResult => ({
|
|
|
57
90
|
resolveProvider: false,
|
|
58
91
|
},
|
|
59
92
|
inlayHintProvider: true,
|
|
93
|
+
definitionProvider: true,
|
|
94
|
+
referencesProvider: true,
|
|
95
|
+
renameProvider: true,
|
|
96
|
+
codeActionProvider: {
|
|
97
|
+
codeActionKinds: ["quickfix"],
|
|
98
|
+
},
|
|
99
|
+
signatureHelpProvider: {
|
|
100
|
+
triggerCharacters: ["(", ","],
|
|
101
|
+
},
|
|
102
|
+
semanticTokensProvider: {
|
|
103
|
+
full: true,
|
|
104
|
+
legend: {
|
|
105
|
+
tokenTypes: [...TOKEN_TYPES],
|
|
106
|
+
tokenModifiers: [...TOKEN_MODIFIERS],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
executeCommandProvider: {
|
|
110
|
+
commands: [...NUDO_COMMANDS],
|
|
111
|
+
},
|
|
112
|
+
diagnosticProvider: {
|
|
113
|
+
interFileDependencies: false,
|
|
114
|
+
workspaceDiagnostics: false,
|
|
115
|
+
},
|
|
60
116
|
},
|
|
61
117
|
}));
|
|
62
118
|
|
|
63
119
|
let debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
64
120
|
|
|
121
|
+
// 打开即验证:didOpen 不会触发 onDidChangeContent,若不在此主动验证,
|
|
122
|
+
// 新打开的文件要等到首次编辑(300ms 防抖后)或客户端 pull 诊断才有结果。
|
|
123
|
+
// propagate=true 与编辑防抖路径同语义(含对打开依赖项的一次脏传播)。
|
|
124
|
+
documents.onDidOpen((event) => {
|
|
125
|
+
nudoFileCache.delete(event.document.uri);
|
|
126
|
+
validateDocument(event.document, true).catch(() => {});
|
|
127
|
+
});
|
|
128
|
+
|
|
65
129
|
documents.onDidChangeContent((change) => {
|
|
66
130
|
const uri = change.document.uri;
|
|
67
131
|
nudoFileCache.delete(uri);
|
|
@@ -72,7 +136,7 @@ documents.onDidChangeContent((change) => {
|
|
|
72
136
|
uri,
|
|
73
137
|
setTimeout(() => {
|
|
74
138
|
debounceTimers.delete(uri);
|
|
75
|
-
validateDocument(change.document);
|
|
139
|
+
validateDocument(change.document, true).catch(() => {});
|
|
76
140
|
}, 300),
|
|
77
141
|
);
|
|
78
142
|
});
|
|
@@ -82,51 +146,101 @@ documents.onDidClose((event) => {
|
|
|
82
146
|
if (timer) clearTimeout(timer);
|
|
83
147
|
debounceTimers.delete(event.document.uri);
|
|
84
148
|
nudoFileCache.delete(event.document.uri);
|
|
149
|
+
analysisCache.delete(uriToFilePath(event.document.uri));
|
|
85
150
|
connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] });
|
|
86
151
|
});
|
|
87
152
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
153
|
+
/**
|
|
154
|
+
* watched-files 删除事件监听器:接收「被删除且不在打开集」的 uri 列表。
|
|
155
|
+
* 缓存逐出等后续逻辑通过 registerWatchedFilesListener 挂到这里。
|
|
156
|
+
*/
|
|
157
|
+
export const watchedFilesListeners: Array<(uris: string[]) => void> = [];
|
|
158
|
+
|
|
159
|
+
/** 注册 watched-files 监听器,返回注销函数。 */
|
|
160
|
+
export function registerWatchedFilesListener(listener: (uris: string[]) => void): () => void {
|
|
161
|
+
watchedFilesListeners.push(listener);
|
|
162
|
+
return () => {
|
|
163
|
+
const idx = watchedFilesListeners.indexOf(listener);
|
|
164
|
+
if (idx >= 0) watchedFilesListeners.splice(idx, 1);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
94
167
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
168
|
+
/**
|
|
169
|
+
* watched-files 事件核心:对 Deleted 且不在打开集的文件清理会话登记项
|
|
170
|
+
* (knownFiles/analysisCache 走 forgetValidatedFile,activeCases/nudoFileCache 按 uri),
|
|
171
|
+
* 清空其已发布诊断,并把被清理的 uri 列表广播给监听器。
|
|
172
|
+
* 打开中的文件跳过——其内容由编辑流负责,外部删除会被编辑器以 didOpen/didChange 覆盖。
|
|
173
|
+
*/
|
|
174
|
+
function isNudoDepPath(filePath: string): boolean {
|
|
175
|
+
return /\.nudo\.(js|mjs|ts)$/.test(filePath);
|
|
176
|
+
}
|
|
98
177
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
};
|
|
111
|
-
if (
|
|
112
|
-
|
|
178
|
+
function handleWatchedFilesChanges(changes: readonly FileEvent[], isOpen: (uri: string) => boolean): string[] {
|
|
179
|
+
const gone: string[] = [];
|
|
180
|
+
const nudoTouched: string[] = [];
|
|
181
|
+
for (const change of changes) {
|
|
182
|
+
const filePath = uriToFilePath(change.uri);
|
|
183
|
+
if (change.type === FileChangeType.Deleted) {
|
|
184
|
+
if (isOpen(change.uri)) continue;
|
|
185
|
+
gone.push(change.uri);
|
|
186
|
+
forgetValidatedFile(filePath);
|
|
187
|
+
activeCases.delete(change.uri);
|
|
188
|
+
nudoFileCache.delete(change.uri);
|
|
189
|
+
connection.sendDiagnostics({ uri: change.uri, diagnostics: [] });
|
|
190
|
+
if (isNudoDepPath(filePath)) nudoTouched.push(filePath);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Create/Change:契约模板变更 → 定向逐出 L0 + 重检打开中的父文件
|
|
194
|
+
if (isNudoDepPath(filePath)) {
|
|
195
|
+
nudoTouched.push(filePath);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (gone.length > 0) {
|
|
199
|
+
// 拷贝后再遍历:监听器内注销自身不应影响本轮广播
|
|
200
|
+
for (const listener of [...watchedFilesListeners]) {
|
|
201
|
+
try {
|
|
202
|
+
listener(gone);
|
|
203
|
+
} catch {
|
|
204
|
+
// 单个监听器异常不阻断其余监听器的缓存逐出
|
|
113
205
|
}
|
|
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
|
-
});
|
|
206
|
+
}
|
|
129
207
|
}
|
|
208
|
+
if (nudoTouched.length > 0) {
|
|
209
|
+
const deps = validationDeps();
|
|
210
|
+
for (const p of nudoTouched) {
|
|
211
|
+
void handleNudoDepFileChanged(p, deps).catch(() => {});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return gone;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
connection.onDidChangeWatchedFiles((event) =>
|
|
218
|
+
handleWatchedFilesChanges(event.changes, (uri) => documents.get(uri) !== undefined),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// 模块图边缓存逐出:收到被清理 uri 时逐出 moduleGraphCache 对应 filePath 的条目。
|
|
222
|
+
// 会话级常驻注册,无需持有注销函数。
|
|
223
|
+
registerWatchedFilesListener(evictModuleGraphCacheEntries);
|
|
224
|
+
|
|
225
|
+
function validationDeps(): ValidateTextDeps {
|
|
226
|
+
return {
|
|
227
|
+
sendDiagnostics: (params) => connection.sendDiagnostics(params),
|
|
228
|
+
isNudoUri: (uri) => isNudoFile(uri),
|
|
229
|
+
getActiveCases: (uri) => getActiveCasesForUri(uri),
|
|
230
|
+
getOpenDocumentByPath: (filePath) =>
|
|
231
|
+
documents.all().find((doc) => uriToFilePath(doc.uri) === filePath),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function validateDocument(document: TextDocument, propagate = false): Promise<void> {
|
|
236
|
+
return validateText(
|
|
237
|
+
uriToFilePath(document.uri),
|
|
238
|
+
document.uri,
|
|
239
|
+
document.getText(),
|
|
240
|
+
document.version,
|
|
241
|
+
validationDeps(),
|
|
242
|
+
propagate,
|
|
243
|
+
);
|
|
130
244
|
}
|
|
131
245
|
|
|
132
246
|
connection.onHover((params) => {
|
|
@@ -141,13 +255,30 @@ connection.onHover((params) => {
|
|
|
141
255
|
const cases = getActiveCasesForUri(params.textDocument.uri);
|
|
142
256
|
|
|
143
257
|
try {
|
|
144
|
-
const
|
|
145
|
-
if (!
|
|
146
|
-
|
|
258
|
+
const hover = getHoverAtPosition(filePath, source, line, column, cases);
|
|
259
|
+
if (!hover) return null;
|
|
260
|
+
|
|
261
|
+
const lines: string[] = [];
|
|
262
|
+
// 无损 Abs 优先(类型即计算本体)
|
|
263
|
+
if (hover.absMultiline) {
|
|
264
|
+
lines.push("```nudo", hover.absMultiline, "```");
|
|
265
|
+
} else if (hover.abs) {
|
|
266
|
+
lines.push("```nudo", hover.abs, "```");
|
|
267
|
+
}
|
|
268
|
+
if (hover.intension && hover.intension !== hover.abs) {
|
|
269
|
+
lines.push("```nudo", hover.intension, "```");
|
|
270
|
+
}
|
|
271
|
+
// 外延 TypeValue 仅作对照,且与内涵不同时才显示
|
|
272
|
+
if (hover.typeText && hover.typeText !== hover.intension && hover.typeText !== hover.abs) {
|
|
273
|
+
lines.push("```nudo", `ext: ${hover.typeText}`, "```");
|
|
274
|
+
}
|
|
275
|
+
if (lines.length === 0) {
|
|
276
|
+
lines.push("```nudo", hover.typeText, "```");
|
|
277
|
+
}
|
|
147
278
|
return {
|
|
148
279
|
contents: {
|
|
149
280
|
kind: MarkupKind.Markdown,
|
|
150
|
-
value:
|
|
281
|
+
value: lines.join("\n"),
|
|
151
282
|
},
|
|
152
283
|
};
|
|
153
284
|
} catch {
|
|
@@ -232,7 +363,7 @@ connection.languages.inlayHint.on((params) => {
|
|
|
232
363
|
const lines = source.split("\n");
|
|
233
364
|
|
|
234
365
|
try {
|
|
235
|
-
const result =
|
|
366
|
+
const result = getCachedOrAnalyze(filePath, source, document.version, cases);
|
|
236
367
|
const hints: InlayHint[] = [];
|
|
237
368
|
|
|
238
369
|
for (const hint of result.caseHints) {
|
|
@@ -248,39 +379,362 @@ connection.languages.inlayHint.on((params) => {
|
|
|
248
379
|
});
|
|
249
380
|
}
|
|
250
381
|
|
|
382
|
+
// Abs inlay:参数约束 + 返回 term/pred(类型即计算,无损)
|
|
383
|
+
try {
|
|
384
|
+
for (const abs of collectAbsInlays(source, {
|
|
385
|
+
loadModule: lspLoadModule,
|
|
386
|
+
fromFile: filePath,
|
|
387
|
+
})) {
|
|
388
|
+
const lineIdx = abs.line - 1;
|
|
389
|
+
if (lineIdx < 0 || lineIdx >= lines.length) continue;
|
|
390
|
+
hints.push({
|
|
391
|
+
position: { line: lineIdx, character: abs.character },
|
|
392
|
+
label: abs.label,
|
|
393
|
+
kind:
|
|
394
|
+
abs.kind === "parameter"
|
|
395
|
+
? InlayHintKind.Parameter
|
|
396
|
+
: InlayHintKind.Type,
|
|
397
|
+
paddingLeft: true,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
// Abs inlay 失败不影响 caseHints
|
|
402
|
+
}
|
|
403
|
+
|
|
251
404
|
return hints;
|
|
252
405
|
} catch {
|
|
253
406
|
return [];
|
|
254
407
|
}
|
|
255
408
|
});
|
|
256
409
|
|
|
257
|
-
connection.
|
|
258
|
-
const
|
|
410
|
+
connection.onDefinition((params) => {
|
|
411
|
+
const document = documents.get(params.textDocument.uri);
|
|
412
|
+
if (!document) return null;
|
|
413
|
+
if (!isNudoFile(params.textDocument.uri)) return null;
|
|
414
|
+
|
|
415
|
+
const source = document.getText();
|
|
416
|
+
const ast = parse(source);
|
|
417
|
+
const table = buildSymbolTable(ast, params.textDocument.uri);
|
|
418
|
+
|
|
419
|
+
const line = params.position.line + 1;
|
|
420
|
+
const column = params.position.character;
|
|
421
|
+
const identAtPos = findIdentifierAtPosition(ast, line, column);
|
|
422
|
+
if (!identAtPos) return null;
|
|
423
|
+
|
|
424
|
+
const def = findDefinition(table, identAtPos);
|
|
425
|
+
if (!def) return null;
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
uri: params.textDocument.uri,
|
|
429
|
+
range: {
|
|
430
|
+
start: { line: def.loc.start.line - 1, character: def.loc.start.column },
|
|
431
|
+
end: { line: def.loc.end.line - 1, character: def.loc.end.column },
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
connection.onReferences((params) => {
|
|
437
|
+
const document = documents.get(params.textDocument.uri);
|
|
438
|
+
if (!document) return [];
|
|
439
|
+
if (!isNudoFile(params.textDocument.uri)) return [];
|
|
440
|
+
|
|
441
|
+
const source = document.getText();
|
|
442
|
+
const ast = parse(source);
|
|
443
|
+
const table = buildSymbolTable(ast, params.textDocument.uri);
|
|
444
|
+
|
|
445
|
+
const line = params.position.line + 1;
|
|
446
|
+
const column = params.position.character;
|
|
447
|
+
const identAtPos = findIdentifierAtPosition(ast, line, column);
|
|
448
|
+
if (!identAtPos) return [];
|
|
449
|
+
|
|
450
|
+
const refs = findReferences(table, identAtPos);
|
|
451
|
+
return refs.map((ref) => ({
|
|
452
|
+
uri: params.textDocument.uri,
|
|
453
|
+
range: {
|
|
454
|
+
start: { line: ref.loc.start.line - 1, character: ref.loc.start.column },
|
|
455
|
+
end: { line: ref.loc.end.line - 1, character: ref.loc.end.column },
|
|
456
|
+
},
|
|
457
|
+
}));
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
connection.onRenameRequest((params) => {
|
|
461
|
+
const document = documents.get(params.textDocument.uri);
|
|
462
|
+
if (!document) return null;
|
|
463
|
+
if (!isNudoFile(params.textDocument.uri)) return null;
|
|
464
|
+
|
|
465
|
+
const source = document.getText();
|
|
466
|
+
const ast = parse(source);
|
|
467
|
+
const table = buildSymbolTable(ast, params.textDocument.uri);
|
|
468
|
+
|
|
469
|
+
const line = params.position.line + 1;
|
|
470
|
+
const column = params.position.character;
|
|
471
|
+
const identAtPos = findIdentifierAtPosition(ast, line, column);
|
|
472
|
+
if (!identAtPos) return null;
|
|
473
|
+
|
|
474
|
+
const def = findDefinition(table, identAtPos);
|
|
475
|
+
const refs = findReferences(table, identAtPos);
|
|
476
|
+
|
|
477
|
+
const edits = [];
|
|
478
|
+
|
|
479
|
+
if (def) {
|
|
480
|
+
edits.push({
|
|
481
|
+
range: {
|
|
482
|
+
start: { line: def.loc.start.line - 1, character: def.loc.start.column },
|
|
483
|
+
end: { line: def.loc.end.line - 1, character: def.loc.end.column },
|
|
484
|
+
},
|
|
485
|
+
newText: params.newName,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
for (const ref of refs) {
|
|
490
|
+
edits.push({
|
|
491
|
+
range: {
|
|
492
|
+
start: { line: ref.loc.start.line - 1, character: ref.loc.start.column },
|
|
493
|
+
end: { line: ref.loc.end.line - 1, character: ref.loc.end.column },
|
|
494
|
+
},
|
|
495
|
+
newText: params.newName,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
changes: {
|
|
501
|
+
[params.textDocument.uri]: edits,
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
connection.onCodeAction((params) => {
|
|
507
|
+
const document = documents.get(params.textDocument.uri);
|
|
508
|
+
if (!document) return [];
|
|
509
|
+
if (!isNudoFile(params.textDocument.uri)) return [];
|
|
510
|
+
|
|
511
|
+
const actions = [];
|
|
512
|
+
|
|
513
|
+
for (const diag of params.context.diagnostics) {
|
|
514
|
+
if (diag.code === "nudo-unreachable") {
|
|
515
|
+
actions.push({
|
|
516
|
+
title: "Remove unreachable code",
|
|
517
|
+
kind: "quickfix",
|
|
518
|
+
diagnostics: [diag],
|
|
519
|
+
edit: {
|
|
520
|
+
changes: {
|
|
521
|
+
[params.textDocument.uri]: [{
|
|
522
|
+
range: diag.range,
|
|
523
|
+
newText: "",
|
|
524
|
+
}],
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return actions;
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
connection.onSignatureHelp((params) => {
|
|
535
|
+
const document = documents.get(params.textDocument.uri);
|
|
536
|
+
if (!document) return null;
|
|
537
|
+
if (!isNudoFile(params.textDocument.uri)) return null;
|
|
538
|
+
|
|
539
|
+
const filePath = uriToFilePath(params.textDocument.uri);
|
|
540
|
+
const source = document.getText();
|
|
541
|
+
const line = params.position.line + 1;
|
|
542
|
+
const column = params.position.character;
|
|
543
|
+
const cases = getActiveCasesForUri(params.textDocument.uri);
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const ast = parse(source);
|
|
547
|
+
const callInfo = findEnclosingCall(ast, line, column);
|
|
548
|
+
if (!callInfo) return null;
|
|
549
|
+
|
|
550
|
+
const fnType = getTypeAtPosition(filePath, source, callInfo.calleeLine, callInfo.calleeCol, cases);
|
|
551
|
+
if (!fnType || fnType.kind !== "function") return null;
|
|
552
|
+
|
|
553
|
+
const paramLabels = fnType.params.map((p) => `${p}: unknown`);
|
|
554
|
+
const activeParam = callInfo.currentParamIndex;
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
signatures: [{
|
|
558
|
+
label: `(${paramLabels.join(", ")}) => unknown`,
|
|
559
|
+
parameters: paramLabels.map((label) => ({ label })),
|
|
560
|
+
activeParameter: activeParam,
|
|
561
|
+
}],
|
|
562
|
+
activeSignature: 0,
|
|
563
|
+
activeParameter: activeParam,
|
|
564
|
+
};
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
function findEnclosingCall(ast: any, line: number, column: number): { calleeLine: number; calleeCol: number; currentParamIndex: number } | null {
|
|
571
|
+
let result: any = null;
|
|
572
|
+
|
|
573
|
+
function visit(node: any): void {
|
|
574
|
+
if (!node || result) return;
|
|
575
|
+
|
|
576
|
+
if (node.type === "CallExpression") {
|
|
577
|
+
const loc = node.loc;
|
|
578
|
+
if (loc && loc.start.line <= line && loc.end.line >= line) {
|
|
579
|
+
const calleeLoc = node.callee.loc;
|
|
580
|
+
if (calleeLoc) {
|
|
581
|
+
let paramIndex = 0;
|
|
582
|
+
for (let i = 0; i < node.arguments.length; i++) {
|
|
583
|
+
const argLoc = node.arguments[i].loc;
|
|
584
|
+
if (argLoc) {
|
|
585
|
+
if (argLoc.start.line < line || (argLoc.start.line === line && argLoc.start.column <= column)) {
|
|
586
|
+
paramIndex = i + 1;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
result = {
|
|
591
|
+
calleeLine: calleeLoc.start.line,
|
|
592
|
+
calleeCol: calleeLoc.start.column,
|
|
593
|
+
currentParamIndex: Math.min(paramIndex, node.arguments.length),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
for (const key of Object.keys(node)) {
|
|
600
|
+
if (key === "type" || key === "loc" || key === "start" || key === "end") continue;
|
|
601
|
+
const child = node[key];
|
|
602
|
+
if (Array.isArray(child)) {
|
|
603
|
+
for (const item of child) {
|
|
604
|
+
if (item && typeof item === "object" && item.type) visit(item);
|
|
605
|
+
}
|
|
606
|
+
} else if (child && typeof child === "object" && child.type) {
|
|
607
|
+
visit(child);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
visit(ast);
|
|
613
|
+
return result;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
connection.languages.semanticTokens.on((params) => {
|
|
617
|
+
const document = documents.get(params.textDocument.uri);
|
|
618
|
+
if (!document) return { data: [] };
|
|
619
|
+
if (!isNudoFile(params.textDocument.uri)) return { data: [] };
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
const filePath = uriToFilePath(document.uri);
|
|
623
|
+
return { data: buildSemanticTokens(filePath, document.getText()) };
|
|
624
|
+
} catch {
|
|
625
|
+
return { data: [] };
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
/** Resolve a `uri`- or `file`-identified target to the uri key used by activeCases/documents. */
|
|
630
|
+
function uriForFileOrUri(params: { uri?: string; file?: string }): string {
|
|
631
|
+
if (params.uri) return params.uri;
|
|
632
|
+
const filePath = normalizeFilePath(params.file ?? "");
|
|
633
|
+
const doc = documents.all().find((d) => uriToFilePath(d.uri) === filePath);
|
|
634
|
+
return doc ? doc.uri : `file://${filePath}`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function handleSelectCase(params: { uri?: string; file?: string; functionName: string; caseIndex: number }) {
|
|
638
|
+
const uri = uriForFileOrUri(params);
|
|
639
|
+
const cases = getActiveCasesForUri(uri);
|
|
259
640
|
cases.set(params.functionName, params.caseIndex);
|
|
260
641
|
|
|
261
|
-
const document = documents.get(
|
|
642
|
+
const document = documents.get(uri);
|
|
262
643
|
if (document) {
|
|
263
|
-
validateDocument(document);
|
|
644
|
+
await validateDocument(document);
|
|
264
645
|
}
|
|
265
646
|
|
|
266
647
|
connection.sendRequest(CodeLensRefreshRequest.type).catch(() => {});
|
|
267
648
|
|
|
268
649
|
return { success: true };
|
|
269
|
-
}
|
|
650
|
+
}
|
|
270
651
|
|
|
271
|
-
|
|
272
|
-
const cases = getActiveCasesForUri(params
|
|
652
|
+
function handleGetActiveCases(params: { uri?: string; file?: string }) {
|
|
653
|
+
const cases = getActiveCasesForUri(uriForFileOrUri(params));
|
|
273
654
|
const result: Record<string, number> = {};
|
|
274
655
|
for (const [fn, idx] of cases) {
|
|
275
656
|
result[fn] = idx;
|
|
276
657
|
}
|
|
277
658
|
return result;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const agentToolDeps: AgentToolDeps = {
|
|
662
|
+
getOpenText: (filePath) => {
|
|
663
|
+
const doc = documents.all().find((d) => uriToFilePath(d.uri) === filePath);
|
|
664
|
+
return doc ? { text: doc.getText() } : undefined;
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* One dispatch table shared by the executeCommand commands (`nudo.*`) and the
|
|
670
|
+
* custom request aliases (`nudo/…`) — both channels run the same handlers.
|
|
671
|
+
*/
|
|
672
|
+
function dispatchNudoCommand(command: string, arg: Record<string, unknown>) {
|
|
673
|
+
switch (command) {
|
|
674
|
+
case "nudo.whatIf":
|
|
675
|
+
return whatIf(arg as Parameters<typeof whatIf>[0], agentToolDeps);
|
|
676
|
+
case "nudo.suggestCase":
|
|
677
|
+
return suggestCase(arg as Parameters<typeof suggestCase>[0], agentToolDeps);
|
|
678
|
+
case "nudo.trace":
|
|
679
|
+
return trace(arg as Parameters<typeof trace>[0], agentToolDeps);
|
|
680
|
+
case "nudo.check":
|
|
681
|
+
return checkTool(arg as Parameters<typeof checkTool>[0], agentToolDeps);
|
|
682
|
+
case "nudo.hover":
|
|
683
|
+
return hoverTool(arg as Parameters<typeof hoverTool>[0], agentToolDeps);
|
|
684
|
+
case "nudo.infer":
|
|
685
|
+
return inferTool(arg as Parameters<typeof inferTool>[0], agentToolDeps);
|
|
686
|
+
case "nudo.selectCase":
|
|
687
|
+
return handleSelectCase(arg as Parameters<typeof handleSelectCase>[0]);
|
|
688
|
+
case "nudo.getActiveCases":
|
|
689
|
+
return handleGetActiveCases(arg as Parameters<typeof handleGetActiveCases>[0]);
|
|
690
|
+
default:
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
connection.onExecuteCommand((params) =>
|
|
696
|
+
dispatchNudoCommand(params.command, (params.arguments?.[0] as Record<string, unknown>) ?? {}),
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
connection.onRequest("nudo/selectCase", handleSelectCase);
|
|
700
|
+
|
|
701
|
+
connection.onRequest("nudo/getActiveCases", handleGetActiveCases);
|
|
702
|
+
|
|
703
|
+
/** Request aliases share the command handlers; the pinned return type keeps onRequest overload inference happy. */
|
|
704
|
+
function dispatchAgentRequest(command: string, params: Record<string, unknown>): AgentToolResult {
|
|
705
|
+
return dispatchNudoCommand(command, params) as AgentToolResult;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
connection.onRequest("nudo/whatIf", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.whatIf", params));
|
|
709
|
+
connection.onRequest("nudo/suggestCase", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.suggestCase", params));
|
|
710
|
+
connection.onRequest("nudo/trace", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.trace", params));
|
|
711
|
+
connection.onRequest("nudo/check", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.check", params));
|
|
712
|
+
connection.onRequest("nudo/hover", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.hover", params));
|
|
713
|
+
connection.onRequest("nudo/infer", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.infer", params));
|
|
714
|
+
|
|
715
|
+
connection.languages.diagnostics.on((params) => {
|
|
716
|
+
const document = documents.get(params.textDocument.uri);
|
|
717
|
+
if (!document) return { kind: "full", items: [] };
|
|
718
|
+
if (!isNudoFile(params.textDocument.uri)) return { kind: "full", items: [] };
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
const filePath = uriToFilePath(document.uri);
|
|
722
|
+
const result = getCachedOrAnalyze(filePath, document.getText(), document.version, getActiveCasesForUri(document.uri));
|
|
723
|
+
return {
|
|
724
|
+
kind: "full",
|
|
725
|
+
items: result.diagnostics.map((d) => toLspDiagnostic(d, document.uri)),
|
|
726
|
+
};
|
|
727
|
+
} catch {
|
|
728
|
+
return { kind: "full", items: [] };
|
|
729
|
+
}
|
|
278
730
|
});
|
|
279
731
|
|
|
280
732
|
const nudoFileCache = new Map<string, boolean>();
|
|
281
733
|
|
|
282
734
|
function isNudoFile(uri: string): boolean {
|
|
283
|
-
|
|
735
|
+
// 推断目标判定收敛到 service 层(TaskA 提供):.js/.mjs/.ts 为目标,
|
|
736
|
+
// .d.ts(类型声明,harvester 输入)与 .tsx/.jsx 等一律排除
|
|
737
|
+
if (!isNudoTargetPath(uriToFilePath(uri))) return false;
|
|
284
738
|
const cached = nudoFileCache.get(uri);
|
|
285
739
|
if (cached !== undefined) return cached;
|
|
286
740
|
const doc = documents.get(uri);
|
|
@@ -290,13 +744,5 @@ function isNudoFile(uri: string): boolean {
|
|
|
290
744
|
return result;
|
|
291
745
|
}
|
|
292
746
|
|
|
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
747
|
documents.listen(connection);
|
|
302
748
|
connection.listen();
|