@nudojs/parser 1.5.2 → 1.5.4
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/dist/index.d.ts +28 -0
- package/dist/index.js +525 -82
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -118,10 +118,20 @@ type MockDirective = {
|
|
|
118
118
|
name: string;
|
|
119
119
|
expression?: string;
|
|
120
120
|
fromPath?: string;
|
|
121
|
+
/**
|
|
122
|
+
* 内联箭头 mock 的扫描结果:形参(源码形态)+ 体 + async 标志。
|
|
123
|
+
* - `params`:1:1 于形参个数的**源码形态**(签名用;默认形参保留默认值、
|
|
124
|
+
* rest 保留 `...`、解构形参保留 `{ a, b }`)。
|
|
125
|
+
* - `paramPatterns`:形参 AST(原样)——消费面可自行解构绑定。
|
|
126
|
+
* - `boundNames`:每个形参**绑定的名字**(作用域用;解构列出顶层绑定名)。
|
|
127
|
+
* - `async`:`async (x) => …` 的 async 语义(Bug 857)。
|
|
128
|
+
*/
|
|
121
129
|
arrowFn?: {
|
|
122
130
|
params: string[];
|
|
123
131
|
body: Node;
|
|
124
132
|
paramPatterns: Node[];
|
|
133
|
+
async?: boolean;
|
|
134
|
+
boundNames?: string[][];
|
|
125
135
|
};
|
|
126
136
|
sinonExpr?: SinonExpression;
|
|
127
137
|
nudoMock?: MockHelper;
|
|
@@ -171,9 +181,27 @@ type FunctionWithDirectives = {
|
|
|
171
181
|
directives: Directive[];
|
|
172
182
|
};
|
|
173
183
|
|
|
184
|
+
/**
|
|
185
|
+
* 指令文法诊断。
|
|
186
|
+
*
|
|
187
|
+
* `at`:**出错指令在源码中的位置**(行 1-based / 列 0-based)。
|
|
188
|
+
*
|
|
189
|
+
* Bug 848:去重身份此前只有 `code + message`(**无位置**)⇒ 同文件两处
|
|
190
|
+
* **逐字相同**的畸形指令**塌缩成一条** ⇒ 第二处**完全不可见**(用户修好
|
|
191
|
+
* 第一处后以为已经修完)。身份必须含位置:同一处同一问题仍去重(extract
|
|
192
|
+
* + 再解析共域),**不同位置**的同文案问题各报一条。
|
|
193
|
+
*
|
|
194
|
+
* 不传 `at` 的调用点保持旧身份(`code + message`)——兼容未升级的调用方,
|
|
195
|
+
* 但**同文案塌缩**在这些族上仍存在(见交付报告的「需收口」清单)。
|
|
196
|
+
*/
|
|
174
197
|
type DirectiveDiag = {
|
|
175
198
|
code: string;
|
|
176
199
|
message: string;
|
|
200
|
+
/** 出错指令的位置(行 1-based / 列 0-based);缺省 ⇒ 旧身份(无位置) */
|
|
201
|
+
at?: {
|
|
202
|
+
line: number;
|
|
203
|
+
column: number;
|
|
204
|
+
};
|
|
177
205
|
};
|
|
178
206
|
/**
|
|
179
207
|
* 在指定诊断数组上执行 fn:fn 内所有指令文法诊断(含不传 diags 的 extract
|
package/dist/index.js
CHANGED
|
@@ -188,7 +188,9 @@ function runWithDirectiveDiags(diags, fn) {
|
|
|
188
188
|
}
|
|
189
189
|
function emitDirectiveDiag(d) {
|
|
190
190
|
if (!activeDiagSink) return;
|
|
191
|
-
const
|
|
191
|
+
const at = d.at;
|
|
192
|
+
const posKey = at ? `${at.line}:${at.column}` : "";
|
|
193
|
+
const key = `${d.code}\0${d.message}\0${posKey}`;
|
|
192
194
|
if (activeDiagSink.seen.has(key)) return;
|
|
193
195
|
activeDiagSink.seen.add(key);
|
|
194
196
|
activeDiagSink.list.push(d);
|
|
@@ -202,6 +204,8 @@ import {
|
|
|
202
204
|
numLit,
|
|
203
205
|
strLit,
|
|
204
206
|
boolLit,
|
|
207
|
+
bigintLit,
|
|
208
|
+
litValue,
|
|
205
209
|
execNudoModule,
|
|
206
210
|
isNudoConstraint,
|
|
207
211
|
constraintToEntryAbs,
|
|
@@ -212,6 +216,7 @@ import {
|
|
|
212
216
|
import { absLit, DIAG } from "@nudojs/core/internal";
|
|
213
217
|
|
|
214
218
|
// src/directives/expr-scan.ts
|
|
219
|
+
import { formalParamsFromNodes } from "@nudojs/core";
|
|
215
220
|
function scanWithStrings(s, onChar, onStructural) {
|
|
216
221
|
let depth = 0;
|
|
217
222
|
let inString = null;
|
|
@@ -307,9 +312,32 @@ function findTopLevelArrow(s) {
|
|
|
307
312
|
}
|
|
308
313
|
return -1;
|
|
309
314
|
}
|
|
315
|
+
var ARROW_WRAPPER_PREFIX = "const __mock = ";
|
|
316
|
+
function paramSource(param, expr) {
|
|
317
|
+
const start = param.start;
|
|
318
|
+
const end = param.end;
|
|
319
|
+
if (typeof start === "number" && typeof end === "number") {
|
|
320
|
+
const from = start - ARROW_WRAPPER_PREFIX.length;
|
|
321
|
+
const to = end - ARROW_WRAPPER_PREFIX.length;
|
|
322
|
+
if (from >= 0 && to <= expr.length && from < to) return expr.slice(from, to);
|
|
323
|
+
}
|
|
324
|
+
return "";
|
|
325
|
+
}
|
|
326
|
+
function boundNamesOf(formal) {
|
|
327
|
+
if (!formal) return [];
|
|
328
|
+
switch (formal.kind) {
|
|
329
|
+
case "id":
|
|
330
|
+
case "default":
|
|
331
|
+
return [formal.name];
|
|
332
|
+
case "rest":
|
|
333
|
+
return [formal.name];
|
|
334
|
+
case "pattern":
|
|
335
|
+
return [...formal.bound];
|
|
336
|
+
}
|
|
337
|
+
}
|
|
310
338
|
function parseArrowFunctionExpr(expr) {
|
|
311
339
|
try {
|
|
312
|
-
const wrapped =
|
|
340
|
+
const wrapped = `${ARROW_WRAPPER_PREFIX}${expr};`;
|
|
313
341
|
const ast = parse(wrapped);
|
|
314
342
|
const decl = ast.program.body[0];
|
|
315
343
|
if (decl.type !== "VariableDeclaration") return null;
|
|
@@ -317,27 +345,68 @@ function parseArrowFunctionExpr(expr) {
|
|
|
317
345
|
if (!init || init.type !== "ArrowFunctionExpression" && init.type !== "FunctionExpression") {
|
|
318
346
|
return null;
|
|
319
347
|
}
|
|
348
|
+
const rawParams = init.params.filter(
|
|
349
|
+
(p) => !(p.type === "Identifier" && p.name === "this")
|
|
350
|
+
);
|
|
351
|
+
const formals = formalParamsFromNodes(rawParams);
|
|
320
352
|
const params = [];
|
|
353
|
+
const boundNames = [];
|
|
321
354
|
const paramPatterns = [];
|
|
322
|
-
for (let i = 0; i <
|
|
323
|
-
const param =
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
} else if (param.type === "RestElement" && param.argument.type === "Identifier") {
|
|
328
|
-
params.push(`...${param.argument.name}`);
|
|
329
|
-
paramPatterns.push(param);
|
|
330
|
-
} else if (param.type === "ArrayPattern" || param.type === "ObjectPattern") {
|
|
331
|
-
params.push(`__destructured_${i}`);
|
|
332
|
-
paramPatterns.push(param);
|
|
333
|
-
}
|
|
355
|
+
for (let i = 0; i < rawParams.length; i++) {
|
|
356
|
+
const param = rawParams[i];
|
|
357
|
+
paramPatterns.push(param);
|
|
358
|
+
params.push(paramSource(param, expr));
|
|
359
|
+
boundNames.push(boundNamesOf(formals[i]));
|
|
334
360
|
}
|
|
335
|
-
return {
|
|
361
|
+
return {
|
|
362
|
+
params,
|
|
363
|
+
body: init.body,
|
|
364
|
+
paramPatterns,
|
|
365
|
+
async: init.async === true,
|
|
366
|
+
boundNames
|
|
367
|
+
};
|
|
336
368
|
} catch {
|
|
337
369
|
return null;
|
|
338
370
|
}
|
|
339
371
|
}
|
|
340
372
|
|
|
373
|
+
// src/directives/string-literal.ts
|
|
374
|
+
function findClosingQuote(s, quote) {
|
|
375
|
+
for (let i = 1; i < s.length; i++) {
|
|
376
|
+
const c = s[i];
|
|
377
|
+
if (c === "\\") {
|
|
378
|
+
i += 1;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (c === quote) return i;
|
|
382
|
+
}
|
|
383
|
+
return -1;
|
|
384
|
+
}
|
|
385
|
+
function scanStringLiteral(text) {
|
|
386
|
+
const s = text.trim();
|
|
387
|
+
const quote = s[0];
|
|
388
|
+
if (quote !== '"' && quote !== "'") {
|
|
389
|
+
return { kind: "not-literal", reason: "not-quoted" };
|
|
390
|
+
}
|
|
391
|
+
const close = findClosingQuote(s, quote);
|
|
392
|
+
if (close === -1) return { kind: "not-literal", reason: "unterminated" };
|
|
393
|
+
if (close !== s.length - 1) return { kind: "not-literal", reason: "trailing-text" };
|
|
394
|
+
try {
|
|
395
|
+
const ast = parse(`(${s});`);
|
|
396
|
+
const stmt = ast.program.body[0];
|
|
397
|
+
if (!stmt || stmt.type !== "ExpressionStatement") {
|
|
398
|
+
return { kind: "not-literal", reason: "not-parseable" };
|
|
399
|
+
}
|
|
400
|
+
const expr = stmt.expression;
|
|
401
|
+
if (expr.type !== "StringLiteral") {
|
|
402
|
+
return { kind: "not-literal", reason: "not-parseable" };
|
|
403
|
+
}
|
|
404
|
+
return { kind: "literal", value: expr.value };
|
|
405
|
+
} catch {
|
|
406
|
+
return { kind: "not-literal", reason: "not-parseable" };
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
341
410
|
// src/directives/case-arg.ts
|
|
342
411
|
function absExact(shape) {
|
|
343
412
|
return makeAbs(shape, void 0, void 0, "exact");
|
|
@@ -482,7 +551,13 @@ function constraintToCaseArgAbs(c, depth) {
|
|
|
482
551
|
if (c.fields) {
|
|
483
552
|
const slots = {};
|
|
484
553
|
for (const [key, field] of Object.entries(c.fields)) {
|
|
485
|
-
slots[key] = {
|
|
554
|
+
slots[key] = {
|
|
555
|
+
value: constraintToCaseArgAbs(field.constraint, depth + 1),
|
|
556
|
+
// 可选标记必须投影进槽(与 core 的 constraintToEntryAbs 同口径):
|
|
557
|
+
// 丢掉它 ⇒ 可选槽变必填 ⇒ 缺该字段的**合法**实际值被判红,
|
|
558
|
+
// 且报出的 expected 文案不带 `?`(与契约面同文法自相矛盾)。
|
|
559
|
+
...field.optional || field.constraint.isOptional ? { optional: true } : {}
|
|
560
|
+
};
|
|
486
561
|
}
|
|
487
562
|
return absExact({ k: "obj", slots });
|
|
488
563
|
}
|
|
@@ -536,23 +611,70 @@ function parseLiteralOrStructure(expr, depth) {
|
|
|
536
611
|
return absFunction(fnExpr.params, { body: fnExpr.body });
|
|
537
612
|
}
|
|
538
613
|
}
|
|
539
|
-
|
|
540
|
-
if (
|
|
541
|
-
|
|
614
|
+
const numAbs = parseNumberLiteralExpr(s);
|
|
615
|
+
if (numAbs) return numAbs;
|
|
616
|
+
if (s.startsWith('"') || s.startsWith("'")) {
|
|
617
|
+
const scan = scanStringLiteral(s);
|
|
618
|
+
if (scan.kind === "literal") return absLit(scan.value);
|
|
619
|
+
return absUnknown();
|
|
620
|
+
}
|
|
621
|
+
if (s.startsWith("{") || s.startsWith("[")) {
|
|
622
|
+
if (literalNestingDepth(s) > MAX_CASE_ARG_DEPTH) return depthCapDiag(s);
|
|
623
|
+
const kind = structureLiteralKind(s);
|
|
624
|
+
if (kind === "obj") {
|
|
625
|
+
return parseObjectLiteral(s.slice(1, -1).trim(), depth);
|
|
626
|
+
}
|
|
627
|
+
if (kind === "arr") {
|
|
628
|
+
const content = s.slice(1, -1).trim();
|
|
629
|
+
if (!content) return absExact({ k: "tuple", elements: [] });
|
|
630
|
+
const elements = splitTopLevelArgs(content);
|
|
631
|
+
return absExact({
|
|
632
|
+
k: "tuple",
|
|
633
|
+
elements: elements.map((e) => parseCaseArgExpr(e, depth + 1))
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
return absUnknown();
|
|
542
637
|
}
|
|
543
|
-
|
|
544
|
-
|
|
638
|
+
const templateAbs = parseTemplateLiteralExpr(s);
|
|
639
|
+
if (templateAbs) return templateAbs;
|
|
640
|
+
return absUnknown();
|
|
641
|
+
}
|
|
642
|
+
function literalNestingDepth(s) {
|
|
643
|
+
let depth = 0;
|
|
644
|
+
let max = 0;
|
|
645
|
+
let inString = null;
|
|
646
|
+
for (let i = 0; i < s.length; i++) {
|
|
647
|
+
const ch = s[i];
|
|
648
|
+
if (inString) {
|
|
649
|
+
if (ch === inString) inString = null;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (ch === '"' || ch === "'") {
|
|
653
|
+
inString = ch;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
657
|
+
depth++;
|
|
658
|
+
if (depth > max) max = depth;
|
|
659
|
+
} else if (ch === ")" || ch === "]" || ch === "}") {
|
|
660
|
+
depth--;
|
|
661
|
+
}
|
|
545
662
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
663
|
+
return max;
|
|
664
|
+
}
|
|
665
|
+
function structureLiteralKind(s) {
|
|
666
|
+
try {
|
|
667
|
+
const ast = parse(`(${s});`);
|
|
668
|
+
if (ast.program.body.length !== 1) return void 0;
|
|
669
|
+
const stmt = ast.program.body[0];
|
|
670
|
+
if (stmt.type !== "ExpressionStatement") return void 0;
|
|
671
|
+
const expr = stmt.expression;
|
|
672
|
+
if (expr.type === "ObjectExpression") return "obj";
|
|
673
|
+
if (expr.type === "ArrayExpression") return "arr";
|
|
674
|
+
return void 0;
|
|
675
|
+
} catch {
|
|
676
|
+
return void 0;
|
|
554
677
|
}
|
|
555
|
-
return absUnknown();
|
|
556
678
|
}
|
|
557
679
|
function parseObjectLiteral(content, depth) {
|
|
558
680
|
if (!content) return absExact({ k: "obj", slots: {} });
|
|
@@ -560,7 +682,13 @@ function parseObjectLiteral(content, depth) {
|
|
|
560
682
|
const slots = {};
|
|
561
683
|
for (const entry of entries) {
|
|
562
684
|
const colonIdx = findTopLevelColon(entry);
|
|
563
|
-
if (colonIdx === -1)
|
|
685
|
+
if (colonIdx === -1) {
|
|
686
|
+
emitDirectiveDiag({
|
|
687
|
+
code: DIAG.directiveSyntax,
|
|
688
|
+
message: `Unrecognized property in object literal: ${entry.slice(0, 60)} (getter / method shorthand / shorthand property are not supported in directive payloads)`
|
|
689
|
+
});
|
|
690
|
+
return absUnknown();
|
|
691
|
+
}
|
|
564
692
|
const key = entry.slice(0, colonIdx).trim().replace(/^["']|["']$/g, "");
|
|
565
693
|
const val = entry.slice(colonIdx + 1).trim();
|
|
566
694
|
slots[key] = { value: parseCaseArgExpr(val, depth + 1) };
|
|
@@ -577,13 +705,93 @@ function parseAbsExpr(expr) {
|
|
|
577
705
|
function parseAbsExprDiag(expr, context) {
|
|
578
706
|
const abs = parseCaseArgExpr(expr);
|
|
579
707
|
if (abs.shape.k === "unknown" && !abs.term && !isExplicitUnknownExpr(expr)) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
message: `Unrecognized type expression in ${context}: ${expr.trim()} (use constraint builders, literals, or structure syntax)`
|
|
583
|
-
});
|
|
708
|
+
emitUnrecognizedExprDiag(expr, context);
|
|
709
|
+
if (isCaseExpectedContext(context)) return absParseFailure();
|
|
584
710
|
}
|
|
585
711
|
return abs;
|
|
586
712
|
}
|
|
713
|
+
function emitUnrecognizedExprDiag(expr, context) {
|
|
714
|
+
emitDirectiveDiag({
|
|
715
|
+
code: DIAG.directiveSyntax,
|
|
716
|
+
message: `Unrecognized type expression in ${context}: ${expr.trim()} (use constraint builders, literals, or structure syntax)`
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
function absParseFailure() {
|
|
720
|
+
return absExact({ k: "never" });
|
|
721
|
+
}
|
|
722
|
+
function isCaseExpectedContext(context) {
|
|
723
|
+
return /^@nudo:case\b/.test(context) && /=>\s*expected\s*$/.test(context);
|
|
724
|
+
}
|
|
725
|
+
var PLAIN_DECIMAL_RE = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/;
|
|
726
|
+
var LEGACY_LEADING_ZERO_RE = /^([+-]?)0([0-9]+)$/;
|
|
727
|
+
function legacyLeadingZeroValue(s) {
|
|
728
|
+
const m = LEGACY_LEADING_ZERO_RE.exec(s);
|
|
729
|
+
if (!m) return void 0;
|
|
730
|
+
const sign = m[1] === "-" ? -1 : 1;
|
|
731
|
+
const digits = `0${m[2]}`;
|
|
732
|
+
if (/^0[0-7]+$/.test(digits)) return sign * parseInt(digits, 8);
|
|
733
|
+
return sign * Number(digits);
|
|
734
|
+
}
|
|
735
|
+
function parseDirectiveExprNode(s) {
|
|
736
|
+
try {
|
|
737
|
+
const ast = parse(`(${s});`);
|
|
738
|
+
if (ast.program.body.length !== 1) return void 0;
|
|
739
|
+
const stmt = ast.program.body[0];
|
|
740
|
+
if (stmt.type !== "ExpressionStatement") return void 0;
|
|
741
|
+
return stmt.expression;
|
|
742
|
+
} catch {
|
|
743
|
+
return void 0;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function numericNodeValue(node) {
|
|
747
|
+
switch (node.type) {
|
|
748
|
+
case "NumericLiteral":
|
|
749
|
+
return typeof node.value === "number" ? node.value : void 0;
|
|
750
|
+
case "BigIntLiteral": {
|
|
751
|
+
const raw = node.value;
|
|
752
|
+
try {
|
|
753
|
+
return typeof raw === "bigint" ? raw : BigInt(raw);
|
|
754
|
+
} catch {
|
|
755
|
+
return void 0;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
case "UnaryExpression": {
|
|
759
|
+
if (node.operator !== "+" && node.operator !== "-") return void 0;
|
|
760
|
+
const inner = numericNodeValue(node.argument);
|
|
761
|
+
if (inner === void 0) return void 0;
|
|
762
|
+
if (node.operator === "+") return inner;
|
|
763
|
+
return typeof inner === "bigint" ? -inner : -inner;
|
|
764
|
+
}
|
|
765
|
+
case "Identifier":
|
|
766
|
+
if (node.name === "Infinity") return Infinity;
|
|
767
|
+
if (node.name === "NaN") return NaN;
|
|
768
|
+
return void 0;
|
|
769
|
+
default:
|
|
770
|
+
return void 0;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
function parseNumberLiteralExpr(s) {
|
|
774
|
+
const body = s.trim();
|
|
775
|
+
if (PLAIN_DECIMAL_RE.test(body)) return numLit(Number(body));
|
|
776
|
+
const legacy = legacyLeadingZeroValue(body);
|
|
777
|
+
if (legacy !== void 0) return numLit(legacy);
|
|
778
|
+
const node = parseDirectiveExprNode(body);
|
|
779
|
+
if (!node) return void 0;
|
|
780
|
+
const value = numericNodeValue(node);
|
|
781
|
+
if (value === void 0) return void 0;
|
|
782
|
+
if (typeof value === "bigint") return bigintLit(value);
|
|
783
|
+
return numLit(value);
|
|
784
|
+
}
|
|
785
|
+
function parseTemplateLiteralExpr(s) {
|
|
786
|
+
const body = s.trim();
|
|
787
|
+
if (!body.startsWith("`") || !body.endsWith("`") || body.length < 2) return void 0;
|
|
788
|
+
const node = parseDirectiveExprNode(body);
|
|
789
|
+
if (!node || node.type !== "TemplateLiteral") return void 0;
|
|
790
|
+
if (node.expressions.length > 0) return void 0;
|
|
791
|
+
const cooked = node.quasis[0]?.value.cooked;
|
|
792
|
+
if (typeof cooked !== "string") return void 0;
|
|
793
|
+
return strLit(cooked);
|
|
794
|
+
}
|
|
587
795
|
|
|
588
796
|
// src/directives/fn-directives.ts
|
|
589
797
|
import {
|
|
@@ -660,6 +868,8 @@ function parseSinonExpr(expr) {
|
|
|
660
868
|
if (arrowFn) {
|
|
661
869
|
return {
|
|
662
870
|
type: "stub",
|
|
871
|
+
// 严格性不可得:mock 表达式是**独立解析的载荷文本**,enclosing 源
|
|
872
|
+
// 文件的 ESM/CJS 种类未随指令流转 ⇒ 不写 strict(sloppy,保守)。
|
|
663
873
|
returnValue: absFunction2(arrowFn.params, {
|
|
664
874
|
body: arrowFn.body
|
|
665
875
|
})
|
|
@@ -706,7 +916,14 @@ function parseNudoMockExpr(expr) {
|
|
|
706
916
|
if (stubCallsFakeMatch) {
|
|
707
917
|
const arrowFn = parseArrowFunctionExpr(stubCallsFakeMatch[1].trim());
|
|
708
918
|
if (arrowFn) {
|
|
709
|
-
return stub.callsFake({
|
|
919
|
+
return stub.callsFake({
|
|
920
|
+
params: arrowFn.params,
|
|
921
|
+
body: arrowFn.body,
|
|
922
|
+
// Bug 857 的第三处:sinon 路径此前也把 async 硬编码成 false ⇒
|
|
923
|
+
// `stub().callsFake(async (x) => …)` 被建模成同步函数 ⇒ 调用点
|
|
924
|
+
// `.then(…)` 报「method 'then' missing」。
|
|
925
|
+
async: arrowFn.async === true
|
|
926
|
+
});
|
|
710
927
|
}
|
|
711
928
|
return { kind: "mock-helper" };
|
|
712
929
|
}
|
|
@@ -762,18 +979,18 @@ function parseNudoMockExpr(expr) {
|
|
|
762
979
|
}
|
|
763
980
|
|
|
764
981
|
// src/directives/fn-directives.ts
|
|
765
|
-
var CASE_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:case\b[ \t]*([^\n]*)/g;
|
|
982
|
+
var CASE_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:case\b([ \t]*)([^\n]*)/g;
|
|
766
983
|
var JS_IDENT_RE = /^[\p{L}$_][\p{L}\p{N}$_]*$/u;
|
|
767
|
-
var MOCK_INLINE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock
|
|
768
|
-
var MOCK_FROM_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock
|
|
769
|
-
var MOCK_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock
|
|
984
|
+
var MOCK_INLINE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock(?![-\w])[ \t]+([^\s=]+)[ \t]*=[ \t]*(.+)/g;
|
|
985
|
+
var MOCK_FROM_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock(?![-\w])[ \t]+([^\s]+)[ \t]+from[ \t]+(?:"([^"\n]+)"|'([^'\n]+)')/g;
|
|
986
|
+
var MOCK_TAG_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:mock(?!-)([ \t]*)([^\n]*)/g;
|
|
770
987
|
var PURE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:pure\b/g;
|
|
771
|
-
var SKIP_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:skip\b
|
|
988
|
+
var SKIP_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:skip\b[ \t]*(.*)/g;
|
|
772
989
|
var SAMPLE_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*|\/[ \t]*)?@nudo:sample\b(?:[ \t]+(\S+))?/g;
|
|
773
990
|
var SAMPLE_NUM_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
774
991
|
function parseSkipReturnsExpr(rest) {
|
|
775
992
|
if (rest === void 0) return void 0;
|
|
776
|
-
const s = rest
|
|
993
|
+
const s = stripTrailingComment(rest);
|
|
777
994
|
if (s === "") return void 0;
|
|
778
995
|
if (s.startsWith("=>")) {
|
|
779
996
|
const expr = s.slice(2).trim();
|
|
@@ -801,14 +1018,51 @@ function parseSkipReturnsExpr(rest) {
|
|
|
801
1018
|
}
|
|
802
1019
|
return void 0;
|
|
803
1020
|
}
|
|
1021
|
+
function siteInCommentText(text, startLine, offset) {
|
|
1022
|
+
const before = text.slice(0, Math.max(0, offset));
|
|
1023
|
+
const nl = before.lastIndexOf("\n");
|
|
1024
|
+
const newlines = before.split("\n").length - 1;
|
|
1025
|
+
return {
|
|
1026
|
+
line: startLine + newlines,
|
|
1027
|
+
column: nl < 0 ? offset : offset - nl - 1
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
function stripTrailingComment(text) {
|
|
1031
|
+
let quote = null;
|
|
1032
|
+
for (let i = 0; i < text.length; i++) {
|
|
1033
|
+
const ch = text[i];
|
|
1034
|
+
if (quote !== null) {
|
|
1035
|
+
if (ch === "\\") {
|
|
1036
|
+
i++;
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (ch === quote) quote = null;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
1043
|
+
quote = ch;
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (ch === "/" && text[i + 1] === "/") return text.slice(0, i).trim();
|
|
1047
|
+
}
|
|
1048
|
+
return text.trim();
|
|
1049
|
+
}
|
|
804
1050
|
function parseDirectivesFromCommentTexts(comments) {
|
|
805
1051
|
const directives = [];
|
|
806
1052
|
for (const { text, startLine: commentStartLine } of comments) {
|
|
807
1053
|
const caseArgSpans = scanCaseArgSpans(text);
|
|
808
1054
|
const caseTags = scanCaseTags(text);
|
|
809
|
-
scanMalformedCaseNames(text, caseArgSpans);
|
|
1055
|
+
scanMalformedCaseNames(text, caseArgSpans, commentStartLine);
|
|
1056
|
+
for (const tag of caseTags) {
|
|
1057
|
+
if (!tag.expectedIssue) continue;
|
|
1058
|
+
emitDirectiveDiag({
|
|
1059
|
+
code: DIAG2.directiveSyntax,
|
|
1060
|
+
message: `Malformed @nudo:case: ${tag.expectedIssue} \u2014 got: @nudo:case "${tag.name}" (\u2026)`,
|
|
1061
|
+
at: siteInCommentText(text, commentStartLine, tag.tagOffset)
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
810
1064
|
const inCaseArgs = (idx) => caseArgSpans.some(([s, e]) => idx >= s && idx < e);
|
|
811
|
-
scanMalformedMockNames(text, caseArgSpans);
|
|
1065
|
+
scanMalformedMockNames(text, caseArgSpans, commentStartLine);
|
|
812
1066
|
MOCK_FROM_REGEX.lastIndex = 0;
|
|
813
1067
|
let mockFromMatch;
|
|
814
1068
|
const mockFromRanges = [];
|
|
@@ -833,6 +1087,7 @@ function parseDirectivesFromCommentTexts(comments) {
|
|
|
833
1087
|
const mockName = mockMatch[1];
|
|
834
1088
|
if (!JS_IDENT_RE.test(mockName)) continue;
|
|
835
1089
|
const expr = mockMatch[2].trim();
|
|
1090
|
+
if (expr === "") continue;
|
|
836
1091
|
let arrowFn = parseArrowFunctionExpr(expr);
|
|
837
1092
|
const sinonExpr = parseSinonExpr(expr);
|
|
838
1093
|
const nudoMock = parseNudoMockExpr(expr);
|
|
@@ -856,7 +1111,8 @@ function parseDirectivesFromCommentTexts(comments) {
|
|
|
856
1111
|
const cleaned = tag.argsText.split("\n").map((line) => line.replace(/^\s*\*\s?/, "")).join("\n");
|
|
857
1112
|
const rawArgs = splitTopLevelArgs(cleaned);
|
|
858
1113
|
const argsAbs = rawArgs.map((a) => parseAbsExprDiag(a, `@nudo:case "${name}" argument`));
|
|
859
|
-
const
|
|
1114
|
+
const expectedText = tag.expectedText !== void 0 ? stripTrailingComment(tag.expectedText) : void 0;
|
|
1115
|
+
const expected = expectedText !== void 0 ? parseAbsExprDiag(expectedText, `@nudo:case "${name}" => expected`) : void 0;
|
|
860
1116
|
const linesBeforeMatch = text.slice(0, tag.tagOffset).split("\n").length - 1;
|
|
861
1117
|
const commentLine = commentStartLine + linesBeforeMatch;
|
|
862
1118
|
directives.push({
|
|
@@ -892,7 +1148,8 @@ function parseDirectivesFromCommentTexts(comments) {
|
|
|
892
1148
|
if (raw === void 0 || !SAMPLE_NUM_RE.test(raw)) {
|
|
893
1149
|
emitDirectiveDiag({
|
|
894
1150
|
code: DIAG2.directiveSyntax,
|
|
895
|
-
message: `Malformed @nudo:sample: expected a numeric count (integer, decimal, or negative) \u2014 got: @nudo:sample ${raw ?? "(missing count)"}
|
|
1151
|
+
message: `Malformed @nudo:sample: expected a numeric count (integer, decimal, or negative) \u2014 got: @nudo:sample ${raw ?? "(missing count)"}`,
|
|
1152
|
+
at: siteInCommentText(text, commentStartLine, sampleMatch.index)
|
|
896
1153
|
});
|
|
897
1154
|
continue;
|
|
898
1155
|
}
|
|
@@ -901,20 +1158,30 @@ function parseDirectivesFromCommentTexts(comments) {
|
|
|
901
1158
|
}
|
|
902
1159
|
return directives;
|
|
903
1160
|
}
|
|
904
|
-
function scanMalformedCaseNames(text, caseArgSpans) {
|
|
1161
|
+
function scanMalformedCaseNames(text, caseArgSpans, startLine) {
|
|
905
1162
|
const inCaseArgs = (idx) => caseArgSpans.some(([s, e]) => idx >= s && idx < e);
|
|
906
1163
|
CASE_TAG_REGEX.lastIndex = 0;
|
|
907
1164
|
let m;
|
|
908
1165
|
while ((m = CASE_TAG_REGEX.exec(text)) !== null) {
|
|
909
1166
|
if (inCaseArgs(m.index)) continue;
|
|
910
|
-
const
|
|
1167
|
+
const separator = m[1];
|
|
1168
|
+
const rest = m[2].trim();
|
|
1169
|
+
if (separator === "" && rest.startsWith('"')) {
|
|
1170
|
+
emitDirectiveDiag({
|
|
1171
|
+
code: DIAG2.directiveSyntax,
|
|
1172
|
+
message: `Malformed @nudo:case: expected a space between '@nudo:case' and the name (expected @nudo:case "name" (\u2026)) \u2014 got: @nudo:case${rest.slice(0, 60)}`,
|
|
1173
|
+
at: siteInCommentText(text, startLine, m.index)
|
|
1174
|
+
});
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
911
1177
|
const legal = rest.match(/^("[^"]+")[ \t]*\(/);
|
|
912
1178
|
if (legal) {
|
|
913
|
-
const parenIdx = m.index + m[0].length - m[
|
|
1179
|
+
const parenIdx = m.index + m[0].length - m[2].length + legal[0].length - 1;
|
|
914
1180
|
if (extractBalancedParens(text, parenIdx) === null) {
|
|
915
1181
|
emitDirectiveDiag({
|
|
916
1182
|
code: DIAG2.directiveSyntax,
|
|
917
|
-
message: `Malformed @nudo:case: unclosed argument list (missing closing ")") \u2014 got: @nudo:case ${rest.slice(0, 60)}
|
|
1183
|
+
message: `Malformed @nudo:case: unclosed argument list (missing closing ")") \u2014 got: @nudo:case ${rest.slice(0, 60)}`,
|
|
1184
|
+
at: siteInCommentText(text, startLine, m.index)
|
|
918
1185
|
});
|
|
919
1186
|
}
|
|
920
1187
|
continue;
|
|
@@ -933,22 +1200,41 @@ function scanMalformedCaseNames(text, caseArgSpans) {
|
|
|
933
1200
|
}
|
|
934
1201
|
emitDirectiveDiag({
|
|
935
1202
|
code: DIAG2.directiveSyntax,
|
|
936
|
-
message: `Malformed @nudo:case: ${reason} \u2014 got: @nudo:case ${rest.slice(0, 60)}
|
|
1203
|
+
message: `Malformed @nudo:case: ${reason} \u2014 got: @nudo:case ${rest.slice(0, 60)}`,
|
|
1204
|
+
at: siteInCommentText(text, startLine, m.index)
|
|
937
1205
|
});
|
|
938
1206
|
}
|
|
939
1207
|
}
|
|
940
|
-
function
|
|
1208
|
+
function mockStrictGrammarHits(text, index) {
|
|
1209
|
+
MOCK_INLINE_REGEX.lastIndex = index;
|
|
1210
|
+
const inline = MOCK_INLINE_REGEX.exec(text);
|
|
1211
|
+
if (inline !== null && inline.index === index && inline[2].trim() !== "") return true;
|
|
1212
|
+
MOCK_FROM_REGEX.lastIndex = index;
|
|
1213
|
+
const from = MOCK_FROM_REGEX.exec(text);
|
|
1214
|
+
return from !== null && from.index === index;
|
|
1215
|
+
}
|
|
1216
|
+
function scanMalformedMockNames(text, caseArgSpans, startLine) {
|
|
941
1217
|
const inCaseArgs = (idx) => caseArgSpans.some(([s, e]) => idx >= s && idx < e);
|
|
942
1218
|
MOCK_TAG_REGEX.lastIndex = 0;
|
|
943
1219
|
let m;
|
|
944
1220
|
while ((m = MOCK_TAG_REGEX.exec(text)) !== null) {
|
|
945
1221
|
if (inCaseArgs(m.index)) continue;
|
|
946
|
-
const
|
|
1222
|
+
const separator = m[1];
|
|
1223
|
+
const rest = m[2].trim();
|
|
1224
|
+
if (separator === "" && /^[A-Za-z_$]/.test(rest)) {
|
|
1225
|
+
emitDirectiveDiag({
|
|
1226
|
+
code: DIAG2.directiveSyntax,
|
|
1227
|
+
message: `Malformed @nudo:mock: expected a space between '@nudo:mock' and the name (expected @nudo:mock <ident> = <expr> or @nudo:mock <ident> from "path" / 'path') \u2014 got: @nudo:mock${rest.slice(0, 60)}`,
|
|
1228
|
+
at: siteInCommentText(text, startLine, m.index)
|
|
1229
|
+
});
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
947
1232
|
const nameMatch = rest.match(/^([^\s=]+)(?:\s*=|\s+from\b)/);
|
|
948
1233
|
if (!nameMatch) {
|
|
949
1234
|
emitDirectiveDiag({
|
|
950
1235
|
code: DIAG2.directiveSyntax,
|
|
951
|
-
message: `Malformed @nudo:mock: cannot parse name (expected @nudo:mock <ident> = <expr> or @nudo:mock <ident> from "path" / 'path') \u2014 got: ${rest.slice(0, 60)}
|
|
1236
|
+
message: `Malformed @nudo:mock: cannot parse name (expected @nudo:mock <ident> = <expr> or @nudo:mock <ident> from "path" / 'path') \u2014 got: ${rest.slice(0, 60)}`,
|
|
1237
|
+
at: siteInCommentText(text, startLine, m.index)
|
|
952
1238
|
});
|
|
953
1239
|
continue;
|
|
954
1240
|
}
|
|
@@ -956,7 +1242,8 @@ function scanMalformedMockNames(text, caseArgSpans) {
|
|
|
956
1242
|
if (!JS_IDENT_RE.test(name)) {
|
|
957
1243
|
emitDirectiveDiag({
|
|
958
1244
|
code: DIAG2.directiveSyntax,
|
|
959
|
-
message: `Malformed @nudo:mock name '${name}': not a valid identifier (use letters, digits, _, $; Unicode letters allowed)
|
|
1245
|
+
message: `Malformed @nudo:mock name '${name}': not a valid identifier (use letters, digits, _, $; Unicode letters allowed)`,
|
|
1246
|
+
at: siteInCommentText(text, startLine, m.index)
|
|
960
1247
|
});
|
|
961
1248
|
continue;
|
|
962
1249
|
}
|
|
@@ -964,7 +1251,16 @@ function scanMalformedMockNames(text, caseArgSpans) {
|
|
|
964
1251
|
if (/^[ \t]+from[ \t]+(?:"[^"\n]*|'[^'\n]*)$/.test(afterName)) {
|
|
965
1252
|
emitDirectiveDiag({
|
|
966
1253
|
code: DIAG2.directiveSyntax,
|
|
967
|
-
message: `Malformed @nudo:mock from: unclosed quote in path (expected @nudo:mock ${name} from "path" / 'path') \u2014 got: @nudo:mock ${rest.slice(0, 60)}
|
|
1254
|
+
message: `Malformed @nudo:mock from: unclosed quote in path (expected @nudo:mock ${name} from "path" / 'path') \u2014 got: @nudo:mock ${rest.slice(0, 60)}`,
|
|
1255
|
+
at: siteInCommentText(text, startLine, m.index)
|
|
1256
|
+
});
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
if (!mockStrictGrammarHits(text, m.index)) {
|
|
1260
|
+
emitDirectiveDiag({
|
|
1261
|
+
code: DIAG2.directiveSyntax,
|
|
1262
|
+
message: `Malformed @nudo:mock: empty or invalid payload (expected @nudo:mock ${name} = <expr> or @nudo:mock ${name} from "path" / 'path') \u2014 got: @nudo:mock ${rest.slice(0, 60)}`,
|
|
1263
|
+
at: siteInCommentText(text, startLine, m.index)
|
|
968
1264
|
});
|
|
969
1265
|
}
|
|
970
1266
|
}
|
|
@@ -1000,10 +1296,48 @@ function stripInlineCommentPrefix(line, kind) {
|
|
|
1000
1296
|
const s = kind === "block" ? line.replace(/^\s*\*\s?/, "") : line.replace(/^\//, "");
|
|
1001
1297
|
return s.trim();
|
|
1002
1298
|
}
|
|
1003
|
-
var AS_LINE_REGEX = /^@nudo:as[ \t]+(.+)$/;
|
|
1004
1299
|
var REPLACE_LINE_REGEX = /^@nudo:replace[ \t]+(.+)$/;
|
|
1005
|
-
var AS_BARE_RE = /^@nudo:as[ \t]*$/;
|
|
1006
1300
|
var REPLACE_BARE_RE = /^@nudo:replace[ \t]*$/;
|
|
1301
|
+
var REPLACE_GLUED_RE = /^@nudo:replace[A-Za-z0-9_$]/;
|
|
1302
|
+
var AS_TAG = "@nudo:as";
|
|
1303
|
+
function asPayloadOf(line) {
|
|
1304
|
+
if (!line.startsWith(AS_TAG)) return null;
|
|
1305
|
+
const rest = line.slice(AS_TAG.length);
|
|
1306
|
+
const next = rest[0];
|
|
1307
|
+
if (next !== void 0 && /[A-Za-z0-9_$]/.test(next)) return null;
|
|
1308
|
+
return rest.trim();
|
|
1309
|
+
}
|
|
1310
|
+
function unwrapOuterParens(s) {
|
|
1311
|
+
let cur = s.trim();
|
|
1312
|
+
while (cur.length >= 2 && cur.startsWith("(") && cur.endsWith(")") && outerParensWrapAll(cur)) {
|
|
1313
|
+
cur = cur.slice(1, -1).trim();
|
|
1314
|
+
}
|
|
1315
|
+
return cur;
|
|
1316
|
+
}
|
|
1317
|
+
function outerParensWrapAll(s) {
|
|
1318
|
+
let depth = 0;
|
|
1319
|
+
let inString = null;
|
|
1320
|
+
for (let i = 0; i < s.length; i++) {
|
|
1321
|
+
const ch = s[i];
|
|
1322
|
+
if (inString) {
|
|
1323
|
+
if (ch === inString) inString = null;
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
if (ch === '"' || ch === "'") {
|
|
1327
|
+
inString = ch;
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
if (ch === "(") {
|
|
1331
|
+
depth++;
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
if (ch === ")") {
|
|
1335
|
+
depth--;
|
|
1336
|
+
if (depth === 0) return i === s.length - 1;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
return false;
|
|
1340
|
+
}
|
|
1007
1341
|
function findTrailingCommentStart(expr) {
|
|
1008
1342
|
let inString = null;
|
|
1009
1343
|
for (let i = 0; i < expr.length; i++) {
|
|
@@ -1016,12 +1350,23 @@ function findTrailingCommentStart(expr) {
|
|
|
1016
1350
|
inString = ch;
|
|
1017
1351
|
continue;
|
|
1018
1352
|
}
|
|
1353
|
+
if (ch === "/" && (expr[i + 1] === "/" || expr[i + 1] === "*")) {
|
|
1354
|
+
if (!looksLikeDivision(expr, i)) return i;
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1019
1357
|
if ((ch === " " || ch === " ") && /^[ \t]+(?:\/\/|\/\*)/.test(expr.slice(i))) {
|
|
1020
1358
|
return i;
|
|
1021
1359
|
}
|
|
1022
1360
|
}
|
|
1023
1361
|
return -1;
|
|
1024
1362
|
}
|
|
1363
|
+
function looksLikeDivision(expr, slashIdx) {
|
|
1364
|
+
let left = slashIdx - 1;
|
|
1365
|
+
while (left >= 0 && (expr[left] === " " || expr[left] === " ")) left--;
|
|
1366
|
+
const ch = left >= 0 ? expr[left] : "";
|
|
1367
|
+
if (ch !== "." && !/^[0-9]$/.test(ch)) return false;
|
|
1368
|
+
return /^[0-9.]/.test(expr.slice(slashIdx + 2));
|
|
1369
|
+
}
|
|
1025
1370
|
function extractInlineDirectives(node, opts) {
|
|
1026
1371
|
const comments = node.leadingComments;
|
|
1027
1372
|
if (!comments) return [];
|
|
@@ -1033,29 +1378,36 @@ function collectInlineDirectivesFromComments(comments) {
|
|
|
1033
1378
|
for (const comment of comments) {
|
|
1034
1379
|
if (comment.type !== "CommentLine" && comment.type !== "CommentBlock") continue;
|
|
1035
1380
|
const kind = comment.type === "CommentLine" ? "line" : "block";
|
|
1036
|
-
|
|
1381
|
+
const rawLines = comment.value.split("\n");
|
|
1382
|
+
for (let lineIdx = 0; lineIdx < rawLines.length; lineIdx++) {
|
|
1383
|
+
const rawLine = rawLines[lineIdx];
|
|
1037
1384
|
const line = stripInlineCommentPrefix(rawLine, kind);
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1385
|
+
const at = commentSite(comment, rawLine, lineIdx, line);
|
|
1386
|
+
const asPayload = asPayloadOf(line);
|
|
1387
|
+
if (asPayload !== null) {
|
|
1388
|
+
if (asPayload === "") {
|
|
1389
|
+
emitDirectiveDiag({
|
|
1390
|
+
code: DIAG3.directiveSyntax,
|
|
1391
|
+
message: `@nudo:as requires a type expression (e.g. @nudo:as number())`,
|
|
1392
|
+
at
|
|
1393
|
+
});
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
const trailingIdx = findTrailingCommentStart(asPayload);
|
|
1042
1397
|
if (trailingIdx !== -1) {
|
|
1043
|
-
const typePart =
|
|
1398
|
+
const typePart = asPayload.slice(0, trailingIdx).trim();
|
|
1044
1399
|
emitDirectiveDiag({
|
|
1045
1400
|
code: DIAG3.directiveSyntax,
|
|
1046
|
-
message: `Trailing comment in @nudo:as type expression: '${
|
|
1401
|
+
message: `Trailing comment in @nudo:as type expression: '${asPayload}' (remove the comment; the type must be a clean expression)`,
|
|
1402
|
+
at
|
|
1403
|
+
});
|
|
1404
|
+
results.push({
|
|
1405
|
+
kind: "as",
|
|
1406
|
+
typeAbs: typePart ? parseAbsExprDiag(unwrapOuterParens(typePart), "@nudo:as") : absUnknown()
|
|
1047
1407
|
});
|
|
1048
|
-
results.push({ kind: "as", typeAbs: typePart ? parseAbsExprDiag(typePart, "@nudo:as") : absUnknown() });
|
|
1049
1408
|
continue;
|
|
1050
1409
|
}
|
|
1051
|
-
results.push({ kind: "as", typeAbs: parseAbsExprDiag(
|
|
1052
|
-
continue;
|
|
1053
|
-
}
|
|
1054
|
-
if (AS_BARE_RE.test(line)) {
|
|
1055
|
-
emitDirectiveDiag({
|
|
1056
|
-
code: DIAG3.directiveSyntax,
|
|
1057
|
-
message: `@nudo:as requires a type expression (e.g. @nudo:as number())`
|
|
1058
|
-
});
|
|
1410
|
+
results.push({ kind: "as", typeAbs: parseAbsExprDiag(unwrapOuterParens(asPayload), "@nudo:as") });
|
|
1059
1411
|
continue;
|
|
1060
1412
|
}
|
|
1061
1413
|
const replaceMatch = line.match(REPLACE_LINE_REGEX);
|
|
@@ -1073,7 +1425,8 @@ function collectInlineDirectivesFromComments(comments) {
|
|
|
1073
1425
|
} else {
|
|
1074
1426
|
emitDirectiveDiag({
|
|
1075
1427
|
code: DIAG3.directiveSyntax,
|
|
1076
|
-
message: `Malformed @nudo:replace: cannot separate target from type (expected @nudo:replace <target> <typeExpr>) \u2014 got: ${raw.slice(0, 60)}
|
|
1428
|
+
message: `Malformed @nudo:replace: cannot separate target from type (expected @nudo:replace <target> <typeExpr>) \u2014 got: ${raw.slice(0, 60)}`,
|
|
1429
|
+
at
|
|
1077
1430
|
});
|
|
1078
1431
|
}
|
|
1079
1432
|
continue;
|
|
@@ -1081,13 +1434,33 @@ function collectInlineDirectivesFromComments(comments) {
|
|
|
1081
1434
|
if (REPLACE_BARE_RE.test(line)) {
|
|
1082
1435
|
emitDirectiveDiag({
|
|
1083
1436
|
code: DIAG3.directiveSyntax,
|
|
1084
|
-
message: `@nudo:replace requires a target and type expression (expected @nudo:replace <target> <typeExpr>)
|
|
1437
|
+
message: `@nudo:replace requires a target and type expression (expected @nudo:replace <target> <typeExpr>)`,
|
|
1438
|
+
at
|
|
1439
|
+
});
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
if (REPLACE_GLUED_RE.test(line)) {
|
|
1443
|
+
emitDirectiveDiag({
|
|
1444
|
+
code: DIAG3.directiveSyntax,
|
|
1445
|
+
message: `Malformed @nudo:replace directive '${line.slice(0, 60)}' (expected a space after the tag: @nudo:replace <target> <typeExpr>)`,
|
|
1446
|
+
at
|
|
1085
1447
|
});
|
|
1448
|
+
continue;
|
|
1086
1449
|
}
|
|
1087
1450
|
}
|
|
1088
1451
|
}
|
|
1089
1452
|
return results;
|
|
1090
1453
|
}
|
|
1454
|
+
function commentSite(comment, rawLine, lineIdx, stripped) {
|
|
1455
|
+
const locStart = comment.loc?.start;
|
|
1456
|
+
if (!locStart) return void 0;
|
|
1457
|
+
const base = lineIdx === 0 ? locStart.column + 2 : 0;
|
|
1458
|
+
const inLine = stripped.length > 0 ? rawLine.indexOf(stripped) : 0;
|
|
1459
|
+
return {
|
|
1460
|
+
line: locStart.line + lineIdx,
|
|
1461
|
+
column: base + (inLine < 0 ? 0 : inLine)
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1091
1464
|
function findReplaceSeparator(raw) {
|
|
1092
1465
|
let depth = 0;
|
|
1093
1466
|
let inString = null;
|
|
@@ -1120,11 +1493,16 @@ function findReplaceSeparator(raw) {
|
|
|
1120
1493
|
}
|
|
1121
1494
|
|
|
1122
1495
|
// src/directives/file-directives.ts
|
|
1123
|
-
import {
|
|
1496
|
+
import {
|
|
1497
|
+
listFnDirectiveScopes as listFnDirectiveScopes2,
|
|
1498
|
+
parseEnvPayload,
|
|
1499
|
+
parseMockModulePayload
|
|
1500
|
+
} from "@nudojs/core";
|
|
1124
1501
|
import { DIAG as DIAG4 } from "@nudojs/core/internal";
|
|
1125
1502
|
var ENV_REGEX = /^\/?\s*@nudo:env\s+(.+)/;
|
|
1503
|
+
var ENV_BARE_REGEX = /^\/?\s*@nudo:env\b[ \t]*$/;
|
|
1126
1504
|
var MOCK_MODULE_REGEX = /^\/?\s*@nudo:mock-module\s+(.+)/;
|
|
1127
|
-
var FILE_TAG_IN_BLOCK_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*)?@nudo:(env|mock-module)\b/g;
|
|
1505
|
+
var FILE_TAG_IN_BLOCK_REGEX = /(?:^|\n)[ \t]*(?:\*[ \t]*)?@nudo:(env|mock-module|import)\b/g;
|
|
1128
1506
|
function warnMisplacedFileTags(blockText) {
|
|
1129
1507
|
FILE_TAG_IN_BLOCK_REGEX.lastIndex = 0;
|
|
1130
1508
|
let m;
|
|
@@ -1132,10 +1510,48 @@ function warnMisplacedFileTags(blockText) {
|
|
|
1132
1510
|
const tag = m[1];
|
|
1133
1511
|
emitDirectiveDiag({
|
|
1134
1512
|
code: DIAG4.directiveSyntax,
|
|
1135
|
-
message: tag === "env" ? `@nudo:env is a file-level directive \u2014 use '/// @nudo:env node' at the top of the file (JSDoc block form is ignored)` : `@nudo:mock-module is a file-level directive \u2014 use '/// @nudo:mock-module "fs" from "./mock-fs.js"' at the top of the file (JSDoc block form is ignored)`
|
|
1513
|
+
message: tag === "env" ? `@nudo:env is a file-level directive \u2014 use '/// @nudo:env node' at the top of the file (JSDoc block form is ignored)` : tag === "mock-module" ? `@nudo:mock-module is a file-level directive \u2014 use '/// @nudo:mock-module "fs" from "./mock-fs.js"' at the top of the file (JSDoc block form is ignored)` : `@nudo:import is a file-level directive \u2014 use '/// @nudo:import { a } from "./x.nudo.js"' at the top of the file (JSDoc block form is ignored)`
|
|
1136
1514
|
});
|
|
1137
1515
|
}
|
|
1138
1516
|
}
|
|
1517
|
+
function warnMisplacedFileTagLine(tag) {
|
|
1518
|
+
emitDirectiveDiag({
|
|
1519
|
+
code: DIAG4.directiveSyntax,
|
|
1520
|
+
message: tag === "env" ? `@nudo:env is a file-level directive \u2014 use '/// @nudo:env node' at the top of the file (line comment inside a function/class/object/array/destructuring body is ignored)` : `@nudo:mock-module is a file-level directive \u2014 use '/// @nudo:mock-module "fs" from "./mock-fs.js"' at the top of the file (line comment inside a function/class/object/array/destructuring body is ignored)`
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
function functionBodyRanges(ast) {
|
|
1524
|
+
if (ast.type !== "File") return [];
|
|
1525
|
+
return listFnDirectiveScopes2(ast).map((scope) => [
|
|
1526
|
+
scope.node.start ?? 0,
|
|
1527
|
+
scope.node.end ?? 0
|
|
1528
|
+
]);
|
|
1529
|
+
}
|
|
1530
|
+
function nestedBodyRanges(ast) {
|
|
1531
|
+
const out = [];
|
|
1532
|
+
const visit = (node) => {
|
|
1533
|
+
if (!node || typeof node !== "object") return;
|
|
1534
|
+
if (Array.isArray(node)) {
|
|
1535
|
+
for (const item of node) visit(item);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
const n = node;
|
|
1539
|
+
if ((n.type === "ClassBody" || n.type === "ObjectExpression" || n.type === "ArrayExpression" || n.type === "ObjectPattern" || n.type === "ArrayPattern") && typeof n.start === "number" && typeof n.end === "number") {
|
|
1540
|
+
out.push([n.start, n.end]);
|
|
1541
|
+
}
|
|
1542
|
+
for (const key of Object.keys(n)) {
|
|
1543
|
+
if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments" || key === "comments" || key === "tokens" || key === "extra") {
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
visit(n[key]);
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
visit(ast);
|
|
1550
|
+
return out;
|
|
1551
|
+
}
|
|
1552
|
+
function inRanges(ranges, offset) {
|
|
1553
|
+
return ranges.some(([s, e]) => offset >= s && offset < e);
|
|
1554
|
+
}
|
|
1139
1555
|
function extractFileDirectives(ast, opts) {
|
|
1140
1556
|
if (!opts?.diags) return collectFileDirectives(ast);
|
|
1141
1557
|
return runWithDirectiveDiags(opts.diags, () => collectFileDirectives(ast));
|
|
@@ -1144,6 +1560,8 @@ function collectFileDirectives(ast) {
|
|
|
1144
1560
|
const results = [];
|
|
1145
1561
|
if (ast.type !== "File") return results;
|
|
1146
1562
|
const comments = ast.comments ?? [];
|
|
1563
|
+
const fnRanges = functionBodyRanges(ast);
|
|
1564
|
+
const bodyRanges = nestedBodyRanges(ast);
|
|
1147
1565
|
for (const comment of comments) {
|
|
1148
1566
|
if (comment.type === "CommentBlock") {
|
|
1149
1567
|
warnMisplacedFileTags(comment.value);
|
|
@@ -1151,6 +1569,12 @@ function collectFileDirectives(ast) {
|
|
|
1151
1569
|
}
|
|
1152
1570
|
if (comment.type !== "CommentLine") continue;
|
|
1153
1571
|
const text = comment.value;
|
|
1572
|
+
const misplaced = typeof comment.start === "number" && (inRanges(fnRanges, comment.start) || inRanges(bodyRanges, comment.start));
|
|
1573
|
+
if (misplaced) {
|
|
1574
|
+
if (MOCK_MODULE_REGEX.test(text)) warnMisplacedFileTagLine("mock-module");
|
|
1575
|
+
else if (ENV_REGEX.test(text) || ENV_BARE_REGEX.test(text)) warnMisplacedFileTagLine("env");
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1154
1578
|
const mockModuleMatch = text.match(MOCK_MODULE_REGEX);
|
|
1155
1579
|
if (mockModuleMatch) {
|
|
1156
1580
|
const rec = parseMockModulePayload(mockModuleMatch[1]);
|
|
@@ -1163,11 +1587,30 @@ function collectFileDirectives(ast) {
|
|
|
1163
1587
|
});
|
|
1164
1588
|
continue;
|
|
1165
1589
|
}
|
|
1590
|
+
emitDirectiveDiag({
|
|
1591
|
+
code: DIAG4.directiveSyntax,
|
|
1592
|
+
message: `Unrecognized @nudo:mock-module form (expected '"src" from "path"' or '"src" { a, b } from "path"')`
|
|
1593
|
+
});
|
|
1594
|
+
continue;
|
|
1166
1595
|
}
|
|
1167
1596
|
const envMatch = text.match(ENV_REGEX);
|
|
1168
1597
|
if (envMatch) {
|
|
1169
1598
|
const envs = parseEnvPayload(envMatch[1]);
|
|
1170
|
-
if (envs.length > 0)
|
|
1599
|
+
if (envs.length > 0) {
|
|
1600
|
+
results.push({ kind: "env", envs });
|
|
1601
|
+
} else {
|
|
1602
|
+
emitDirectiveDiag({
|
|
1603
|
+
code: DIAG4.directiveSyntax,
|
|
1604
|
+
message: `Unrecognized @nudo:env list (expected env names, e.g. '/// @nudo:env node, web')`
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
continue;
|
|
1608
|
+
}
|
|
1609
|
+
if (ENV_BARE_REGEX.test(text)) {
|
|
1610
|
+
emitDirectiveDiag({
|
|
1611
|
+
code: DIAG4.directiveSyntax,
|
|
1612
|
+
message: `@nudo:env requires at least one env name (e.g. '/// @nudo:env node')`
|
|
1613
|
+
});
|
|
1171
1614
|
}
|
|
1172
1615
|
}
|
|
1173
1616
|
return results;
|
|
@@ -1180,7 +1623,7 @@ import {
|
|
|
1180
1623
|
extractNudoImportRecords,
|
|
1181
1624
|
findFnDirectiveScope,
|
|
1182
1625
|
fnDirectiveCommentLines,
|
|
1183
|
-
listFnDirectiveScopes as
|
|
1626
|
+
listFnDirectiveScopes as listFnDirectiveScopes3,
|
|
1184
1627
|
parseEnvPayload as parseEnvPayload2,
|
|
1185
1628
|
parseMockModulePayload as parseMockModulePayload2,
|
|
1186
1629
|
parseNudoImportPayload,
|
|
@@ -1222,7 +1665,7 @@ export {
|
|
|
1222
1665
|
getFirstDeclaratorId,
|
|
1223
1666
|
identifierName,
|
|
1224
1667
|
isModuleExportsMember,
|
|
1225
|
-
|
|
1668
|
+
listFnDirectiveScopes3 as listFnDirectiveScopes,
|
|
1226
1669
|
memberObjectName,
|
|
1227
1670
|
memberPropertyKey,
|
|
1228
1671
|
memberPropertyName,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nudojs/parser",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=22"
|
|
6
6
|
},
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"registry": "https://registry.npmjs.org"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@nudojs/core": "1.
|
|
42
|
+
"@nudojs/core": "1.17.0",
|
|
43
43
|
"@babel/types": "^8.0.6"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|