@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.
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Agent-facing tool implementations (`nudo.whatIf` / `nudo.suggestCase` /
3
+ * `nudo.trace`), ported from the MCP server (packages/mcp/src/tools.ts) and
4
+ * wired into both LSP channels in server.ts: executeCommand commands
5
+ * (`nudo.*`) and custom requests (`nudo/…`). Like validation.ts, this module
6
+ * holds pure logic with injected readers so tests exercise it without a live
7
+ * connection; server.ts supplies the document/disk readers.
8
+ *
9
+ * Unlike the MCP original, whatIf really applies its bindings: each binding
10
+ * becomes a `// @nudo:as <type>` comment inserted above the declaring
11
+ * statement (source-level injection). The evaluator already honors `as` for
12
+ * variable declarations, expression statements and returns, so the assumed
13
+ * type flows through the whole program like any other directive.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import { analyzeFile, buildCaseDirective } from "@nudojs/service";
18
+ import { parse } from "@nudojs/parser";
19
+ import { T, typeValueToString } from "@nudojs/core";
20
+ import type { TypeValue } from "@nudojs/core";
21
+
22
+ export type TypeBinding = { name: string; type: string };
23
+
24
+ /** MCP-compatible tool result shape — keeps bridge layers zero-rewrite. */
25
+ export type AgentToolResult = { content: [{ type: "text"; text: string }] };
26
+
27
+ export type AgentToolDeps = {
28
+ /** Disk reader for files not open in the editor; defaults to readFileSync. */
29
+ readFile?: (filePath: string) => string;
30
+ /** Open-document lookup by absolute file path; the editor buffer wins over disk. */
31
+ getOpenText?: (filePath: string) => { text: string } | undefined;
32
+ };
33
+
34
+ export function textResult(text: string): AgentToolResult {
35
+ return { content: [{ type: "text", text }] };
36
+ }
37
+
38
+ /**
39
+ * Parse an agent-facing type expression into a TypeValue.
40
+ * Ported from packages/mcp/src/tools.ts: primitive names, `|` unions,
41
+ * everything else unknown.
42
+ */
43
+ export function parseTypeExpr(expr: string): TypeValue {
44
+ const trimmed = expr.trim();
45
+ if (trimmed === "number") return T.number;
46
+ if (trimmed === "string") return T.string;
47
+ if (trimmed === "boolean") return T.boolean;
48
+ if (trimmed === "null") return T.null;
49
+ if (trimmed === "undefined") return T.undefined;
50
+ if (trimmed === "bigint") return T.bigint;
51
+ if (trimmed === "symbol") return T.symbol;
52
+ if (trimmed.includes("|")) {
53
+ const members = trimmed.split("|").map(parseTypeExpr);
54
+ return T.union(...members);
55
+ }
56
+ return T.unknown;
57
+ }
58
+
59
+ /**
60
+ * Normalize a `file` parameter: strips and percent-decodes a `file://` prefix
61
+ * (mirrors validation.ts uriToFilePath), then resolves to an absolute path.
62
+ */
63
+ export function normalizeFilePath(file: string): string {
64
+ const path = file.startsWith("file://") ? decodeURIComponent(file.slice(7)) : file;
65
+ return resolve(path);
66
+ }
67
+
68
+ const BARE_PRIMITIVES = new Set(["number", "string", "boolean", "bigint", "symbol"]);
69
+
70
+ /** Split a type expression on top-level `|`, respecting nesting and strings. */
71
+ function splitTopLevelUnion(expr: string): string[] {
72
+ const members: string[] = [];
73
+ let depth = 0;
74
+ let inString: string | null = null;
75
+ let start = 0;
76
+ for (let i = 0; i < expr.length; i++) {
77
+ const ch = expr[i];
78
+ if (inString) {
79
+ if (ch === inString && expr[i - 1] !== "\\") inString = null;
80
+ continue;
81
+ }
82
+ if (ch === '"' || ch === "'") {
83
+ inString = ch;
84
+ continue;
85
+ }
86
+ if (ch === "(" || ch === "[" || ch === "{") {
87
+ depth++;
88
+ continue;
89
+ }
90
+ if (ch === ")" || ch === "]" || ch === "}") {
91
+ depth--;
92
+ continue;
93
+ }
94
+ if (ch === "|" && depth === 0) {
95
+ members.push(expr.slice(start, i));
96
+ start = i + 1;
97
+ }
98
+ }
99
+ members.push(expr.slice(start));
100
+ return members.map((m) => m.trim()).filter(Boolean);
101
+ }
102
+
103
+ /**
104
+ * Translate an agent-facing type expression into `@nudo:as` directive syntax
105
+ * (parser's parseTypeValueExpr language). Bare primitives gain a `T.` prefix;
106
+ * unions become `T.union(...)`; structural forms pass through untouched.
107
+ */
108
+ export function typeExprToDirective(expr: string): string {
109
+ const members = splitTopLevelUnion(expr);
110
+ if (members.length === 0) return "T.unknown";
111
+ const mapped = members.map((m) => {
112
+ if (m.startsWith("T.")) return m;
113
+ if (BARE_PRIMITIVES.has(m)) return `T.${m}`;
114
+ if (m === "null" || m === "undefined" || m === "true" || m === "false") return m;
115
+ if (/^-?\d+(\.\d+)?$/.test(m)) return m;
116
+ if (/^["']/.test(m)) return m;
117
+ if (/[([{]|=>/.test(m)) return m;
118
+ return "T.unknown";
119
+ });
120
+ return mapped.length === 1 ? mapped[0] : `T.union(${mapped.join(", ")})`;
121
+ }
122
+
123
+ /** Collect the names a top-level statement declares (descends into exports). */
124
+ function declaredNames(stmt: any, out: Set<string>): void {
125
+ if (stmt.type === "FunctionDeclaration" && stmt.id) {
126
+ out.add(stmt.id.name);
127
+ } else if (stmt.type === "ClassDeclaration" && stmt.id) {
128
+ out.add(stmt.id.name);
129
+ } else if (stmt.type === "VariableDeclaration") {
130
+ for (const decl of stmt.declarations) {
131
+ if (decl.id?.type === "Identifier") out.add(decl.id.name);
132
+ }
133
+ } else if (stmt.type === "ExportNamedDeclaration" && stmt.declaration) {
134
+ declaredNames(stmt.declaration, out);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Inject each binding as a `// @nudo:as <type>` line above the statement that
140
+ * declares its name. The comment is placed above any existing leading
141
+ * comments, so the assumption takes priority over source-level directives
142
+ * (only the first `as` on a statement wins — hence one binding per statement;
143
+ * siblings of multi-declarator statements share the override, a known
144
+ * limitation of statement-granular `as`).
145
+ */
146
+ export function injectBindings(
147
+ source: string,
148
+ bindings: TypeBinding[],
149
+ ): { source: string; applied: string[]; unapplied: string[] } {
150
+ const applied: string[] = [];
151
+ const unapplied: string[] = [];
152
+ if (bindings.length === 0) return { source, applied, unapplied };
153
+
154
+ const ast = parse(source);
155
+ const declLines = new Map<string, number>();
156
+ for (const stmt of ast.program.body as any[]) {
157
+ const names = new Set<string>();
158
+ declaredNames(stmt, names);
159
+ if (names.size === 0 || !stmt.loc) continue;
160
+ // anchor above existing leading comments so the injected `as` wins
161
+ const anchorLine = stmt.leadingComments?.[0]?.loc?.start.line ?? stmt.loc.start.line;
162
+ for (const name of names) {
163
+ if (!declLines.has(name)) declLines.set(name, anchorLine);
164
+ }
165
+ }
166
+
167
+ const byLine = new Map<number, TypeBinding[]>();
168
+ for (const binding of bindings) {
169
+ const line = declLines.get(binding.name);
170
+ if (line === undefined) {
171
+ unapplied.push(binding.name);
172
+ continue;
173
+ }
174
+ const group = byLine.get(line) ?? [];
175
+ group.push(binding);
176
+ byLine.set(line, group);
177
+ }
178
+
179
+ const insertions: Array<{ index: number; text: string }> = [];
180
+ for (const [line, group] of byLine) {
181
+ insertions.push({
182
+ index: line - 1,
183
+ text: `// @nudo:as ${typeExprToDirective(group[0].type)}`,
184
+ });
185
+ applied.push(`${group[0].name}: ${group[0].type}`);
186
+ for (const shadowed of group.slice(1)) unapplied.push(shadowed.name);
187
+ }
188
+ // bottom-up so earlier indices stay valid
189
+ insertions.sort((a, b) => b.index - a.index);
190
+ const lines = source.split("\n");
191
+ for (const ins of insertions) lines.splice(ins.index, 0, ins.text);
192
+
193
+ return { source: lines.join("\n"), applied, unapplied };
194
+ }
195
+
196
+ /**
197
+ * Source resolution for agent tools: open editor buffer first, disk fallback
198
+ * (readFileSync) for files the agent changed without didOpen.
199
+ */
200
+ export function readSource(filePath: string, deps: AgentToolDeps = {}): string {
201
+ const open = deps.getOpenText?.(filePath);
202
+ if (open) return open.text;
203
+ return (deps.readFile ?? ((p: string) => readFileSync(p, "utf-8")))(filePath);
204
+ }
205
+
206
+ function analysisError(err: unknown): AgentToolResult {
207
+ return textResult(`Error: ${(err as Error).message}`);
208
+ }
209
+
210
+ export type WhatIfParams = {
211
+ file: string;
212
+ bindings?: TypeBinding[];
213
+ target: string;
214
+ /** Pre-read source (tests / callers that already hold the text). */
215
+ source?: string;
216
+ };
217
+
218
+ /**
219
+ * Set type assumptions and observe the inferred type of `target`. Bindings
220
+ * are genuinely injected via `@nudo:as` before analysis — the inference gap
221
+ * of the original MCP tool.
222
+ */
223
+ export function whatIf(params: WhatIfParams, deps: AgentToolDeps = {}): AgentToolResult {
224
+ try {
225
+ const filePath = normalizeFilePath(params.file);
226
+ const original = params.source ?? readSource(filePath, deps);
227
+ const { source, applied, unapplied } = injectBindings(original, params.bindings ?? []);
228
+ // Direct analysis: the injected source never matches the version-keyed
229
+ // editor cache, so bypass it entirely.
230
+ const result = analyzeFile(filePath, source);
231
+ const typeStr = result.bindings.has(params.target)
232
+ ? typeValueToString(result.bindings.get(params.target)!.type)
233
+ : "unknown";
234
+
235
+ const notes: string[] = [];
236
+ if (applied.length > 0) notes.push(`Bindings applied: ${applied.join(", ")}`);
237
+ if (unapplied.length > 0) {
238
+ notes.push(`Bindings not applied (no top-level declaration found): ${unapplied.join(", ")}`);
239
+ }
240
+ return textResult(
241
+ `Type of "${params.target}": ${typeStr}${notes.length > 0 ? `\n${notes.join("\n")}` : ""}`,
242
+ );
243
+ } catch (err) {
244
+ return analysisError(err);
245
+ }
246
+ }
247
+
248
+ export type FunctionToolParams = {
249
+ file: string;
250
+ functionName: string;
251
+ /** Pre-read source (tests / callers that already hold the text). */
252
+ source?: string;
253
+ };
254
+
255
+ /** Suggest @nudo:case directives for a function (ported from MCP). */
256
+ export function suggestCase(params: FunctionToolParams, deps: AgentToolDeps = {}): AgentToolResult {
257
+ try {
258
+ const filePath = normalizeFilePath(params.file);
259
+ const source = params.source ?? readSource(filePath, deps);
260
+ const result = analyzeFile(filePath, source);
261
+ const fn = result.functions.find((f) => f.name === params.functionName);
262
+
263
+ if (!fn) {
264
+ return textResult(`Function "${params.functionName}" not found`);
265
+ }
266
+
267
+ if (fn.cases.length > 0) {
268
+ // 手写指令 case 的 source 未标记,合成 case 才带 "callsite";
269
+ // 全部为合成 case 时可产出直接粘贴回源码的指令文本
270
+ if (fn.cases.every((c) => c.source === "callsite")) {
271
+ const directives = fn.cases
272
+ .map((c) => buildCaseDirective(c.name, c.args))
273
+ .filter((d): d is string => d !== null);
274
+ if (directives.length > 0) {
275
+ const skipped = fn.cases.length - directives.length;
276
+ const lines = [
277
+ `Function "${params.functionName}" has ${fn.cases.length} synthesized case(s); suggested directives:`,
278
+ "/**",
279
+ ...directives,
280
+ "*/",
281
+ ];
282
+ if (skipped > 0) {
283
+ lines.push(`(${skipped} case(s) skipped: not serializable as directives)`);
284
+ }
285
+ return textResult(lines.join("\n"));
286
+ }
287
+ return textResult(
288
+ `Function "${params.functionName}" already has ${fn.cases.length} case(s) (none serializable as directives)`,
289
+ );
290
+ }
291
+ return textResult(`Function "${params.functionName}" already has ${fn.cases.length} case(s)`);
292
+ }
293
+
294
+ return textResult(`Suggested: /** @nudo:case */\nfunction ${params.functionName}(...) { ... }`);
295
+ } catch (err) {
296
+ return analysisError(err);
297
+ }
298
+ }
299
+
300
+ /** Trace how a type transforms from input to output across a function's cases (ported from MCP). */
301
+ export function trace(params: FunctionToolParams, deps: AgentToolDeps = {}): AgentToolResult {
302
+ try {
303
+ const filePath = normalizeFilePath(params.file);
304
+ const source = params.source ?? readSource(filePath, deps);
305
+ const result = analyzeFile(filePath, source);
306
+ const fn = result.functions.find((f) => f.name === params.functionName);
307
+
308
+ if (!fn) {
309
+ return textResult(`Function "${params.functionName}" not found`);
310
+ }
311
+
312
+ if (fn.cases.length === 0) {
313
+ return textResult(`No cases found for "${params.functionName}"`);
314
+ }
315
+
316
+ const traces = fn.cases.map((c) => {
317
+ const args = c.args.map(typeValueToString).join(", ");
318
+ return `Input: (${args}) => Output: ${typeValueToString(c.result)}`;
319
+ }).join("\n");
320
+
321
+ return textResult(traces);
322
+ } catch (err) {
323
+ return analysisError(err);
324
+ }
325
+ }
@@ -0,0 +1,45 @@
1
+ export const TOKEN_TYPES = [
2
+ "function",
3
+ "variable",
4
+ "parameter",
5
+ "property",
6
+ "type",
7
+ "keyword",
8
+ "string",
9
+ "number",
10
+ "comment",
11
+ "decorator",
12
+ ] as const;
13
+
14
+ export const TOKEN_MODIFIERS = [
15
+ "declaration",
16
+ "readonly",
17
+ "deprecated",
18
+ "unreachable",
19
+ ] as const;
20
+
21
+ export type SemanticToken = {
22
+ line: number;
23
+ char: number;
24
+ length: number;
25
+ typeIndex: number;
26
+ modifierBitmask: number;
27
+ };
28
+
29
+ export function encodeSemanticTokens(tokens: SemanticToken[]): number[] {
30
+ const result: number[] = [];
31
+ let prevLine = 0;
32
+ let prevChar = 0;
33
+
34
+ for (const token of tokens) {
35
+ const deltaLine = token.line - prevLine;
36
+ const deltaChar = deltaLine === 0 ? token.char - prevChar : token.char;
37
+
38
+ result.push(deltaLine, deltaChar, token.length, token.typeIndex, token.modifierBitmask);
39
+
40
+ prevLine = token.line;
41
+ prevChar = token.char;
42
+ }
43
+
44
+ return result;
45
+ }