ReviewOS

also looking at this

stacks/dtsx

fix(scanner): end a statement at the newline after a trailing line comment

#3117
Open mkantautas wants to merge fix/trailing-line-comment into main
2 files +160 -13
packages/dtsx/src/extractor/scanner.tsmodified+92-13
Changes to packages/dtsx/src/extractor/scanner.ts
@@ -37,6 +37,16 @@ const CH_AT = 64
3737const CH_DOLLAR = 36
3838const CH_UNDERSCORE = 95
3939
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).
44const DECL_NAME = String.raw`(?!(?:as|satisfies|in|of|instanceof|extends|is|keyof|typeof)(?![\p{L}\p{N}_$\\]))[\p{L}_$][\p{L}\p{N}_$]*`
45const DECL_END = String.raw`(?![\p{L}\p{N}_$\\])`
46const 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`.
48const OTHER_LINE_BREAK_RE = /\r(?!\n)|[\u2028\u2029]/
49
4050function isWhitespace(ch: number): boolean {
4151 return ch === CH_SPACE || ch === CH_TAB || ch === CH_LF || ch === CH_CR
4252}
@@ -128,6 +138,14 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
128138 const nonExportedTypes = new Map<string, Declaration>()
129139 const funcBodyIndices = new Set<number>()
130140 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
131149
132150 // Skip BOM (byte order mark)
133151 if (pos < len && source.charCodeAt(pos) === 0xFEFF)
@@ -340,15 +358,23 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
340358 if (ch === CH_SLASH && pos + 1 < len) {
341359 const next = source.charCodeAt(pos + 1)
342360 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.
344365 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
346370 return true
347371 }
348372 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
352378 return true
353379 }
354380 // Check for regex literal: / not followed by / or *, preceded by non-expression char
@@ -409,6 +435,47 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
409435 }
410436 }
411437
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
412479 /** Index of the `//` that comments out `upTo` on this line, or -1. */
413480 function lineCommentStart(lineStart: number, upTo: number): number {
414481 let quote = 0
@@ -574,7 +641,13 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
574641 }
575642 break
576643 }
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())
578651 pos = saved
579652 return result
580653 }
@@ -648,6 +721,8 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
648721 const ch = source.charCodeAt(pos)
649722 if (ch !== CH_LF && ch !== CH_CR)
650723 return false
724 if (pos === skippedCommentEnd)
725 return false // see checkASITopLevel
651726 if (endsMidExpression(pos))
652727 return false
653728 const saved = pos
@@ -702,6 +777,8 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
702777 const ch = source.charCodeAt(pos)
703778 if (ch !== CH_LF && ch !== CH_CR)
704779 return false
780 if (pos === skippedCommentEnd)
781 return false // see checkASITopLevel
705782 if (endsMidExpression(pos))
706783 return false
707784 const saved = pos
@@ -741,6 +818,8 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
741818 const ch = source.charCodeAt(pos)
742819 if (ch !== CH_LF && ch !== CH_CR)
743820 return false
821 if (pos === skippedCommentEnd)
822 return false // see checkASITopLevel
744823 const saved = pos
745824 pos++
746825 if (ch === CH_CR && pos < len && source.charCodeAt(pos) === CH_LF)
@@ -2289,7 +2368,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
22892368 break
22902369 pos++
22912370 }
2292 typeAnnotation = sliceTrimmed(typeStart, pos)
2371 typeAnnotation = sliceTrimmed(typeStart, codeEndBefore(pos))
22932372 }
22942373
22952374 // Initializer
@@ -2364,7 +2443,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
23642443 }
23652444 break
23662445 }
2367 initializerText = sliceTrimmed(initStart, pos)
2446 initializerText = sliceTrimmed(initStart, codeEndBefore(pos))
23682447 if (initializerText.endsWith(' as const') || initializerText === 'const') {
23692448 isAsConst = true
23702449 if (!typeAnnotation) {
@@ -2546,7 +2625,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
25462625 break
25472626 pos++
25482627 }
2549 const typeBody = sliceTrimmed(typeStart, pos)
2628 const typeBody = sliceTrimmed(typeStart, codeEndBefore(pos))
25502629 if (pos < len && source.charCodeAt(pos) === CH_SEMI)
25512630 pos++
25522631
@@ -3321,7 +3400,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
33213400 break
33223401 pos++
33233402 }
3324 vtype = sliceTrimmed(ts, pos)
3403 vtype = sliceTrimmed(ts, codeEndBefore(pos))
33253404 }
33263405
33273406 let initText = ''
@@ -3346,7 +3425,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
33463425 break
33473426 pos++
33483427 }
3349 initText = sliceTrimmed(is2, pos)
3428 initText = sliceTrimmed(is2, codeEndBefore(pos))
33503429 }
33513430 if (pos < len && source.charCodeAt(pos) === CH_SEMI)
33523431 pos++
@@ -3431,7 +3510,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
34313510 break
34323511 pos++
34333512 }
3434 const typeBody = sliceTrimmed(ts, pos)
3513 const typeBody = sliceTrimmed(ts, codeEndBefore(pos))
34353514 if (pos < len && source.charCodeAt(pos) === CH_SEMI)
34363515 pos++
34373516 lines.push(`${indent}${prefix}type ${tname}${generics} = ${typeBody}`)
@@ -3606,7 +3685,7 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
36063685 /** Convert a default-export expression into an ambient binding plus export. */
36073686 function extractDefaultExpression(stmtStart: number, expressionStart: number): void {
36083687 skipToStatementEnd()
3609 let expression = sliceTrimmed(expressionStart, pos)
3688 let expression = sliceTrimmed(expressionStart, codeEndBefore(pos))
36103689 if (expression.charCodeAt(expression.length - 1) === CH_SEMI) expression = expression.slice(0, -1).trimEnd()
36113690 if (!expression) return
36123691