@nudojs/lsp 0.2.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 +12 -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 +15 -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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core validation + analysis-cache logic for the LSP server, extracted from
|
|
3
|
+
* server.ts so it can be exercised directly in tests without a live LSP
|
|
4
|
+
* connection. server.ts wires these functions to `connection` / `documents`;
|
|
5
|
+
* tests wire them to fakes.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
analyzeFile,
|
|
9
|
+
analyzeFileAsync,
|
|
10
|
+
buildModuleGraph,
|
|
11
|
+
computeDirtySet,
|
|
12
|
+
type AnalysisResult,
|
|
13
|
+
type Diagnostic as JsDiagnostic,
|
|
14
|
+
type DiagnosticSeverity as JsDiagSeverity,
|
|
15
|
+
type ModuleGraphCache,
|
|
16
|
+
} from "@nudojs/service";
|
|
17
|
+
import {
|
|
18
|
+
DiagnosticSeverity,
|
|
19
|
+
DiagnosticTag,
|
|
20
|
+
type Diagnostic as LspDiagnostic,
|
|
21
|
+
} from "vscode-languageserver/node";
|
|
22
|
+
|
|
23
|
+
/** Per-file analysis cache; version comes from TextDocument.version. */
|
|
24
|
+
export const analysisCache = new Map<string, { version: number; result: AnalysisResult }>();
|
|
25
|
+
|
|
26
|
+
/** Every file analyzed successfully in this session (import-graph nodes for dirty propagation). */
|
|
27
|
+
export const knownFiles = new Set<string>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 模块图边缓存(会话级常驻):key 为文件路径,value 为 mtimeMs+size+已抽取的 import 边。
|
|
31
|
+
* validateText 的脏传播把整个 knownFiles 喂给 buildModuleGraph——命中条目只做 stat
|
|
32
|
+
* 比对、跳过磁盘重读与重解析,使「编辑防抖→脏传播」不再每次全量重读会话摸过的
|
|
33
|
+
* 所有文件。条目仅是路径+边集字符串数组,内存随会话文件数线性有界;
|
|
34
|
+
* watched-files 删除事件经 evictModuleGraphCacheEntries 逐出对应条目。
|
|
35
|
+
*/
|
|
36
|
+
export const moduleGraphCache: ModuleGraphCache = new Map();
|
|
37
|
+
|
|
38
|
+
/** Test hook — resets module-level session state. */
|
|
39
|
+
export function clearValidationState(): void {
|
|
40
|
+
analysisCache.clear();
|
|
41
|
+
knownFiles.clear();
|
|
42
|
+
moduleGraphCache.clear();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* watched-files Deleted 清理:从会话登记中移除一个文件(knownFiles + analysisCache)。
|
|
47
|
+
* server.ts 的 DidChangeWatchedFiles handler 对「被删除且不在打开集」的文件逐个调用;
|
|
48
|
+
* 仅关闭(文件仍在磁盘上)不走这里,关闭文件仍可作为依赖图节点参与脏传播。
|
|
49
|
+
*/
|
|
50
|
+
export function forgetValidatedFile(filePath: string): void {
|
|
51
|
+
knownFiles.delete(filePath);
|
|
52
|
+
analysisCache.delete(filePath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* watched-files 删除事件的模块图边缓存逐出:server.ts 通过 registerWatchedFilesListener
|
|
57
|
+
* 把「被删除且不在打开集」的 uri 列表广播到这里(uri→filePath 复用 uriToFilePath)。
|
|
58
|
+
* 已删除文件的条目只剩内存驻留价值——同名重建文件若 mtime/size 恰好撞上旧值,
|
|
59
|
+
* 会复用陈旧边集得出错误 dirty 集,因此删除时立即逐出。
|
|
60
|
+
*/
|
|
61
|
+
export function evictModuleGraphCacheEntries(uris: string[]): void {
|
|
62
|
+
for (const uri of uris) moduleGraphCache.delete(uriToFilePath(uri));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function hasNudoDirectives(source: string): boolean {
|
|
66
|
+
return /@nudo:(case|mock|pure|skip|sample|returns|env|mock-module|as|replace)\b/.test(source);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function uriToFilePath(uri: string): string {
|
|
70
|
+
return uri.startsWith("file://") ? decodeURIComponent(uri.slice(7)) : uri;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cache-aware sync analysis for high-frequency handlers: returns the cached
|
|
75
|
+
* result when the document version matches, otherwise falls back to sync
|
|
76
|
+
* analyzeFile (path-based `@nudo:env` files degrade here — the async preload
|
|
77
|
+
* only runs on the validation path).
|
|
78
|
+
*/
|
|
79
|
+
export function getCachedOrAnalyze(
|
|
80
|
+
filePath: string,
|
|
81
|
+
source: string,
|
|
82
|
+
version: number,
|
|
83
|
+
activeCases?: Map<string, number>,
|
|
84
|
+
): AnalysisResult {
|
|
85
|
+
const cached = analysisCache.get(filePath);
|
|
86
|
+
if (cached && cached.version === version) return cached.result;
|
|
87
|
+
const result = analyzeFile(filePath, source, activeCases);
|
|
88
|
+
analysisCache.set(filePath, { version, result });
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type OpenDocumentLike = {
|
|
93
|
+
uri: string;
|
|
94
|
+
version: number;
|
|
95
|
+
getText(): string;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type ValidateTextDeps = {
|
|
99
|
+
sendDiagnostics: (params: { uri: string; diagnostics: LspDiagnostic[] }) => void;
|
|
100
|
+
/** Nudo-file gate; when omitted every uri is validated. */
|
|
101
|
+
isNudoUri?: (uri: string) => boolean;
|
|
102
|
+
getActiveCases?: (uri: string) => Map<string, number>;
|
|
103
|
+
/** Open-document lookup by file path — enables dirty propagation to dependents. */
|
|
104
|
+
getOpenDocumentByPath?: (filePath: string) => OpenDocumentLike | undefined;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const severityMap: Record<JsDiagSeverity, DiagnosticSeverity> = {
|
|
108
|
+
error: DiagnosticSeverity.Error,
|
|
109
|
+
warning: DiagnosticSeverity.Warning,
|
|
110
|
+
info: DiagnosticSeverity.Information,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export function toLspDiagnostic(d: JsDiagnostic, uri: string): LspDiagnostic {
|
|
114
|
+
const diag: LspDiagnostic = {
|
|
115
|
+
severity: severityMap[d.severity],
|
|
116
|
+
range: {
|
|
117
|
+
start: { line: d.range.start.line - 1, character: d.range.start.column },
|
|
118
|
+
end: { line: d.range.end.line - 1, character: d.range.end.column },
|
|
119
|
+
},
|
|
120
|
+
message: d.message,
|
|
121
|
+
source: "nudo",
|
|
122
|
+
code: d.code,
|
|
123
|
+
data: { ...((d.data as object) ?? {}), suggestions: d.suggestions },
|
|
124
|
+
};
|
|
125
|
+
if (d.tags?.includes("unnecessary")) {
|
|
126
|
+
diag.tags = [DiagnosticTag.Unnecessary];
|
|
127
|
+
}
|
|
128
|
+
if (d.origin) {
|
|
129
|
+
diag.relatedInformation = [{
|
|
130
|
+
location: {
|
|
131
|
+
uri,
|
|
132
|
+
range: {
|
|
133
|
+
start: { line: d.origin.line - 1, character: d.origin.column },
|
|
134
|
+
end: { line: d.origin.line - 1, character: d.origin.column },
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
message: "value originates here",
|
|
138
|
+
}];
|
|
139
|
+
}
|
|
140
|
+
return diag;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Analyze one document (async so path-based `@nudo:env` files preload),
|
|
145
|
+
* publish diagnostics, refresh the analysis cache, and — when `propagate` —
|
|
146
|
+
* revalidate open dependents of the changed file once. Propagation-triggered
|
|
147
|
+
* revalidations pass propagate=false, so dirt never cascades further.
|
|
148
|
+
*/
|
|
149
|
+
export async function validateText(
|
|
150
|
+
filePath: string,
|
|
151
|
+
uri: string,
|
|
152
|
+
text: string,
|
|
153
|
+
version: number,
|
|
154
|
+
deps: ValidateTextDeps,
|
|
155
|
+
propagate = false,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
if (deps.isNudoUri && !deps.isNudoUri(uri)) {
|
|
158
|
+
deps.sendDiagnostics({ uri, diagnostics: [] });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let result: AnalysisResult;
|
|
163
|
+
try {
|
|
164
|
+
result = await analyzeFileAsync(filePath, text, deps.getActiveCases?.(uri));
|
|
165
|
+
} catch (err) {
|
|
166
|
+
deps.sendDiagnostics({
|
|
167
|
+
uri,
|
|
168
|
+
diagnostics: [{
|
|
169
|
+
severity: DiagnosticSeverity.Error,
|
|
170
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
|
|
171
|
+
message: `Analysis error: ${(err as Error).message}`,
|
|
172
|
+
source: "nudo",
|
|
173
|
+
}],
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
analysisCache.set(filePath, { version, result });
|
|
179
|
+
knownFiles.add(filePath);
|
|
180
|
+
deps.sendDiagnostics({ uri, diagnostics: result.diagnostics.map((d) => toLspDiagnostic(d, uri)) });
|
|
181
|
+
|
|
182
|
+
if (!propagate || !deps.getOpenDocumentByPath) return;
|
|
183
|
+
|
|
184
|
+
let dependents: Map<string, Set<string>>;
|
|
185
|
+
try {
|
|
186
|
+
// 传入会话级 moduleGraphCache:未变文件仅 stat 比对即复用边集,跳过重读重解析
|
|
187
|
+
({ dependents } = buildModuleGraph([...knownFiles], moduleGraphCache));
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (const dirtyPath of computeDirtySet(dependents, filePath)) {
|
|
192
|
+
if (dirtyPath === filePath) continue;
|
|
193
|
+
const doc = deps.getOpenDocumentByPath(dirtyPath);
|
|
194
|
+
if (!doc) continue;
|
|
195
|
+
await validateText(dirtyPath, doc.uri, doc.getText(), doc.version, deps, false);
|
|
196
|
+
}
|
|
197
|
+
}
|