partial-tsx 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/partial.ts +161 -48
package/package.json
CHANGED
package/src/partial.ts
CHANGED
|
@@ -4,7 +4,11 @@ const BLOCK_CLOSERS = new Set(Object.values(BLOCK_FOLLOWERS));
|
|
|
4
4
|
export type PartialTsxMode = "streaming" | "final";
|
|
5
5
|
export type PartialTsxOptions = { mode?: PartialTsxMode };
|
|
6
6
|
type BlockKind = "function" | "control" | "arrow" | "class" | "method" | "block" | "other";
|
|
7
|
-
type CompletionFrame = { type: "block"; closer: string; statement: boolean; open?: number; close?: number; jsxExpression?: boolean; statementStart?: number; kind?: BlockKind } | { type: "jsx"; tag: string; open?: number };
|
|
7
|
+
type CompletionFrame = { type: "block"; closer: string; statement: boolean; open?: number; close?: number; headerEnd?: number; jsxExpression?: boolean; statementStart?: number; kind?: BlockKind } | { type: "jsx"; tag: string; open?: number };
|
|
8
|
+
|
|
9
|
+
// Single source of truth for the JSX-text fast path in the four lexer loops below: only `<` (child tag) and `{` (child
|
|
10
|
+
// expression) leave text mode, so none of them reads `(`/`[`/quotes/`//` in ordinary text as unfinished JavaScript.
|
|
11
|
+
const isJsxTextBoundary = (char: string) => char === "<" || char === "{";
|
|
8
12
|
|
|
9
13
|
const stripMarkdownFence = (code: string) => {
|
|
10
14
|
let next = code.trim();
|
|
@@ -78,7 +82,7 @@ const maskIgnoredTextForExportScan = (code: string, regexLiteralStarts?: Readonl
|
|
|
78
82
|
const stack: CompletionFrame[] = [];
|
|
79
83
|
for (let index = 0; index < code.length; index += 1) {
|
|
80
84
|
const char = code[index];
|
|
81
|
-
if (isInsideJsxText(stack) && char
|
|
85
|
+
if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) {
|
|
82
86
|
if (chars[index] !== "\n") chars[index] = " ";
|
|
83
87
|
continue;
|
|
84
88
|
}
|
|
@@ -157,14 +161,28 @@ const findWrappedComponentCallOpen = (code: string, index: number) => {
|
|
|
157
161
|
return code[index] === "(" ? index : -1;
|
|
158
162
|
};
|
|
159
163
|
const isWrappedImplementationFunction = (code: string, index: number) => /\b(?:memo|forwardRef)\s*(?:<[\s\S]*>)?\s*\($/.test(code.slice(0, index).trimEnd());
|
|
164
|
+
const isAmbientDeclaration = (code: string, index: number) => /\bdeclare\s+(?:abstract\s+)?$/.test(code.slice(0, index));
|
|
165
|
+
// A class/function expression's own name is bound only inside its body, so the enclosing binding it is assigned to is the
|
|
166
|
+
// exportable one. Without a binding (argument, object value, `return`) nothing is exportable and the candidate is dropped.
|
|
167
|
+
// A lone HOC call (`withTheme(class Inner …)`) is skipped so arbitrary wrappers resolve like the `memo`/`forwardRef` loop below.
|
|
168
|
+
const findExpressionBindingName = (code: string, index: number) => /\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$.]*\s*(?:<[^<>()]*>)?\s*\(\s*)?$/.exec(code.slice(0, index))?.[1] ?? null;
|
|
169
|
+
const isExpressionPosition = (code: string, index: number) => !/(?:^|[;}]|(?<!\$)\{|\n)\s*(?:(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?)$/.test(code.slice(0, index));
|
|
160
170
|
|
|
161
171
|
const findLastExportableComponent = (code: string) => {
|
|
162
172
|
const candidates: Array<{ name: string; index: number }> = [];
|
|
163
173
|
for (const match of code.matchAll(/\bfunction\s+([A-Z][\w$]*)\s*\(/g)) {
|
|
164
|
-
|
|
174
|
+
const index = match.index ?? 0;
|
|
175
|
+
if (isWrappedImplementationFunction(code, index) || isAmbientDeclaration(code, index)) continue;
|
|
176
|
+
const name = isExpressionPosition(code, index) ? findExpressionBindingName(code, index) : match[1];
|
|
177
|
+
if (name) candidates.push({ name, index });
|
|
165
178
|
}
|
|
166
|
-
for (const match of code.matchAll(/\bclass\s+([A-Z][\w$]*)\s+extends\s+(?:
|
|
167
|
-
|
|
179
|
+
for (const match of code.matchAll(/\bclass\s+([A-Z][\w$]*)\s+extends\s+(?:[A-Za-z_$][\w$]*\.)?(?:Pure)?Component\b/g)) {
|
|
180
|
+
const index = match.index ?? 0;
|
|
181
|
+
if (isAmbientDeclaration(code, index)) continue;
|
|
182
|
+
const name = isExpressionPosition(code, index) ? findExpressionBindingName(code, index) : match[1];
|
|
183
|
+
if (name) candidates.push({ name, index });
|
|
184
|
+
}
|
|
185
|
+
for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*(?::[^=;\n]+)?=>/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
|
|
168
186
|
for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$]*\.)?(?:memo|forwardRef)\s*/g)) {
|
|
169
187
|
if (findWrappedComponentCallOpen(code, (match.index ?? 0) + match[0].length) !== -1) candidates.push({ name: match[1], index: match.index ?? 0 });
|
|
170
188
|
}
|
|
@@ -174,6 +192,37 @@ const findLastExportableComponent = (code: string) => {
|
|
|
174
192
|
// so the scan does not miss an existing default and append a second one (`Duplicate export of 'default'`).
|
|
175
193
|
const hasDefaultExportDeclaration = (code: string) => /(?:^|[\n;}])\s*export\s+default\s+(?:async\s+)?(?:function|class|[A-Za-z_$]|\(|<)/m.test(code) || /(?:^|[\n;}])\s*export\s*\{[^}]*\b(?:as\s+default|default\s*(?:,|}))[^}]*\}/m.test(code);
|
|
176
194
|
|
|
195
|
+
const maskAmbientBlocks = (scanned: string) => {
|
|
196
|
+
const matches = [...scanned.matchAll(/\bdeclare\s+(?:namespace|module|global)\b[^{;]*\{/g)];
|
|
197
|
+
if (!matches.length) return scanned;
|
|
198
|
+
const chars = scanned.split("");
|
|
199
|
+
// Ambient blocks can contain component-shaped declarations, but none survive into the runtime module.
|
|
200
|
+
for (const match of matches) {
|
|
201
|
+
let depth = 1;
|
|
202
|
+
let end = match.index + match[0].length;
|
|
203
|
+
while (end < scanned.length && depth) {
|
|
204
|
+
if (scanned[end] === "{") depth += 1;
|
|
205
|
+
else if (scanned[end] === "}") depth -= 1;
|
|
206
|
+
end += 1;
|
|
207
|
+
}
|
|
208
|
+
maskExportScanRange(chars, match.index, end);
|
|
209
|
+
}
|
|
210
|
+
return chars.join("");
|
|
211
|
+
};
|
|
212
|
+
// Reuse the normalizer's lexical scan: component declarations inside comments or strings are not preview entry points.
|
|
213
|
+
export function hasReactComponent(code: string) {
|
|
214
|
+
const scanned = maskAmbientBlocks(maskIgnoredTextForExportScan(code));
|
|
215
|
+
if (findLastExportableComponent(scanned)) return true;
|
|
216
|
+
for (const match of scanned.matchAll(/(?:^|[\n;}])\s*export\s+default\s+(?:async\s+)?/g)) {
|
|
217
|
+
const start = match.index + match[0].length;
|
|
218
|
+
const expression = scanned.slice(start);
|
|
219
|
+
if (/^(?:function\b|class\s+(?:[A-Za-z_$][\w$]*\s+)?extends\s+(?:[A-Za-z_$][\w$]*\.)?(?:Pure)?Component\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=;\n]+)?=>)/.test(expression)) return true;
|
|
220
|
+
const wrapper = /^(?:[A-Za-z_$][\w$]*\.)?(?:memo|forwardRef)\b/.exec(expression);
|
|
221
|
+
if (wrapper && findWrappedComponentCallOpen(scanned, start + wrapper[0].length) !== -1) return true;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
177
226
|
// React hooks the model commonly forgets to import. Stay conservative: anything outside this set (custom hooks, react-dom hooks like useFormStatus) is left alone.
|
|
178
227
|
const REACT_HOOK_NAMES = new Set([
|
|
179
228
|
"useState",
|
|
@@ -272,9 +321,10 @@ const isLikelyJsxTagStart = (code: string, index: number) => {
|
|
|
272
321
|
return previousChar !== ">";
|
|
273
322
|
};
|
|
274
323
|
|
|
275
|
-
const isLikelyStatementBlock = (code: string, index: number) => {
|
|
276
|
-
|
|
277
|
-
|
|
324
|
+
const isLikelyStatementBlock = (code: string, index: number, headerEnd: number, stack: CompletionFrame[]) => {
|
|
325
|
+
// A JSX child expression stays an expression even when the preceding prose looks like a JavaScript block header.
|
|
326
|
+
if (code[index] !== "{" || isInsideJsxText(stack)) return false;
|
|
327
|
+
const before = code.slice(0, headerEnd).trimEnd();
|
|
278
328
|
return /\bfunction(?:\s+[\w$]+)?\s*\([^)]*\)$/.test(before) || /\b(?:if|for|while|switch|with)\s*\([^)]*\)$/.test(before) || /\bcatch(?:\s*\([^)]*\))?$/.test(before) || /(?:^|[^\w$])(?:else|try|finally|do)$/.test(before) || before.endsWith("=>");
|
|
279
329
|
};
|
|
280
330
|
|
|
@@ -475,25 +525,26 @@ const collectCompletionStackBefore = (code: string, end: number) => {
|
|
|
475
525
|
const stack: CompletionFrame[] = [];
|
|
476
526
|
for (let index = 0; index < end; index += 1) {
|
|
477
527
|
const char = code[index];
|
|
478
|
-
if (
|
|
528
|
+
if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) continue;
|
|
529
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
479
530
|
const newlineIndex = code.indexOf("\n", index + 2);
|
|
480
531
|
index = (newlineIndex === -1 ? end : Math.min(newlineIndex, end)) - 1;
|
|
481
532
|
continue;
|
|
482
533
|
}
|
|
483
|
-
if (
|
|
534
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
484
535
|
const closeIndex = code.indexOf("*/", index + 2);
|
|
485
536
|
index = (closeIndex === -1 ? end : Math.min(closeIndex + 2, end)) - 1;
|
|
486
537
|
continue;
|
|
487
538
|
}
|
|
488
|
-
if (
|
|
539
|
+
if (isLikelyRegexLiteralStart(code, index)) {
|
|
489
540
|
index = Math.min(findRegexLiteralEnd(code, index), end) - 1;
|
|
490
541
|
continue;
|
|
491
542
|
}
|
|
492
|
-
if (
|
|
543
|
+
if (char === '"' || char === "'") {
|
|
493
544
|
index = Math.min(findStringLiteralEnd(code, index), end) - 1;
|
|
494
545
|
continue;
|
|
495
546
|
}
|
|
496
|
-
if (
|
|
547
|
+
if (char === "`") {
|
|
497
548
|
index = Math.min(findTemplateLiteralEnd(code, index), end) - 1;
|
|
498
549
|
continue;
|
|
499
550
|
}
|
|
@@ -504,7 +555,7 @@ const collectCompletionStackBefore = (code: string, end: number) => {
|
|
|
504
555
|
index = closeIndex;
|
|
505
556
|
continue;
|
|
506
557
|
}
|
|
507
|
-
if (char in BLOCK_FOLLOWERS) stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement:
|
|
558
|
+
if (char in BLOCK_FOLLOWERS) stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: false });
|
|
508
559
|
else if (BLOCK_CLOSERS.has(char)) {
|
|
509
560
|
const frame = stack.at(-1);
|
|
510
561
|
if (frame?.type === "block" && frame.closer === char) stack.pop();
|
|
@@ -629,11 +680,13 @@ const hasUnclosedTypeArgument = (body: string) => {
|
|
|
629
680
|
return true;
|
|
630
681
|
};
|
|
631
682
|
// Locate the last `const`/`let`/`var` whose declaration runs to EOF without a depth-0 `;` terminator, scanning the
|
|
632
|
-
// literal-masked text so a `;`/`{`/`<`
|
|
683
|
+
// JSX/literal-masked text so prose declarations and a `;`/`{`/`<` inside a string or comment (`const s = "a; const x = useState<T"`) are never
|
|
633
684
|
// mistaken for a statement boundary. A declaration can begin at the buffer start or right after `{`, `}`, `;`, or `=>`; we keep
|
|
634
685
|
// the latest one that isn't closed by a depth-0 `;` or by a `)`/`]`/`}` that pops out of its enclosing block.
|
|
635
686
|
const locateTrailingDeclaration = (value: string) => {
|
|
636
|
-
|
|
687
|
+
// Masking cannot create a keyword, so skip the JSX scan on keyword-free streaming prefixes.
|
|
688
|
+
if (!/\b(?:const|let|var)\b/.test(value)) return null;
|
|
689
|
+
const masked = maskIgnoredTextForExportScan(value);
|
|
637
690
|
const re = /(?:^|[{};\n]|=>)\s*(const(?!\s+enum\b)|let|var)\b/g;
|
|
638
691
|
let match: RegExpExecArray | null;
|
|
639
692
|
let start = -1;
|
|
@@ -732,18 +785,25 @@ const isCompleteDoWhileStatement = (value: string, scan = scanStreamingSource(va
|
|
|
732
785
|
return false;
|
|
733
786
|
};
|
|
734
787
|
const dropTrailingBareLoopHeader = (value: string) => {
|
|
735
|
-
|
|
788
|
+
// Masking only ever blanks spans, so it can neither synthesize a `)` nor the letters of the keyword: a value missing
|
|
789
|
+
// either can never yield a droppable header, and skipping the mask here saves it on most calls of a stream. This does
|
|
790
|
+
// NOT license hoisting the `endsWith(")")` check below — blanking trailing trivia changes *which* `)` is last, so
|
|
791
|
+
// `while (a) // note` masks to a droppable header the raw value does not end with.
|
|
792
|
+
if (!value.includes(")") || !/\b(?:for|while)\b/.test(value)) return value;
|
|
793
|
+
// Mask JSX too, not just literals: prose like `<p>run for (i` ends in a synthesized `)` closer whose matching `(` sits in
|
|
794
|
+
// committed text, and `maskLiteralSpans` would let the preceding word read as a real `for`/`while` keyword — rolling the
|
|
795
|
+
// already-rendered JSX back to `<p>run`.
|
|
796
|
+
const masked = maskIgnoredTextForExportScan(value).trimEnd();
|
|
736
797
|
if (!masked.endsWith(")")) return value;
|
|
737
798
|
const index = findMatchingOpener(masked, "(", ")");
|
|
738
799
|
if (index <= 0) return value;
|
|
739
800
|
const head = masked.slice(0, index).trimEnd();
|
|
740
|
-
const match = /\b(for
|
|
801
|
+
const match = /\b(for(?:\s+await)?|while)$/.exec(head);
|
|
741
802
|
if (!match) return value;
|
|
742
803
|
const before = head.slice(0, match.index);
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
746
|
-
if (/[.$]$/.test(before.trimEnd())) return value;
|
|
804
|
+
// `\b` only understands ASCII word characters: member names and Unicode/escaped identifier suffixes are not loops.
|
|
805
|
+
// Keep whitespace for the identifier check so a separate keyword after an identifier still gets dropped.
|
|
806
|
+
if (/[.#]$/.test(before.trimEnd()) || /[$\p{ID_Continue}\u200c\u200d]$|\\u\{[\da-f]+\}$/iu.test(before)) return value;
|
|
747
807
|
// `do { ... } while (cond)` is a complete statement that needs no body — keep it. Tell it apart from a plain `while` that
|
|
748
808
|
// merely follows another block (`while(a){...} while(b`) by parsing the source and checking this trailing `while` closes a `do`.
|
|
749
809
|
if (match[1] === "while" && isCompleteDoWhileStatement(value)) return value;
|
|
@@ -757,7 +817,9 @@ const dropTrailingBareLoopHeader = (value: string) => {
|
|
|
757
817
|
// inner frames whose openers were inside the rolled-back span.
|
|
758
818
|
const completeBlockFrame = (output: string, frame: Extract<CompletionFrame, { type: "block" }>) => {
|
|
759
819
|
if (frame.statement) return { text: `${output}${frame.closer}`, dropped: false };
|
|
760
|
-
|
|
820
|
+
// Clean JSX child expressions before `}` hides their loop header; other frames can contain valid type methods `for(...)`.
|
|
821
|
+
// Drop the header before completing its prefix: it can expose an operator or be hidden by a synthesized ternary branch.
|
|
822
|
+
const completed = completeIncompleteExpressionPrefix(frame.jsxExpression ? dropTrailingBareLoopHeader(output) : output, true);
|
|
761
823
|
const openerGone = frame.open !== undefined ? completed.length <= frame.open : completed.length < output.length;
|
|
762
824
|
if (openerGone) return { text: completed, dropped: true };
|
|
763
825
|
return { text: `${completed}${frame.closer}`, dropped: false };
|
|
@@ -779,12 +841,16 @@ const completeStatementPrefixFrame = (output: string, frame: CompletionFrame): {
|
|
|
779
841
|
// Close frames from the stack top down. A frame whose own opening bracket was rolled back by a declaration drop must not emit a
|
|
780
842
|
// closer — `completeBlockFrame` detects that and reports `open: number` for the bracket position it would have closed, so once
|
|
781
843
|
// a drop happens we know which later frames lost their opener (those whose recorded bracket offset is past the drop point).
|
|
782
|
-
const closeStackFrames = (output: string, frames: CompletionFrame[]) => {
|
|
844
|
+
const closeStackFrames = (output: string, frames: CompletionFrame[], beforeCloser?: (text: string, closer: string) => string) => {
|
|
783
845
|
let text = output;
|
|
784
846
|
let dropPoint = Infinity;
|
|
785
847
|
for (const frame of frames) {
|
|
786
848
|
if (frame.type === "block" && frame.open !== undefined && frame.open >= dropPoint) continue;
|
|
787
849
|
const result = completeStatementPrefixFrame(text, frame);
|
|
850
|
+
if (beforeCloser && !result.dropped) {
|
|
851
|
+
const closer = closeFrame(frame);
|
|
852
|
+
result.text = beforeCloser(result.text.slice(0, -closer.length), closer);
|
|
853
|
+
}
|
|
788
854
|
if (result.dropped) dropPoint = Math.min(dropPoint, result.text.length);
|
|
789
855
|
text = result.text;
|
|
790
856
|
}
|
|
@@ -1008,7 +1074,7 @@ const isAsiBoundary = (before: string, after: string, leftEndsWithRegexLiteral =
|
|
|
1008
1074
|
const endsWithContinuingPunctuator = /[=+\-*/%&|^~?!<>.:,([]$/.test(left) && !endsWithPostfixUpdate(left) && !(left.endsWith("/") && leftEndsWithRegexLiteral) && !(left.endsWith(">") && endsWithCompleteJsxTag(left));
|
|
1009
1075
|
if (!left || !right || endsWithContinuingPunctuator || right.startsWith(".") || LINE_CONTINUING_KEYWORD_OPERATOR.test(left)) return false;
|
|
1010
1076
|
if (FUNCTION_HEADER.test(left) || (/(?:^|\n)\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\b/.test(left) && !hasTopLevelClassBody(left))) return false;
|
|
1011
|
-
return /^(?:const|let|var|return|throw|if|else|for|while|switch|try|catch|finally|do|function|class|import|export)\b|^[A-Za-z_$][\w$]*(?:\s*(?:[=(]|$))/.test(right);
|
|
1077
|
+
return /^(?:const|let|var|return|throw|if|else|for|while|switch|try|catch|finally|do|function|class|import|export|type)\b|^[A-Za-z_$][\w$]*(?:\s*(?:[=(]|$))/.test(right);
|
|
1012
1078
|
};
|
|
1013
1079
|
|
|
1014
1080
|
const isClosedQuotedLiteral = (code: string, start: number, end: number, quote: string) => end > start + 1 && code[end - 1] === quote && (code.slice(start, end - 1).match(/\\+$/)?.[0].length ?? 0) % 2 === 0;
|
|
@@ -1087,9 +1153,22 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1087
1153
|
unclosedLiteralStatementStart = isTrailingClosedRegexLiteral(code, index, lastRegexLiteralStart) || (Boolean(statement.trim()) && hasSafeExpressionTail(statement)) ? index : statementStart;
|
|
1088
1154
|
};
|
|
1089
1155
|
while (index < code.length) {
|
|
1090
|
-
const insideJsxText = stack
|
|
1156
|
+
const insideJsxText = isInsideJsxText(stack);
|
|
1091
1157
|
const char = code[index];
|
|
1092
|
-
|
|
1158
|
+
// A label such as "shape (E, r" is committed JSX text, not an unfinished JS call that can roll back the whole return.
|
|
1159
|
+
// Text advances `lastSignificantEnd` but must NOT reach the `;`/`\n` boundary gates below: committing a statement at a
|
|
1160
|
+
// `;` in prose would move the rollback cut into the label. Kept parallel to the shared tail rather than falling through
|
|
1161
|
+
// to it for that reason. `statementRegexBoundary` is only ever set by a statement `}` pop, which leaves a block frame on
|
|
1162
|
+
// top, so it is already false here — mirroring the tail costs one line and keeps the two blocks diffable.
|
|
1163
|
+
if (insideJsxText && !isJsxTextBoundary(char)) {
|
|
1164
|
+
if (!/\s/.test(char)) {
|
|
1165
|
+
statementRegexBoundary = false;
|
|
1166
|
+
lastSignificantEnd = index + 1;
|
|
1167
|
+
}
|
|
1168
|
+
index += 1;
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
1093
1172
|
const newlineIndex = code.indexOf("\n", index + 2);
|
|
1094
1173
|
if (newlineIndex === -1) {
|
|
1095
1174
|
markDisposableComment(); // the comment itself is disposable; preserve only a complete statement before it
|
|
@@ -1101,7 +1180,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1101
1180
|
index = newlineIndex + 1;
|
|
1102
1181
|
continue;
|
|
1103
1182
|
}
|
|
1104
|
-
if (
|
|
1183
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
1105
1184
|
const closeIndex = code.indexOf("*/", index + 2);
|
|
1106
1185
|
if (closeIndex === -1) {
|
|
1107
1186
|
markDisposableComment();
|
|
@@ -1110,7 +1189,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1110
1189
|
index = closeIndex + 2;
|
|
1111
1190
|
continue;
|
|
1112
1191
|
}
|
|
1113
|
-
if (
|
|
1192
|
+
if ((statementRegexBoundary && char === "/") || isLikelyRegexLiteralStart(code, index)) {
|
|
1114
1193
|
regexLiteralStarts.add(index);
|
|
1115
1194
|
lastRegexLiteralStart = index;
|
|
1116
1195
|
statementRegexBoundary = false;
|
|
@@ -1123,7 +1202,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1123
1202
|
index = end;
|
|
1124
1203
|
continue;
|
|
1125
1204
|
}
|
|
1126
|
-
if (
|
|
1205
|
+
if (char === '"' || char === "'") {
|
|
1127
1206
|
statementRegexBoundary = false;
|
|
1128
1207
|
const end = findStringLiteralEnd(code, index);
|
|
1129
1208
|
if (!isClosedQuotedLiteral(code, index, end, char)) {
|
|
@@ -1134,7 +1213,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1134
1213
|
index = end;
|
|
1135
1214
|
continue;
|
|
1136
1215
|
}
|
|
1137
|
-
if (
|
|
1216
|
+
if (char === "`") {
|
|
1138
1217
|
statementRegexBoundary = false;
|
|
1139
1218
|
const literal = scanTemplateLiteralEnd(code, index);
|
|
1140
1219
|
if (!literal.closed) {
|
|
@@ -1161,10 +1240,13 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1161
1240
|
statementRegexBoundary = false;
|
|
1162
1241
|
const inheritedStart = currentStatementStart();
|
|
1163
1242
|
const jsxExpression = char === "{" && insideJsxText;
|
|
1164
|
-
|
|
1243
|
+
// Both classification and later JSX-shell checks must stop before trailing comments. Reuse lexical provenance
|
|
1244
|
+
// instead of rescanning the prefix or stripping comment-like text inside literals (#1814).
|
|
1245
|
+
const headerEnd = lastSignificantEnd;
|
|
1246
|
+
const kind = jsxExpression ? undefined : getBlockKind(code, headerEnd, inheritedStart);
|
|
1165
1247
|
const statement = !jsxExpression && kind !== "other";
|
|
1166
|
-
const statementStart = statement ? findKeywordStart(code,
|
|
1167
|
-
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement, open: index, jsxExpression, statementStart, kind });
|
|
1248
|
+
const statementStart = statement ? findKeywordStart(code, headerEnd, inheritedStart, kind ?? "other") : inheritedStart;
|
|
1249
|
+
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement, open: index, headerEnd, jsxExpression, statementStart, kind });
|
|
1168
1250
|
if (statement && char === "{") scopes.push({ statementStart: index + 1 });
|
|
1169
1251
|
lastSignificantEnd = index + 1;
|
|
1170
1252
|
index += 1;
|
|
@@ -1193,7 +1275,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1193
1275
|
const scope = scopes.at(-1);
|
|
1194
1276
|
if (scope) scope.statementStart = index + 1;
|
|
1195
1277
|
}
|
|
1196
|
-
if (char === "\n" &&
|
|
1278
|
+
if (char === "\n" && canCommitBoundary(stack)) {
|
|
1197
1279
|
const scope = scopes.at(-1);
|
|
1198
1280
|
const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
|
|
1199
1281
|
if (scope && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(index + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = index + 1;
|
|
@@ -1207,7 +1289,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1207
1289
|
return { stack, closedBlocks, scopes, regexLiteralStarts, lastSignificantEnd, unclosedLiteralStart, unclosedLiteralStatementStart, incompleteJsxTagStart };
|
|
1208
1290
|
};
|
|
1209
1291
|
|
|
1210
|
-
const getFrameHeader = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => code.slice(frame.statementStart ?? 0, frame.open ?? 0).trim();
|
|
1292
|
+
const getFrameHeader = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => code.slice(frame.statementStart ?? 0, frame.headerEnd ?? frame.open ?? 0).trim();
|
|
1211
1293
|
const isComponentFunctionFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(FUNCTION_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
|
|
1212
1294
|
const isFunctionDeclarationFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => FUNCTION_HEADER.test(getFrameHeader(code, frame));
|
|
1213
1295
|
const isComponentClassFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(CLASS_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
|
|
@@ -1328,7 +1410,7 @@ const isTypedParameterList = (initializer: string) => {
|
|
|
1328
1410
|
};
|
|
1329
1411
|
|
|
1330
1412
|
const hasUnsafeStreamingTypeArgument = (code: string) => {
|
|
1331
|
-
const masked =
|
|
1413
|
+
const masked = maskIgnoredTextForExportScan(code);
|
|
1332
1414
|
if (/(?:^|=)\s*<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)[^>]*$/.test(neutralizeAngleOperators(masked))) return true;
|
|
1333
1415
|
if (!hasUnclosedTypeArgument(masked)) return false;
|
|
1334
1416
|
const lastOpen = masked.lastIndexOf("<");
|
|
@@ -1345,7 +1427,12 @@ const hasSafeExpressionTail = (tail: string) => {
|
|
|
1345
1427
|
if (hasIncompleteControlTail(trimmed) || dropTrailingIncompleteExpressionOperator(trimmed) !== trimmed) return false;
|
|
1346
1428
|
if (completeTrailingIncompleteTernary(trimmed) !== trimmed) return false;
|
|
1347
1429
|
if (dropTrailingIncompleteFunctionDeclaration(trimmed) !== trimmed) return false;
|
|
1348
|
-
|
|
1430
|
+
// Strip JSX prefix before checking for unclosed type arguments: `<div>text</div>\ntype Foo` should only examine `type Foo`.
|
|
1431
|
+
// `maskIgnoredTextForExportScan` blanks JSX content but preserves `<`/`>` delimiters, which `hasUnclosedTypeArgument` would
|
|
1432
|
+
// misread as type-argument brackets. Find the last complete JSX tag and check only the tail after it.
|
|
1433
|
+
const lastJsxTagEnd = trimmed.lastIndexOf(">");
|
|
1434
|
+
const tailAfterJsx = lastJsxTagEnd >= 0 && endsWithCompleteJsxTag(trimmed.slice(0, lastJsxTagEnd + 1)) ? trimmed.slice(lastJsxTagEnd + 1).trimStart() : trimmed;
|
|
1435
|
+
if (tailAfterJsx && hasUnsafeStreamingTypeArgument(tailAfterJsx)) return false;
|
|
1349
1436
|
if (/^import\b/.test(trimmed) && !/^import\s+(?:["'][\s\S]*["']|[\s\S]+\s+from\s+["'][\s\S]*["'])\s*;?$/.test(trimmed)) return false;
|
|
1350
1437
|
if (/^export\s+(?:d(?:e(?:f(?:a(?:u(?:l(?:t)?)?)?)?)?)?)?\s*$/.test(trimmed) || /^export\s+default\s+[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
|
|
1351
1438
|
if (/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class(?:\s+[A-Za-z_$][\w$]*)?\s*$/.test(trimmed)) return false;
|
|
@@ -1550,21 +1637,38 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1550
1637
|
regexLiteralStarts = committed.regexLiteralStarts;
|
|
1551
1638
|
}
|
|
1552
1639
|
let output = "";
|
|
1640
|
+
let pendingTrivia = "";
|
|
1641
|
+
const finish = (text: string, suffix = "") => {
|
|
1642
|
+
const trivia = pendingTrivia;
|
|
1643
|
+
pendingTrivia = "";
|
|
1644
|
+
return `${text}${trivia}${suffix}`;
|
|
1645
|
+
};
|
|
1553
1646
|
const stack: CompletionFrame[] = [];
|
|
1554
1647
|
let index = 0;
|
|
1648
|
+
let lastSignificantEnd = 0;
|
|
1555
1649
|
const push = (value: string) => {
|
|
1556
1650
|
output += value;
|
|
1557
1651
|
};
|
|
1558
1652
|
while (index < code.length) {
|
|
1559
1653
|
const char = code[index];
|
|
1560
|
-
|
|
1654
|
+
// Keep completion and committed-prefix scanning in the same lexical mode; never append ')' or ']' to plain text.
|
|
1655
|
+
if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) {
|
|
1656
|
+
push(char);
|
|
1657
|
+
if (!/\s/.test(char)) lastSignificantEnd = index + 1;
|
|
1658
|
+
index += 1;
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
1561
1662
|
const newlineIndex = code.indexOf("\n", index + 2);
|
|
1562
1663
|
const end = newlineIndex === -1 ? code.length : newlineIndex;
|
|
1563
|
-
|
|
1664
|
+
// A newline alone is not enough: expression cleanup can strip an operator from the comment and eat that newline.
|
|
1665
|
+
// Hold EOF trivia apart until the first surviving frame is complete, then restore it before its synthetic closer.
|
|
1666
|
+
if (newlineIndex === -1 && stack.length) pendingTrivia = `${code.slice(index, end)}\n`;
|
|
1667
|
+
else push(code.slice(index, end));
|
|
1564
1668
|
index = end;
|
|
1565
1669
|
continue;
|
|
1566
1670
|
}
|
|
1567
|
-
if (
|
|
1671
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
1568
1672
|
const closeIndex = code.indexOf("*/", index + 2);
|
|
1569
1673
|
if (closeIndex === -1) {
|
|
1570
1674
|
push(`${code.slice(index)}*/`);
|
|
@@ -1575,16 +1679,22 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1575
1679
|
index = closeIndex + 2;
|
|
1576
1680
|
continue;
|
|
1577
1681
|
}
|
|
1578
|
-
|
|
1682
|
+
// Match scanStreamingSource's header boundary without losing comments from the completed output.
|
|
1683
|
+
const headerEnd = lastSignificantEnd;
|
|
1684
|
+
if (!/\s/.test(char)) lastSignificantEnd = index + 1;
|
|
1685
|
+
if (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index)) {
|
|
1579
1686
|
index = consumeRegexLiteral(code, index, push);
|
|
1687
|
+
lastSignificantEnd = index;
|
|
1580
1688
|
continue;
|
|
1581
1689
|
}
|
|
1582
|
-
if (
|
|
1690
|
+
if (char === '"' || char === "'") {
|
|
1583
1691
|
index = consumeStringLiteral(code, index, push);
|
|
1692
|
+
lastSignificantEnd = index;
|
|
1584
1693
|
continue;
|
|
1585
1694
|
}
|
|
1586
|
-
if (
|
|
1695
|
+
if (char === "`") {
|
|
1587
1696
|
index = consumeTemplateLiteral(code, index, push);
|
|
1697
|
+
lastSignificantEnd = index;
|
|
1588
1698
|
continue;
|
|
1589
1699
|
}
|
|
1590
1700
|
if (char === "<" && isInsideJsxText(stack) && !shouldStartJsxTag(code, index, stack) && index < code.length - 1) {
|
|
@@ -1613,11 +1723,12 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1613
1723
|
const tagName = match?.[1] ?? (tag === "<>" || tag === "</>" ? "" : null);
|
|
1614
1724
|
if (!(tagName && VOID_TAGS.has(tagName) && tag.startsWith("</"))) push(tagName && VOID_TAGS.has(tagName) && !tag.endsWith("/>") ? tag.replace(/\s*\/?\s*>$/, " />") : tag);
|
|
1615
1725
|
index = closeIndex + 1;
|
|
1726
|
+
lastSignificantEnd = index;
|
|
1616
1727
|
updateJsxStackForTag(stack, tag);
|
|
1617
1728
|
continue;
|
|
1618
1729
|
}
|
|
1619
1730
|
if (char in BLOCK_FOLLOWERS) {
|
|
1620
|
-
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index), open: output.length });
|
|
1731
|
+
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index, headerEnd, stack), open: output.length, jsxExpression: char === "{" && isInsideJsxText(stack) });
|
|
1621
1732
|
push(char);
|
|
1622
1733
|
index += 1;
|
|
1623
1734
|
continue;
|
|
@@ -1634,14 +1745,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1634
1745
|
}
|
|
1635
1746
|
const lastRegexLiteralStart = regexLiteralStarts ? findLastRegexLiteralStart(regexLiteralStarts, code.length) : undefined;
|
|
1636
1747
|
if (isTrailingClosedRegexLiteral(code, code.length, lastRegexLiteralStart) && canCommitBoundary(stack)) output += ";";
|
|
1748
|
+
const beforeCloser = pendingTrivia ? finish : undefined;
|
|
1637
1749
|
const closingFrames = stack.toReversed();
|
|
1638
1750
|
const closers = closingFrames.map(closeFrame).join("");
|
|
1639
1751
|
if (!closers) return completeIncompleteExpressionPrefix(output, true, options.mode === "streaming");
|
|
1640
1752
|
const statementIndex = closingFrames.findIndex((frame) => frame.type === "block" && frame.statement);
|
|
1641
|
-
if (statementIndex === -1) return closeStackFrames(output, closingFrames);
|
|
1642
|
-
const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex));
|
|
1753
|
+
if (statementIndex === -1) return finish(closeStackFrames(output, closingFrames, beforeCloser));
|
|
1754
|
+
const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex), beforeCloser);
|
|
1643
1755
|
const completedExpression = dropTrailingBareLoopHeader(fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true, options.mode === "streaming")));
|
|
1644
|
-
return `${
|
|
1756
|
+
return finish(completedExpression, `${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`);
|
|
1645
1757
|
}
|
|
1646
1758
|
|
|
1647
1759
|
export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions = {}) {
|
|
@@ -1650,6 +1762,7 @@ export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions =
|
|
|
1650
1762
|
// The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
|
|
1651
1763
|
// false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
|
|
1652
1764
|
if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
|
|
1765
|
+
exportScanCode = maskAmbientBlocks(exportScanCode);
|
|
1653
1766
|
if (!hasDefaultExportDeclaration(exportScanCode)) {
|
|
1654
1767
|
const componentName = findLastExportableComponent(exportScanCode);
|
|
1655
1768
|
if (componentName) next += `\nexport default ${componentName};`;
|