@nudojs/parser 0.3.0 → 0.3.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/package.json +11 -4
- package/CHANGELOG.md +0 -43
- package/src/__tests__/directives-inline.test.ts +0 -95
- package/src/__tests__/directives-phase2.test.ts +0 -113
- package/src/__tests__/directives-phase3.test.ts +0 -75
- package/src/__tests__/directives.test.ts +0 -411
- package/src/__tests__/mock-directive.test.ts +0 -115
- package/src/__tests__/sinon-directive.test.ts +0 -200
- package/src/directives.ts +0 -754
- package/src/index.ts +0 -23
- package/src/parse.ts +0 -15
- package/tsconfig.json +0 -9
package/src/directives.ts
DELETED
|
@@ -1,754 +0,0 @@
|
|
|
1
|
-
import type { Node, Comment } from "@babel/types";
|
|
2
|
-
import { type TypeValue, type MockHelper, T, createEnvironment, stub, spy, mock } from "@nudojs/core";
|
|
3
|
-
import { parse as babelParse } from "./parse.ts";
|
|
4
|
-
|
|
5
|
-
export type CaseDirective = {
|
|
6
|
-
kind: "case";
|
|
7
|
-
name: string;
|
|
8
|
-
args: TypeValue[];
|
|
9
|
-
expected?: TypeValue;
|
|
10
|
-
commentLine?: number;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export type MockDirective = {
|
|
14
|
-
kind: "mock";
|
|
15
|
-
name: string;
|
|
16
|
-
expression?: string;
|
|
17
|
-
fromPath?: string;
|
|
18
|
-
arrowFn?: { params: string[]; body: Node; paramPatterns: Node[] };
|
|
19
|
-
sinonExpr?: SinonExpression;
|
|
20
|
-
nudoMock?: MockHelper;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export type SinonExpression = {
|
|
24
|
-
type: "stub" | "spy" | "mock";
|
|
25
|
-
returnValue?: TypeValue;
|
|
26
|
-
resolvedValue?: TypeValue;
|
|
27
|
-
rejectedValue?: TypeValue;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export type PureDirective = {
|
|
31
|
-
kind: "pure";
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export type SkipDirective = {
|
|
35
|
-
kind: "skip";
|
|
36
|
-
returns?: TypeValue;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
export type SampleDirective = {
|
|
40
|
-
kind: "sample";
|
|
41
|
-
count: number;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
export type EnvDirective = {
|
|
45
|
-
kind: "env";
|
|
46
|
-
envs: string[];
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
export type MockModuleDirective = {
|
|
50
|
-
kind: "mock-module";
|
|
51
|
-
source: string;
|
|
52
|
-
names?: string[];
|
|
53
|
-
fromPath: string;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
export type AsDirective = {
|
|
57
|
-
kind: "as";
|
|
58
|
-
typeExpr: TypeValue;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
export type ReplaceDirective = {
|
|
62
|
-
kind: "replace";
|
|
63
|
-
targetSource: string;
|
|
64
|
-
typeExpr: TypeValue;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
export type InlineDirective = AsDirective | ReplaceDirective;
|
|
68
|
-
|
|
69
|
-
export type FileDirective = EnvDirective | MockModuleDirective;
|
|
70
|
-
|
|
71
|
-
export type Directive = CaseDirective | MockDirective | PureDirective | SkipDirective | SampleDirective;
|
|
72
|
-
|
|
73
|
-
export type FunctionWithDirectives = {
|
|
74
|
-
node: Node;
|
|
75
|
-
name: string;
|
|
76
|
-
directives: Directive[];
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const CASE_NAME_REGEX = /@nudo:case\s+"([^"]+)"\s*\(/g;
|
|
80
|
-
const MOCK_INLINE_REGEX = /@nudo:mock\s+(\w+)\s*=\s*(.+)/g;
|
|
81
|
-
const MOCK_FROM_REGEX = /@nudo:mock\s+(\w+)\s+from\s+"([^"]+)"/g;
|
|
82
|
-
const PURE_REGEX = /@nudo:pure\b/g;
|
|
83
|
-
const SKIP_REGEX = /@nudo:skip(?:\s+(.+))?/g;
|
|
84
|
-
const SAMPLE_REGEX = /@nudo:sample\s+(\d+)/g;
|
|
85
|
-
|
|
86
|
-
export function parseTypeValueExpr(expr: string): TypeValue {
|
|
87
|
-
const s = expr.trim();
|
|
88
|
-
|
|
89
|
-
if (s === "T.number") return T.number;
|
|
90
|
-
if (s === "T.string") return T.string;
|
|
91
|
-
if (s === "T.boolean") return T.boolean;
|
|
92
|
-
if (s === "T.unknown") return T.unknown;
|
|
93
|
-
if (s === "T.never") return T.never;
|
|
94
|
-
if (s === "T.null") return T.null;
|
|
95
|
-
if (s === "T.undefined") return T.undefined;
|
|
96
|
-
|
|
97
|
-
if (s === "true") return T.literal(true);
|
|
98
|
-
if (s === "false") return T.literal(false);
|
|
99
|
-
if (s === "null") return T.literal(null);
|
|
100
|
-
if (s === "undefined") return T.literal(undefined);
|
|
101
|
-
|
|
102
|
-
const literalMatch = s.match(/^T\.literal\((.+)\)$/);
|
|
103
|
-
if (literalMatch) {
|
|
104
|
-
return T.literal(parsePrimitiveValue(literalMatch[1].trim()));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (s.startsWith("T.union(") && s.endsWith(")")) {
|
|
108
|
-
const inner = s.slice("T.union(".length, -1);
|
|
109
|
-
const args = splitTopLevelArgs(inner);
|
|
110
|
-
return T.union(...args.map(parseTypeValueExpr));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (s.startsWith("T.array(") && s.endsWith(")")) {
|
|
114
|
-
const inner = s.slice("T.array(".length, -1);
|
|
115
|
-
return T.array(parseTypeValueExpr(inner));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (s.startsWith("T.tuple(") && s.endsWith(")")) {
|
|
119
|
-
const inner = s.slice("T.tuple(".length, -1).trim();
|
|
120
|
-
if (inner.startsWith("[") && inner.endsWith("]")) {
|
|
121
|
-
const elements = splitTopLevelArgs(inner.slice(1, -1));
|
|
122
|
-
return T.tuple(elements.map(parseTypeValueExpr));
|
|
123
|
-
}
|
|
124
|
-
return T.tuple([]);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if (s.startsWith("T.object(") && s.endsWith(")")) {
|
|
128
|
-
const inner = s.slice("T.object(".length, -1).trim();
|
|
129
|
-
if (inner.startsWith("{") && inner.endsWith("}")) {
|
|
130
|
-
const content = inner.slice(1, -1).trim();
|
|
131
|
-
if (!content) return T.object({});
|
|
132
|
-
const entries = splitTopLevelArgs(content);
|
|
133
|
-
const props: Record<string, TypeValue> = {};
|
|
134
|
-
for (const entry of entries) {
|
|
135
|
-
const colonIdx = entry.indexOf(":");
|
|
136
|
-
if (colonIdx === -1) continue;
|
|
137
|
-
const key = entry.slice(0, colonIdx).trim();
|
|
138
|
-
const val = entry.slice(colonIdx + 1).trim();
|
|
139
|
-
props[key] = parseTypeValueExpr(val);
|
|
140
|
-
}
|
|
141
|
-
return T.object(props);
|
|
142
|
-
}
|
|
143
|
-
return T.object({});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Function literals: (x) => expr, x => expr, (x, y) => expr, function(x) { ... }
|
|
147
|
-
if (findTopLevelArrow(s) !== -1 || /^function\s*[\w$]*\s*\(/.test(s)) {
|
|
148
|
-
const fnExpr = parseArrowFunctionExpr(s);
|
|
149
|
-
if (fnExpr) {
|
|
150
|
-
const fnType = T.fn(fnExpr.params, fnExpr.body, createEnvironment());
|
|
151
|
-
(fnType as any)._paramPatterns = fnExpr.paramPatterns;
|
|
152
|
-
return fnType;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (/^-?\d+(\.\d+)?$/.test(s)) return T.literal(Number(s));
|
|
157
|
-
|
|
158
|
-
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
159
|
-
return T.literal(s.slice(1, -1));
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (s.startsWith("{") && s.endsWith("}")) {
|
|
163
|
-
const content = s.slice(1, -1).trim();
|
|
164
|
-
if (!content) return T.object({});
|
|
165
|
-
const entries = splitTopLevelArgs(content);
|
|
166
|
-
const props: Record<string, TypeValue> = {};
|
|
167
|
-
for (const entry of entries) {
|
|
168
|
-
const colonIdx = findTopLevelColon(entry);
|
|
169
|
-
if (colonIdx === -1) continue;
|
|
170
|
-
const key = entry.slice(0, colonIdx).trim().replace(/^["']|["']$/g, "");
|
|
171
|
-
const val = entry.slice(colonIdx + 1).trim();
|
|
172
|
-
props[key] = parseTypeValueExpr(val);
|
|
173
|
-
}
|
|
174
|
-
return T.object(props);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (s.startsWith("[") && s.endsWith("]")) {
|
|
178
|
-
const content = s.slice(1, -1).trim();
|
|
179
|
-
if (!content) return T.tuple([]);
|
|
180
|
-
const elements = splitTopLevelArgs(content);
|
|
181
|
-
return T.tuple(elements.map(parseTypeValueExpr));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return T.unknown;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function parsePrimitiveValue(s: string): string | number | boolean | null | undefined {
|
|
188
|
-
if (s === "true") return true;
|
|
189
|
-
if (s === "false") return false;
|
|
190
|
-
if (s === "null") return null;
|
|
191
|
-
if (s === "undefined") return undefined;
|
|
192
|
-
if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
|
|
193
|
-
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
194
|
-
return s.slice(1, -1);
|
|
195
|
-
}
|
|
196
|
-
return s;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function splitTopLevelArgs(s: string): string[] {
|
|
200
|
-
const result: string[] = [];
|
|
201
|
-
let depth = 0;
|
|
202
|
-
let current = "";
|
|
203
|
-
for (const ch of s) {
|
|
204
|
-
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
205
|
-
if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
206
|
-
if (ch === "," && depth === 0) {
|
|
207
|
-
result.push(current.trim());
|
|
208
|
-
current = "";
|
|
209
|
-
} else {
|
|
210
|
-
current += ch;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
if (current.trim()) result.push(current.trim());
|
|
214
|
-
return result;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function findTopLevelColon(s: string): number {
|
|
218
|
-
let depth = 0;
|
|
219
|
-
for (let i = 0; i < s.length; i++) {
|
|
220
|
-
const ch = s[i];
|
|
221
|
-
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
222
|
-
if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
223
|
-
if (ch === ":" && depth === 0) return i;
|
|
224
|
-
}
|
|
225
|
-
return -1;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function findTopLevelArrow(s: string): number {
|
|
229
|
-
let depth = 0;
|
|
230
|
-
for (let i = 0; i < s.length - 1; i++) {
|
|
231
|
-
const ch = s[i];
|
|
232
|
-
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
233
|
-
else if (ch === ")" || ch === "]" || ch === "}") depth--;
|
|
234
|
-
else if (depth === 0 && ch === "=" && s[i + 1] === ">") return i;
|
|
235
|
-
}
|
|
236
|
-
return -1;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function extractBalancedParens(text: string, startIdx: number): string | null {
|
|
240
|
-
if (text[startIdx] !== "(") return null;
|
|
241
|
-
let depth = 0;
|
|
242
|
-
for (let i = startIdx; i < text.length; i++) {
|
|
243
|
-
if (text[i] === "(") depth++;
|
|
244
|
-
if (text[i] === ")") depth--;
|
|
245
|
-
if (depth === 0) return text.slice(startIdx + 1, i);
|
|
246
|
-
}
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function parseArrowFunctionExpr(expr: string): { params: string[]; body: Node; paramPatterns: Node[] } | null {
|
|
251
|
-
// Try to parse as an arrow function expression
|
|
252
|
-
try {
|
|
253
|
-
// Wrap in a variable declaration to make it a valid statement
|
|
254
|
-
const wrapped = `const __mock = ${expr};`;
|
|
255
|
-
const ast = babelParse(wrapped);
|
|
256
|
-
|
|
257
|
-
const decl = ast.program.body[0];
|
|
258
|
-
if (decl.type !== "VariableDeclaration") return null;
|
|
259
|
-
|
|
260
|
-
const init = decl.declarations[0]?.init;
|
|
261
|
-
if (!init || (init.type !== "ArrowFunctionExpression" && init.type !== "FunctionExpression")) {
|
|
262
|
-
return null;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const params: string[] = [];
|
|
266
|
-
const paramPatterns: Node[] = [];
|
|
267
|
-
for (let i = 0; i < init.params.length; i++) {
|
|
268
|
-
const param = init.params[i];
|
|
269
|
-
if (param.type === "Identifier") {
|
|
270
|
-
params.push(param.name);
|
|
271
|
-
paramPatterns.push(param);
|
|
272
|
-
} else if (param.type === "RestElement" && param.argument.type === "Identifier") {
|
|
273
|
-
// Handle rest parameters: (...args) => ...
|
|
274
|
-
params.push(`...${param.argument.name}`);
|
|
275
|
-
paramPatterns.push(param);
|
|
276
|
-
} else if (param.type === "ArrayPattern" || param.type === "ObjectPattern") {
|
|
277
|
-
// Handle destructuring patterns: ([a, b]) => ... or ({x, y}) => ...
|
|
278
|
-
params.push(`__destructured_${i}`);
|
|
279
|
-
paramPatterns.push(param);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return { params, body: init.body, paramPatterns };
|
|
284
|
-
} catch {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function parseSinonExpr(expr: string): SinonExpression | null {
|
|
290
|
-
const s = expr.trim();
|
|
291
|
-
|
|
292
|
-
// Match sinon.stub().returns(value)
|
|
293
|
-
const stubReturnsMatch = s.match(/^sinon\.stub\(\)\.returns\((.+)\)$/);
|
|
294
|
-
if (stubReturnsMatch) {
|
|
295
|
-
return {
|
|
296
|
-
type: "stub",
|
|
297
|
-
returnValue: parseTypeValueExpr(stubReturnsMatch[1].trim()),
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Match sinon.stub().resolves(value)
|
|
302
|
-
const stubResolvesMatch = s.match(/^sinon\.stub\(\)\.resolves\((.+)\)$/);
|
|
303
|
-
if (stubResolvesMatch) {
|
|
304
|
-
return {
|
|
305
|
-
type: "stub",
|
|
306
|
-
resolvedValue: parseTypeValueExpr(stubResolvesMatch[1].trim()),
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// Match sinon.stub().rejects(value)
|
|
311
|
-
const stubRejectsMatch = s.match(/^sinon\.stub\(\)\.rejects\((.+)\)$/);
|
|
312
|
-
if (stubRejectsMatch) {
|
|
313
|
-
return {
|
|
314
|
-
type: "stub",
|
|
315
|
-
rejectedValue: parseTypeValueExpr(stubRejectsMatch[1].trim()),
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Match sinon.stub().onFirstCall().returns(value)
|
|
320
|
-
const onFirstCallReturnsMatch = s.match(/^sinon\.stub\(\)\.onFirstCall\(\)\.returns\((.+)\)$/);
|
|
321
|
-
if (onFirstCallReturnsMatch) {
|
|
322
|
-
return {
|
|
323
|
-
type: "stub",
|
|
324
|
-
returnValue: parseTypeValueExpr(onFirstCallReturnsMatch[1].trim()),
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Match sinon.stub().onSecondCall().returns(value)
|
|
329
|
-
const onSecondCallReturnsMatch = s.match(/^sinon\.stub\(\)\.onSecondCall\(\)\.returns\((.+)\)$/);
|
|
330
|
-
if (onSecondCallReturnsMatch) {
|
|
331
|
-
return {
|
|
332
|
-
type: "stub",
|
|
333
|
-
returnValue: parseTypeValueExpr(onSecondCallReturnsMatch[1].trim()),
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Match sinon.stub().onThirdCall().returns(value)
|
|
338
|
-
const onThirdCallReturnsMatch = s.match(/^sinon\.stub\(\)\.onThirdCall\(\)\.returns\((.+)\)$/);
|
|
339
|
-
if (onThirdCallReturnsMatch) {
|
|
340
|
-
return {
|
|
341
|
-
type: "stub",
|
|
342
|
-
returnValue: parseTypeValueExpr(onThirdCallReturnsMatch[1].trim()),
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Match sinon.stub().onCall(n).returns(value)
|
|
347
|
-
const onCallReturnsMatch = s.match(/^sinon\.stub\(\)\.onCall\(\d+\)\.returns\((.+)\)$/);
|
|
348
|
-
if (onCallReturnsMatch) {
|
|
349
|
-
return {
|
|
350
|
-
type: "stub",
|
|
351
|
-
returnValue: parseTypeValueExpr(onCallReturnsMatch[1].trim()),
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Match sinon.stub().withArgs(args).returns(value)
|
|
356
|
-
const withArgsReturnsMatch = s.match(/^sinon\.stub\(\)\.withArgs\(.+\)\.returns\((.+)\)$/);
|
|
357
|
-
if (withArgsReturnsMatch) {
|
|
358
|
-
return {
|
|
359
|
-
type: "stub",
|
|
360
|
-
returnValue: parseTypeValueExpr(withArgsReturnsMatch[1].trim()),
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Match sinon.stub().callsFake(fn)
|
|
365
|
-
const callsFakeMatch = s.match(/^sinon\.stub\(\)\.callsFake\((.+)\)$/);
|
|
366
|
-
if (callsFakeMatch) {
|
|
367
|
-
// For callsFake, we try to parse the function as an arrow function
|
|
368
|
-
const arrowFn = parseArrowFunctionExpr(callsFakeMatch[1].trim());
|
|
369
|
-
if (arrowFn) {
|
|
370
|
-
return {
|
|
371
|
-
type: "stub",
|
|
372
|
-
returnValue: T.fn(arrowFn.params, arrowFn.body, createEnvironment()),
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
return { type: "stub" };
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Match sinon.stub().returns(value).onFirstCall().returns(otherValue)
|
|
379
|
-
// This is a complex chain - we'll just use the first .returns() value
|
|
380
|
-
const complexChainMatch = s.match(/^sinon\.stub\(\)\.returns\((.+)\)\./);
|
|
381
|
-
if (complexChainMatch) {
|
|
382
|
-
return {
|
|
383
|
-
type: "stub",
|
|
384
|
-
returnValue: parseTypeValueExpr(complexChainMatch[1].trim()),
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Match sinon.stub() (no chaining)
|
|
389
|
-
if (s === "sinon.stub()") {
|
|
390
|
-
return { type: "stub" };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Match sinon.spy()
|
|
394
|
-
if (s === "sinon.spy()") {
|
|
395
|
-
return { type: "spy" };
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// Match sinon.mock()
|
|
399
|
-
if (s === "sinon.mock()") {
|
|
400
|
-
return { type: "mock" };
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
return null;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function parseNudoMockExpr(expr: string): MockHelper | null {
|
|
407
|
-
const s = expr.trim();
|
|
408
|
-
|
|
409
|
-
// Match stub().returns(value).onFirstCall()
|
|
410
|
-
const stubReturnsOnFirstMatch = s.match(/^stub\(\)\.returns\((.+)\)\.onFirstCall\(\)$/);
|
|
411
|
-
if (stubReturnsOnFirstMatch) {
|
|
412
|
-
const helper = stub.returns(parseTypeValueExpr(stubReturnsOnFirstMatch[1].trim()));
|
|
413
|
-
helper.onFirstCallValue = helper.returnValue;
|
|
414
|
-
return helper;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Match stub().returns(value).onSecondCall()
|
|
418
|
-
const stubReturnsOnSecondMatch = s.match(/^stub\(\)\.returns\((.+)\)\.onSecondCall\(\)$/);
|
|
419
|
-
if (stubReturnsOnSecondMatch) {
|
|
420
|
-
return stub.returns(parseTypeValueExpr(stubReturnsOnSecondMatch[1].trim()));
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Match stub().returns(value).onCall(n)
|
|
424
|
-
const stubReturnsOnCallMatch = s.match(/^stub\(\)\.returns\((.+)\)\.onCall\(\d+\)$/);
|
|
425
|
-
if (stubReturnsOnCallMatch) {
|
|
426
|
-
return stub.returns(parseTypeValueExpr(stubReturnsOnCallMatch[1].trim()));
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Match stub().callsFake((args) => body)
|
|
430
|
-
const stubCallsFakeMatch = s.match(/^stub\(\)\.callsFake\((.+)\)$/);
|
|
431
|
-
if (stubCallsFakeMatch) {
|
|
432
|
-
const helper: MockHelper = { kind: "mock-helper" };
|
|
433
|
-
helper.callsFakeImpl = parseTypeValueExpr(stubCallsFakeMatch[1].trim());
|
|
434
|
-
return helper;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Match stub().withArgs(args).returns(value)
|
|
438
|
-
const stubWithArgsMatch = s.match(/^stub\(\)\.withArgs\((.+)\)\.returns\((.+)\)$/);
|
|
439
|
-
if (stubWithArgsMatch) {
|
|
440
|
-
const retVal = parseTypeValueExpr(stubWithArgsMatch[2].trim());
|
|
441
|
-
const argsStr = stubWithArgsMatch[1].trim();
|
|
442
|
-
const args = splitTopLevelArgs(argsStr).map(parseTypeValueExpr);
|
|
443
|
-
// 返回值只挂在 withArgs 分支上(sinon 语义:实参匹配才返回),不设全局
|
|
444
|
-
// returnValue —— 否则未命中调用会错误复用链返回值
|
|
445
|
-
const helper: MockHelper = { kind: "mock-helper" };
|
|
446
|
-
helper.withArgsCases = [{ args, returnValue: retVal }];
|
|
447
|
-
return helper;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Match stub().onFirstCall().returns(value) —— 链在 returns 上取值
|
|
451
|
-
const stubOnFirstReturnsMatch = s.match(/^stub\(\)\.onFirstCall\(\)\.returns\((.+)\)$/);
|
|
452
|
-
if (stubOnFirstReturnsMatch) {
|
|
453
|
-
return stub.returns(parseTypeValueExpr(stubOnFirstReturnsMatch[1].trim()));
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Match stub().onFirstCall(value) —— 无 returnValue 时 TypeValue/Abs 均作默认返回
|
|
457
|
-
// [^()]* 避免把 onFirstCall().returns(...) 的尾链吞进实参
|
|
458
|
-
const stubOnFirstValueMatch = s.match(/^stub\(\)\.onFirstCall\(([^()]*)\)$/);
|
|
459
|
-
if (stubOnFirstValueMatch && stubOnFirstValueMatch[1].trim() !== "") {
|
|
460
|
-
const helper: MockHelper = { kind: "mock-helper" };
|
|
461
|
-
helper.onFirstCallValue = parseTypeValueExpr(stubOnFirstValueMatch[1].trim());
|
|
462
|
-
return helper;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Match spy().returns(value)
|
|
466
|
-
const spyReturnsMatch = s.match(/^spy\(\)\.returns\((.+)\)$/);
|
|
467
|
-
if (spyReturnsMatch) {
|
|
468
|
-
return spy.returns(parseTypeValueExpr(spyReturnsMatch[1].trim()));
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// Match stub().resolves(value).onFirstCall()
|
|
472
|
-
const stubResolvesOnFirstMatch = s.match(/^stub\(\)\.resolves\((.+)\)\.onFirstCall\(\)$/);
|
|
473
|
-
if (stubResolvesOnFirstMatch) {
|
|
474
|
-
return stub.resolves(parseTypeValueExpr(stubResolvesOnFirstMatch[1].trim()));
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// Match stub().returns(value)
|
|
478
|
-
const stubReturnsMatch = s.match(/^stub\(\)\.returns\((.+)\)$/);
|
|
479
|
-
if (stubReturnsMatch) {
|
|
480
|
-
return stub.returns(parseTypeValueExpr(stubReturnsMatch[1].trim()));
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Match stub().resolves(value)
|
|
484
|
-
const stubResolvesMatch = s.match(/^stub\(\)\.resolves\((.+)\)$/);
|
|
485
|
-
if (stubResolvesMatch) {
|
|
486
|
-
return stub.resolves(parseTypeValueExpr(stubResolvesMatch[1].trim()));
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Match stub().rejects(value)
|
|
490
|
-
const stubRejectsMatch = s.match(/^stub\(\)\.rejects\((.+)\)$/);
|
|
491
|
-
if (stubRejectsMatch) {
|
|
492
|
-
return stub.rejects(parseTypeValueExpr(stubRejectsMatch[1].trim()));
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Match stub()
|
|
496
|
-
if (s === "stub()") {
|
|
497
|
-
return stub();
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Match spy()
|
|
501
|
-
if (s === "spy()") {
|
|
502
|
-
return spy();
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Match mock()
|
|
506
|
-
if (s === "mock()") {
|
|
507
|
-
return mock();
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// sinon 前缀链统一为同一 MockHelper 形态:strip 前缀后复用裸 stub 解析,
|
|
511
|
-
// 使下游(applyMocks → mockHelperToTypeValue)两条路径消费一致。
|
|
512
|
-
// 解析不出的 sinon 链仍回落 sinonExpr 路径(parseSinonExpr 自己的分支)。
|
|
513
|
-
if (s.startsWith("sinon.")) {
|
|
514
|
-
return parseNudoMockExpr(s.slice("sinon.".length));
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return null;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function parseDirectivesFromComments(comments: readonly Comment[]): Directive[] {
|
|
521
|
-
const directives: Directive[] = [];
|
|
522
|
-
for (const comment of comments) {
|
|
523
|
-
const text = comment.value;
|
|
524
|
-
|
|
525
|
-
MOCK_FROM_REGEX.lastIndex = 0;
|
|
526
|
-
let mockFromMatch: RegExpExecArray | null;
|
|
527
|
-
const mockFromRanges: [number, number][] = [];
|
|
528
|
-
while ((mockFromMatch = MOCK_FROM_REGEX.exec(text)) !== null) {
|
|
529
|
-
directives.push({
|
|
530
|
-
kind: "mock",
|
|
531
|
-
name: mockFromMatch[1],
|
|
532
|
-
fromPath: mockFromMatch[2],
|
|
533
|
-
});
|
|
534
|
-
mockFromRanges.push([mockFromMatch.index, mockFromMatch.index + mockFromMatch[0].length]);
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
MOCK_INLINE_REGEX.lastIndex = 0;
|
|
538
|
-
let mockMatch: RegExpExecArray | null;
|
|
539
|
-
while ((mockMatch = MOCK_INLINE_REGEX.exec(text)) !== null) {
|
|
540
|
-
const inFromRange = mockFromRanges.some(
|
|
541
|
-
([s, e]) => mockMatch!.index >= s && mockMatch!.index < e,
|
|
542
|
-
);
|
|
543
|
-
if (inFromRange) continue;
|
|
544
|
-
|
|
545
|
-
const expr = mockMatch[2].trim();
|
|
546
|
-
let arrowFn = parseArrowFunctionExpr(expr);
|
|
547
|
-
const sinonExpr = parseSinonExpr(expr);
|
|
548
|
-
const nudoMock = parseNudoMockExpr(expr);
|
|
549
|
-
|
|
550
|
-
// callsFake 统一:sinon.stub().callsFake(fn) / stub().callsFake(fn) 都把
|
|
551
|
-
// fn 提升到 arrowFn 字段——与 @nudo:mock f = (x) => ... 走完全同一绑定机制
|
|
552
|
-
// (applyMocks 的 T.fn + _paramPatterns + 全局 env 闭包),调用时以实参求值
|
|
553
|
-
if (!arrowFn) {
|
|
554
|
-
const callsFakeChain = expr.match(/^(?:sinon\.)?stub\(\)\.callsFake\((.+)\)$/);
|
|
555
|
-
if (callsFakeChain) {
|
|
556
|
-
arrowFn = parseArrowFunctionExpr(callsFakeChain[1].trim());
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
directives.push({
|
|
561
|
-
kind: "mock",
|
|
562
|
-
name: mockMatch[1],
|
|
563
|
-
expression: expr,
|
|
564
|
-
arrowFn: arrowFn ?? undefined,
|
|
565
|
-
sinonExpr: sinonExpr ?? undefined,
|
|
566
|
-
nudoMock: nudoMock ?? undefined,
|
|
567
|
-
});
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
CASE_NAME_REGEX.lastIndex = 0;
|
|
571
|
-
let match: RegExpExecArray | null;
|
|
572
|
-
const commentStartLine = comment.loc?.start.line ?? 0;
|
|
573
|
-
while ((match = CASE_NAME_REGEX.exec(text)) !== null) {
|
|
574
|
-
const name = match[1];
|
|
575
|
-
const parenStart = match.index + match[0].length - 1;
|
|
576
|
-
const argsStr = extractBalancedParens(text, parenStart);
|
|
577
|
-
if (argsStr === null) continue;
|
|
578
|
-
const args = splitTopLevelArgs(argsStr).map(parseTypeValueExpr);
|
|
579
|
-
|
|
580
|
-
const afterParen = parenStart + argsStr.length + 2;
|
|
581
|
-
const restLine = text.slice(afterParen).split("\n")[0].trim();
|
|
582
|
-
const arrowMatch = restLine.match(/^=>\s*(.+)/);
|
|
583
|
-
const expected = arrowMatch ? parseTypeValueExpr(arrowMatch[1].trim()) : undefined;
|
|
584
|
-
|
|
585
|
-
const linesBeforeMatch = text.slice(0, match.index).split("\n").length - 1;
|
|
586
|
-
const commentLine = commentStartLine + linesBeforeMatch;
|
|
587
|
-
|
|
588
|
-
directives.push({ kind: "case", name, args, expected, commentLine });
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
PURE_REGEX.lastIndex = 0;
|
|
592
|
-
if (PURE_REGEX.test(text)) {
|
|
593
|
-
directives.push({ kind: "pure" });
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
SKIP_REGEX.lastIndex = 0;
|
|
597
|
-
let skipMatch: RegExpExecArray | null;
|
|
598
|
-
while ((skipMatch = SKIP_REGEX.exec(text)) !== null) {
|
|
599
|
-
const returnsExpr = skipMatch[1]?.trim();
|
|
600
|
-
directives.push({
|
|
601
|
-
kind: "skip",
|
|
602
|
-
returns: returnsExpr ? parseTypeValueExpr(returnsExpr) : undefined,
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
SAMPLE_REGEX.lastIndex = 0;
|
|
607
|
-
let sampleMatch: RegExpExecArray | null;
|
|
608
|
-
while ((sampleMatch = SAMPLE_REGEX.exec(text)) !== null) {
|
|
609
|
-
directives.push({ kind: "sample", count: Number(sampleMatch[1]) });
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
return directives;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
function getFunctionName(node: Node): string {
|
|
616
|
-
if (node.type === "FunctionDeclaration" && node.id) return node.id.name;
|
|
617
|
-
if (node.type === "ExportNamedDeclaration" && node.declaration?.type === "FunctionDeclaration" && node.declaration.id) {
|
|
618
|
-
return node.declaration.id.name;
|
|
619
|
-
}
|
|
620
|
-
if (node.type === "ExportDefaultDeclaration" && node.declaration.type === "FunctionDeclaration" && node.declaration.id) {
|
|
621
|
-
return node.declaration.id.name;
|
|
622
|
-
}
|
|
623
|
-
if (node.type === "VariableDeclaration") {
|
|
624
|
-
const decl = node.declarations[0];
|
|
625
|
-
if (decl.id.type === "Identifier") return decl.id.name;
|
|
626
|
-
}
|
|
627
|
-
return "<anonymous>";
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
export function extractDirectives(ast: Node): FunctionWithDirectives[] {
|
|
631
|
-
const results: FunctionWithDirectives[] = [];
|
|
632
|
-
|
|
633
|
-
if (ast.type !== "File") return results;
|
|
634
|
-
const body = ast.program.body;
|
|
635
|
-
|
|
636
|
-
for (const stmt of body) {
|
|
637
|
-
const leadingComments = stmt.leadingComments;
|
|
638
|
-
if (!leadingComments || leadingComments.length === 0) continue;
|
|
639
|
-
|
|
640
|
-
const directives = parseDirectivesFromComments(leadingComments);
|
|
641
|
-
if (directives.length === 0) continue;
|
|
642
|
-
|
|
643
|
-
const name = getFunctionName(stmt);
|
|
644
|
-
results.push({ node: stmt, name, directives });
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
return results;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
const AS_REGEX = /^\s*@nudo:as\s+(.+)/;
|
|
651
|
-
const REPLACE_REGEX = /^\s*@nudo:replace\s+(.+)/;
|
|
652
|
-
|
|
653
|
-
export function extractInlineDirectives(node: Node): InlineDirective[] {
|
|
654
|
-
const comments = (node as any).leadingComments as Comment[] | undefined;
|
|
655
|
-
if (!comments) return [];
|
|
656
|
-
|
|
657
|
-
const results: InlineDirective[] = [];
|
|
658
|
-
for (const comment of comments) {
|
|
659
|
-
if (comment.type !== "CommentLine") continue;
|
|
660
|
-
const text = comment.value;
|
|
661
|
-
|
|
662
|
-
const asMatch = text.match(AS_REGEX);
|
|
663
|
-
if (asMatch) {
|
|
664
|
-
results.push({ kind: "as", typeExpr: parseTypeValueExpr(asMatch[1].trim()) });
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const replaceMatch = text.match(REPLACE_REGEX);
|
|
669
|
-
if (replaceMatch) {
|
|
670
|
-
const raw = replaceMatch[1].trim();
|
|
671
|
-
const sepIdx = findReplaceSeparator(raw);
|
|
672
|
-
if (sepIdx !== -1) {
|
|
673
|
-
const targetSource = raw.slice(0, sepIdx).trim();
|
|
674
|
-
const typeExprStr = raw.slice(sepIdx + 1).trim();
|
|
675
|
-
results.push({
|
|
676
|
-
kind: "replace",
|
|
677
|
-
targetSource,
|
|
678
|
-
typeExpr: parseTypeValueExpr(typeExprStr),
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
return results;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
function findReplaceSeparator(raw: string): number {
|
|
687
|
-
let depth = 0;
|
|
688
|
-
let inString: string | null = null;
|
|
689
|
-
for (let i = 0; i < raw.length; i++) {
|
|
690
|
-
const ch = raw[i];
|
|
691
|
-
if (inString) {
|
|
692
|
-
if (ch === inString && raw[i - 1] !== "\\") inString = null;
|
|
693
|
-
continue;
|
|
694
|
-
}
|
|
695
|
-
if (ch === '"' || ch === "'") { inString = ch; continue; }
|
|
696
|
-
if (ch === "(" || ch === "[" || ch === "{") { depth++; continue; }
|
|
697
|
-
if (ch === ")" || ch === "]" || ch === "}") { depth--; continue; }
|
|
698
|
-
if (ch === " " && depth === 0) {
|
|
699
|
-
const rest = raw.slice(i + 1).trimStart();
|
|
700
|
-
if (rest.startsWith("T.") || rest.startsWith("{") || rest.startsWith("[") ||
|
|
701
|
-
rest.startsWith('"') || rest.startsWith("'") ||
|
|
702
|
-
/^-?\d/.test(rest) || rest === "true" || rest === "false" ||
|
|
703
|
-
rest === "null" || rest === "undefined") {
|
|
704
|
-
return i;
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
return -1;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
const ENV_REGEX = /^\/\s*@nudo:env\s+(.+)/;
|
|
712
|
-
const MOCK_MODULE_REGEX = /^\/\s*@nudo:mock-module\s+"([^"]+)"\s+from\s+"([^"]+)"/;
|
|
713
|
-
const MOCK_MODULE_PARTIAL_REGEX = /^\/\s*@nudo:mock-module\s+"([^"]+)"\s*\{([^}]+)\}\s*from\s+"([^"]+)"/;
|
|
714
|
-
|
|
715
|
-
export function extractFileDirectives(ast: Node): FileDirective[] {
|
|
716
|
-
const results: FileDirective[] = [];
|
|
717
|
-
if (ast.type !== "File") return results;
|
|
718
|
-
|
|
719
|
-
const comments: readonly Comment[] = (ast as any).comments ?? [];
|
|
720
|
-
for (const comment of comments) {
|
|
721
|
-
if (comment.type !== "CommentLine") continue;
|
|
722
|
-
const text = comment.value;
|
|
723
|
-
|
|
724
|
-
const partialMatch = text.match(MOCK_MODULE_PARTIAL_REGEX);
|
|
725
|
-
if (partialMatch) {
|
|
726
|
-
const names = partialMatch[2].split(",").map((n) => n.trim()).filter(Boolean);
|
|
727
|
-
results.push({
|
|
728
|
-
kind: "mock-module",
|
|
729
|
-
source: partialMatch[1],
|
|
730
|
-
names,
|
|
731
|
-
fromPath: partialMatch[3],
|
|
732
|
-
});
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const mockModuleMatch = text.match(MOCK_MODULE_REGEX);
|
|
737
|
-
if (mockModuleMatch) {
|
|
738
|
-
results.push({
|
|
739
|
-
kind: "mock-module",
|
|
740
|
-
source: mockModuleMatch[1],
|
|
741
|
-
fromPath: mockModuleMatch[2],
|
|
742
|
-
});
|
|
743
|
-
continue;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
const envMatch = text.match(ENV_REGEX);
|
|
747
|
-
if (envMatch) {
|
|
748
|
-
const envs = envMatch[1].split(",").map((e) => e.trim()).filter(Boolean);
|
|
749
|
-
results.push({ kind: "env", envs });
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
return results;
|
|
754
|
-
}
|