@nudojs/service 0.1.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 +14 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +523 -0
- package/package.json +16 -0
- package/src/__tests__/analyzer.test.ts +137 -0
- package/src/__tests__/dts-generator.test.ts +98 -0
- package/src/analyzer.ts +650 -0
- package/src/dts-generator.ts +85 -0
- package/src/index.ts +22 -0
- package/tsconfig.json +13 -0
package/src/analyzer.ts
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import type { Node } from "@babel/types";
|
|
4
|
+
import traverse from "@babel/traverse";
|
|
5
|
+
import {
|
|
6
|
+
type TypeValue,
|
|
7
|
+
T,
|
|
8
|
+
typeValueToString,
|
|
9
|
+
simplifyUnion,
|
|
10
|
+
createEnvironment,
|
|
11
|
+
isSubtypeOf,
|
|
12
|
+
type Environment,
|
|
13
|
+
} from "@nudojs/core";
|
|
14
|
+
import { parse, extractDirectives, parseTypeValueExpr } from "@nudojs/parser";
|
|
15
|
+
import type { FunctionWithDirectives } from "@nudojs/parser";
|
|
16
|
+
import {
|
|
17
|
+
evaluate,
|
|
18
|
+
evaluateFunction,
|
|
19
|
+
evaluateFunctionFull,
|
|
20
|
+
evaluateProgram,
|
|
21
|
+
setModuleResolver,
|
|
22
|
+
setCurrentFileDir,
|
|
23
|
+
resetMemo,
|
|
24
|
+
getUnreachableRanges,
|
|
25
|
+
resetUnreachableRanges,
|
|
26
|
+
setNodeTypeCollector,
|
|
27
|
+
} from "@nudojs/cli/evaluator";
|
|
28
|
+
|
|
29
|
+
export type SourceLocation = {
|
|
30
|
+
start: { line: number; column: number };
|
|
31
|
+
end: { line: number; column: number };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type DiagnosticSeverity = "error" | "warning" | "info";
|
|
35
|
+
|
|
36
|
+
export type DiagnosticTag = "unnecessary";
|
|
37
|
+
|
|
38
|
+
export type Diagnostic = {
|
|
39
|
+
range: SourceLocation;
|
|
40
|
+
severity: DiagnosticSeverity;
|
|
41
|
+
message: string;
|
|
42
|
+
tags?: DiagnosticTag[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type CaseResult = {
|
|
46
|
+
name: string;
|
|
47
|
+
args: TypeValue[];
|
|
48
|
+
result: TypeValue;
|
|
49
|
+
throws: TypeValue;
|
|
50
|
+
throwLoc?: SourceLocation;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type FunctionAnalysis = {
|
|
54
|
+
name: string;
|
|
55
|
+
loc: SourceLocation;
|
|
56
|
+
cases: CaseResult[];
|
|
57
|
+
combined?: TypeValue;
|
|
58
|
+
assertionErrors?: string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type BindingInfo = {
|
|
62
|
+
type: TypeValue;
|
|
63
|
+
loc?: SourceLocation;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type CaseHint = {
|
|
67
|
+
line: number;
|
|
68
|
+
label: string;
|
|
69
|
+
ok: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type AnalysisResult = {
|
|
73
|
+
functions: FunctionAnalysis[];
|
|
74
|
+
diagnostics: Diagnostic[];
|
|
75
|
+
bindings: Map<string, BindingInfo>;
|
|
76
|
+
nodeTypeMap: Map<Node, TypeValue>;
|
|
77
|
+
caseHints: CaseHint[];
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type CompletionItem = {
|
|
81
|
+
label: string;
|
|
82
|
+
kind: "property" | "method" | "variable";
|
|
83
|
+
detail?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function resolveModule(source: string, fromDir: string): { ast: ReturnType<typeof parse>; filePath: string } | null {
|
|
87
|
+
const extensions = [".js", ".ts", ".mjs"];
|
|
88
|
+
const basePath = resolve(fromDir, source);
|
|
89
|
+
for (const ext of ["", ...extensions]) {
|
|
90
|
+
const candidate = basePath + ext;
|
|
91
|
+
if (existsSync(candidate)) {
|
|
92
|
+
const src = readFileSync(candidate, "utf-8");
|
|
93
|
+
return { ast: parse(src), filePath: candidate };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyMocks(
|
|
100
|
+
directives: FunctionWithDirectives["directives"],
|
|
101
|
+
env: Environment,
|
|
102
|
+
filePath: string,
|
|
103
|
+
): void {
|
|
104
|
+
for (const d of directives) {
|
|
105
|
+
if (d.kind !== "mock") continue;
|
|
106
|
+
if (d.expression) {
|
|
107
|
+
env.bind(d.name, parseTypeValueExpr(d.expression));
|
|
108
|
+
} else if (d.fromPath) {
|
|
109
|
+
const mockPath = resolve(dirname(filePath), d.fromPath);
|
|
110
|
+
const mockSource = readFileSync(mockPath, "utf-8");
|
|
111
|
+
const mockAst = parse(mockSource);
|
|
112
|
+
const mockEnv = createEnvironment();
|
|
113
|
+
evaluateProgram(mockAst, mockEnv);
|
|
114
|
+
const mockVal = mockEnv.lookup(d.name);
|
|
115
|
+
env.bind(d.name, mockVal);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function rangeKey(r: SourceLocation): string {
|
|
121
|
+
return `${r.start.line}:${r.start.column}-${r.end.line}:${r.end.column}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function findCommonUnreachable(perCase: SourceLocation[][]): SourceLocation[] {
|
|
125
|
+
if (perCase.length === 0) return [];
|
|
126
|
+
const counts = new Map<string, { count: number; range: SourceLocation }>();
|
|
127
|
+
for (const ranges of perCase) {
|
|
128
|
+
for (const r of ranges) {
|
|
129
|
+
const key = rangeKey(r);
|
|
130
|
+
const existing = counts.get(key);
|
|
131
|
+
if (existing) {
|
|
132
|
+
existing.count++;
|
|
133
|
+
} else {
|
|
134
|
+
counts.set(key, { count: 1, range: r });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [...counts.values()]
|
|
139
|
+
.filter((v) => v.count === perCase.length)
|
|
140
|
+
.map((v) => v.range);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function locFromNode(node: Node): SourceLocation {
|
|
144
|
+
return {
|
|
145
|
+
start: { line: node.loc?.start.line ?? 1, column: node.loc?.start.column ?? 0 },
|
|
146
|
+
end: { line: node.loc?.end.line ?? 1, column: node.loc?.end.column ?? 0 },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function analyzeFile(filePath: string, source: string, activeCases?: Map<string, number>): AnalysisResult {
|
|
151
|
+
const ast = parse(source);
|
|
152
|
+
const functions = extractDirectives(ast);
|
|
153
|
+
const diagnostics: Diagnostic[] = [];
|
|
154
|
+
const bindings = new Map<string, BindingInfo>();
|
|
155
|
+
const nodeTypeMap = new Map<Node, TypeValue>();
|
|
156
|
+
const functionResults: FunctionAnalysis[] = [];
|
|
157
|
+
const caseHints: CaseHint[] = [];
|
|
158
|
+
|
|
159
|
+
resetMemo();
|
|
160
|
+
resetUnreachableRanges();
|
|
161
|
+
setModuleResolver(resolveModule);
|
|
162
|
+
setCurrentFileDir(dirname(filePath));
|
|
163
|
+
|
|
164
|
+
const globalEnv = createEnvironment();
|
|
165
|
+
evaluateProgram(ast, globalEnv);
|
|
166
|
+
|
|
167
|
+
const unreachableRanges = getUnreachableRanges();
|
|
168
|
+
for (const ur of unreachableRanges) {
|
|
169
|
+
diagnostics.push({
|
|
170
|
+
range: ur,
|
|
171
|
+
severity: "info",
|
|
172
|
+
message: "Unreachable code",
|
|
173
|
+
tags: ["unnecessary"],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
collectBindings(ast, globalEnv, bindings);
|
|
178
|
+
|
|
179
|
+
for (const fn of functions) {
|
|
180
|
+
applyMocks(fn.directives, globalEnv, filePath);
|
|
181
|
+
|
|
182
|
+
const isPure = fn.directives.some((d) => d.kind === "pure");
|
|
183
|
+
const skipDirective = fn.directives.find((d) => d.kind === "skip");
|
|
184
|
+
const returnsDirective = fn.directives.find((d) => d.kind === "returns");
|
|
185
|
+
|
|
186
|
+
const fnLoc = locFromNode(fn.node);
|
|
187
|
+
const analysis: FunctionAnalysis = { name: fn.name, loc: fnLoc, cases: [] };
|
|
188
|
+
|
|
189
|
+
if (skipDirective && skipDirective.kind === "skip") {
|
|
190
|
+
if (skipDirective.returns) {
|
|
191
|
+
analysis.combined = skipDirective.returns;
|
|
192
|
+
}
|
|
193
|
+
functionResults.push(analysis);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (isPure) {
|
|
198
|
+
const fnVal = globalEnv.has(fn.name) ? globalEnv.lookup(fn.name) : null;
|
|
199
|
+
if (fnVal && fnVal.kind === "function") {
|
|
200
|
+
(fnVal as any)._memoize = fn.name;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const caseDirectives = fn.directives.filter((d) => d.kind === "case");
|
|
205
|
+
const activeCaseIdx = activeCases?.get(fn.name) ?? 0;
|
|
206
|
+
|
|
207
|
+
for (let ci = 0; ci < caseDirectives.length; ci++) {
|
|
208
|
+
const directive = caseDirectives[ci];
|
|
209
|
+
resetUnreachableRanges();
|
|
210
|
+
const fullResult = evaluateFunctionFull(fn.node, directive.args, globalEnv);
|
|
211
|
+
const caseUnreachable = [...getUnreachableRanges()];
|
|
212
|
+
|
|
213
|
+
analysis.cases.push({
|
|
214
|
+
name: directive.name,
|
|
215
|
+
args: directive.args,
|
|
216
|
+
result: fullResult.value,
|
|
217
|
+
throws: fullResult.throws,
|
|
218
|
+
throwLoc: fullResult.throwLoc,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
if (directive.commentLine) {
|
|
222
|
+
const hasThrow = fullResult.throws.kind !== "never";
|
|
223
|
+
const resultStr = fullResult.value.kind !== "never"
|
|
224
|
+
? typeValueToString(fullResult.value)
|
|
225
|
+
: "";
|
|
226
|
+
const throwStr = hasThrow
|
|
227
|
+
? `throws ${typeValueToString(fullResult.throws)}`
|
|
228
|
+
: "";
|
|
229
|
+
const label = [resultStr, throwStr].filter(Boolean).join(" ");
|
|
230
|
+
const hintLabel = `=> ${label}`;
|
|
231
|
+
|
|
232
|
+
let ok = true;
|
|
233
|
+
if (directive.expected) {
|
|
234
|
+
ok = isSubtypeOf(fullResult.value, directive.expected);
|
|
235
|
+
if (!ok) {
|
|
236
|
+
diagnostics.push({
|
|
237
|
+
range: { start: { line: directive.commentLine, column: 0 }, end: { line: directive.commentLine, column: 999 } },
|
|
238
|
+
severity: "error",
|
|
239
|
+
message: `Case "${directive.name}": expected ${typeValueToString(directive.expected)}, got ${typeValueToString(fullResult.value)}`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
caseHints.push({ line: directive.commentLine, label: hintLabel, ok });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const isActive = ci === Math.min(activeCaseIdx, caseDirectives.length - 1);
|
|
248
|
+
|
|
249
|
+
if (isActive) {
|
|
250
|
+
if (fullResult.throws.kind !== "never") {
|
|
251
|
+
const throwRange = fullResult.throwLoc ?? fnLoc;
|
|
252
|
+
diagnostics.push({
|
|
253
|
+
range: throwRange,
|
|
254
|
+
severity: "warning",
|
|
255
|
+
message: `Function "${fn.name}" case "${directive.name}" may throw: ${typeValueToString(fullResult.throws)}`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const ur of caseUnreachable) {
|
|
260
|
+
diagnostics.push({
|
|
261
|
+
range: ur,
|
|
262
|
+
severity: "info",
|
|
263
|
+
message: "Unreachable code",
|
|
264
|
+
tags: ["unnecessary"],
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (analysis.cases.length > 1) {
|
|
271
|
+
analysis.combined = simplifyUnion(analysis.cases.map((c) => c.result));
|
|
272
|
+
} else if (analysis.cases.length === 1) {
|
|
273
|
+
analysis.combined = analysis.cases[0].result;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (returnsDirective && returnsDirective.kind === "returns") {
|
|
277
|
+
analysis.assertionErrors = [];
|
|
278
|
+
for (const directive of caseDirectives) {
|
|
279
|
+
const result = evaluateFunction(fn.node, directive.args, globalEnv);
|
|
280
|
+
const matches = isSubtypeOf(result, returnsDirective.expected);
|
|
281
|
+
if (!matches) {
|
|
282
|
+
const msg = `@nudo:returns assertion failed for case "${directive.name}": expected ${typeValueToString(returnsDirective.expected)}, got ${typeValueToString(result)}`;
|
|
283
|
+
analysis.assertionErrors.push(msg);
|
|
284
|
+
diagnostics.push({
|
|
285
|
+
range: fnLoc,
|
|
286
|
+
severity: "error",
|
|
287
|
+
message: msg,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
functionResults.push(analysis);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
buildNodeTypeMap(ast, globalEnv, nodeTypeMap);
|
|
297
|
+
|
|
298
|
+
setModuleResolver(null);
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
functions: functionResults,
|
|
302
|
+
diagnostics,
|
|
303
|
+
bindings,
|
|
304
|
+
nodeTypeMap,
|
|
305
|
+
caseHints,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function collectBindings(ast: Node, env: Environment, bindings: Map<string, BindingInfo>): void {
|
|
310
|
+
if (ast.type !== "File") return;
|
|
311
|
+
for (const stmt of (ast as any).program.body) {
|
|
312
|
+
if (stmt.type === "FunctionDeclaration" && stmt.id) {
|
|
313
|
+
const val = env.has(stmt.id.name) ? env.lookup(stmt.id.name) : T.unknown;
|
|
314
|
+
bindings.set(stmt.id.name, { type: val, loc: locFromNode(stmt) });
|
|
315
|
+
}
|
|
316
|
+
if (stmt.type === "VariableDeclaration") {
|
|
317
|
+
for (const decl of stmt.declarations) {
|
|
318
|
+
if (decl.id.type === "Identifier") {
|
|
319
|
+
const val = env.has(decl.id.name) ? env.lookup(decl.id.name) : T.unknown;
|
|
320
|
+
bindings.set(decl.id.name, { type: val, loc: locFromNode(decl) });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (stmt.type === "ClassDeclaration" && stmt.id) {
|
|
325
|
+
const val = env.has(stmt.id.name) ? env.lookup(stmt.id.name) : T.unknown;
|
|
326
|
+
bindings.set(stmt.id.name, { type: val, loc: locFromNode(stmt) });
|
|
327
|
+
}
|
|
328
|
+
if (stmt.type === "ExportNamedDeclaration" && stmt.declaration) {
|
|
329
|
+
collectBindings({ type: "File", program: { type: "Program", body: [stmt.declaration] } } as any, env, bindings);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildNodeTypeMap(ast: Node, env: Environment, nodeTypeMap: Map<Node, TypeValue>): void {
|
|
335
|
+
const traverseFn = (typeof traverse === "function" ? traverse : (traverse as any).default) as typeof traverse;
|
|
336
|
+
try {
|
|
337
|
+
traverseFn(ast, {
|
|
338
|
+
enter(path) {
|
|
339
|
+
const node = path.node;
|
|
340
|
+
try {
|
|
341
|
+
if (
|
|
342
|
+
node.type === "Identifier" &&
|
|
343
|
+
node.name !== "undefined" &&
|
|
344
|
+
path.parentPath?.node.type !== "FunctionDeclaration" &&
|
|
345
|
+
path.parentPath?.node.type !== "VariableDeclarator"
|
|
346
|
+
) {
|
|
347
|
+
if (env.has(node.name)) {
|
|
348
|
+
nodeTypeMap.set(node, env.lookup(node.name));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (node.type === "NumericLiteral") {
|
|
352
|
+
nodeTypeMap.set(node, T.literal(node.value));
|
|
353
|
+
}
|
|
354
|
+
if (node.type === "StringLiteral") {
|
|
355
|
+
nodeTypeMap.set(node, T.literal(node.value));
|
|
356
|
+
}
|
|
357
|
+
if (node.type === "BooleanLiteral") {
|
|
358
|
+
nodeTypeMap.set(node, T.literal(node.value));
|
|
359
|
+
}
|
|
360
|
+
if (node.type === "NullLiteral") {
|
|
361
|
+
nodeTypeMap.set(node, T.null);
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
// skip nodes that fail
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
} catch {
|
|
369
|
+
// traverse may fail on partial ASTs
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export type CaseInfo = {
|
|
374
|
+
functionName: string;
|
|
375
|
+
caseName: string;
|
|
376
|
+
caseIndex: number;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
export function getCasesForFile(filePath: string, source: string): { functionName: string; cases: { name: string; index: number }[]; loc: SourceLocation }[] {
|
|
380
|
+
const ast = parse(source);
|
|
381
|
+
const functions = extractDirectives(ast);
|
|
382
|
+
return functions.map((fn) => {
|
|
383
|
+
const cases = fn.directives
|
|
384
|
+
.filter((d) => d.kind === "case")
|
|
385
|
+
.map((d, i) => ({ name: d.name, index: i }));
|
|
386
|
+
return { functionName: fn.name, cases, loc: locFromNode(fn.node) };
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function getTypeAtPosition(
|
|
391
|
+
filePath: string,
|
|
392
|
+
source: string,
|
|
393
|
+
line: number,
|
|
394
|
+
column: number,
|
|
395
|
+
activeCases?: Map<string, number>,
|
|
396
|
+
): TypeValue | null {
|
|
397
|
+
const ast = parse(source);
|
|
398
|
+
resetMemo();
|
|
399
|
+
setModuleResolver(resolveModule);
|
|
400
|
+
setCurrentFileDir(dirname(filePath));
|
|
401
|
+
|
|
402
|
+
const globalEnv = createEnvironment();
|
|
403
|
+
evaluateProgram(ast, globalEnv);
|
|
404
|
+
|
|
405
|
+
const nodeTypeMap = new Map<Node, TypeValue>();
|
|
406
|
+
buildNodeTypeMap(ast, globalEnv, nodeTypeMap);
|
|
407
|
+
|
|
408
|
+
const functions = extractDirectives(ast);
|
|
409
|
+
const enclosingFn = findEnclosingFunction(functions, line);
|
|
410
|
+
|
|
411
|
+
if (enclosingFn) {
|
|
412
|
+
const caseDirectives = enclosingFn.directives.filter((d) => d.kind === "case");
|
|
413
|
+
if (caseDirectives.length > 0) {
|
|
414
|
+
const caseIndex = activeCases?.get(enclosingFn.name) ?? 0;
|
|
415
|
+
const directive = caseDirectives[Math.min(caseIndex, caseDirectives.length - 1)];
|
|
416
|
+
|
|
417
|
+
const fnNodeTypeMap = new Map<Node, TypeValue>();
|
|
418
|
+
setNodeTypeCollector((node, tv) => fnNodeTypeMap.set(node, tv));
|
|
419
|
+
evaluateFunctionFull(enclosingFn.node, directive.args, globalEnv);
|
|
420
|
+
setNodeTypeCollector(null);
|
|
421
|
+
|
|
422
|
+
for (const [node, tv] of fnNodeTypeMap) {
|
|
423
|
+
nodeTypeMap.set(node, tv);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
setModuleResolver(null);
|
|
429
|
+
return findBestTypeAtPosition(nodeTypeMap, globalEnv, ast, line, column);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function findEnclosingFunction(
|
|
433
|
+
functions: FunctionWithDirectives[],
|
|
434
|
+
line: number,
|
|
435
|
+
): FunctionWithDirectives | null {
|
|
436
|
+
for (const fn of functions) {
|
|
437
|
+
const loc = fn.node.loc;
|
|
438
|
+
if (!loc) continue;
|
|
439
|
+
if (loc.start.line <= line && loc.end.line >= line) {
|
|
440
|
+
return fn;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function findBestTypeAtPosition(
|
|
447
|
+
nodeTypeMap: Map<Node, TypeValue>,
|
|
448
|
+
globalEnv: Environment,
|
|
449
|
+
ast: Node,
|
|
450
|
+
line: number,
|
|
451
|
+
column: number,
|
|
452
|
+
): TypeValue | null {
|
|
453
|
+
let bestMatch: TypeValue | null = null;
|
|
454
|
+
let bestSize = Infinity;
|
|
455
|
+
|
|
456
|
+
for (const [node, tv] of nodeTypeMap) {
|
|
457
|
+
const loc = node.loc;
|
|
458
|
+
if (!loc) continue;
|
|
459
|
+
if (
|
|
460
|
+
loc.start.line <= line &&
|
|
461
|
+
loc.end.line >= line &&
|
|
462
|
+
(loc.start.line < line || loc.start.column <= column) &&
|
|
463
|
+
(loc.end.line > line || loc.end.column >= column)
|
|
464
|
+
) {
|
|
465
|
+
const size = (loc.end.line - loc.start.line) * 10000 + (loc.end.column - loc.start.column);
|
|
466
|
+
if (size < bestSize) {
|
|
467
|
+
bestSize = size;
|
|
468
|
+
bestMatch = tv;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!bestMatch) {
|
|
474
|
+
const identAtPos = findIdentifierAtPosition(ast, line, column);
|
|
475
|
+
if (identAtPos && globalEnv.has(identAtPos)) {
|
|
476
|
+
bestMatch = globalEnv.lookup(identAtPos);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return bestMatch;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function findIdentifierAtPosition(ast: Node, line: number, column: number): string | null {
|
|
484
|
+
let found: string | null = null;
|
|
485
|
+
const traverseFn = (typeof traverse === "function" ? traverse : (traverse as any).default) as typeof traverse;
|
|
486
|
+
try {
|
|
487
|
+
traverseFn(ast, {
|
|
488
|
+
Identifier(path) {
|
|
489
|
+
const loc = path.node.loc;
|
|
490
|
+
if (!loc) return;
|
|
491
|
+
if (
|
|
492
|
+
loc.start.line === line &&
|
|
493
|
+
loc.start.column <= column &&
|
|
494
|
+
loc.end.column >= column
|
|
495
|
+
) {
|
|
496
|
+
found = path.node.name;
|
|
497
|
+
path.stop();
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
} catch {
|
|
502
|
+
// ignore
|
|
503
|
+
}
|
|
504
|
+
return found;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function getCompletionsAtPosition(
|
|
508
|
+
filePath: string,
|
|
509
|
+
source: string,
|
|
510
|
+
line: number,
|
|
511
|
+
column: number,
|
|
512
|
+
): CompletionItem[] {
|
|
513
|
+
const textBefore = getTextBeforePosition(source, line, column);
|
|
514
|
+
const dotMatch = textBefore.match(/(\w+)\.\s*\w*$/);
|
|
515
|
+
if (!dotMatch) return getVariableCompletions(filePath, source);
|
|
516
|
+
|
|
517
|
+
const objName = dotMatch[1];
|
|
518
|
+
|
|
519
|
+
const safeSource = sanitizeSourceForParsing(source);
|
|
520
|
+
|
|
521
|
+
let ast;
|
|
522
|
+
try {
|
|
523
|
+
ast = parse(safeSource);
|
|
524
|
+
} catch {
|
|
525
|
+
try {
|
|
526
|
+
ast = parse(source);
|
|
527
|
+
} catch {
|
|
528
|
+
return [];
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
resetMemo();
|
|
533
|
+
setModuleResolver(resolveModule);
|
|
534
|
+
setCurrentFileDir(dirname(filePath));
|
|
535
|
+
|
|
536
|
+
const globalEnv = createEnvironment();
|
|
537
|
+
evaluateProgram(ast, globalEnv);
|
|
538
|
+
|
|
539
|
+
if (!globalEnv.has(objName)) {
|
|
540
|
+
setModuleResolver(null);
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const objType = globalEnv.lookup(objName);
|
|
545
|
+
const completions = getCompletionsForType(objType);
|
|
546
|
+
|
|
547
|
+
setModuleResolver(null);
|
|
548
|
+
return completions;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function sanitizeSourceForParsing(source: string): string {
|
|
552
|
+
return source.replace(/(\w+)\.\s*$/gm, "$1._ ");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function getTextBeforePosition(source: string, line: number, column: number): string {
|
|
556
|
+
const lines = source.split("\n");
|
|
557
|
+
if (line < 1 || line > lines.length) return "";
|
|
558
|
+
return lines[line - 1].slice(0, column);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function getVariableCompletions(filePath: string, source: string): CompletionItem[] {
|
|
562
|
+
const ast = parse(source);
|
|
563
|
+
resetMemo();
|
|
564
|
+
setModuleResolver(resolveModule);
|
|
565
|
+
setCurrentFileDir(dirname(filePath));
|
|
566
|
+
|
|
567
|
+
const globalEnv = createEnvironment();
|
|
568
|
+
evaluateProgram(ast, globalEnv);
|
|
569
|
+
|
|
570
|
+
const ownBindings = globalEnv.getOwnBindings();
|
|
571
|
+
const completions: CompletionItem[] = [];
|
|
572
|
+
for (const [name, tv] of Object.entries(ownBindings)) {
|
|
573
|
+
if (name.startsWith("__export_")) continue;
|
|
574
|
+
completions.push({
|
|
575
|
+
label: name,
|
|
576
|
+
kind: tv.kind === "function" ? "method" : "variable",
|
|
577
|
+
detail: typeValueToString(tv),
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
setModuleResolver(null);
|
|
582
|
+
return completions;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function getCompletionsForType(tv: TypeValue): CompletionItem[] {
|
|
586
|
+
const completions: CompletionItem[] = [];
|
|
587
|
+
|
|
588
|
+
if (tv.kind === "object") {
|
|
589
|
+
for (const [key, val] of Object.entries(tv.properties)) {
|
|
590
|
+
completions.push({
|
|
591
|
+
label: key,
|
|
592
|
+
kind: val.kind === "function" ? "method" : "property",
|
|
593
|
+
detail: typeValueToString(val),
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return completions;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (tv.kind === "instance") {
|
|
600
|
+
for (const [key, val] of Object.entries(tv.properties)) {
|
|
601
|
+
completions.push({
|
|
602
|
+
label: key,
|
|
603
|
+
kind: val.kind === "function" ? "method" : "property",
|
|
604
|
+
detail: typeValueToString(val),
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
return completions;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (tv.kind === "array" || tv.kind === "tuple") {
|
|
611
|
+
const arrayMethods = [
|
|
612
|
+
{ label: "map", detail: "map(callback)" },
|
|
613
|
+
{ label: "filter", detail: "filter(callback)" },
|
|
614
|
+
{ label: "reduce", detail: "reduce(callback, init)" },
|
|
615
|
+
{ label: "find", detail: "find(callback)" },
|
|
616
|
+
{ label: "some", detail: "some(callback)" },
|
|
617
|
+
{ label: "every", detail: "every(callback)" },
|
|
618
|
+
{ label: "forEach", detail: "forEach(callback)" },
|
|
619
|
+
{ label: "flatMap", detail: "flatMap(callback)" },
|
|
620
|
+
{ label: "includes", detail: "includes(value)" },
|
|
621
|
+
{ label: "indexOf", detail: "indexOf(value)" },
|
|
622
|
+
{ label: "join", detail: "join(separator)" },
|
|
623
|
+
{ label: "slice", detail: "slice(start, end)" },
|
|
624
|
+
{ label: "concat", detail: "concat(other)" },
|
|
625
|
+
{ label: "push", detail: "push(value)" },
|
|
626
|
+
{ label: "length", detail: tv.kind === "tuple" ? `${tv.elements.length}` : "number" },
|
|
627
|
+
];
|
|
628
|
+
for (const m of arrayMethods) {
|
|
629
|
+
completions.push({ label: m.label, kind: "method", detail: m.detail });
|
|
630
|
+
}
|
|
631
|
+
return completions;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (tv.kind === "promise") {
|
|
635
|
+
completions.push({ label: "then", kind: "method", detail: "then(callback)" });
|
|
636
|
+
completions.push({ label: "catch", kind: "method", detail: "catch(callback)" });
|
|
637
|
+
completions.push({ label: "finally", kind: "method", detail: "finally(callback)" });
|
|
638
|
+
return completions;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (tv.kind === "primitive" && tv.type === "string") {
|
|
642
|
+
const stringMethods = ["toUpperCase", "toLowerCase", "trim", "split", "slice", "includes", "indexOf", "replace", "startsWith", "endsWith", "charAt", "length"];
|
|
643
|
+
for (const m of stringMethods) {
|
|
644
|
+
completions.push({ label: m, kind: "method", detail: `string.${m}` });
|
|
645
|
+
}
|
|
646
|
+
return completions;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return completions;
|
|
650
|
+
}
|