@nudojs/lsp 0.4.0 → 0.4.1

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/src/server.ts DELETED
@@ -1,748 +0,0 @@
1
- import {
2
- createConnection,
3
- TextDocuments,
4
- ProposedFeatures,
5
- TextDocumentSyncKind,
6
- type InitializeParams,
7
- type InitializeResult,
8
- type CompletionItem as LspCompletionItem,
9
- CompletionItemKind,
10
- MarkupKind,
11
- type CodeLens,
12
- CodeLensRefreshRequest,
13
- FileChangeType,
14
- type FileEvent,
15
- type InlayHint,
16
- InlayHintKind,
17
- } from "vscode-languageserver/node";
18
- import { TextDocument } from "vscode-languageserver-textdocument";
19
- import { typeValueToString } from "@nudojs/core";
20
- import {
21
- getTypeAtPosition,
22
- getHoverAtPosition,
23
- getCompletionsAtPosition,
24
- getCasesForFile,
25
- buildSemanticTokens,
26
- isNudoTargetPath,
27
- collectAbsInlays,
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;
67
-
68
- const connection = createConnection(ProposedFeatures.all);
69
- const documents = new TextDocuments(TextDocument);
70
-
71
- const activeCases = new Map<string, Map<string, number>>();
72
-
73
- function getActiveCasesForUri(uri: string): Map<string, number> {
74
- const existing = activeCases.get(uri);
75
- if (existing) return existing;
76
- const map = new Map<string, number>();
77
- activeCases.set(uri, map);
78
- return map;
79
- }
80
-
81
- connection.onInitialize((_params: InitializeParams): InitializeResult => ({
82
- capabilities: {
83
- textDocumentSync: TextDocumentSyncKind.Full,
84
- hoverProvider: true,
85
- completionProvider: {
86
- triggerCharacters: ["."],
87
- resolveProvider: false,
88
- },
89
- codeLensProvider: {
90
- resolveProvider: false,
91
- },
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
- },
116
- },
117
- }));
118
-
119
- let debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
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
-
129
- documents.onDidChangeContent((change) => {
130
- const uri = change.document.uri;
131
- nudoFileCache.delete(uri);
132
- const existing = debounceTimers.get(uri);
133
- if (existing) clearTimeout(existing);
134
-
135
- debounceTimers.set(
136
- uri,
137
- setTimeout(() => {
138
- debounceTimers.delete(uri);
139
- validateDocument(change.document, true).catch(() => {});
140
- }, 300),
141
- );
142
- });
143
-
144
- documents.onDidClose((event) => {
145
- const timer = debounceTimers.get(event.document.uri);
146
- if (timer) clearTimeout(timer);
147
- debounceTimers.delete(event.document.uri);
148
- nudoFileCache.delete(event.document.uri);
149
- analysisCache.delete(uriToFilePath(event.document.uri));
150
- connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] });
151
- });
152
-
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
- }
167
-
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
- }
177
-
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
- // 单个监听器异常不阻断其余监听器的缓存逐出
205
- }
206
- }
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
- );
244
- }
245
-
246
- connection.onHover((params) => {
247
- const document = documents.get(params.textDocument.uri);
248
- if (!document) return null;
249
- if (!isNudoFile(params.textDocument.uri)) return null;
250
-
251
- const filePath = uriToFilePath(params.textDocument.uri);
252
- const source = document.getText();
253
- const line = params.position.line + 1;
254
- const column = params.position.character;
255
- const cases = getActiveCasesForUri(params.textDocument.uri);
256
-
257
- try {
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
- }
278
- return {
279
- contents: {
280
- kind: MarkupKind.Markdown,
281
- value: lines.join("\n"),
282
- },
283
- };
284
- } catch {
285
- return null;
286
- }
287
- });
288
-
289
- connection.onCompletion((params) => {
290
- const document = documents.get(params.textDocument.uri);
291
- if (!document) return [];
292
- if (!isNudoFile(params.textDocument.uri)) return [];
293
-
294
- const filePath = uriToFilePath(params.textDocument.uri);
295
- const source = document.getText();
296
- const line = params.position.line + 1;
297
- const column = params.position.character;
298
-
299
- try {
300
- const items = getCompletionsAtPosition(filePath, source, line, column);
301
- return items.map((item): LspCompletionItem => ({
302
- label: item.label,
303
- kind: item.kind === "method"
304
- ? CompletionItemKind.Method
305
- : item.kind === "property"
306
- ? CompletionItemKind.Property
307
- : CompletionItemKind.Variable,
308
- detail: item.detail,
309
- }));
310
- } catch {
311
- return [];
312
- }
313
- });
314
-
315
- connection.onCodeLens((params) => {
316
- const document = documents.get(params.textDocument.uri);
317
- if (!document) return [];
318
- if (!isNudoFile(params.textDocument.uri)) return [];
319
-
320
- const filePath = uriToFilePath(params.textDocument.uri);
321
- const source = document.getText();
322
- const cases = getActiveCasesForUri(params.textDocument.uri);
323
-
324
- try {
325
- const fnCases = getCasesForFile(filePath, source);
326
- const lenses: CodeLens[] = [];
327
-
328
- for (const fn of fnCases) {
329
- if (fn.cases.length === 0) continue;
330
- const activeIdx = cases.get(fn.functionName) ?? 0;
331
-
332
- for (const c of fn.cases) {
333
- const isActive = c.index === activeIdx;
334
- const title = isActive ? `● case "${c.name}"` : `○ case "${c.name}"`;
335
- lenses.push({
336
- range: {
337
- start: { line: fn.loc.start.line - 1, character: 0 },
338
- end: { line: fn.loc.start.line - 1, character: 0 },
339
- },
340
- command: {
341
- title,
342
- command: "nudo.selectCase",
343
- arguments: [params.textDocument.uri, fn.functionName, c.index, c.name],
344
- },
345
- });
346
- }
347
- }
348
-
349
- return lenses;
350
- } catch {
351
- return [];
352
- }
353
- });
354
-
355
- connection.languages.inlayHint.on((params) => {
356
- const document = documents.get(params.textDocument.uri);
357
- if (!document) return [];
358
- if (!isNudoFile(params.textDocument.uri)) return [];
359
-
360
- const filePath = uriToFilePath(params.textDocument.uri);
361
- const source = document.getText();
362
- const cases = getActiveCasesForUri(params.textDocument.uri);
363
- const lines = source.split("\n");
364
-
365
- try {
366
- const result = getCachedOrAnalyze(filePath, source, document.version, cases);
367
- const hints: InlayHint[] = [];
368
-
369
- for (const hint of result.caseHints) {
370
- const lineIdx = hint.line - 1;
371
- if (lineIdx < 0 || lineIdx >= lines.length) continue;
372
- const lineLen = lines[lineIdx].length;
373
-
374
- hints.push({
375
- position: { line: lineIdx, character: lineLen },
376
- label: ` ${hint.label}`,
377
- kind: InlayHintKind.Type,
378
- paddingLeft: true,
379
- });
380
- }
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
-
404
- return hints;
405
- } catch {
406
- return [];
407
- }
408
- });
409
-
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);
640
- cases.set(params.functionName, params.caseIndex);
641
-
642
- const document = documents.get(uri);
643
- if (document) {
644
- await validateDocument(document);
645
- }
646
-
647
- connection.sendRequest(CodeLensRefreshRequest.type).catch(() => {});
648
-
649
- return { success: true };
650
- }
651
-
652
- function handleGetActiveCases(params: { uri?: string; file?: string }) {
653
- const cases = getActiveCasesForUri(uriForFileOrUri(params));
654
- const result: Record<string, number> = {};
655
- for (const [fn, idx] of cases) {
656
- result[fn] = idx;
657
- }
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
- }
730
- });
731
-
732
- const nudoFileCache = new Map<string, boolean>();
733
-
734
- function isNudoFile(uri: string): boolean {
735
- // 推断目标判定收敛到 service 层(TaskA 提供):.js/.mjs/.ts 为目标,
736
- // .d.ts(类型声明,harvester 输入)与 .tsx/.jsx 等一律排除
737
- if (!isNudoTargetPath(uriToFilePath(uri))) return false;
738
- const cached = nudoFileCache.get(uri);
739
- if (cached !== undefined) return cached;
740
- const doc = documents.get(uri);
741
- if (!doc) return false;
742
- const result = hasNudoDirectives(doc.getText());
743
- nudoFileCache.set(uri, result);
744
- return result;
745
- }
746
-
747
- documents.listen(connection);
748
- connection.listen();