@nudojs/lsp 0.3.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/src/server.ts CHANGED
@@ -19,8 +19,12 @@ import { TextDocument } from "vscode-languageserver-textdocument";
19
19
  import { typeValueToString } from "@nudojs/core";
20
20
  import {
21
21
  getTypeAtPosition,
22
+ getHoverAtPosition,
22
23
  getCompletionsAtPosition,
23
24
  getCasesForFile,
25
+ buildSemanticTokens,
26
+ isNudoTargetPath,
27
+ collectAbsInlays,
24
28
  } from "@nudojs/service";
25
29
  import { parse } from "@nudojs/parser";
26
30
  import { buildSymbolTable, findDefinition, findReferences, findIdentifierAtPosition } from "./symbols.ts";
@@ -30,7 +34,9 @@ import {
30
34
  evictModuleGraphCacheEntries,
31
35
  forgetValidatedFile,
32
36
  getCachedOrAnalyze,
37
+ handleNudoDepFileChanged,
33
38
  hasNudoDirectives,
39
+ lspLoadModule,
34
40
  toLspDiagnostic,
35
41
  uriToFilePath,
36
42
  validateText,
@@ -41,6 +47,9 @@ import {
41
47
  suggestCase,
42
48
  trace,
43
49
  whatIf,
50
+ checkTool,
51
+ hoverTool,
52
+ inferTool,
44
53
  type AgentToolDeps,
45
54
  type AgentToolResult,
46
55
  } from "./agent-tools.ts";
@@ -49,6 +58,9 @@ const NUDO_COMMANDS = [
49
58
  "nudo.whatIf",
50
59
  "nudo.suggestCase",
51
60
  "nudo.trace",
61
+ "nudo.check",
62
+ "nudo.hover",
63
+ "nudo.infer",
52
64
  "nudo.selectCase",
53
65
  "nudo.getActiveCases",
54
66
  ] as const;
@@ -159,16 +171,29 @@ export function registerWatchedFilesListener(listener: (uris: string[]) => void)
159
171
  * 清空其已发布诊断,并把被清理的 uri 列表广播给监听器。
160
172
  * 打开中的文件跳过——其内容由编辑流负责,外部删除会被编辑器以 didOpen/didChange 覆盖。
161
173
  */
174
+ function isNudoDepPath(filePath: string): boolean {
175
+ return /\.nudo\.(js|mjs|ts)$/.test(filePath);
176
+ }
177
+
162
178
  function handleWatchedFilesChanges(changes: readonly FileEvent[], isOpen: (uri: string) => boolean): string[] {
163
179
  const gone: string[] = [];
180
+ const nudoTouched: string[] = [];
164
181
  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: [] });
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
+ }
172
197
  }
173
198
  if (gone.length > 0) {
174
199
  // 拷贝后再遍历:监听器内注销自身不应影响本轮广播
@@ -180,6 +205,12 @@ function handleWatchedFilesChanges(changes: readonly FileEvent[], isOpen: (uri:
180
205
  }
181
206
  }
182
207
  }
208
+ if (nudoTouched.length > 0) {
209
+ const deps = validationDeps();
210
+ for (const p of nudoTouched) {
211
+ void handleNudoDepFileChanged(p, deps).catch(() => {});
212
+ }
213
+ }
183
214
  return gone;
184
215
  }
185
216
 
@@ -224,13 +255,30 @@ connection.onHover((params) => {
224
255
  const cases = getActiveCasesForUri(params.textDocument.uri);
225
256
 
226
257
  try {
227
- const tv = getTypeAtPosition(filePath, source, line, column, cases);
228
- if (!tv) return null;
229
-
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
+ }
230
278
  return {
231
279
  contents: {
232
280
  kind: MarkupKind.Markdown,
233
- value: `\`\`\`nudo\n${typeValueToString(tv)}\n\`\`\``,
281
+ value: lines.join("\n"),
234
282
  },
235
283
  };
236
284
  } catch {
@@ -331,6 +379,28 @@ connection.languages.inlayHint.on((params) => {
331
379
  });
332
380
  }
333
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
+
334
404
  return hints;
335
405
  } catch {
336
406
  return [];
@@ -456,15 +526,6 @@ connection.onCodeAction((params) => {
456
526
  },
457
527
  });
458
528
  }
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
529
  }
469
530
 
470
531
  return actions;
@@ -557,8 +618,12 @@ connection.languages.semanticTokens.on((params) => {
557
618
  if (!document) return { data: [] };
558
619
  if (!isNudoFile(params.textDocument.uri)) return { data: [] };
559
620
 
560
- // For now, return empty tokens - can be enhanced later
561
- return { data: [] };
621
+ try {
622
+ const filePath = uriToFilePath(document.uri);
623
+ return { data: buildSemanticTokens(filePath, document.getText()) };
624
+ } catch {
625
+ return { data: [] };
626
+ }
562
627
  });
563
628
 
564
629
  /** Resolve a `uri`- or `file`-identified target to the uri key used by activeCases/documents. */
@@ -612,6 +677,12 @@ function dispatchNudoCommand(command: string, arg: Record<string, unknown>) {
612
677
  return suggestCase(arg as Parameters<typeof suggestCase>[0], agentToolDeps);
613
678
  case "nudo.trace":
614
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);
615
686
  case "nudo.selectCase":
616
687
  return handleSelectCase(arg as Parameters<typeof handleSelectCase>[0]);
617
688
  case "nudo.getActiveCases":
@@ -637,6 +708,9 @@ function dispatchAgentRequest(command: string, params: Record<string, unknown>):
637
708
  connection.onRequest("nudo/whatIf", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.whatIf", params));
638
709
  connection.onRequest("nudo/suggestCase", (params: Record<string, unknown>) => dispatchAgentRequest("nudo.suggestCase", params));
639
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));
640
714
 
641
715
  connection.languages.diagnostics.on((params) => {
642
716
  const document = documents.get(params.textDocument.uri);
@@ -658,7 +732,9 @@ connection.languages.diagnostics.on((params) => {
658
732
  const nudoFileCache = new Map<string, boolean>();
659
733
 
660
734
  function isNudoFile(uri: string): boolean {
661
- if (!uri.endsWith(".js") && !uri.endsWith(".ts") && !uri.endsWith(".mjs")) return false;
735
+ // 推断目标判定收敛到 service 层(TaskA 提供):.js/.mjs/.ts 为目标,
736
+ // .d.ts(类型声明,harvester 输入)与 .tsx/.jsx 等一律排除
737
+ if (!isNudoTargetPath(uriToFilePath(uri))) return false;
662
738
  const cached = nudoFileCache.get(uri);
663
739
  if (cached !== undefined) return cached;
664
740
  const doc = documents.get(uri);
package/src/validation.ts CHANGED
@@ -4,16 +4,31 @@
4
4
  * connection. server.ts wires these functions to `connection` / `documents`;
5
5
  * tests wire them to fakes.
6
6
  */
7
+ import { readFileSync, existsSync, statSync } from "node:fs";
8
+ import { dirname, resolve as resolvePath, join } from "node:path";
7
9
  import {
8
10
  analyzeFile,
9
11
  analyzeFileAsync,
10
12
  buildModuleGraph,
11
13
  computeDirtySet,
14
+ defaultLoadModule,
15
+ clearAnalysisSessionCaches,
16
+ evictAbsModuleCacheFiles,
17
+ evictBPathCacheForFiles,
18
+ evictAnalysisFileCacheForFiles,
19
+ evictFnAnalysisCacheForFiles,
12
20
  type AnalysisResult,
13
21
  type Diagnostic as JsDiagnostic,
14
22
  type DiagnosticSeverity as JsDiagSeverity,
15
23
  type ModuleGraphCache,
16
24
  } from "@nudojs/service";
25
+ import {
26
+ checkSource,
27
+ pTrue,
28
+ evictGeneralizeMemoForPaths,
29
+ evictCheckSourceMemoForPaths,
30
+ extractNudoImports,
31
+ } from "@nudojs/core";
17
32
  import {
18
33
  DiagnosticSeverity,
19
34
  DiagnosticTag,
@@ -35,11 +50,76 @@ export const knownFiles = new Set<string>();
35
50
  */
36
51
  export const moduleGraphCache: ModuleGraphCache = new Map();
37
52
 
53
+ /**
54
+ * `*.nudo.js` → 父分析文件(含 `@nudo:import` 的 .js/.ts)。
55
+ * 供 watched-files 变更时定向重检打开中的父缓冲。
56
+ */
57
+ export const nudoDepParents = new Map<string, Set<string>>();
58
+
59
+ function normPath(p: string): string {
60
+ return p.replace(/\\/g, "/");
61
+ }
62
+
63
+ /** 每次 validate 后刷新:parent 的全部 @nudo:import 边 */
64
+ export function registerNudoImportDeps(filePath: string, source: string): void {
65
+ const parent = normPath(resolvePath(filePath));
66
+ for (const set of nudoDepParents.values()) {
67
+ set.delete(parent);
68
+ }
69
+ const imports = extractNudoImports(source);
70
+ for (const imp of imports) {
71
+ if (!imp.spec.startsWith(".") && !imp.spec.startsWith("/")) continue;
72
+ const dep = normPath(resolvePath(dirname(filePath), imp.spec));
73
+ let set = nudoDepParents.get(dep);
74
+ if (!set) {
75
+ set = new Set();
76
+ nudoDepParents.set(dep, set);
77
+ }
78
+ set.add(parent);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * `*.nudo.js` 创建/变更:定向逐出 L0 memo 中依赖该文件的条目,
84
+ * 并重检打开中的父文件(propagate=false,不级联)。
85
+ */
86
+ export async function handleNudoDepFileChanged(
87
+ nudoPath: string,
88
+ deps: ValidateTextDeps,
89
+ ): Promise<void> {
90
+ const p = normPath(resolvePath(nudoPath));
91
+ evictGeneralizeMemoForPaths([p]);
92
+ evictCheckSourceMemoForPaths([p]);
93
+ const parents = nudoDepParents.get(p);
94
+ if (!parents || parents.size === 0) return;
95
+ const parentList = [...parents];
96
+ // 父文件源码未变但依赖内容变了:整文件 check / B-path / AnalysisResult / fn-cache 都可能陈旧
97
+ evictBPathCacheForFiles(parentList);
98
+ evictAnalysisFileCacheForFiles(parentList);
99
+ evictFnAnalysisCacheForFiles(parentList);
100
+ for (const parent of parentList) {
101
+ const doc = deps.getOpenDocumentByPath?.(parent);
102
+ if (!doc) continue;
103
+ await validateText(
104
+ parent,
105
+ doc.uri,
106
+ doc.getText(),
107
+ doc.version,
108
+ deps,
109
+ false,
110
+ );
111
+ }
112
+ }
113
+
38
114
  /** Test hook — resets module-level session state. */
39
115
  export function clearValidationState(): void {
40
116
  analysisCache.clear();
41
117
  knownFiles.clear();
42
118
  moduleGraphCache.clear();
119
+ nudoDepParents.clear();
120
+ // service+core 会话 memo 全清(B-path / analysis-file / fn-analysis /
121
+ // abs-module / generalize L0 / check 整文件 / nudo-module exec)
122
+ clearAnalysisSessionCaches();
43
123
  }
44
124
 
45
125
  /**
@@ -48,8 +128,12 @@ export function clearValidationState(): void {
48
128
  * 仅关闭(文件仍在磁盘上)不走这里,关闭文件仍可作为依赖图节点参与脏传播。
49
129
  */
50
130
  export function forgetValidatedFile(filePath: string): void {
131
+ const parent = normPath(resolvePath(filePath));
51
132
  knownFiles.delete(filePath);
52
133
  analysisCache.delete(filePath);
134
+ for (const set of nudoDepParents.values()) {
135
+ set.delete(parent);
136
+ }
53
137
  }
54
138
 
55
139
  /**
@@ -57,13 +141,17 @@ export function forgetValidatedFile(filePath: string): void {
57
141
  * 把「被删除且不在打开集」的 uri 列表广播到这里(uri→filePath 复用 uriToFilePath)。
58
142
  * 已删除文件的条目只剩内存驻留价值——同名重建文件若 mtime/size 恰好撞上旧值,
59
143
  * 会复用陈旧边集得出错误 dirty 集,因此删除时立即逐出。
144
+ * Abs 依赖模块导出缓存(abs-modules-graph)同口径逐出:虽然 stat 失效自愈,
145
+ * 但删除文件的内存驻留条目应随会话清理释放。
60
146
  */
61
147
  export function evictModuleGraphCacheEntries(uris: string[]): void {
62
- for (const uri of uris) moduleGraphCache.delete(uriToFilePath(uri));
148
+ const paths = uris.map(uriToFilePath);
149
+ for (const p of paths) moduleGraphCache.delete(p);
150
+ evictAbsModuleCacheFiles(paths);
63
151
  }
64
152
 
65
153
  export function hasNudoDirectives(source: string): boolean {
66
- return /@nudo:(case|mock|pure|skip|sample|returns|env|mock-module|as|replace)\b/.test(source);
154
+ return /@nudo:(case|mock|pure|skip|sample|refine|import|env|mock-module|as|replace)\b/.test(source);
67
155
  }
68
156
 
69
157
  export function uriToFilePath(uri: string): string {
@@ -140,6 +228,60 @@ export function toLspDiagnostic(d: JsDiagnostic, uri: string): LspDiagnostic {
140
228
  return diag;
141
229
  }
142
230
 
231
+ /** CLI 与 LSP 共用的相对 require 解析(service defaultLoadModule) */
232
+ export function lspLoadModule(spec: string, fromFile: string): string | undefined {
233
+ return defaultLoadModule(spec, fromFile);
234
+ }
235
+
236
+ /**
237
+ * Abs check → LSP diagnostics(主通道:约束蕴含,非 TS assignability)。
238
+ */
239
+ export function checkToLspDiagnostics(
240
+ filePath: string,
241
+ source: string,
242
+ loadModule?: (spec: string, fromFile: string) => string | undefined,
243
+ ): LspDiagnostic[] {
244
+ try {
245
+ const report = checkSource(filePath, source, pTrue, {
246
+ loadModule: loadModule ?? lspLoadModule,
247
+ fromFile: filePath,
248
+ });
249
+ return report.issues
250
+ .filter((i) => i.severity === "error" || i.severity === "warning")
251
+ .map((i) => {
252
+ const line = (i.line ?? 1) - 1;
253
+ const col = i.column ?? 0;
254
+ const parts = [i.message];
255
+ if (i.actual) parts.push(`actual: ${i.actual}`);
256
+ if (i.expected) parts.push(`expected: ${i.expected}`);
257
+ if (i.suggestion) parts.push(i.suggestion);
258
+ return {
259
+ severity:
260
+ i.severity === "error"
261
+ ? DiagnosticSeverity.Error
262
+ : i.severity === "warning"
263
+ ? DiagnosticSeverity.Warning
264
+ : DiagnosticSeverity.Information,
265
+ range: {
266
+ start: { line, character: col },
267
+ end: { line, character: col + 1 },
268
+ },
269
+ message: parts.join(" · "),
270
+ source: "nudo-check",
271
+ code: i.code,
272
+ data: {
273
+ actual: i.actual,
274
+ expected: i.expected,
275
+ fn: i.fn,
276
+ suggestions: i.suggestion ? [i.suggestion] : [],
277
+ },
278
+ } satisfies LspDiagnostic;
279
+ });
280
+ } catch {
281
+ return [];
282
+ }
283
+ }
284
+
143
285
  /**
144
286
  * Analyze one document (async so path-based `@nudo:env` files preload),
145
287
  * publish diagnostics, refresh the analysis cache, and — when `propagate` —
@@ -177,7 +319,12 @@ export async function validateText(
177
319
 
178
320
  analysisCache.set(filePath, { version, result });
179
321
  knownFiles.add(filePath);
180
- deps.sendDiagnostics({ uri, diagnostics: result.diagnostics.map((d) => toLspDiagnostic(d, uri)) });
322
+ registerNudoImportDeps(filePath, text);
323
+
324
+ // Abs check 主通道 + evaluator 诊断
325
+ const checkDiags = checkToLspDiagnostics(filePath, text);
326
+ const evalDiags = result.diagnostics.map((d) => toLspDiagnostic(d, uri));
327
+ deps.sendDiagnostics({ uri, diagnostics: [...checkDiags, ...evalDiags] });
181
328
 
182
329
  if (!propagate || !deps.getOpenDocumentByPath) return;
183
330
 
@@ -192,6 +339,10 @@ export async function validateText(
192
339
  if (dirtyPath === filePath) continue;
193
340
  const doc = deps.getOpenDocumentByPath(dirtyPath);
194
341
  if (!doc) continue;
342
+ // 依赖内容变了但父文件源码未变:整文件 AnalysisResult / B-path / fn-cache 键不含 dep 指纹
343
+ evictBPathCacheForFiles([dirtyPath]);
344
+ evictAnalysisFileCacheForFiles([dirtyPath]);
345
+ evictFnAnalysisCacheForFiles([dirtyPath]);
195
346
  await validateText(dirtyPath, doc.uri, doc.getText(), doc.version, deps, false);
196
347
  }
197
348
  }