@velarscript/compiler 0.12.0 → 0.13.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.
Files changed (109) hide show
  1. package/dist/advisory-suppression.d.ts +62 -0
  2. package/dist/advisory-suppression.d.ts.map +1 -0
  3. package/dist/advisory-suppression.js +179 -0
  4. package/dist/advisory-suppression.js.map +1 -0
  5. package/dist/analyzer.d.ts +405 -17
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/analyzer.js +1755 -211
  8. package/dist/analyzer.js.map +1 -1
  9. package/dist/ast.d.ts +6 -3
  10. package/dist/ast.d.ts.map +1 -1
  11. package/dist/ast.js.map +1 -1
  12. package/dist/binding-stability.d.ts +22 -0
  13. package/dist/binding-stability.d.ts.map +1 -0
  14. package/dist/binding-stability.js +63 -0
  15. package/dist/binding-stability.js.map +1 -0
  16. package/dist/collection-lowering-runtime.d.ts +1 -1
  17. package/dist/collection-lowering-runtime.d.ts.map +1 -1
  18. package/dist/collection-lowering-runtime.js +183 -56
  19. package/dist/collection-lowering-runtime.js.map +1 -1
  20. package/dist/diagnostic.d.ts +34 -0
  21. package/dist/diagnostic.d.ts.map +1 -1
  22. package/dist/diagnostic.js +11 -1
  23. package/dist/diagnostic.js.map +1 -1
  24. package/dist/embedded-javascript.d.ts +12 -1
  25. package/dist/embedded-javascript.d.ts.map +1 -1
  26. package/dist/embedded-javascript.js +111 -2
  27. package/dist/embedded-javascript.js.map +1 -1
  28. package/dist/embedded-module.d.ts.map +1 -1
  29. package/dist/embedded-module.js +22 -0
  30. package/dist/embedded-module.js.map +1 -1
  31. package/dist/emitter.d.ts +83 -1
  32. package/dist/emitter.d.ts.map +1 -1
  33. package/dist/emitter.js +518 -41
  34. package/dist/emitter.js.map +1 -1
  35. package/dist/error-runtime.d.ts +9 -0
  36. package/dist/error-runtime.d.ts.map +1 -1
  37. package/dist/error-runtime.js +21 -0
  38. package/dist/error-runtime.js.map +1 -1
  39. package/dist/extension.d.ts +5 -2
  40. package/dist/extension.d.ts.map +1 -1
  41. package/dist/extension.js +2 -1
  42. package/dist/extension.js.map +1 -1
  43. package/dist/formatter.d.ts.map +1 -1
  44. package/dist/formatter.js +222 -91
  45. package/dist/formatter.js.map +1 -1
  46. package/dist/index.d.ts +10 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +43 -5
  49. package/dist/index.js.map +1 -1
  50. package/dist/interpolated-string.d.ts.map +1 -1
  51. package/dist/interpolated-string.js +29 -2
  52. package/dist/interpolated-string.js.map +1 -1
  53. package/dist/json-runtime.d.ts.map +1 -1
  54. package/dist/json-runtime.js +27 -3
  55. package/dist/json-runtime.js.map +1 -1
  56. package/dist/lexer.d.ts +217 -1
  57. package/dist/lexer.d.ts.map +1 -1
  58. package/dist/lexer.js +799 -37
  59. package/dist/lexer.js.map +1 -1
  60. package/dist/limits.d.ts +9 -0
  61. package/dist/limits.d.ts.map +1 -1
  62. package/dist/limits.js +9 -0
  63. package/dist/limits.js.map +1 -1
  64. package/dist/mechanical-fix.d.ts.map +1 -1
  65. package/dist/mechanical-fix.js +38 -0
  66. package/dist/mechanical-fix.js.map +1 -1
  67. package/dist/parser.d.ts +30 -1
  68. package/dist/parser.d.ts.map +1 -1
  69. package/dist/parser.js +137 -21
  70. package/dist/parser.js.map +1 -1
  71. package/dist/promise-runtime.d.ts +20 -1
  72. package/dist/promise-runtime.d.ts.map +1 -1
  73. package/dist/promise-runtime.js +38 -3
  74. package/dist/promise-runtime.js.map +1 -1
  75. package/dist/reactive-bridge-runtime.d.ts.map +1 -1
  76. package/dist/reactive-bridge-runtime.js +7 -1
  77. package/dist/reactive-bridge-runtime.js.map +1 -1
  78. package/dist/runtime-abi.d.ts +21 -1
  79. package/dist/runtime-abi.d.ts.map +1 -1
  80. package/dist/runtime-abi.js +23 -3
  81. package/dist/runtime-abi.js.map +1 -1
  82. package/dist/source-names.d.ts +26 -0
  83. package/dist/source-names.d.ts.map +1 -1
  84. package/dist/source-names.js +49 -5
  85. package/dist/source-names.js.map +1 -1
  86. package/dist/source.d.ts.map +1 -1
  87. package/dist/source.js +13 -0
  88. package/dist/source.js.map +1 -1
  89. package/dist/stable-order.d.ts +16 -0
  90. package/dist/stable-order.d.ts.map +1 -0
  91. package/dist/stable-order.js +18 -0
  92. package/dist/stable-order.js.map +1 -0
  93. package/dist/text-runtime.d.ts.map +1 -1
  94. package/dist/text-runtime.js +182 -41
  95. package/dist/text-runtime.js.map +1 -1
  96. package/dist/token.d.ts +11 -0
  97. package/dist/token.d.ts.map +1 -1
  98. package/dist/token.js.map +1 -1
  99. package/dist/type-registry-runtime.d.ts.map +1 -1
  100. package/dist/type-registry-runtime.js +8 -1
  101. package/dist/type-registry-runtime.js.map +1 -1
  102. package/dist/type-validation-runtime.d.ts.map +1 -1
  103. package/dist/type-validation-runtime.js +129 -1
  104. package/dist/type-validation-runtime.js.map +1 -1
  105. package/dist/types.d.ts +17 -1
  106. package/dist/types.d.ts.map +1 -1
  107. package/dist/types.js +161 -17
  108. package/dist/types.js.map +1 -1
  109. package/package.json +1 -1
package/dist/lexer.js CHANGED
@@ -1,8 +1,10 @@
1
+ import { scanAdvisorySuppressions } from "./advisory-suppression.js";
1
2
  import { CORE_NUMERIC_SUFFIXES } from "./core-vocabulary.js";
2
- import { diagnostic, mechanicalFix, recoveredDiagnostic } from "./diagnostic.js";
3
+ import { advisory, diagnostic, mechanicalEdits, mechanicalFix, recoveredDiagnostic } from "./diagnostic.js";
3
4
  import { scanEmbeddedJavaScriptLiteral } from "./embedded-javascript.js";
4
5
  import { findInterpolatedExpressionEnd, scanStringEscape, scanStringLiteral } from "./interpolated-string.js";
5
6
  import { webNumericUnitOwner } from "./language-guidance.js";
7
+ import { MAX_LEX_DIAGNOSTICS } from "./limits.js";
6
8
  import { forbiddenSourceIdentifiers, isForbiddenPrototypeMember, isSourceIdentifierPart, isSourceIdentifierStart } from "./source-names.js";
7
9
  import { span } from "./source.js";
8
10
  import { keywordKinds } from "./token.js";
@@ -29,6 +31,221 @@ const chainContinuationEndKinds = new Set([
29
31
  "true", "false", "null", "super", "rightParen", "rightBracket", "rightBrace",
30
32
  "extensionToken",
31
33
  ]);
34
+ // D89 A1 reads back the primary expression its comment follows. These are the
35
+ // kinds a primary tail is made of outside brackets — names, literals, member
36
+ // steps, and the two postfix marks; brackets themselves are matched by depth.
37
+ // Anything else ends the walk, which is what keeps the advisory's rewrite from
38
+ // reaching across an operator that binds looser than `//` does.
39
+ //
40
+ // A literal is here for its interior reading, not its final one: `"abc".size`
41
+ // and `f"{a}".size` are dividends whose walk passes back through the literal
42
+ // on the way to the name that ends them. What may *end* a dividend is the
43
+ // narrower question `floorDivisionDividendEndKinds` answers.
44
+ const primaryTailKinds = new Set([
45
+ "identifier", "number", "unitNumber", "string", "fstring", "extensionToken",
46
+ "true", "false", "null", "super", "dot", "optionalDot", "bang",
47
+ ]);
48
+ // D90: the token a floor-division mistake can actually stand on. A1 used to
49
+ // borrow `chainContinuationEndKinds`, which answers a different question — a
50
+ // string, an f-string, `true`, `false`, `null`, `super` and a record's `}` all
51
+ // end an expression that a leading-dot line may continue, and none of them can
52
+ // be divided, so `const s = "x" // 2` drew an advisory suggesting
53
+ // `("x" / 2).floor()`, which the author cannot act on. D89 admits an advisory
54
+ // only when its trigger narrows to near-zero false positives, which makes that
55
+ // disqualifying. What remains: a name, a plain numeric literal, `)` closing a
56
+ // call or a group, `]` closing an index (`xs[0] // 2`), and `!` closing a
57
+ // required-value unwrap (`total! // 2`).
58
+ //
59
+ // A unit number is not here either. `10s` is a Duration, `(10s / 2).floor()`
60
+ // does not typecheck (Duration has no `floor`), and nobody reaches for Python's
61
+ // floor division on a duration literal — so the rewrite would be one the author
62
+ // cannot use, which is the same disqualification the string tail carried.
63
+ const floorDivisionDividendEndKinds = new Set([
64
+ "identifier", "number", "rightParen", "rightBracket", "bang",
65
+ ]);
66
+ // The tokens that end one logical line's token run, and the words a class
67
+ // header may carry ahead of `class`. Both are read when a block opens, to
68
+ // decide whether the block being entered is a class body.
69
+ const lineBoundaryKinds = new Set(["newline", "indent", "dedent"]);
70
+ const classHeaderModifierKinds = new Set(["export", "abstract"]);
71
+ /** How far back the receiver-parameter walk reads before giving the name up. */
72
+ const RECEIVER_PARAMETER_SCAN_LIMIT = 4096;
73
+ // D90 (compiler-front-14): the words that open a declaration or a statement.
74
+ // A physical line inside an open bracket that begins with one of these, at or
75
+ // below the indentation of the line that opened the bracket, is the evidence
76
+ // that the bracket was never closed rather than still being filled in.
77
+ const statementHeadWords = new Set([
78
+ "export", "def", "class", "const", "let", "enum", "import", "return",
79
+ "if", "for", "while", "match", "type",
80
+ ]);
81
+ // What may stand after one of those words when the line is *not* a statement
82
+ // head: a record key's ':', the separators and closers that finish a read of a
83
+ // binding named `type` or `match`, the '=' of a named argument written
84
+ // `type=1`, and the '.' of a member step. None of them can follow a real
85
+ // declaration keyword, so withholding recovery on them refuses nothing.
86
+ const statementReadFollowers = new Set([":", ",", ")", "]", "}", "=", "."]);
87
+ /**
88
+ * D89 A1's comment body, split into what Python's `//` would have divided by
89
+ * and what it would have gone on to do. `//` binds as tightly as `*`, so the
90
+ * Python author who wrote `total // 2 + 3` divided by 2 and *then* added 3.
91
+ * Both readings that came before this were wrong: quoting the body verbatim
92
+ * gave `(total / 2 + 3).floor()`, and wrapping the whole body gave
93
+ * `(total / (2 + 3)).floor()`, which for `total = 10` answers 2 where Python
94
+ * answers 8. The divisor is the leading primary alone — a number or an
95
+ * already-parenthesised group — and the tail is re-emitted after `.floor()`,
96
+ * where it binds exactly as it did after Python's `//`.
97
+ *
98
+ * `null` withholds the advisory. D89's admission bar, item 4, requires that a
99
+ * zero-cost rewrite exist and that the advisory name that one unambiguous
100
+ * spelling, which makes an advisory that cannot name a correct rewrite
101
+ * inadmissible. A body this cannot translate by a single substitution therefore
102
+ * reports nothing at all: unbalanced parentheses (`// 2)` is as
103
+ * likely a stray keystroke as a divisor), a body that does not open with a
104
+ * primary (`// 2 3` does not parse either way), a tail that is not one
105
+ * arithmetic step (`+`, `-`, `*` and then something), and a second `/` or `%`
106
+ * anywhere in the tail — a second floor division or a modulo cannot be
107
+ * expressed by one substitution, and `//` inside the suggested text would open
108
+ * a comment in the very line it is telling the author to write. Silence is the
109
+ * safe half of that trade; a wrong suggestion is a new defect.
110
+ */
111
+ function floorDivisionRewrite(body) {
112
+ let depth = 0;
113
+ let primaryEnd = -1;
114
+ for (let index = 0; index < body.length; index += 1) {
115
+ if (body[index] === "(")
116
+ depth += 1;
117
+ else if (body[index] === ")") {
118
+ depth -= 1;
119
+ if (depth < 0)
120
+ return null;
121
+ if (depth === 0 && primaryEnd < 0 && body.startsWith("("))
122
+ primaryEnd = index + 1;
123
+ }
124
+ }
125
+ if (depth !== 0)
126
+ return null;
127
+ if (!body.startsWith("(")) {
128
+ const primary = /^[0-9]+(?:\.[0-9]+)?/u.exec(body);
129
+ if (primary === null)
130
+ return null;
131
+ primaryEnd = primary[0].length;
132
+ }
133
+ const divisor = body.slice(0, primaryEnd);
134
+ if (divisor.includes("//"))
135
+ return null;
136
+ const rest = body.slice(primaryEnd).trim();
137
+ if (rest === "")
138
+ return { divisor, tail: "" };
139
+ if (!/^[+\-*]\s*\S/u.test(rest) || rest.includes("/") || rest.includes("%"))
140
+ return null;
141
+ return { divisor, tail: ` ${rest[0]} ${rest.slice(1).trim().replace(/\s+/gu, " ")}` };
142
+ }
143
+ /**
144
+ * D89 A5/A6: the interpolation bodies whose rewrite is registered as a
145
+ * mechanical fix. D38 §48 admits a fix only where no judgment is involved, and
146
+ * the judgment-free core of "delete the `$`" is a body that is already a Vel
147
+ * expression: a dotted name path reads identically in JavaScript and in an
148
+ * `f` string, so the rewrite is a spelling change. Anything wider — a call, an
149
+ * operator, a JavaScript-only form like `a ?? b.x()` — might not compile once
150
+ * it becomes an interpolation, and a registered fix that hands back a new
151
+ * diagnostic is a guess, not a fix. Those bodies keep the advisory and lose
152
+ * only the one-click edit.
153
+ */
154
+ const interpolationPathBody = /^[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)*$/u;
155
+ /**
156
+ * D89 A5/A6: reads the `${...}` occurrences of one string literal's content.
157
+ * The walk mirrors `diagnoseStringContents` so the two never disagree about
158
+ * what a brace means: escapes are skipped in non-raw text (`"\u{E9}"` carries
159
+ * a brace that belongs to the escape), and in an interpolated string a `{{`
160
+ * pair and a real `{...}` interpolation are stepped over rather than counted,
161
+ * because there they already mean what the author asked for. Brace matching
162
+ * inside a `${...}` body is by depth, which is the same reading JavaScript
163
+ * gives the template it came from.
164
+ */
165
+ function templateInterpolationScan(content, syntax) {
166
+ const occurrences = [];
167
+ let allWellFormed = true;
168
+ let bareBrace = false;
169
+ for (let index = 0; index < content.length; index += 1) {
170
+ const character = content[index];
171
+ if (!syntax.raw && character === "\\") {
172
+ index = scanStringEscape(content, index).end - 1;
173
+ continue;
174
+ }
175
+ if (character !== "{") {
176
+ if (!syntax.interpolated && character === "}")
177
+ bareBrace = true;
178
+ continue;
179
+ }
180
+ if (content[index - 1] === "$" && !(syntax.interpolated && content[index + 1] === "{")) {
181
+ // In an interpolated string `${{x}}` is a literal `$` ahead of a real
182
+ // interpolation, so the `$` there is not holding anything back and the
183
+ // next iteration reads the braces the way the lexer does.
184
+ const close = matchInterpolationClose(content, index + 1);
185
+ if (close < 0 || content.slice(index + 1, close).trim() === "") {
186
+ allWellFormed = false;
187
+ continue;
188
+ }
189
+ occurrences.push({ dollar: index - 1, close, body: content.slice(index + 1, close) });
190
+ index = close;
191
+ continue;
192
+ }
193
+ if (!syntax.interpolated) {
194
+ bareBrace = true;
195
+ continue;
196
+ }
197
+ if (content[index + 1] === "{") {
198
+ index += 1;
199
+ continue;
200
+ }
201
+ const close = findInterpolatedExpressionEnd(content, index + 1);
202
+ if (close < 0)
203
+ break;
204
+ index = close;
205
+ }
206
+ return { occurrences, allWellFormed, bareBrace };
207
+ }
208
+ /** The index of the `}` closing the brace at `open - 1`, matched by depth, or -1. */
209
+ function matchInterpolationClose(content, open) {
210
+ let depth = 1;
211
+ for (let index = open; index < content.length; index += 1) {
212
+ if (content[index] === "{")
213
+ depth += 1;
214
+ else if (content[index] === "}") {
215
+ depth -= 1;
216
+ if (depth === 0)
217
+ return index;
218
+ }
219
+ }
220
+ return -1;
221
+ }
222
+ /**
223
+ * The diagnostics of one lex, capped. Pathological input reports once per
224
+ * character — a minified JavaScript file pasted into a `.vel` buffer that the
225
+ * language server re-lexes on every keystroke — and millions of retained
226
+ * reports help nobody. The cap never drops the tail silently: its last slot
227
+ * says that it closed, so a real error can never hide behind the truncation.
228
+ */
229
+ class DiagnosticLog {
230
+ entries = [];
231
+ closed = false;
232
+ push(...reports) {
233
+ for (const report of reports) {
234
+ if (this.entries.length < MAX_LEX_DIAGNOSTICS - 1) {
235
+ this.entries.push(report);
236
+ continue;
237
+ }
238
+ if (this.closed)
239
+ return;
240
+ this.closed = true;
241
+ this.entries.push(diagnostic("VEL1013", `This module reported ${MAX_LEX_DIAGNOSTICS - 1} lexical errors, which is as many as VelarScript reports at once; fix these and compile again to see the rest`, report.span));
242
+ return;
243
+ }
244
+ }
245
+ get reports() {
246
+ return this.entries;
247
+ }
248
+ }
32
249
  export class Lexer {
33
250
  text;
34
251
  extensionForbiddenIdentifiers = new Map();
@@ -40,12 +257,39 @@ export class Lexer {
40
257
  // extension republishing them.
41
258
  numericSuffixes = new Set(CORE_NUMERIC_SUFFIXES);
42
259
  tokens = [];
43
- diagnostics = [];
260
+ diagnostics = new DiagnosticLog();
261
+ advisories = [];
262
+ suppressions = [];
44
263
  diagnosedBidirectionalOffsets = new Set();
45
264
  indentStack = [0];
265
+ // D90 (compiler-front-9): whether each open block is a class body, kept in
266
+ // step with `indentStack`. A member may be spelled `with` or `int`; a binding
267
+ // may not, and `def with(...)` at module scope would emit `function with`,
268
+ // which is not JavaScript. Only the enclosing block tells the two apart.
269
+ classBodyStack = [false];
270
+ // D90 (compiler-front-14): the brackets still open, with the indentation of
271
+ // the physical line each one was opened on.
272
+ openBrackets = [];
46
273
  index = 0;
47
274
  atLineStart = true;
275
+ // A physical line began while brackets were open, where no newline token is
276
+ // emitted and no indentation is read. The unclosed-bracket recovery is the
277
+ // only thing that looks at those lines.
278
+ bracketLineStart = false;
48
279
  nesting = 0;
280
+ // The indentation of the physical line that opened the current logical line.
281
+ // A leading-dot continuation is measured against this rather than against the
282
+ // previous physical line, so every line of one chain answers to one rule.
283
+ logicalLineIndent = 0;
284
+ // The forward line scan `lineStart` and `lineEnd` share; see `lineStart`.
285
+ scannedLineStart = 0;
286
+ scannedTo = 0;
287
+ cachedLineEndFrom = -1;
288
+ cachedLineEnd = -1;
289
+ // The end of the run of semicolons and blanks a trailing-semicolon fix last
290
+ // measured. Every semicolon in one run reaches the same offset, so the run is
291
+ // walked once rather than once per semicolon.
292
+ semicolonRunEnd = -1;
49
293
  // A bracket fragment is an expression lexed inside an enclosing bracket
50
294
  // context, such as an extension-owned bracket interpolation: newlines are insignificant
51
295
  // and physical-line indentation never opens or closes blocks, exactly as
@@ -80,6 +324,10 @@ export class Lexer {
80
324
  this.index = this.text.length;
81
325
  break;
82
326
  }
327
+ if (this.bracketLineStart) {
328
+ this.bracketLineStart = false;
329
+ this.recoverUnclosedBrackets();
330
+ }
83
331
  if (this.atLineStart && this.nesting === 0 && !this.bracketFragment) {
84
332
  this.readIndentation();
85
333
  }
@@ -143,27 +391,27 @@ export class Lexer {
143
391
  switch (character) {
144
392
  case "(":
145
393
  this.simple("leftParen", start, 1);
146
- this.nesting += 1;
394
+ this.openBracket(start);
147
395
  break;
148
396
  case ")":
149
397
  this.simple("rightParen", start, 1);
150
- this.nesting = Math.max(0, this.nesting - 1);
398
+ this.closeBracket();
151
399
  break;
152
400
  case "[":
153
401
  this.simple("leftBracket", start, 1);
154
- this.nesting += 1;
402
+ this.openBracket(start);
155
403
  break;
156
404
  case "]":
157
405
  this.simple("rightBracket", start, 1);
158
- this.nesting = Math.max(0, this.nesting - 1);
406
+ this.closeBracket();
159
407
  break;
160
408
  case "{":
161
409
  this.simple("leftBrace", start, 1);
162
- this.nesting += 1;
410
+ this.openBracket(start);
163
411
  break;
164
412
  case "}":
165
413
  this.simple("rightBrace", start, 1);
166
- this.nesting = Math.max(0, this.nesting - 1);
414
+ this.closeBracket();
167
415
  break;
168
416
  case ":":
169
417
  if (this.peek(1) === "=") {
@@ -336,7 +584,7 @@ export class Lexer {
336
584
  this.tokens.push({ kind: "dedent", value: "", span: span(this.index, this.index) });
337
585
  }
338
586
  this.tokens.push({ kind: "eof", value: "", span: span(this.index, this.index) });
339
- return { tokens: this.tokens, diagnostics: this.diagnostics };
587
+ return { tokens: this.tokens, diagnostics: this.diagnostics.reports, advisories: this.advisories, suppressions: this.suppressions };
340
588
  }
341
589
  readIndentation() {
342
590
  const start = this.index;
@@ -364,12 +612,20 @@ export class Lexer {
364
612
  // A leading-dot line continues the previous logical line: the newline
365
613
  // tokens that ended it are withdrawn and this line's indentation does not
366
614
  // open or close a block, so '.filter(...)' chains span physical lines.
367
- if (this.isChainContinuation()) {
368
- while (this.tokens.at(-1)?.kind === "newline")
615
+ const dotWidth = this.leadingDotWidth();
616
+ if (dotWidth > 0) {
617
+ if (this.isChainContinuation(width)) {
369
618
  this.tokens.pop();
370
- return;
619
+ return;
620
+ }
621
+ // The line looked like a continuation and is not one, so it is read as
622
+ // its own statement — which it cannot be, because no statement begins
623
+ // with a member step. Saying so here is the whole point of tightening
624
+ // the rule: the alternative is the silent reattachment this replaces.
625
+ this.diagnostics.push(diagnostic("VEL1004", `A line beginning with '${dotWidth === 2 ? "?." : "."}' continues the line above it, so it must follow that line directly and be indented past the statement it continues`, span(start, this.index + dotWidth)));
371
626
  }
372
627
  const current = this.indentStack.at(-1) ?? 0;
628
+ this.logicalLineIndent = width;
373
629
  if (width > current) {
374
630
  if (this.indentStack.length > MAX_NESTING) {
375
631
  this.diagnostics.push(diagnostic("VEL1006", `Indentation nesting cannot exceed ${MAX_NESTING} levels`, span(start, this.index)));
@@ -377,12 +633,14 @@ export class Lexer {
377
633
  return;
378
634
  }
379
635
  this.indentStack.push(width);
636
+ this.classBodyStack.push(this.opensClassBody());
380
637
  this.tokens.push({ kind: "indent", value: "", span: span(start, this.index) });
381
638
  return;
382
639
  }
383
640
  if (width < current) {
384
641
  while (this.indentStack.length > 1 && width < (this.indentStack.at(-1) ?? 0)) {
385
642
  this.indentStack.pop();
643
+ this.classBodyStack.pop();
386
644
  this.tokens.push({ kind: "dedent", value: "", span: span(start, this.index) });
387
645
  }
388
646
  if (width !== (this.indentStack.at(-1) ?? 0)) {
@@ -390,17 +648,57 @@ export class Lexer {
390
648
  }
391
649
  }
392
650
  }
393
- isChainContinuation() {
394
- const dotWidth = this.peek() === "." ? 1 : this.peek() === "?" && this.peek(1) === "." ? 2 : 0;
395
- if (dotWidth === 0 || !this.isIdentifierStart(this.peek(dotWidth)))
651
+ /** The width of a leading member step, or 0 where the line does not open with one. */
652
+ leadingDotWidth() {
653
+ const width = this.peek() === "." ? 1 : this.peek() === "?" && this.peek(1) === "." ? 2 : 0;
654
+ // '.5' is a decimal literal with its leading digit missing, not a member
655
+ // step, and it carries its own diagnostic.
656
+ return width > 0 && this.isIdentifierStart(this.peek(width)) ? width : 0;
657
+ }
658
+ /**
659
+ * Whether the leading-dot line at `width` joins the line above it. Two
660
+ * conditions, and the file's own contract has always claimed both:
661
+ *
662
+ * - It is the *next* line. The backward walk used to skip an unbounded run of
663
+ * `newline` tokens, so a chain joined a value that appeared any number of
664
+ * blank lines and whole-line comments earlier — the case the header comment
665
+ * says "never join accidentally" (D90, compiler-front-10). One `newline`
666
+ * token is the line that ended the statement; a second is a blank or
667
+ * comment line, and the statement ended there.
668
+ * - It is indented past the statement it continues. The charter called the
669
+ * deeper indentation canonical and nothing enforced it, so a column-0
670
+ * `.sorted()` dedented out of a function body and silently became part of
671
+ * it. `logicalLineIndent` is the *statement's* indentation rather than the
672
+ * previous physical line's, so every line of one chain answers to one rule.
673
+ */
674
+ isChainContinuation(width) {
675
+ const index = this.tokens.length - 1;
676
+ if (this.tokens[index]?.kind !== "newline")
677
+ return false;
678
+ if (this.tokens[index - 1]?.kind === "newline")
396
679
  return false;
680
+ const previous = this.tokens[index - 1];
681
+ if (previous === undefined || !chainContinuationEndKinds.has(previous.kind))
682
+ return false;
683
+ return width > this.logicalLineIndent;
684
+ }
685
+ /**
686
+ * Whether the logical line that just ended opens a class body. Read by the
687
+ * member-name exemption: `def with(...)` declares a member here and a binding
688
+ * anywhere else, and only the enclosing block distinguishes them.
689
+ */
690
+ opensClassBody() {
397
691
  let index = this.tokens.length - 1;
398
- if (this.tokens[index]?.kind !== "newline")
692
+ while (index >= 0 && this.tokens[index].kind === "newline")
693
+ index -= 1;
694
+ if (this.tokens[index]?.kind !== "colon")
399
695
  return false;
400
- while (this.tokens[index]?.kind === "newline")
696
+ while (index >= 0 && !lineBoundaryKinds.has(this.tokens[index].kind))
401
697
  index -= 1;
402
- const previous = this.tokens[index];
403
- return previous !== undefined && chainContinuationEndKinds.has(previous.kind);
698
+ let head = index + 1;
699
+ while (classHeaderModifierKinds.has(this.tokens[head]?.kind ?? "eof"))
700
+ head += 1;
701
+ return this.tokens[head]?.kind === "class";
404
702
  }
405
703
  readNewline() {
406
704
  const start = this.index;
@@ -414,11 +712,238 @@ export class Lexer {
414
712
  this.tokens.push({ kind: "newline", value: "", span: span(start, this.index) });
415
713
  this.atLineStart = true;
416
714
  }
715
+ else if (!this.bracketFragment) {
716
+ this.bracketLineStart = true;
717
+ }
417
718
  }
719
+ /**
720
+ * D90 (compiler-front-14): an unclosed bracket used to swallow the rest of
721
+ * the module. While `nesting > 0` no newline token is emitted and no
722
+ * indentation is read, so one mistyped `(` turned every declaration below it
723
+ * into a continuation of one logical line — a file of fifty exports became
724
+ * one symbol, and none of the reported diagnostics named the bracket.
725
+ *
726
+ * Recovery is narrow because line breaks inside brackets really are
727
+ * insignificant: `compute(` with its arguments at column 0 is legal and must
728
+ * keep compiling, so indentation alone decides nothing. What decides is a
729
+ * physical line that begins a declaration or a statement — a word no
730
+ * bracketed expression can continue with — at or below the indentation of the
731
+ * line that opened the bracket. An indentation-significant language can read
732
+ * on from there; a brace language cannot.
733
+ */
734
+ recoverUnclosedBrackets() {
735
+ const opening = this.openBrackets[0];
736
+ if (opening === undefined)
737
+ return;
738
+ let cursor = this.index;
739
+ let width = 0;
740
+ while (cursor < this.text.length && (this.text[cursor] === " " || this.text[cursor] === "\t")) {
741
+ width += this.text[cursor] === "\t" ? 4 : 1;
742
+ cursor += 1;
743
+ }
744
+ // An arrow body's statements are the parser's to report: VEL2030 names the
745
+ // remedy, and a '}' one line down means the bracket was never unclosed in
746
+ // the first place. Recovering here would trade a teaching diagnostic for a
747
+ // structural one.
748
+ if (opening.arrowBody)
749
+ return;
750
+ if (width > opening.lineIndent)
751
+ return;
752
+ let end = cursor;
753
+ while (end < this.text.length && this.isIdentifierPart(this.text[end] ?? ""))
754
+ end += 1;
755
+ if (!statementHeadWords.has(this.text.slice(cursor, end)))
756
+ return;
757
+ // Two of those words are ordinary names as well — `type` and `match` are
758
+ // contextual keywords the charter keeps available (section 3) — and any of
759
+ // them may spell a record key. What follows the word tells a declaration
760
+ // from a read: a declaration continues with its subject (`type Name =`,
761
+ // `match value:`, `const x`), while a read is finished, so the next thing
762
+ // it can carry is the punctuation that separates or closes it. A statement
763
+ // head is never followed by one of those, which is what makes withholding
764
+ // on them cost nothing.
765
+ if (statementReadFollowers.has(this.text[this.skipHorizontalWhitespace(end)] ?? ""))
766
+ return;
767
+ this.diagnostics.push(diagnostic("VEL1003", `Unclosed '${opening.text}'; the line below it starts a new declaration, so VelarScript reads on from there rather than to the end of the module`, opening.span));
768
+ this.openBrackets.length = 0;
769
+ this.nesting = 0;
770
+ this.tokens.push({ kind: "newline", value: "", span: span(this.index, this.index) });
771
+ this.atLineStart = true;
772
+ }
773
+ openBracket(start) {
774
+ this.nesting += 1;
775
+ if (this.bracketFragment)
776
+ return;
777
+ const lineStart = this.lineStart(start);
778
+ let width = 0;
779
+ for (let cursor = lineStart; cursor < start; cursor += 1) {
780
+ if (this.text[cursor] === " ")
781
+ width += 1;
782
+ else if (this.text[cursor] === "\t")
783
+ width += 4;
784
+ else
785
+ break;
786
+ }
787
+ // A '{' straight after '=>' is an arrow body, and a statement inside one is
788
+ // exactly what VEL2030 exists to report — a message that names the fix
789
+ // ("move multi-statement logic into a named 'def'") where VEL1003 only
790
+ // reports structure. Recovery yields to it; see recoverUnclosedBrackets.
791
+ // The '{' token is pushed by the caller before this runs, so the arrow — if
792
+ // there is one — sits one further back.
793
+ const arrowBody = (this.text[start] ?? "") === "{" && this.tokens[this.tokens.length - 2]?.kind === "fatArrow";
794
+ this.openBrackets.push({ span: span(start, start + 1), text: this.text[start] ?? "(", lineIndent: width, arrowBody });
795
+ }
796
+ closeBracket() {
797
+ this.nesting = Math.max(0, this.nesting - 1);
798
+ this.openBrackets.pop();
799
+ }
800
+ // D89: a line comment carries no token, but it may carry a `velar-allow`
801
+ // suppression, so its text is read once here. A malformed suppression is a
802
+ // diagnostic rather than an advisory: an unreasoned one may not pass.
418
803
  readComment() {
804
+ const start = this.index;
805
+ const bodyStart = this.text.startsWith("//", start) ? start + 2 : start;
419
806
  while (!this.isAtEnd() && this.peek() !== "\n" && this.peek() !== "\r") {
420
807
  this.advance();
421
808
  }
809
+ const scanned = scanAdvisorySuppressions(this.text, start, bodyStart, this.index);
810
+ this.suppressions.push(...scanned.suppressions);
811
+ this.diagnostics.push(...scanned.diagnostics);
812
+ if (bodyStart !== start)
813
+ this.adviseFloorDivisionComment(start, bodyStart, scanned.contentEnd);
814
+ }
815
+ /**
816
+ * D89 A1: `//` is VelarScript's only comment spelling, so a Python author's
817
+ * floor division reads as a finished line followed by a comment and the
818
+ * compiler has nothing to object to. `#` cannot take the comment role over:
819
+ * of the branches the `#` dispatch reads (`readJavaScriptPrivateIdentifier`,
820
+ * `readHexColor`, `readHashComment`), `readHexColor` carries bare
821
+ * hexadecimal colors — a hot path in a language with `look:` — and a `#`
822
+ * that opened a comment would swallow `#ff0000` and the rest of its line
823
+ * instead of guiding it to the quoted spelling. With no comment spelling
824
+ * left to give up, an advisory is the only remaining move.
825
+ *
826
+ * The trigger is narrow on both sides. D89 asks for a syntactically complete
827
+ * expression or assignment ahead of the `//`, which here means three things:
828
+ * the line carries code, every bracket *this physical line* opened is closed
829
+ * before the comment (an unclosed one leaves the line unfinished, so
830
+ * `print( // 2` is silent), and the last token can end a dividend. The
831
+ * bracket test counts opens and closes on the line itself rather than reading
832
+ * `nesting`: a bracket opened on an earlier line and closed on a later one
833
+ * leaves the text before `//` complete, so `print(` / ` total // 2` / `)`
834
+ * is exactly the mistake this advisory exists for and used to compile in
835
+ * silence (D90). The comment's own text — with any `velar-allow` clause
836
+ * removed, which is what `contentEnd` is for — must then be a bare arithmetic
837
+ * body carrying a digit and no letter anywhere, so `// TODO`,
838
+ * `// 2. then handle X`, a bare `//`, and a whole-line comment are all
839
+ * silent.
840
+ *
841
+ * A bracket fragment is exempt because its lexer holds the fragment's text
842
+ * rather than the module's: `lineStart` there answers with the fragment's own
843
+ * beginning, and "the rest of this line is a comment" would be a claim about
844
+ * an interpolation rather than about a physical line.
845
+ *
846
+ * No mechanical fix is registered: deciding that the comment really was a
847
+ * divisor is the judgment D38 §48 keeps out of the fix registry.
848
+ */
849
+ adviseFloorDivisionComment(start, bodyStart, contentEnd) {
850
+ if (this.bracketFragment)
851
+ return;
852
+ const lineStart = this.lineStart(start);
853
+ if (this.text.slice(lineStart, start).trim() === "")
854
+ return;
855
+ const previous = this.tokens.at(-1);
856
+ if (!previous || previous.span.end <= lineStart || !floorDivisionDividendEndKinds.has(previous.kind))
857
+ return;
858
+ if (this.hasBracketOpenedOnLine(lineStart))
859
+ return;
860
+ const body = this.text.slice(bodyStart, contentEnd).trim();
861
+ if (!/[0-9]/u.test(body) || !/^[0-9\s+\-*/%()]+$/u.test(body))
862
+ return;
863
+ const division = floorDivisionRewrite(body);
864
+ if (division === null)
865
+ return;
866
+ const dividend = this.dividendBeforeComment(lineStart, start);
867
+ if (!dividend)
868
+ return;
869
+ const rewrite = `(${dividend.value} / ${division.divisor}).floor()${division.tail}`;
870
+ this.advisories.push(advisory("A1", dividend.target === null
871
+ ? `'//' is VelarScript's comment spelling, so the rest of this line is a comment and nothing divides '${dividend.value}'; write '${rewrite}' for Python's floor division`
872
+ : `'//' is VelarScript's comment spelling, so '${dividend.target}' receives '${dividend.value}' and the rest of this line is a comment; write '${rewrite}' for Python's floor division`, span(start, contentEnd)));
873
+ }
874
+ /**
875
+ * Whether a bracket opened on this physical line is still open at the
876
+ * comment. This is the "syntactically complete" half of A1's trigger that
877
+ * `nesting` was standing in for: `nesting` also counts a bracket opened three
878
+ * lines up, whose text before the `//` is complete all the same.
879
+ */
880
+ hasBracketOpenedOnLine(lineStart) {
881
+ const innermost = this.openBrackets.at(-1);
882
+ return innermost !== undefined && innermost.span.start >= lineStart;
883
+ }
884
+ /**
885
+ * What Python's `//` would have divided: the primary expression the comment
886
+ * follows, read back from the source so the advisory quotes the author's own
887
+ * spelling. The walk stops at the first operator or keyword outside brackets,
888
+ * because `//` binds as tightly as `*` — in `a + b // 2` the dividend is `b`,
889
+ * and naming `a + b` would hand back a rewrite that changes the result.
890
+ *
891
+ * A leading unary sign belongs to the dividend. Python's `-7 // 2` is -4, so
892
+ * quoting `7` and suggesting `(7 / 2).floor()` — which answers 3 — is the same
893
+ * class of wrong rewrite the divisor side used to hand back. The sign is unary
894
+ * exactly when nothing that could end an operand stands in front of it, which
895
+ * keeps the binary reading in `a - 7 // 2`, where the dividend is still `7`.
896
+ *
897
+ * `target` is the name the value lands in, and only a plain `=` produces one:
898
+ * a compound assignment reads its target as well as writing it, so calling it
899
+ * the receiver would be a second claim this advisory has not checked.
900
+ */
901
+ dividendBeforeComment(lineStart, commentStart) {
902
+ let index = this.tokens.length - 1;
903
+ let depth = 0;
904
+ while (index >= 0 && this.tokens[index].span.end > lineStart) {
905
+ const kind = this.tokens[index].kind;
906
+ if (kind === "rightParen" || kind === "rightBracket" || kind === "rightBrace")
907
+ depth += 1;
908
+ else if (kind === "leftParen" || kind === "leftBracket" || kind === "leftBrace") {
909
+ if (depth === 0)
910
+ break;
911
+ depth -= 1;
912
+ }
913
+ else if (depth === 0 && !primaryTailKinds.has(kind))
914
+ break;
915
+ index -= 1;
916
+ }
917
+ if (depth !== 0)
918
+ return null;
919
+ const sign = this.tokens[index];
920
+ if (sign !== undefined && sign.span.end > lineStart && (sign.kind === "minus" || sign.kind === "plus")) {
921
+ const before = this.tokens[index - 1];
922
+ const operandStandsBefore = before !== undefined
923
+ && before.span.end > lineStart
924
+ && !lineBoundaryKinds.has(before.kind)
925
+ && (floorDivisionDividendEndKinds.has(before.kind) || before.kind === "rightBrace");
926
+ if (!operandStandsBefore)
927
+ index -= 1;
928
+ }
929
+ const first = this.tokens[index + 1];
930
+ if (!first || first.span.end <= lineStart)
931
+ return null;
932
+ // A leading-dot continuation line holds only the tail of its dividend: the
933
+ // walk is bounded by this physical line, so on `const c = xs` / `.size // 2`
934
+ // it stops at the line's own first token, the `.`. Quoting from there gave
935
+ // `'.size'` and suggested `(.size / 2).floor()`, which does not parse. The
936
+ // head lives on a line this advisory does not read, so there is no rewrite
937
+ // to name and D89's admission bar withholds the advisory (D90).
938
+ if (first.kind === "dot" || first.kind === "optionalDot")
939
+ return null;
940
+ const value = this.text.slice(first.span.start, commentStart).trim();
941
+ if (value === "")
942
+ return null;
943
+ const boundary = this.tokens[index];
944
+ const name = this.tokens[index - 1];
945
+ const assigned = boundary !== undefined && boundary.span.end > lineStart && boundary.kind === "assign";
946
+ return { target: assigned && name?.kind === "identifier" ? name.value : null, value };
422
947
  }
423
948
  readBlockComment() {
424
949
  const start = this.index;
@@ -489,11 +1014,27 @@ export class Lexer {
489
1014
  }
490
1015
  const value = this.text.slice(start, this.index);
491
1016
  const previous = this.tokens.at(-1)?.kind;
492
- // `int` remains forbidden as a type or binding, but velar/random owns the
493
- // method spelling Random.int(...). Member names are not type vocabulary.
494
- const rule = value === "int" && (previous === "dot" || previous === "optionalDot")
495
- ? undefined
496
- : forbiddenSourceIdentifiers.get(value);
1017
+ // D90 (compiler-front-9): a rule's ban is on the spelling as a binding, a
1018
+ // parameter and a type; some of them are ordinary member names and record
1019
+ // keys. `int` remains forbidden as a type, but velar/random owns the method
1020
+ // spelling Random.int(...); `with` remains forbidden as the infix record
1021
+ // update, but `Array.prototype.with` and every builder API spelled that way
1022
+ // must be callable, and `{with: 1}` must be writable. The exemption is a
1023
+ // property of the rule (`memberLegal`) rather than a name spelled here, so
1024
+ // `eval` — which the charter keeps unavailable through direct member
1025
+ // syntax — does not travel with them.
1026
+ const declared = forbiddenSourceIdentifiers.get(value);
1027
+ // D90 (coherence): `def close(this)` used to earn two mechanical fixes on
1028
+ // one span — this rule's `this` -> `self` rewrite and the analyzer's
1029
+ // delete-the-implicit-receiver rewrite — whose texts contradict each
1030
+ // other. Applying the first produces `def close(self)`, which is itself an
1031
+ // error, so a `velar fix` pass never reaches a clean source. The receiver
1032
+ // parameter is the analyzer's report to make: it knows the declaration has
1033
+ // an implicit receiver, and its fix deletes the parameter outright. The
1034
+ // recovery token is still emitted, so the parameter arrives as `self` and
1035
+ // lands on exactly that report.
1036
+ const receiverParameter = value === "this" && this.isReceiverParameterPosition(previous);
1037
+ const rule = declared?.memberLegal === true && this.isMemberNamePosition(previous) ? undefined : declared;
497
1038
  const extensionGuidance = rule ? undefined : this.extensionForbiddenIdentifiers.get(value);
498
1039
  if ((value === "Infinity" || value === "NaN") && previous !== "dot" && previous !== "optionalDot") {
499
1040
  this.diagnostics.push(diagnostic("VEL1007", value === "Infinity"
@@ -506,7 +1047,8 @@ export class Lexer {
506
1047
  if (rule.recovery) {
507
1048
  // The rule carries its successor only when the guidance names exactly
508
1049
  // one ('var' names 'let' or 'const', so it names none).
509
- this.diagnostics.push(recoveredDiagnostic("VEL1005", rule.guidance, span(start, this.index), rule.fix === null ? undefined : mechanicalFix(span(start, rule.fix === "" ? this.skipHorizontalWhitespace(this.index) : this.index), rule.fix, rule.fix === "" ? `Remove '${value}'` : `Use '${rule.fix}'`)));
1050
+ if (!receiverParameter)
1051
+ this.diagnostics.push(recoveredDiagnostic("VEL1005", rule.guidance, span(start, this.index), rule.fix === null ? undefined : mechanicalFix(span(start, rule.fix === "" ? this.skipHorizontalWhitespace(this.index) : this.index), rule.fix, rule.fix === "" ? `Remove '${value}'` : `Use '${rule.fix}'`)));
510
1052
  for (const item of rule.recovery) {
511
1053
  this.tokens.push({ kind: item.kind, value: item.value, span: span(start, this.index) });
512
1054
  }
@@ -523,6 +1065,86 @@ export class Lexer {
523
1065
  const keyword = Object.hasOwn(keywordKinds, value) ? keywordKinds[value] : undefined;
524
1066
  this.tokens.push({ kind: keyword ?? "identifier", value, span: span(start, this.index) });
525
1067
  }
1068
+ /**
1069
+ * The three positions in which a name is a member name rather than a binding:
1070
+ * after a member step, as a record-literal key, and as the name of a member
1071
+ * declared in a class body. The first two are the reads — `q.with("cte")`,
1072
+ * `{with: 1}` — and the third is the declaration an extern module needs to
1073
+ * describe such an API at all.
1074
+ *
1075
+ * A class body is the only place the declaration is legal: `def with(...)`
1076
+ * outside one binds a name, and the generated module would say
1077
+ * `function with`, which is not JavaScript.
1078
+ */
1079
+ isMemberNamePosition(previous) {
1080
+ if (previous === "dot" || previous === "optionalDot")
1081
+ return true;
1082
+ // A record key is followed by ':' — `{with: 1}` and `{a: 1, with: 2}`. The
1083
+ // preceding '{' or ',' is what separates a key from an argument in a call.
1084
+ if ((previous === "leftBrace" || previous === "comma")
1085
+ && this.text[this.skipHorizontalWhitespace(this.index)] === ":")
1086
+ return true;
1087
+ const declaring = previous === "def"
1088
+ || (previous === "identifier" && (this.tokens.at(-1)?.value === "get" || this.tokens.at(-1)?.value === "set"));
1089
+ return declaring && (this.classBodyStack.at(-1) ?? false);
1090
+ }
1091
+ /**
1092
+ * D90 (coherence): the one position where `this` is the Python receiver
1093
+ * reflex rather than JavaScript's dynamic receiver — a parameter name in the
1094
+ * list of an instance method or a constructor inside a class body. The
1095
+ * analyzer owns that report, because only it knows the declaration carries
1096
+ * an implicit receiver, and its rewrite deletes the parameter instead of
1097
+ * renaming it to a spelling that is an error in the same position.
1098
+ *
1099
+ * The walk is paren-balanced so a default value cannot be mistaken for the
1100
+ * list that encloses it — `def m(a = f(this))` is an ordinary receiver read
1101
+ * — and `static def make(this)` is excluded because a static method has no
1102
+ * receiver to delete, so the rename is still the honest answer there.
1103
+ */
1104
+ isReceiverParameterPosition(previous) {
1105
+ if (previous !== "leftParen" && previous !== "comma")
1106
+ return false;
1107
+ if (!(this.classBodyStack.at(-1) ?? false))
1108
+ return false;
1109
+ let depth = 0;
1110
+ let index = this.tokens.length - 1;
1111
+ for (let steps = 0; index >= 0 && steps < RECEIVER_PARAMETER_SCAN_LIMIT; steps += 1, index -= 1) {
1112
+ const kind = this.tokens[index].kind;
1113
+ if (kind === "rightParen")
1114
+ depth += 1;
1115
+ else if (kind === "leftParen") {
1116
+ if (depth === 0)
1117
+ break;
1118
+ depth -= 1;
1119
+ }
1120
+ else if (lineBoundaryKinds.has(kind) && depth === 0 && kind !== "newline")
1121
+ return false;
1122
+ }
1123
+ if (index < 0 || this.tokens[index]?.kind !== "leftParen")
1124
+ return false;
1125
+ let head = index - 1;
1126
+ // A generic method writes its parameters after the type parameter list, so
1127
+ // `def m<T>(this)` has to walk back over a balanced '<...>' to reach the
1128
+ // name that says which declaration this list belongs to.
1129
+ if (this.tokens[head]?.kind === "greater") {
1130
+ let angle = 0;
1131
+ for (let steps = 0; head >= 0 && steps < RECEIVER_PARAMETER_SCAN_LIMIT; steps += 1, head -= 1) {
1132
+ const kind = this.tokens[head].kind;
1133
+ if (kind === "greater")
1134
+ angle += 1;
1135
+ else if (kind === "less" && (angle -= 1) === 0) {
1136
+ head -= 1;
1137
+ break;
1138
+ }
1139
+ }
1140
+ }
1141
+ const name = this.tokens[head];
1142
+ if (name?.kind !== "identifier")
1143
+ return false;
1144
+ if (name.value === "constructor")
1145
+ return true;
1146
+ return this.tokens[head - 1]?.kind === "def" && this.tokens[head - 2]?.kind !== "static";
1147
+ }
526
1148
  readNumber() {
527
1149
  const start = this.index;
528
1150
  if (this.peek() === "0" && ["x", "X", "b", "B", "o", "O"].includes(this.peek(1))) {
@@ -567,7 +1189,7 @@ export class Lexer {
567
1189
  this.advance();
568
1190
  const suffix = this.text.slice(numberEnd, this.index);
569
1191
  if (suffix && this.numericSuffixes.has(suffix)) {
570
- this.tokens.push({ kind: "unitNumber", value: `${value}${suffix}`, span: span(start, this.index) });
1192
+ this.pushNumber("unitNumber", `${value}${suffix}`, span(start, this.index));
571
1193
  return;
572
1194
  }
573
1195
  if (suffix) {
@@ -586,7 +1208,7 @@ export class Lexer {
586
1208
  ? `Unknown numeric unit '${suffix}'`
587
1209
  : `Unexpected characters '${suffix}' after a number`, span(numberEnd, this.index)));
588
1210
  }
589
- this.tokens.push({ kind: "number", value, span: span(start, numberEnd) });
1211
+ this.pushNumber("number", value, span(start, numberEnd));
590
1212
  }
591
1213
  readRadixNumber() {
592
1214
  const start = this.index;
@@ -623,7 +1245,24 @@ export class Lexer {
623
1245
  this.diagnostics.push(diagnostic("VEL1007", `${radixName[0].toUpperCase()}${radixName.slice(1)} integer literals require at least one digit`, span(start, this.index)));
624
1246
  digits = "0";
625
1247
  }
626
- this.tokens.push({ kind: "number", value: `0${prefix}${digits}`, span: span(start, this.index) });
1248
+ this.pushNumber("number", `0${prefix}${digits}`, span(start, this.index));
1249
+ }
1250
+ /**
1251
+ * A number token, carrying the author's own spelling whenever the value it
1252
+ * holds is spelled differently — `1_000` loses its separators and `0X20`
1253
+ * loses its uppercase prefix on the way to the token. D90 R6 quotes the
1254
+ * literal back when it is not exactly representable, and it must quote what
1255
+ * was written: reporting `'10000000000000000001'` for a source line that
1256
+ * reads `1_000_000_000_000_000_000_1` sends the author looking for text that
1257
+ * is not there.
1258
+ */
1259
+ pushNumber(kind, value, tokenSpan) {
1260
+ const written = this.text.slice(tokenSpan.start, tokenSpan.end);
1261
+ if (written === value) {
1262
+ this.tokens.push({ kind, value, span: tokenSpan });
1263
+ return;
1264
+ }
1265
+ this.tokens.push({ kind, value, span: tokenSpan, payload: { written } });
627
1266
  }
628
1267
  isRadixDigit(character, radix) {
629
1268
  if (character >= "0" && character <= "9")
@@ -656,7 +1295,7 @@ export class Lexer {
656
1295
  value += this.readDigitsWithSeparators();
657
1296
  }
658
1297
  this.diagnostics.push(recoveredDiagnostic("VEL1007", `Write '${value}'; decimal literals require a digit before the point`, span(start, this.index), mechanicalFix(span(start, this.index), value, `Write '${value}'`)));
659
- this.tokens.push({ kind: "number", value, span: span(start, this.index) });
1298
+ this.pushNumber("number", value, span(start, this.index));
660
1299
  }
661
1300
  readDigitsWithSeparators() {
662
1301
  let value = "";
@@ -695,6 +1334,7 @@ export class Lexer {
695
1334
  if (scanned.quote === "'") {
696
1335
  this.diagnostics.push(diagnostic("VEL1005", "Use double quotes or backticks for strings; single-quoted strings are not part of VelarScript", span(start + scanned.prefixLength, Math.min(this.index, start + scanned.prefixLength + 1))));
697
1336
  }
1337
+ this.adviseTemplateInterpolation(scanned, start);
698
1338
  const payload = {
699
1339
  prefixLength: scanned.prefixLength,
700
1340
  quote: scanned.quote,
@@ -739,6 +1379,87 @@ export class Lexer {
739
1379
  },
740
1380
  });
741
1381
  }
1382
+ /**
1383
+ * D89 A5/A6: JavaScript's `${...}` never interpolates in VelarScript. The
1384
+ * charter keeps it literal on purpose — generating JavaScript source is a
1385
+ * real use of these literals — so the spelling cannot become an error, and a
1386
+ * string that carries it compiles in silence with a meaning the JavaScript
1387
+ * reflex behind it did not intend. That is D89's admission shape exactly,
1388
+ * on both sides of the `f` prefix:
1389
+ *
1390
+ * - A5, a plain double-quoted or backtick string: nothing interpolates, the
1391
+ * `${name}` stays text. The way out is the `f` prefix with `{name}`.
1392
+ * - A6, an `f` or `rf` string: the `$` ahead of `{` keeps that brace
1393
+ * literal, so the author who wrote the prefix *and* the JavaScript
1394
+ * spelling still gets text. The way out is dropping the `$`.
1395
+ *
1396
+ * The non-triggers, each deliberate: an empty `${}` or an unclosed `${`
1397
+ * carries no expression to interpolate; a raw `r"..."` string is the author
1398
+ * asking for literal text by name, so the deliberate-literal reading wins
1399
+ * there (`rf` still triggers A6 — its rawness is about backslashes, not
1400
+ * about interpolation); a single-quoted string is already VEL1005; an
1401
+ * unterminated string is already VEL1003; and an inline `extern js` /
1402
+ * `unsafe js` block never reaches this method at all — it is scanned by
1403
+ * `readEmbeddedJavaScript`, where `${...}` is documented literal
1404
+ * JavaScript. One advisory speaks per literal: the rewrite names the whole
1405
+ * string, so a second occurrence adds nothing the first did not say.
1406
+ *
1407
+ * The fix is registered only where D38 §48's no-judgment bar holds: every
1408
+ * `${...}` well-formed, every body a plain dotted name path (see
1409
+ * `interpolationPathBody`), no `$` immediately ahead of an occurrence's own
1410
+ * `$` — JavaScript's `$${x}` spells a literal `$` before an interpolation,
1411
+ * and deleting the occurrence's `$` leaves `${x}`, whose surviving `$`
1412
+ * holds the brace literal all over again, so the quoted rewrite would not
1413
+ * interpolate — and, for A5, no bare brace outside the occurrences, because
1414
+ * the `f` prefix would turn `{` into an interpolation opener and `{{` into
1415
+ * a single literal brace. Everything else keeps the message and loses the
1416
+ * one-click edit.
1417
+ *
1418
+ * A bracket fragment is exempt for A1's reason: its lexer holds the
1419
+ * fragment's text rather than the module's, so a span there would not land
1420
+ * on the physical line a `velar-allow` reads.
1421
+ */
1422
+ adviseTemplateInterpolation(scanned, start) {
1423
+ if (this.bracketFragment || !scanned.closed || scanned.quote === "'")
1424
+ return;
1425
+ if (scanned.raw && !scanned.interpolated)
1426
+ return;
1427
+ const scan = templateInterpolationScan(scanned.content, scanned);
1428
+ const first = scan.occurrences[0];
1429
+ if (!first)
1430
+ return;
1431
+ const sourceOffset = (index) => scanned.contentOffsets?.[index] ?? scanned.contentStart + index;
1432
+ const reportSpan = span(sourceOffset(first.dollar), sourceOffset(first.close) + 1);
1433
+ const quotedFirst = scanned.content.slice(first.dollar, first.close + 1);
1434
+ const shown = quotedFirst.length <= 40 ? quotedFirst : `${quotedFirst.slice(0, 39)}…`;
1435
+ const fixable = scan.allWellFormed
1436
+ && (scanned.interpolated || !scan.bareBrace)
1437
+ && scan.occurrences.every((occurrence) => interpolationPathBody.test(occurrence.body.trim())
1438
+ && scanned.content[occurrence.dollar - 1] !== "$");
1439
+ const deletions = scan.occurrences.map((occurrence) => ({ span: span(sourceOffset(occurrence.dollar), sourceOffset(occurrence.dollar) + 1), text: "" }));
1440
+ if (scanned.interpolated) {
1441
+ const rewritten = fixable ? this.literalWithoutDollars(start, deletions) : null;
1442
+ this.advisories.push(advisory("A6", rewritten !== null && rewritten.length <= 60
1443
+ ? `VelarScript interpolation is '{...}', and '$' keeps the brace after it literal even under the 'f' prefix, so this stays the characters '${shown}'; drop the '$' and write '${rewritten}'`
1444
+ : `VelarScript interpolation is '{...}', and '$' keeps the brace after it literal even under the 'f' prefix, so this stays the characters '${shown}'; drop the '$' and write '{${first.body.trim()}}'`, reportSpan, fixable ? mechanicalEdits(deletions, "Drop the '$' and interpolate") : undefined));
1445
+ return;
1446
+ }
1447
+ const edits = [{ span: span(start, start), text: "f" }, ...deletions];
1448
+ const rewritten = fixable ? `f${this.literalWithoutDollars(start, deletions)}` : null;
1449
+ this.advisories.push(advisory("A5", rewritten !== null && rewritten.length <= 60
1450
+ ? `'\${...}' is literal text in a VelarScript string — only the 'f' prefix interpolates — so this stays the characters '${shown}'; write '${rewritten}'`
1451
+ : `'\${...}' is literal text in a VelarScript string — only the 'f' prefix interpolates — so this stays the characters '${shown}'; write '{${first.body.trim()}}' under an 'f' prefix`, reportSpan, fixable ? mechanicalEdits(edits, "Interpolate with an 'f' string") : undefined));
1452
+ }
1453
+ /** The literal's source text with each occurrence's `$` removed, for the message that quotes the rewrite. */
1454
+ literalWithoutDollars(start, deletions) {
1455
+ let text = "";
1456
+ let cursor = start;
1457
+ for (const deletion of deletions) {
1458
+ text += this.text.slice(cursor, deletion.span.start);
1459
+ cursor = deletion.span.end;
1460
+ }
1461
+ return text + this.text.slice(cursor, this.index);
1462
+ }
742
1463
  diagnoseStringContents(scanned) {
743
1464
  const sourceOffset = (index) => scanned.contentOffsets?.[index] ?? scanned.contentStart + index;
744
1465
  for (let index = 0; index < scanned.content.length; index += 1) {
@@ -850,6 +1571,7 @@ export class Lexer {
850
1571
  }
851
1572
  this.tokens.push(result.token);
852
1573
  this.diagnostics.push(...result.diagnostics ?? []);
1574
+ this.advisories.push(...result.advisories ?? []);
853
1575
  this.index = result.nextOffset;
854
1576
  this.atLineStart = result.startsLine ?? false;
855
1577
  return true;
@@ -885,8 +1607,14 @@ export class Lexer {
885
1607
  */
886
1608
  trailingSemicolonFix(start) {
887
1609
  let end = start + 1;
888
- while (this.text[end] === ";" || this.text[end] === " " || this.text[end] === "\t")
889
- end += 1;
1610
+ if (end < this.semicolonRunEnd) {
1611
+ end = this.semicolonRunEnd;
1612
+ }
1613
+ else {
1614
+ while (this.text[end] === ";" || this.text[end] === " " || this.text[end] === "\t")
1615
+ end += 1;
1616
+ this.semicolonRunEnd = end;
1617
+ }
890
1618
  const rest = this.text.slice(end, this.lineEnd(end));
891
1619
  if (rest.length > 0 && !rest.startsWith("//") && !rest.startsWith("/*"))
892
1620
  return undefined;
@@ -969,15 +1697,49 @@ export class Lexer {
969
1697
  return (codePoint >= 0 && codePoint <= 0x1f && codePoint !== 0x0a && codePoint !== 0x0d)
970
1698
  || (codePoint >= 0x7f && codePoint <= 0x9f);
971
1699
  }
1700
+ /**
1701
+ * The start of the physical line `index` sits on. D90 (compiler-front-2): the
1702
+ * backward scan this used to be is O(column) per call, and its callers — A1,
1703
+ * the block-comment reader, the semicolon fix, and every opening bracket —
1704
+ * run once per token, so one long physical line cost O(n²). A line of 20000
1705
+ * semicolons took 673 ms and a 4 MiB one would have taken hours, with nothing
1706
+ * to stop it: a ';' produces no token, so `MAX_TOKENS` never fires.
1707
+ *
1708
+ * The offsets those callers ask about only move forward, so the scan is
1709
+ * carried across the file once and each call pays for the characters since
1710
+ * the last one. An earlier offset still falls back to the backward scan,
1711
+ * which is correct and, being off the hot path, is not the cost.
1712
+ */
972
1713
  lineStart(index) {
973
- while (index > 0 && this.text[index - 1] !== "\n" && this.text[index - 1] !== "\r")
974
- index -= 1;
975
- return index;
1714
+ if (index < this.scannedTo) {
1715
+ let cursor = index;
1716
+ while (cursor > 0 && this.text[cursor - 1] !== "\n" && this.text[cursor - 1] !== "\r")
1717
+ cursor -= 1;
1718
+ return cursor;
1719
+ }
1720
+ while (this.scannedTo < index) {
1721
+ const character = this.text[this.scannedTo];
1722
+ this.scannedTo += 1;
1723
+ if (character === "\n" || character === "\r")
1724
+ this.scannedLineStart = this.scannedTo;
1725
+ }
1726
+ return this.scannedLineStart;
976
1727
  }
1728
+ /**
1729
+ * The end of the physical line `index` sits on. Every offset between a line's
1730
+ * start and its end shares that end, so one line answers every call about it
1731
+ * once — which is what keeps a line of N semicolons from paying N forward
1732
+ * scans of its own tail.
1733
+ */
977
1734
  lineEnd(index) {
978
- while (index < this.text.length && this.text[index] !== "\n" && this.text[index] !== "\r")
979
- index += 1;
980
- return index;
1735
+ if (index >= this.cachedLineEndFrom && index <= this.cachedLineEnd)
1736
+ return this.cachedLineEnd;
1737
+ let cursor = index;
1738
+ while (cursor < this.text.length && this.text[cursor] !== "\n" && this.text[cursor] !== "\r")
1739
+ cursor += 1;
1740
+ this.cachedLineEndFrom = index;
1741
+ this.cachedLineEnd = cursor;
1742
+ return cursor;
981
1743
  }
982
1744
  invalidCharacter(character, start) {
983
1745
  this.advance();