partial-tsx 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/partial.ts +143 -42
package/package.json
CHANGED
package/src/partial.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type PartialTsxOptions = { mode?: PartialTsxMode };
|
|
|
6
6
|
type BlockKind = "function" | "control" | "arrow" | "class" | "method" | "block" | "other";
|
|
7
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 };
|
|
8
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 === "{";
|
|
12
|
+
|
|
9
13
|
const stripMarkdownFence = (code: string) => {
|
|
10
14
|
let next = code.trim();
|
|
11
15
|
next = next.replace(/^```(?:[^\r\n]*)?(?:\r?\n|$)/i, "");
|
|
@@ -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,8 +321,9 @@ const isLikelyJsxTagStart = (code: string, index: number) => {
|
|
|
272
321
|
return previousChar !== ">";
|
|
273
322
|
};
|
|
274
323
|
|
|
275
|
-
const isLikelyStatementBlock = (code: string, index: number) => {
|
|
276
|
-
|
|
324
|
+
const isLikelyStatementBlock = (code: string, index: 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;
|
|
277
327
|
const before = code.slice(0, index).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
|
};
|
|
@@ -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) {
|
|
@@ -1193,7 +1272,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1193
1272
|
const scope = scopes.at(-1);
|
|
1194
1273
|
if (scope) scope.statementStart = index + 1;
|
|
1195
1274
|
}
|
|
1196
|
-
if (char === "\n" &&
|
|
1275
|
+
if (char === "\n" && canCommitBoundary(stack)) {
|
|
1197
1276
|
const scope = scopes.at(-1);
|
|
1198
1277
|
const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
|
|
1199
1278
|
if (scope && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(index + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = index + 1;
|
|
@@ -1328,7 +1407,7 @@ const isTypedParameterList = (initializer: string) => {
|
|
|
1328
1407
|
};
|
|
1329
1408
|
|
|
1330
1409
|
const hasUnsafeStreamingTypeArgument = (code: string) => {
|
|
1331
|
-
const masked =
|
|
1410
|
+
const masked = maskIgnoredTextForExportScan(code);
|
|
1332
1411
|
if (/(?:^|=)\s*<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)[^>]*$/.test(neutralizeAngleOperators(masked))) return true;
|
|
1333
1412
|
if (!hasUnclosedTypeArgument(masked)) return false;
|
|
1334
1413
|
const lastOpen = masked.lastIndexOf("<");
|
|
@@ -1345,7 +1424,12 @@ const hasSafeExpressionTail = (tail: string) => {
|
|
|
1345
1424
|
if (hasIncompleteControlTail(trimmed) || dropTrailingIncompleteExpressionOperator(trimmed) !== trimmed) return false;
|
|
1346
1425
|
if (completeTrailingIncompleteTernary(trimmed) !== trimmed) return false;
|
|
1347
1426
|
if (dropTrailingIncompleteFunctionDeclaration(trimmed) !== trimmed) return false;
|
|
1348
|
-
|
|
1427
|
+
// Strip JSX prefix before checking for unclosed type arguments: `<div>text</div>\ntype Foo` should only examine `type Foo`.
|
|
1428
|
+
// `maskIgnoredTextForExportScan` blanks JSX content but preserves `<`/`>` delimiters, which `hasUnclosedTypeArgument` would
|
|
1429
|
+
// misread as type-argument brackets. Find the last complete JSX tag and check only the tail after it.
|
|
1430
|
+
const lastJsxTagEnd = trimmed.lastIndexOf(">");
|
|
1431
|
+
const tailAfterJsx = lastJsxTagEnd >= 0 && endsWithCompleteJsxTag(trimmed.slice(0, lastJsxTagEnd + 1)) ? trimmed.slice(lastJsxTagEnd + 1).trimStart() : trimmed;
|
|
1432
|
+
if (tailAfterJsx && hasUnsafeStreamingTypeArgument(tailAfterJsx)) return false;
|
|
1349
1433
|
if (/^import\b/.test(trimmed) && !/^import\s+(?:["'][\s\S]*["']|[\s\S]+\s+from\s+["'][\s\S]*["'])\s*;?$/.test(trimmed)) return false;
|
|
1350
1434
|
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
1435
|
if (/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class(?:\s+[A-Za-z_$][\w$]*)?\s*$/.test(trimmed)) return false;
|
|
@@ -1550,6 +1634,12 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1550
1634
|
regexLiteralStarts = committed.regexLiteralStarts;
|
|
1551
1635
|
}
|
|
1552
1636
|
let output = "";
|
|
1637
|
+
let pendingTrivia = "";
|
|
1638
|
+
const finish = (text: string, suffix = "") => {
|
|
1639
|
+
const trivia = pendingTrivia;
|
|
1640
|
+
pendingTrivia = "";
|
|
1641
|
+
return `${text}${trivia}${suffix}`;
|
|
1642
|
+
};
|
|
1553
1643
|
const stack: CompletionFrame[] = [];
|
|
1554
1644
|
let index = 0;
|
|
1555
1645
|
const push = (value: string) => {
|
|
@@ -1557,14 +1647,23 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1557
1647
|
};
|
|
1558
1648
|
while (index < code.length) {
|
|
1559
1649
|
const char = code[index];
|
|
1560
|
-
|
|
1650
|
+
// Keep completion and committed-prefix scanning in the same lexical mode; never append ')' or ']' to plain text.
|
|
1651
|
+
if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) {
|
|
1652
|
+
push(char);
|
|
1653
|
+
index += 1;
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
1561
1657
|
const newlineIndex = code.indexOf("\n", index + 2);
|
|
1562
1658
|
const end = newlineIndex === -1 ? code.length : newlineIndex;
|
|
1563
|
-
|
|
1659
|
+
// A newline alone is not enough: expression cleanup can strip an operator from the comment and eat that newline.
|
|
1660
|
+
// Hold EOF trivia apart until the first surviving frame is complete, then restore it before its synthetic closer.
|
|
1661
|
+
if (newlineIndex === -1 && stack.length) pendingTrivia = `${code.slice(index, end)}\n`;
|
|
1662
|
+
else push(code.slice(index, end));
|
|
1564
1663
|
index = end;
|
|
1565
1664
|
continue;
|
|
1566
1665
|
}
|
|
1567
|
-
if (
|
|
1666
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
1568
1667
|
const closeIndex = code.indexOf("*/", index + 2);
|
|
1569
1668
|
if (closeIndex === -1) {
|
|
1570
1669
|
push(`${code.slice(index)}*/`);
|
|
@@ -1575,15 +1674,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1575
1674
|
index = closeIndex + 2;
|
|
1576
1675
|
continue;
|
|
1577
1676
|
}
|
|
1578
|
-
if (
|
|
1677
|
+
if (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index)) {
|
|
1579
1678
|
index = consumeRegexLiteral(code, index, push);
|
|
1580
1679
|
continue;
|
|
1581
1680
|
}
|
|
1582
|
-
if (
|
|
1681
|
+
if (char === '"' || char === "'") {
|
|
1583
1682
|
index = consumeStringLiteral(code, index, push);
|
|
1584
1683
|
continue;
|
|
1585
1684
|
}
|
|
1586
|
-
if (
|
|
1685
|
+
if (char === "`") {
|
|
1587
1686
|
index = consumeTemplateLiteral(code, index, push);
|
|
1588
1687
|
continue;
|
|
1589
1688
|
}
|
|
@@ -1617,7 +1716,7 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1617
1716
|
continue;
|
|
1618
1717
|
}
|
|
1619
1718
|
if (char in BLOCK_FOLLOWERS) {
|
|
1620
|
-
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index), open: output.length });
|
|
1719
|
+
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index, stack), open: output.length, jsxExpression: char === "{" && isInsideJsxText(stack) });
|
|
1621
1720
|
push(char);
|
|
1622
1721
|
index += 1;
|
|
1623
1722
|
continue;
|
|
@@ -1634,14 +1733,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1634
1733
|
}
|
|
1635
1734
|
const lastRegexLiteralStart = regexLiteralStarts ? findLastRegexLiteralStart(regexLiteralStarts, code.length) : undefined;
|
|
1636
1735
|
if (isTrailingClosedRegexLiteral(code, code.length, lastRegexLiteralStart) && canCommitBoundary(stack)) output += ";";
|
|
1736
|
+
const beforeCloser = pendingTrivia ? finish : undefined;
|
|
1637
1737
|
const closingFrames = stack.toReversed();
|
|
1638
1738
|
const closers = closingFrames.map(closeFrame).join("");
|
|
1639
1739
|
if (!closers) return completeIncompleteExpressionPrefix(output, true, options.mode === "streaming");
|
|
1640
1740
|
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));
|
|
1741
|
+
if (statementIndex === -1) return finish(closeStackFrames(output, closingFrames, beforeCloser));
|
|
1742
|
+
const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex), beforeCloser);
|
|
1643
1743
|
const completedExpression = dropTrailingBareLoopHeader(fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true, options.mode === "streaming")));
|
|
1644
|
-
return `${
|
|
1744
|
+
return finish(completedExpression, `${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`);
|
|
1645
1745
|
}
|
|
1646
1746
|
|
|
1647
1747
|
export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions = {}) {
|
|
@@ -1650,6 +1750,7 @@ export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions =
|
|
|
1650
1750
|
// The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
|
|
1651
1751
|
// false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
|
|
1652
1752
|
if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
|
|
1753
|
+
exportScanCode = maskAmbientBlocks(exportScanCode);
|
|
1653
1754
|
if (!hasDefaultExportDeclaration(exportScanCode)) {
|
|
1654
1755
|
const componentName = findLastExportableComponent(exportScanCode);
|
|
1655
1756
|
if (componentName) next += `\nexport default ${componentName};`;
|