also looking at this
fix(scanner): end a statement at the newline after a trailing line comment
#3117
2 files
+160
-13
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -37,6 +37,16 @@ const CH_AT = 64 | ||
| 37 | 37 | const CH_DOLLAR = 36 |
| 38 | 38 | const CH_UNDERSCORE = 95 |
| 39 | 39 | |
| 40 | // A declaration keyword and the name it declares, in the shape only a | |
| 41 | // declaration has, on one line. See `startsDeclaration`. A name is never an | |
| 42 | // operator word (`as // note` then `const satisfies T` continues), and a | |
| 43 | // keyword never runs on into an escape (`export\u0056alue` is an identifier). | |
| 44 | const DECL_NAME = String.raw`(?!(?:as|satisfies|in|of|instanceof|extends|is|keyof|typeof)(?![\p{L}\p{N}_$\\]))[\p{L}_$][\p{L}\p{N}_$]*` | |
| 45 | const DECL_END = String.raw`(?![\p{L}\p{N}_$\\])` | |
| 46 | const DECLARATION_START_RE = new RegExp(String.raw`^(?:(?:export|enum|var)${DECL_END}|import[ \t]+[\p{L}_$*{'"]|(?:const|let)[ \t]+${DECL_NAME}${DECL_END}|declare[ \t]+(?:const|let|var|function|class|enum|type|interface|namespace|module|global|abstract|async)${DECL_END}|abstract[ \t]+class${DECL_END}|interface[ \t]+${DECL_NAME}[ \t]*(?:[{<]|extends${DECL_END})|(?:namespace|module)[ \t]+${DECL_NAME}(?:\.${DECL_NAME})*[ \t]*\{|module[ \t]+['"]|type[ \t]+${DECL_NAME}[ \t]*=)`, 'u') | |
| 47 | // A line break other than LF or CRLF. See `hasOtherLineBreak`. | |
| 48 | const OTHER_LINE_BREAK_RE = /\r(?!\n)|[\u2028\u2029]/ | |
| 49 | ||
| 40 | 50 | function isWhitespace(ch: number): boolean { |
| 41 | 51 | return ch === CH_SPACE || ch === CH_TAB || ch === CH_LF || ch === CH_CR |
| 42 | 52 | } |
| @@ -128,6 +138,14 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 128 | 138 | const nonExportedTypes = new Map<string, Declaration>() |
| 129 | 139 | const funcBodyIndices = new Set<number>() |
| 130 | 140 | let pos = 0 |
| 141 | // The last line comment `skipNonCode` stepped over: its `//` and the newline | |
| 142 | // that ends it. See `codeEndBefore`. | |
| 143 | let skippedCommentStart = -1 | |
| 144 | let skippedCommentEnd = -1 | |
| 145 | // Where the run of comments it belongs to starts, with only whitespace | |
| 146 | // between them, and where the last comment skipped ended. See `noteComment`. | |
| 147 | let commentRunStart = -1 | |
| 148 | let lastCommentEnd = -1 | |
| 131 | 149 | |
| 132 | 150 | // Skip BOM (byte order mark) |
| 133 | 151 | if (pos < len && source.charCodeAt(pos) === 0xFEFF) |
| @@ -340,15 +358,23 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 340 | 358 | if (ch === CH_SLASH && pos + 1 < len) { |
| 341 | 359 | const next = source.charCodeAt(pos + 1) |
| 342 | 360 | if (next === CH_SLASH) { |
| 343 | // Line comment — use native indexOf for speed | |
| 361 | // Line comment – stop AT its newline, not past it. That newline can end | |
| 362 | // the statement the comment trails, and the ASI checks only run on a | |
| 363 | // newline: stepping over it read `export const a = 1 // note` on into | |
| 364 | // the next line, which dropped that declaration and typed `a` unknown. | |
| 344 | 365 | const nl = source.indexOf('\n', pos + 2) |
| 345 | pos = nl === -1 ? len : nl + 1 | |
| 366 | const end = nl === -1 ? len : nl | |
| 367 | noteComment(end) | |
| 368 | skippedCommentStart = pos | |
| 369 | pos = skippedCommentEnd = end | |
| 346 | 370 | return true |
| 347 | 371 | } |
| 348 | 372 | if (next === CH_STAR) { |
| 349 | // Block comment — use native indexOf for speed | |
| 350 | const end = source.indexOf('*/', pos + 2) | |
| 351 | pos = end === -1 ? len : end + 2 | |
| 373 | // Block comment – use native indexOf for speed | |
| 374 | const close = source.indexOf('*/', pos + 2) | |
| 375 | const end = close === -1 ? len : close + 2 | |
| 376 | noteComment(end) | |
| 377 | pos = end | |
| 352 | 378 | return true |
| 353 | 379 | } |
| 354 | 380 | // Check for regex literal: / not followed by / or *, preceded by non-expression char |
| @@ -409,6 +435,47 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 409 | 435 | } |
| 410 | 436 | } |
| 411 | 437 | |
| 438 | /** | |
| 439 | * Whether the source from the last skipped `//` up to `end` holds a lone CR, | |
| 440 | * U+2028 or U+2029. Comments are read to the next LF, so code after such a | |
| 441 | * break passes for comment text; that newline is then left as it was. | |
| 442 | */ | |
| 443 | function hasOtherLineBreak(end: number): boolean { | |
| 444 | return OTHER_LINE_BREAK_RE.test(source.slice(skippedCommentStart, end)) | |
| 445 | } | |
| 446 | ||
| 447 | /** Record a comment from `pos` to `end`, extending the run before it if only whitespace separates them. */ | |
| 448 | function noteComment(end: number): void { | |
| 449 | let gap = lastCommentEnd | |
| 450 | while (gap >= 0 && gap < pos && /\s/.test(source[gap])) gap++ | |
| 451 | if (gap !== pos) | |
| 452 | commentRunStart = pos | |
| 453 | lastCommentEnd = end | |
| 454 | } | |
| 455 | ||
| 456 | /** | |
| 457 | * `end`, moved back to the start of a line comment that runs up to it, so a | |
| 458 | * trailing `// note` stays out of the code read before it. | |
| 459 | */ | |
| 460 | function codeEndBefore(end: number): number { | |
| 461 | // `end + 1` takes the LF in, so a CRLF's CR is not read as a lone one. | |
| 462 | return end === skippedCommentEnd && !hasOtherLineBreak(end + 1) ? skippedCommentStart : end | |
| 463 | } | |
| 464 | ||
| 465 | /** | |
| 466 | * Whether a declaration starts at `pos`, the line after a skipped comment: | |
| 467 | * its keyword followed by the name it declares, which no expression or type | |
| 468 | * runs on into, unless the comment follows a `.` (`api. // note` then | |
| 469 | * `export()`). `function` and `class` are left out: either can be an operand | |
| 470 | * (`void // note` then `function f() {}`, a decorated class expression). | |
| 471 | */ | |
| 472 | function startsDeclaration(): boolean { | |
| 473 | let before = commentRunStart - 1 | |
| 474 | while (before >= 0 && /\s/.test(source[before])) before-- | |
| 475 | const lineEnd = source.indexOf('\n', pos) | |
| 476 | return source.charCodeAt(before) !== CH_DOT && DECLARATION_START_RE.test(source.slice(pos, lineEnd === -1 ? len : lineEnd)) | |
| 477 | } | |
| 478 | ||
| 412 | 479 | /** Index of the `//` that comments out `upTo` on this line, or -1. */ |
| 413 | 480 | function lineCommentStart(lineStart: number, upTo: number): number { |
| 414 | 481 | let quote = 0 |
| @@ -574,7 +641,13 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 574 | 641 | } |
| 575 | 642 | break |
| 576 | 643 | } |
| 577 | const result = pos >= len || isTopLevelKeyword() || source.charCodeAt(pos) === CH_RBRACE | |
| 644 | let result = pos >= len || isTopLevelKeyword() || source.charCodeAt(pos) === CH_RBRACE | |
| 645 | // The newline of a trailing `// note` used to be stepped over, so none of | |
| 646 | // the ASI rules was written for it. It ends the statement only here, before | |
| 647 | // a `}`, the end of the file or a declaration; the other checks read on | |
| 648 | // past it, as they did. | |
| 649 | if (result && saved === skippedCommentEnd) | |
| 650 | result = !hasOtherLineBreak(pos) && (pos >= len || source.charCodeAt(pos) === CH_RBRACE || startsDeclaration()) | |
| 578 | 651 | pos = saved |
| 579 | 652 | return result |
| 580 | 653 | } |
| @@ -648,6 +721,8 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 648 | 721 | const ch = source.charCodeAt(pos) |
| 649 | 722 | if (ch !== CH_LF && ch !== CH_CR) |
| 650 | 723 | return false |
| 724 | if (pos === skippedCommentEnd) | |
| 725 | return false // see checkASITopLevel | |
| 651 | 726 | if (endsMidExpression(pos)) |
| 652 | 727 | return false |
| 653 | 728 | const saved = pos |
| @@ -702,6 +777,8 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 702 | 777 | const ch = source.charCodeAt(pos) |
| 703 | 778 | if (ch !== CH_LF && ch !== CH_CR) |
| 704 | 779 | return false |
| 780 | if (pos === skippedCommentEnd) | |
| 781 | return false // see checkASITopLevel | |
| 705 | 782 | if (endsMidExpression(pos)) |
| 706 | 783 | return false |
| 707 | 784 | const saved = pos |
| @@ -741,6 +818,8 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 741 | 818 | const ch = source.charCodeAt(pos) |
| 742 | 819 | if (ch !== CH_LF && ch !== CH_CR) |
| 743 | 820 | return false |
| 821 | if (pos === skippedCommentEnd) | |
| 822 | return false // see checkASITopLevel | |
| 744 | 823 | const saved = pos |
| 745 | 824 | pos++ |
| 746 | 825 | if (ch === CH_CR && pos < len && source.charCodeAt(pos) === CH_LF) |
| @@ -2289,7 +2368,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 2289 | 2368 | break |
| 2290 | 2369 | pos++ |
| 2291 | 2370 | } |
| 2292 | typeAnnotation = sliceTrimmed(typeStart, pos) | |
| 2371 | typeAnnotation = sliceTrimmed(typeStart, codeEndBefore(pos)) | |
| 2293 | 2372 | } |
| 2294 | 2373 | |
| 2295 | 2374 | // Initializer |
| @@ -2364,7 +2443,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 2364 | 2443 | } |
| 2365 | 2444 | break |
| 2366 | 2445 | } |
| 2367 | initializerText = sliceTrimmed(initStart, pos) | |
| 2446 | initializerText = sliceTrimmed(initStart, codeEndBefore(pos)) | |
| 2368 | 2447 | if (initializerText.endsWith(' as const') || initializerText === 'const') { |
| 2369 | 2448 | isAsConst = true |
| 2370 | 2449 | if (!typeAnnotation) { |
| @@ -2546,7 +2625,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 2546 | 2625 | break |
| 2547 | 2626 | pos++ |
| 2548 | 2627 | } |
| 2549 | const typeBody = sliceTrimmed(typeStart, pos) | |
| 2628 | const typeBody = sliceTrimmed(typeStart, codeEndBefore(pos)) | |
| 2550 | 2629 | if (pos < len && source.charCodeAt(pos) === CH_SEMI) |
| 2551 | 2630 | pos++ |
| 2552 | 2631 | |
| @@ -3321,7 +3400,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 3321 | 3400 | break |
| 3322 | 3401 | pos++ |
| 3323 | 3402 | } |
| 3324 | vtype = sliceTrimmed(ts, pos) | |
| 3403 | vtype = sliceTrimmed(ts, codeEndBefore(pos)) | |
| 3325 | 3404 | } |
| 3326 | 3405 | |
| 3327 | 3406 | let initText = '' |
| @@ -3346,7 +3425,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 3346 | 3425 | break |
| 3347 | 3426 | pos++ |
| 3348 | 3427 | } |
| 3349 | initText = sliceTrimmed(is2, pos) | |
| 3428 | initText = sliceTrimmed(is2, codeEndBefore(pos)) | |
| 3350 | 3429 | } |
| 3351 | 3430 | if (pos < len && source.charCodeAt(pos) === CH_SEMI) |
| 3352 | 3431 | pos++ |
| @@ -3431,7 +3510,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 3431 | 3510 | break |
| 3432 | 3511 | pos++ |
| 3433 | 3512 | } |
| 3434 | const typeBody = sliceTrimmed(ts, pos) | |
| 3513 | const typeBody = sliceTrimmed(ts, codeEndBefore(pos)) | |
| 3435 | 3514 | if (pos < len && source.charCodeAt(pos) === CH_SEMI) |
| 3436 | 3515 | pos++ |
| 3437 | 3516 | lines.push(`${indent}${prefix}type ${tname}${generics} = ${typeBody}`) |
| @@ -3606,7 +3685,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 3606 | 3685 | /** Convert a default-export expression into an ambient binding plus export. */ |
| 3607 | 3686 | function extractDefaultExpression(stmtStart: number, expressionStart: number): void { |
| 3608 | 3687 | skipToStatementEnd() |
| 3609 | let expression = sliceTrimmed(expressionStart, pos) | |
| 3688 | let expression = sliceTrimmed(expressionStart, codeEndBefore(pos)) | |
| 3610 | 3689 | if (expression.charCodeAt(expression.length - 1) === CH_SEMI) expression = expression.slice(0, -1).trimEnd() |
| 3611 | 3690 | if (!expression) return |
| 3612 | 3691 | |
| @@ -117,3 +117,71 @@ export function go(): void {} | ||
| 117 | 117 | expect(processCode(code)).toContain(expected) |
| 118 | 118 | }) |
| 119 | 119 | }) |
| 120 | ||
| 121 | /** | |
| 122 | * A trailing line comment hid the newline that ends the statement, in every read | |
| 123 | * loop above: | |
| 124 | * | |
| 125 | * export const SLASH_RE = /\//g // %2F | |
| 126 | * export const PLUS_RE = /\+/g // %2B | |
| 127 | * | |
| 128 | * Skipping the comment also stepped over its newline, so the ASI checks never ran | |
| 129 | * there and the next line was read into the initializer: `SLASH_RE` came out | |
| 130 | * `unknown` and `PLUS_RE` was missing. After a type annotation or a type alias | |
| 131 | * the next statement leaked into the `.d.ts` instead. | |
| 132 | * | |
| 133 | * Surfaced from vue-router, whose `encoding.ts` exports the two constants above. | |
| 134 | */ | |
| 135 | describe('a line comment before the newline', () => { | |
| 136 | it.each([ | |
| 137 | ['initializer', 'export const a = 1 // note\nexport const z = 3\n', 'const a: 1;'], | |
| 138 | ['initializer with CRLF', 'export const a = 1 // note\r\nexport const z = 3\r\n', 'const a: 1;'], | |
| 139 | ['local declaration next', 'export const a = 1 // note\nconst z = 3\nexport { z }\n', 'const a: 1;'], | |
| 140 | ['type annotation', 'export let a: number // note\nexport const z = 3\n', 'let a: number;'], | |
| 141 | ['type alias', 'export type A = string // note\nexport const z = 3\n', 'type A = string;'], | |
| 142 | ['type ending in void', 'export type F = () => void // note\nexport const z = 3\n', 'type F = () => void;'], | |
| 143 | ['namespace initializer', 'export namespace N {\n export const a = 1 // note\n export const z = 3\n}\n', 'const a: 1;'], | |
| 144 | ['namespace annotation', 'export namespace N {\n export let a: number // note\n export const z = 3\n}\n', 'let a: number;'], | |
| 145 | ['namespace type alias', 'export namespace N {\n export type A = string // note\n export const z = 3\n}\n', 'type A = string'], | |
| 146 | ['default export', 'const a = 1\nexport default a // note\nexport const z = 3\n', 'export default a;'], | |
| 147 | ])('ends the statement: %s', (_label, code, expected) => { | |
| 148 | const dts = processCode(code) | |
| 149 | expect(dts).toContain(expected) | |
| 150 | expect(dts).not.toContain('note') | |
| 151 | expect(dts).toMatch(/const z: 3;/) | |
| 152 | }) | |
| 153 | ||
| 154 | it('ends an initializer at the end of the file', () => { | |
| 155 | expect(processCode('export const a = \'x\' // note')).toContain('const a: \'x\';') | |
| 156 | }) | |
| 157 | ||
| 158 | // The comment ends a statement only before a declaration, `}` or the end of | |
| 159 | // the file. A statement that continues on the next line reads on, as before. | |
| 160 | it.each([ | |
| 161 | ['operator before the comment', 'export let a: string | // note\n number\nexport const z = 3\n', /let a: string \|[^;]*number;/], | |
| 162 | ['comment lines inside a type', 'export type A<T> = T extends string\n ? // note\n // more\n number\n : boolean\n', /\? \/\/ note[^;]*: boolean/], | |
| 163 | ['return type', 'export function f(): string | // note\n number { return 1 }\n', /f\(\): string \|[^;]*number;/], | |
| 164 | ['class member type', 'export class C {\n a: string | // note\n number\n}\n', /a: string \|[^;]*number;/], | |
| 165 | ['keyword before the comment', 'export type A = typeof // note\n import(\'./dep\')\nexport const z = 3\n', /typeof[^;]*import\('\.\/dep'\);/], | |
| 166 | ['call on the next line', 'export const a = (() => 1) // note\n () as number\nexport const z = 3\n', /const a: number;/], | |
| 167 | ['void in a value', 'export const a = void // note\n function f() {} as undefined\nexport const z = 3\n', /const a: undefined;/], | |
| 168 | ['decorator before the comment', 'export const a = @dec() // note\n class {} as { new(): object }\nexport const z = 3\n', /const a: \{ new\(\): object \};/], | |
| 169 | ['as const across the comment', 'export const a = { x: 1 } as // note\n const satisfies { x: number }\nexport const z = 3\n', /const a: \{ x: number \};/], | |
| 170 | ['as const with the operator far along the line', `export const a = { x: 1 } as // note\n const${' '.repeat(200)}satisfies { x: number }\nexport const z = 3\n`, /const a: \{ x: number \};/], | |
| 171 | ['property named like a keyword', 'declare const api: { export(): number }\nexport const a = api. // note\n // more\n export() as number\nexport const z = 3\n', /const a: number;/], | |
| 172 | ['variable named like a keyword', 'const namespace = \'app\'\nexport const a = false ? \'\' : // note\n namespace as string\nexport const z = 3\n', /const a: string;/], | |
| 173 | ])('keeps a statement that continues: %s', (_label, code, expected) => { | |
| 174 | expect(processCode(code)).toMatch(expected) | |
| 175 | }) | |
| 176 | ||
| 177 | // `//` inside a literal is not a comment. | |
| 178 | it.each([ | |
| 179 | ['regex', 'export const a = /[//]/g as RegExp\na.test(\'/\') as boolean\n', 'const a: RegExp;'], | |
| 180 | ['template', 'export type A = `Use:\n// example`\nconsole.log(\'loaded\')\nexport const z = 3\n', 'const z: 3;'], | |
| 181 | ])('is not fooled by // in a %s', (_label, code, expected) => { | |
| 182 | const dts = processCode(code) | |
| 183 | expect(dts).toContain(expected) | |
| 184 | expect(dts).not.toContain('console.log') | |
| 185 | expect(dts).not.toContain('a.test') | |
| 186 | }) | |
| 187 | }) | |