partial-tsx 0.0.1 → 0.0.2

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 (3) hide show
  1. package/README.md +39 -0
  2. package/package.json +1 -1
  3. package/src/partial.ts +724 -41
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # partial-tsx
2
+
3
+ Completes prefix-growing TSX.
4
+
5
+ ## Input
6
+
7
+ ```ts
8
+ import { completePartialTsx, normalizeGeneratedTsx } from "partial-tsx";
9
+
10
+ completePartialTsx("<div>Hello");
11
+ completePartialTsx("function App() { return <div>Hello", { mode: "streaming" });
12
+ normalizeGeneratedTsx("function Card() { return <button>OK");
13
+ ```
14
+
15
+ ## Output
16
+
17
+ - `completePartialTsx(code)` returns syntactically safer TSX for a partial prefix.
18
+ - `normalizeGeneratedTsx(code)` returns renderer-ready TSX:
19
+ - Markdown fences removed.
20
+ - Safe partial syntax completed.
21
+ - Missing default export added when a component can be found.
22
+ - Missing imports added for known React hooks such as `useState`.
23
+ - `{ mode: "streaming" }` first rolls script back to its last committed syntax boundary. It never closes an unfinished call, initializer, callback, control-flow statement, or JSX `{expression`; unterminated JSX string attributes remain the one value-completion exception.
24
+ - The default `{ mode: "final" }` preserves the existing completion behavior. `partial-react` uses streaming mode only for incremental frames and sends final input to the compiler unchanged.
25
+
26
+ ## Corner Cases
27
+
28
+ - Incomplete component/tag names are left alone when completing them would likely render the wrong element.
29
+ - Unterminated JSX string attributes can be closed; incomplete expression attributes are dropped.
30
+ - Void HTML tags are normalized as self-closing tags.
31
+ - Unknown hooks or custom hooks are not auto-imported.
32
+ - Existing default exports are preserved.
33
+ - Invalid TypeScript semantics are not repaired.
34
+
35
+ ## Test
36
+
37
+ ```sh
38
+ bun test lib/partial-tsx
39
+ ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "partial-tsx",
3
3
  "description": "Utilities for completing prefix-growing TSX.",
4
- "version": "0.0.1",
4
+ "version": "0.0.2",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
package/src/partial.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
2
2
  const BLOCK_FOLLOWERS: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
3
3
  const BLOCK_CLOSERS = new Set(Object.values(BLOCK_FOLLOWERS));
4
- type CompletionFrame = { type: "block"; closer: string; statement: boolean; open?: number } | { type: "jsx"; tag: string };
4
+ export type PartialTsxMode = "streaming" | "final";
5
+ export type PartialTsxOptions = { mode?: PartialTsxMode };
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 };
5
8
 
6
9
  const stripMarkdownFence = (code: string) => {
7
10
  let next = code.trim();
@@ -24,17 +27,19 @@ const findStringLiteralEnd = (code: string, index: number) => {
24
27
  }
25
28
  return code.length;
26
29
  };
27
- const findTemplateLiteralEnd = (code: string, index: number) => {
30
+ type TemplateLiteralEnd = { end: number; closed: boolean };
31
+ const scanTemplateLiteralEnd = (code: string, index: number): TemplateLiteralEnd => {
28
32
  index += 1;
29
33
  while (index < code.length) {
30
34
  if (code[index] === "\\") index += 2;
31
- else if (code[index] === "`") return index + 1;
35
+ else if (code[index] === "`") return { end: index + 1, closed: true };
32
36
  // A `${ … }` substitution can hold nested template literals (`${`inner`}`); recurse into it so an inner backtick is not
33
37
  // mistaken for the outer literal's terminator, which would leave the substitution body unmasked in the export scan. Skip
34
38
  // strings/templates inside the substitution so their braces (`${"{"}`) do not throw off the brace-depth tracking.
35
39
  else if (code[index] === "$" && code[index + 1] === "{") {
36
40
  index += 2;
37
- for (let depth = 1; index < code.length && depth > 0; index += 1) {
41
+ let depth = 1;
42
+ for (; index < code.length && depth > 0; index += 1) {
38
43
  if (code[index] === "/" && code[index + 1] === "/") {
39
44
  const next = code.indexOf("\n", index + 2);
40
45
  index = next === -1 ? code.length : next;
@@ -42,16 +47,21 @@ const findTemplateLiteralEnd = (code: string, index: number) => {
42
47
  const next = code.indexOf("*/", index + 2);
43
48
  index = next === -1 ? code.length : next + 1;
44
49
  } else if (isLikelyRegexLiteralStart(code, index)) index = findRegexLiteralEnd(code, index) - 1;
45
- else if (code[index] === "`") index = findTemplateLiteralEnd(code, index) - 1;
46
- else if (code[index] === '"' || code[index] === "'") index = findStringLiteralEnd(code, index) - 1;
50
+ else if (code[index] === "`") {
51
+ const nested = scanTemplateLiteralEnd(code, index);
52
+ if (!nested.closed) return nested;
53
+ index = nested.end - 1;
54
+ } else if (code[index] === '"' || code[index] === "'") index = findStringLiteralEnd(code, index) - 1;
47
55
  else if (code[index] === "{") depth += 1;
48
56
  else if (code[index] === "}") depth -= 1;
49
57
  }
58
+ if (depth > 0) return { end: code.length, closed: false };
50
59
  } else index += 1;
51
60
  }
52
- return code.length;
61
+ return { end: code.length, closed: false };
53
62
  };
54
- const updateJsxStackForTag = (stack: CompletionFrame[], tag: string) => {
63
+ const findTemplateLiteralEnd = (code: string, index: number) => scanTemplateLiteralEnd(code, index).end;
64
+ const updateJsxStackForTag = (stack: CompletionFrame[], tag: string, open?: number) => {
55
65
  const match = /^<\/?\s*([A-Za-z][\w$.-]*)/.exec(tag);
56
66
  const tagName = match?.[1] ?? (tag === "<>" || tag === "</>" ? "" : null);
57
67
  if (tagName === null) return;
@@ -60,9 +70,9 @@ const updateJsxStackForTag = (stack: CompletionFrame[], tag: string) => {
60
70
  if (frame?.type === "jsx" && frame.tag === tagName) stack.pop();
61
71
  return;
62
72
  }
63
- if (!tag.endsWith("/>") && !VOID_TAGS.has(tagName)) stack.push({ type: "jsx", tag: tagName });
73
+ if (!tag.endsWith("/>") && !VOID_TAGS.has(tagName)) stack.push({ type: "jsx", tag: tagName, open });
64
74
  };
65
- const maskIgnoredTextForExportScan = (code: string) => {
75
+ const maskIgnoredTextForExportScan = (code: string, regexLiteralStarts?: ReadonlySet<number>) => {
66
76
  // split("") keeps code-unit indexing aligned with code[index]; spread would split astral chars (emoji) by code point and desync the two.
67
77
  const chars = code.split("");
68
78
  const stack: CompletionFrame[] = [];
@@ -86,9 +96,10 @@ const maskIgnoredTextForExportScan = (code: string) => {
86
96
  index = next - 1;
87
97
  continue;
88
98
  }
89
- if (isLikelyRegexLiteralStart(code, index)) {
99
+ if (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index)) {
90
100
  const next = findRegexLiteralEnd(code, index);
91
101
  maskExportScanRange(chars, index, next);
102
+ chars[index] = "/"; // preserve the token boundary while hiding keyword/bracket-shaped regex contents
92
103
  index = next - 1;
93
104
  continue;
94
105
  }
@@ -157,7 +168,7 @@ const findLastExportableComponent = (code: string) => {
157
168
  for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$]*\.)?(?:memo|forwardRef)\s*/g)) {
158
169
  if (findWrappedComponentCallOpen(code, (match.index ?? 0) + match[0].length) !== -1) candidates.push({ name: match[1], index: match.index ?? 0 });
159
170
  }
160
- return [...candidates].toSorted((left, right) => left.index - right.index).at(-1)?.name ?? null;
171
+ return candidates.toSorted((left, right) => left.index - right.index).at(-1)?.name ?? null;
161
172
  };
162
173
  // `export default` is also valid mid-line after a `;` or block-closing `}` (`;export default …`, `if(a){} export default …`); match those
163
174
  // so the scan does not miss an existing default and append a second one (`Duplicate export of 'default'`).
@@ -264,7 +275,7 @@ const isLikelyJsxTagStart = (code: string, index: number) => {
264
275
  const isLikelyStatementBlock = (code: string, index: number) => {
265
276
  if (code[index] !== "{") return false;
266
277
  const before = code.slice(0, index).trimEnd();
267
- return /\bfunction(?:\s+[\w$]+)?\s*\([^)]*\)$/.test(before) || /\b(?:if|for|while|switch|catch|with)\s*\([^)]*\)$/.test(before) || /(?:^|[^\w$])(?:else|try|finally|do)$/.test(before) || before.endsWith("=>");
278
+ 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("=>");
268
279
  };
269
280
 
270
281
  const findJsxTagEnd = (code: string, start: number) => {
@@ -351,7 +362,7 @@ const completeIncompleteJsxTag = (code: string, start: number) => {
351
362
  // An unterminated string value is safe to close by synthesizing the missing quote — worst case the frame's text is briefly short, never a throw or a wrong binding. Host and component alike.
352
363
  if (!closed) {
353
364
  // A trailing odd backslash is an incomplete escape (`title="foo\`); synthesizing the quote would make `\"` an escaped quote, leaving the string unterminated. Drop the dangling backslash before closing.
354
- if (/\\*$/.exec(literal)![0].length % 2) literal = literal.slice(0, -1);
365
+ if ((/\\*$/.exec(literal)?.[0].length ?? 0) % 2) literal = literal.slice(0, -1);
355
366
  literal += char;
356
367
  }
357
368
  tag += literal;
@@ -467,6 +478,11 @@ const endsWithJsxTagHead = (value: string) => {
467
478
  const tail = value.slice(lastOpen);
468
479
  return /^<\/?(?:[A-Za-z][\w$.-]*|$)/.test(tail) && findJsxTagEnd(value, lastOpen) === -1;
469
480
  };
481
+ const endsWithCompleteJsxTag = (value: string) => {
482
+ const trimmed = value.trimEnd();
483
+ const lastOpen = trimmed.lastIndexOf("<");
484
+ return lastOpen !== -1 && shouldStartJsxTag(trimmed, lastOpen, collectCompletionStackBefore(trimmed, lastOpen)) && findJsxTagEnd(trimmed, lastOpen) === trimmed.length - 1;
485
+ };
470
486
  const hasCompleteRegexLiteralEndingAt = (value: string, endIndex: number) => {
471
487
  for (let index = 0; index <= endIndex; index += 1) {
472
488
  if (value[index] !== "/" || !isLikelyRegexLiteralStart(value, index)) continue;
@@ -476,18 +492,30 @@ const hasCompleteRegexLiteralEndingAt = (value: string, endIndex: number) => {
476
492
  }
477
493
  return false;
478
494
  };
479
- const dropTrailingOperator = (value: string, pattern: RegExp) => {
480
- const match = pattern.exec(value);
495
+ // Window = longest operator `>>>=` (4) + the 1 char the `(?<![.\d])\.` lookbehind inspects, rounded up.
496
+ const TRAILING_OPERATOR_WINDOW = 8;
497
+ const endsWithPostfixUpdate = (value: string) => /[\w$)\]](?:\+\+|--)$/.test(value.trimEnd());
498
+ const dropTrailingOperator = (value: string, pattern: RegExp, preservePostfixUpdate = false) => {
499
+ // `$`-anchored ⇒ the operator is pinned to the trimmed suffix, so a fixed tail window yields the identical match but
500
+ // turns an O(n)-per-call (O(n²) over a char-by-char stream) scan into O(1) in prefix length.
501
+ const trimmed = value.trimEnd();
502
+ const windowStart = Math.max(0, trimmed.length - TRAILING_OPERATOR_WINDOW);
503
+ const match = pattern.exec(trimmed.slice(windowStart));
481
504
  if (!match) return value;
482
505
  const operator = match[1];
483
- const operatorIndex = value.indexOf(operator, match.index);
484
- const before = value.slice(0, match.index).trimEnd();
506
+ const operatorIndex = trimmed.length - operator.length;
507
+ // Trimming the operator's leading-whitespace run reproduces the unwindowed scan's `value.slice(0, match.index)`
508
+ // byte-for-byte (the pattern's `\s*` is greedy, so `match.index` sat at the start of that run).
509
+ const before = value.slice(0, operatorIndex).trimEnd();
485
510
  if (operator === "=>") return `${before} => null`;
511
+ if (preservePostfixUpdate && (operator === "+" || operator === "-") && endsWithPostfixUpdate(value)) return value;
512
+ if (operator === "/" && before.endsWith("*") && before.lastIndexOf("/*") > before.lastIndexOf("*/")) return value;
486
513
  if (operator === "/" && hasCompleteRegexLiteralEndingAt(value, operatorIndex)) return value;
514
+ if (operator === ">" && /\/\s*$/.test(before)) return value;
487
515
  if ((operator === ">" || operator === ">=") && endsWithJsxTagHead(before)) return value;
488
- return value.slice(0, match.index);
516
+ return before;
489
517
  };
490
- const dropTrailingIncompleteExpressionOperator = (value: string) => dropTrailingOperator(value, /\s*(>>>=?|>>=?|<<=?|===|!==|=>|\*\*=|\?\?=|&&=|\|\|=|[+\-*/%&|^]=|\?\?|\?\.|\*\*|[=!<>]=?|&&|\|\||[+\-*/%&|^~]|(?<![.\d])\.)\s*$/s);
518
+ const dropTrailingIncompleteExpressionOperator = (value: string, preservePostfixUpdate = false) => dropTrailingOperator(value, /\s*(>>>=?|>>=?|<<=?|===|!==|=>|\*\*=|\?\?=|&&=|\|\|=|[+\-*/%&|^]=|\?\?|\?\.|\*\*|[=!<>]=?|&&|\|\||[+\-*/%&|^~]|(?<![.\d])\.)\s*$/s, preservePostfixUpdate);
491
519
  // Blanks out string/template/regex/comment spans (but not JSX) so angle-bracket counting in `dropTrailingIncompleteStatement`
492
520
  // only sees real `<`/`>` operators and type arguments, never a literal `<` (`"a<b"`) or a `</div>` closer.
493
521
  const maskLiteralSpans = (body: string) => {
@@ -566,7 +594,7 @@ const hasUnclosedTypeArgument = (body: string) => {
566
594
  // the latest one that isn't closed by a depth-0 `;` or by a `)`/`]`/`}` that pops out of its enclosing block.
567
595
  const locateTrailingDeclaration = (value: string) => {
568
596
  const masked = maskLiteralSpans(value);
569
- const re = /(?:^|[{};\n]|=>)\s*(const|let|var)\b/g;
597
+ const re = /(?:^|[{};\n]|=>)\s*(const(?!\s+enum\b)|let|var)\b/g;
570
598
  let match: RegExpExecArray | null;
571
599
  let start = -1;
572
600
  let kind = "";
@@ -617,7 +645,9 @@ const dropTrailingIncompleteStatement = (value: string) => {
617
645
  };
618
646
  const completeTrailingIncompleteTernary = (value: string) => {
619
647
  const masked = maskIgnoredTextForExportScan(value);
620
- if (/(?<!\?)\?(?![.?])[^?;{}]*:\s*$/s.test(masked)) return `${value} null`;
648
+ // `masked` blanks a string/template else-branch (or regex contents) to spaces, so a *complete* `? a : "x"` looks like it ends at `:`.
649
+ // A genuine trailing `:` is never inside a literal, so confirm against the raw text before synthesizing the else-branch.
650
+ if (/(?<!\?)\?(?![.?])[^?;{}]*:\s*$/s.test(masked) && /:\s*$/s.test(value)) return `${value} null`;
621
651
  const match = /(?<!\?)\?(?![.?])(?<tail>[^?:;{}]*)$/s.exec(masked);
622
652
  if (!match?.groups) return value;
623
653
  return value.slice(match.index + 1).trim() ? `${value} : null` : `${value} null : null`;
@@ -629,11 +659,56 @@ const dropTrailingIncompleteFunctionDeclaration = (value: string) => {
629
659
  const match = /(?:^|[}\n;])\s*((?:export\s+(?:default\s+)?)?(?:async\s+)?function\b\s*\*?\s*[A-Za-z_$]?[\w$]*\s*)$/.exec(value);
630
660
  return match ? value.slice(0, match.index + (match[0].length - match[1].length)) : value;
631
661
  };
632
- const completeIncompleteExpressionPrefix = (value: string, dropStatement = false) => completeTrailingIncompleteTernary(dropTrailingIncompleteExpressionOperator(dropStatement ? dropTrailingIncompleteFunctionDeclaration(dropTrailingIncompleteStatement(value)) : value));
662
+ const completeIncompleteExpressionPrefix = (value: string, dropStatement = false, preservePostfixUpdate = false) =>
663
+ completeTrailingIncompleteTernary(dropTrailingIncompleteExpressionOperator(dropStatement ? dropTrailingIncompleteFunctionDeclaration(dropTrailingIncompleteStatement(value)) : value, preservePostfixUpdate));
633
664
  const fillEmptyReturnExpression = (value: string) => {
634
665
  if (/\breturn\s*$/.test(value)) return `${value} null`;
635
666
  return value.replace(/(\breturn\s*\(\s*)\)$/s, "$1null)");
636
667
  };
668
+ // Walk back from a string ending in `close` to the index of the `open` that matches it, or -1 if it never balances.
669
+ const findMatchingOpener = (text: string, open: string, close: string) => {
670
+ let depth = 0;
671
+ for (let index = text.length - 1; index >= 0; index -= 1) {
672
+ if (text[index] === close) depth += 1;
673
+ else if (text[index] === open && --depth === 0) return index;
674
+ }
675
+ return -1;
676
+ };
677
+ // A loop whose header has streamed but whose body has not ends the completed prefix as a bare `for (...)` / `while (...)`.
678
+ // Finalization would give it an empty `;` body, and a header that never advances its counter (`for (let i = 0; i < n;)`)
679
+ // then spins forever and freezes the tab. Loops only live in statement position (JSX iterates via `.map`), so a body-less
680
+ // header renders nothing regardless — drop it back to before the keyword.
681
+ const controlStatementMatches = (code: string) => code.matchAll(/(^|[;{}\n):])(\s*)((?:[A-Za-z_$][\w$]*\s*:\s*)*)(if|for|while|with|try|do)\b/g);
682
+ const isCompleteDoWhileStatement = (value: string, scan = scanStreamingSource(value)) => {
683
+ const masked = maskIgnoredTextForExportScan(value, scan.regexLiteralStarts);
684
+ const isControlStatementContext = createControlStatementContextLookup(scan);
685
+ for (const match of controlStatementMatches(masked)) {
686
+ const start = (match.index ?? 0) + match[1].length;
687
+ if (match[4] !== "do" || !isControlStatementContext(start)) continue;
688
+ const doStart = skipStatementLabels(masked, start);
689
+ const end = findStatementEnd(masked, doStart);
690
+ if (end !== -1 && !masked.slice(end).trim()) return true;
691
+ }
692
+ return false;
693
+ };
694
+ const dropTrailingBareLoopHeader = (value: string) => {
695
+ const masked = maskLiteralSpans(value).trimEnd();
696
+ if (!masked.endsWith(")")) return value;
697
+ const index = findMatchingOpener(masked, "(", ")");
698
+ if (index <= 0) return value;
699
+ const head = masked.slice(0, index).trimEnd();
700
+ const match = /\b(for|while)$/.exec(head);
701
+ if (!match) return value;
702
+ const before = head.slice(0, match.index);
703
+ // A reserved word used as a member name or identifier suffix is a reference, not a loop: `obj.for(x)`, `arr?.while(p)`,
704
+ // `a$for(1)` must survive intact. `\b` treats `.` and `$` as boundaries, so guard both — `$` is the only identifier-
705
+ // continuation char that can precede the keyword (letters/digits/`_` are word chars `\b` would not break on).
706
+ if (/[.$]$/.test(before.trimEnd())) return value;
707
+ // `do { ... } while (cond)` is a complete statement that needs no body — keep it. Tell it apart from a plain `while` that
708
+ // merely follows another block (`while(a){...} while(b`) by parsing the source and checking this trailing `while` closes a `do`.
709
+ if (match[1] === "while" && isCompleteDoWhileStatement(value)) return value;
710
+ return value.slice(0, match.index).trimEnd();
711
+ };
637
712
  // A non-statement bracket frame gets its expression prefix completed (and possibly dropped). Completing can shorten the output
638
713
  // two different ways: a trailing operator/dot trim (the frame's own `{`/`[` survives, so we still close it) or a whole-declaration
639
714
  // rollback that removes the frame's own opening bracket plus every nested opener — e.g. `useState<{ nested: { count` or an array
@@ -654,6 +729,9 @@ const completeStatementPrefixFrame = (output: string, frame: CompletionFrame): {
654
729
  if (frame.type !== "block") return { text: `${output}${closeFrame(frame)}`, dropped: false };
655
730
  if (frame.closer === ")") {
656
731
  const completed = completeIncompleteExpressionPrefix(output);
732
+ // A paren whose entire body is a balanced empty `()` (a double paren `(()`) can only continue as an arrow head — an
733
+ // empty parenthesized expression is rejected by the compiler. Complete the forced arrow rather than emit illegal `(())`.
734
+ if (frame.open !== undefined && completed.slice(frame.open + 1).trim() === "()") return { text: `${completed} => null${frame.closer}`, dropped: false };
657
735
  return { text: `${completed.endsWith("(") && !isCallArgumentParen(completed) ? `${completed}null` : completed}${frame.closer}`, dropped: false };
658
736
  }
659
737
  return completeBlockFrame(output, frame);
@@ -702,7 +780,12 @@ function findRegexLiteralEnd(code: string, index: number) {
702
780
  if (next === "[" && !inClass) inClass = true;
703
781
  else if (next === "]" && inClass) inClass = false;
704
782
  else if (next === "/" && !inClass) {
705
- while (index < code.length && /[A-Za-z]/.test(code[index])) index += 1;
783
+ while (index < code.length) {
784
+ const point = code.codePointAt(index) ?? 0;
785
+ const char = String.fromCodePoint(point);
786
+ if (!/[A-Za-z0-9_$\\]/.test(char) && (point <= 0x7f || /\s/u.test(char))) break;
787
+ index += point > 0xffff ? 2 : 1;
788
+ }
706
789
  return index;
707
790
  }
708
791
  }
@@ -746,10 +829,11 @@ const isJsxTagHead = (code: string, index: number) => {
746
829
  return match !== null && /^(?:$|[\s/>])/.test(tail.slice(match[0].length));
747
830
  };
748
831
 
749
- // A comma right after the tag name (`<T,`) is a TSX generic type-param list, not JSX — a JSX tag name is only ever
750
- // followed by whitespace, `/`, `>`, or end, never a bare comma. Without this, the generic arrow `<T,>(x:T)=>x` is
751
- // misread as an open `<T>` element and a synthetic `</T>` is appended, breaking the module.
752
- const isGenericTypeParamList = (code: string, index: number) => /^<\s*[A-Za-z_$][\w$]*\s*,/.test(code.slice(index));
832
+ // A `,`, `extends`, or `=` right after the tag name (`<T,`, `<T extends U`, `<T = U`) is a TSX generic type-param
833
+ // list, not JSX — a JSX tag name is only ever followed by whitespace, `/`, `>`, or end, never these tokens. Without
834
+ // this, generic arrows like `<T,>(x:T)=>x` or `<T extends {}>(x:T)=>x` are misread as an open `<T>` element and a
835
+ // synthetic `</T>` is appended, breaking the module.
836
+ const isGenericTypeParamList = (code: string, index: number) => /^<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)/.test(code.slice(index));
753
837
  const shouldStartJsxTag = (code: string, index: number, stack: CompletionFrame[]) => {
754
838
  if (isGenericTypeParamList(code, index)) return false;
755
839
  if (stack.at(-1)?.type === "jsx") return isJsxTagHead(code, index);
@@ -759,6 +843,7 @@ const isInsideJsxText = (stack: CompletionFrame[]) => stack.at(-1)?.type === "js
759
843
 
760
844
  const consumeTemplateExpression = (code: string, index: number, push: PushOutput): number => {
761
845
  let depth = 1;
846
+ const startIndex = index;
762
847
  while (index < code.length && depth > 0) {
763
848
  const expressionChar = code[index];
764
849
  if (expressionChar === "/" && code[index + 1] === "/") {
@@ -795,7 +880,9 @@ const consumeTemplateExpression = (code: string, index: number, push: PushOutput
795
880
  if (expressionChar === "{") depth += 1;
796
881
  else if (expressionChar === "}") depth -= 1;
797
882
  }
798
- if (depth > 0) push("}".repeat(depth));
883
+ // An interpolation that never streamed an expression (`${}` once we synthesize the `}`) is illegal; fill `null` so the
884
+ // streamed frame parses, mirroring the empty-paren and empty-return synthesis.
885
+ if (depth > 0) push((code.slice(startIndex, index).trim() ? "" : "null") + "}".repeat(depth));
799
886
  return index;
800
887
  };
801
888
 
@@ -826,11 +913,605 @@ const consumeTemplateLiteral = (code: string, index: number, push: PushOutput):
826
913
  return index;
827
914
  };
828
915
 
829
- export function completePartialTsx(code: string) {
916
+ type StreamingScope = { statementStart: number };
917
+ type StreamingScan = {
918
+ stack: CompletionFrame[];
919
+ closedBlocks: Array<Extract<CompletionFrame, { type: "block" }>>;
920
+ scopes: StreamingScope[];
921
+ regexLiteralStarts: Set<number>;
922
+ lastSignificantEnd: number;
923
+ unclosedLiteralStart: number | null;
924
+ unclosedLiteralStatementStart: number | null;
925
+ incompleteJsxTagStart: number | null;
926
+ };
927
+
928
+ const FUNCTION_HEADER = /^(?:export\s+(?:default\s+)?)?(?:async\s+)?function(?:\s*\*)?\s*([A-Za-z_$][\w$]*)?(?:\s*<[\s\S]*>)?\s*\([^)]*\)\s*(?::\s*[\s\S]+)?$/;
929
+ const CLASS_HEADER = /^(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)(?:\s+extends[\s\S]+)?(?:\s+implements[\s\S]+)?$/;
930
+ const METHOD_HEADER = /^(?:(?:public|private|protected|static|abstract|async|override|readonly|get|set)\s+)*([A-Za-z_$][\w$]*)(?:\s*<[\s\S]*>)?\s*\([^)]*\)\s*(?::\s*[\s\S]+)?$/;
931
+ const COMPONENT_ARROW_HEADER = /^(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[\s\S]*>\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)(?:\s*:\s*[^=]+)?\s*=>$/;
932
+ const hasTopLevelClassBody = (value: string) => {
933
+ let angleDepth = 0;
934
+ for (const char of maskLiteralSpans(value)) {
935
+ if (char === "<") angleDepth += 1;
936
+ else if (char === ">" && angleDepth > 0) angleDepth -= 1;
937
+ else if (char === "{" && angleDepth === 0) return true;
938
+ }
939
+ return false;
940
+ };
941
+
942
+ const getBlockKind = (code: string, index: number, statementStart: number): BlockKind => {
943
+ const before = code.slice(statementStart, index).trim();
944
+ if (!before || /^(?:[A-Za-z_$][\w$]*\s*:\s*)+$/.test(before) || before === "static") return "block";
945
+ if (FUNCTION_HEADER.test(before)) return "function";
946
+ if (/^(?:if|for|while|switch|with)\s*\([^)]*\)$/.test(before) || /^catch(?:\s*\([^)]*\))?$/.test(before) || /^(?:else|try|finally|do)$/.test(before)) return "control";
947
+ if (before.endsWith("=>")) return "arrow";
948
+ if (CLASS_HEADER.test(before) && !hasUnclosedTypeArgument(maskLiteralSpans(before))) return "class";
949
+ if (METHOD_HEADER.test(before)) return "method";
950
+ return "other";
951
+ };
952
+
953
+ const findKeywordStart = (code: string, index: number, scopeStart: number, kind: BlockKind) => {
954
+ if (kind !== "function" && kind !== "control") return scopeStart;
955
+ const segment = code.slice(scopeStart, index);
956
+ const pattern = kind === "function" ? /(?:^|[;\n{}])\s*((?:export\s+(?:default\s+)?)?(?:async\s+)?function)\b/g : /(?:^|[;\n{}])\s*((?:if|for|while|switch|catch|with|try|finally|do|else))\b/g;
957
+ let start = scopeStart;
958
+ let match: RegExpExecArray | null;
959
+ while ((match = pattern.exec(segment))) start = scopeStart + match.index + match[0].lastIndexOf(match[1]);
960
+ return start;
961
+ };
962
+
963
+ const canCommitBoundary = (stack: CompletionFrame[]) => !stack.some((frame) => frame.type === "block" && (frame.closer !== "}" || !frame.statement));
964
+ const LINE_CONTINUING_KEYWORD_OPERATOR = /(?<![.\w$])(?:as|await|delete|in|instanceof|new|satisfies|throw|typeof|void)$/;
965
+ const isAsiBoundary = (before: string, after: string, leftEndsWithRegexLiteral = false) => {
966
+ const left = before.trimEnd();
967
+ const right = after.trimStart();
968
+ const endsWithContinuingPunctuator = /[=+\-*/%&|^~?!<>.:,([]$/.test(left) && !endsWithPostfixUpdate(left) && !(left.endsWith("/") && leftEndsWithRegexLiteral) && !(left.endsWith(">") && endsWithCompleteJsxTag(left));
969
+ if (!left || !right || endsWithContinuingPunctuator || right.startsWith(".") || LINE_CONTINUING_KEYWORD_OPERATOR.test(left)) return false;
970
+ if (FUNCTION_HEADER.test(left) || (/(?:^|\n)\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\b/.test(left) && !hasTopLevelClassBody(left))) return false;
971
+ 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);
972
+ };
973
+
974
+ 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;
975
+ const findRegexLiteralClosingSlash = (code: string, start: number, end: number) => {
976
+ let inClass = false;
977
+ for (let index = start + 1; index < end; index += 1) {
978
+ const char = code[index];
979
+ if (/\r|\n|\u2028|\u2029/.test(char)) return -1;
980
+ if (char === "\\") {
981
+ if (/\r|\n|\u2028|\u2029/.test(code[index + 1] ?? "")) return -1;
982
+ index += 1;
983
+ } else if (char === "[" && !inClass) inClass = true;
984
+ else if (char === "]" && inClass) inClass = false;
985
+ else if (char === "/" && !inClass) return index;
986
+ }
987
+ return -1;
988
+ };
989
+ const hasValidRegexLiteral = (code: string, start: number, end: number) => {
990
+ const closingSlash = findRegexLiteralClosingSlash(code, start, end);
991
+ if (closingSlash === -1) return false;
992
+ const flags = code.slice(closingSlash + 1, end);
993
+ return /^[dgimsuvy]*$/.test(flags) && new Set(flags).size === flags.length && !(flags.includes("u") && flags.includes("v"));
994
+ };
995
+ const isOnlyJsTrivia = (code: string, start: number, end: number) => {
996
+ while (start < end) {
997
+ if (/\s/.test(code[start])) start += 1;
998
+ else if (code[start] === "/" && code[start + 1] === "/") {
999
+ const newline = code.indexOf("\n", start + 2);
1000
+ start = newline === -1 || newline >= end ? end : newline + 1;
1001
+ } else if (code[start] === "/" && code[start + 1] === "*") {
1002
+ const close = code.indexOf("*/", start + 2);
1003
+ if (close === -1 || close + 2 > end) return false;
1004
+ start = close + 2;
1005
+ } else return false;
1006
+ }
1007
+ return true;
1008
+ };
1009
+ const isTrailingClosedRegexLiteral = (code: string, end: number, start: number | null | undefined) => {
1010
+ if (start === null || start === undefined || start >= end) return false;
1011
+ const regexEnd = findRegexLiteralEnd(code, start);
1012
+ return regexEnd <= end && hasValidRegexLiteral(code, start, regexEnd) && isOnlyJsTrivia(code, regexEnd, end);
1013
+ };
1014
+ const findLastRegexLiteralStart = (starts: ReadonlySet<number>, end: number) => {
1015
+ let last: number | undefined;
1016
+ for (const start of starts) {
1017
+ if (start >= end) break;
1018
+ last = start;
1019
+ }
1020
+ return last;
1021
+ };
1022
+ const STATEMENT_BLOCK_KINDS = new Set<BlockKind>(["block", "class", "control", "function"]);
1023
+
1024
+ // Keep source provenance separate from the legacy completer: JSX escaping and synthetic closers change output offsets, while
1025
+ // rollback must always cut the raw stream. The committed prefix is completed only after this source-only pass returns.
1026
+ const scanStreamingSource = (code: string): StreamingScan => {
1027
+ const stack: CompletionFrame[] = [];
1028
+ const closedBlocks: Array<Extract<CompletionFrame, { type: "block" }>> = [];
1029
+ const scopes: StreamingScope[] = [{ statementStart: 0 }];
1030
+ const regexLiteralStarts = new Set<number>();
1031
+ let unclosedLiteralStart: number | null = null;
1032
+ let unclosedLiteralStatementStart: number | null = null;
1033
+ let incompleteJsxTagStart: number | null = null;
1034
+ let statementRegexBoundary = false;
1035
+ let lastRegexLiteralStart: number | null = null;
1036
+ let lastSignificantEnd = 0;
1037
+ let index = 0;
1038
+ const currentStatementStart = () => scopes.at(-1)?.statementStart ?? 0;
1039
+ const markUnclosedLiteral = () => {
1040
+ unclosedLiteralStart = index;
1041
+ unclosedLiteralStatementStart = currentStatementStart();
1042
+ };
1043
+ const markDisposableComment = () => {
1044
+ const statementStart = currentStatementStart();
1045
+ const statement = code.slice(statementStart, index);
1046
+ unclosedLiteralStart = index;
1047
+ unclosedLiteralStatementStart = isTrailingClosedRegexLiteral(code, index, lastRegexLiteralStart) || (Boolean(statement.trim()) && hasSafeExpressionTail(statement)) ? index : statementStart;
1048
+ };
1049
+ while (index < code.length) {
1050
+ const insideJsxText = stack.at(-1)?.type === "jsx";
1051
+ const char = code[index];
1052
+ if (!insideJsxText && char === "/" && code[index + 1] === "/") {
1053
+ const newlineIndex = code.indexOf("\n", index + 2);
1054
+ if (newlineIndex === -1) {
1055
+ markDisposableComment(); // the comment itself is disposable; preserve only a complete statement before it
1056
+ break;
1057
+ }
1058
+ const scope = scopes.at(-1);
1059
+ const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
1060
+ if (scope && canCommitBoundary(stack) && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(newlineIndex + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = newlineIndex + 1;
1061
+ index = newlineIndex + 1;
1062
+ continue;
1063
+ }
1064
+ if (!insideJsxText && char === "/" && code[index + 1] === "*") {
1065
+ const closeIndex = code.indexOf("*/", index + 2);
1066
+ if (closeIndex === -1) {
1067
+ markDisposableComment();
1068
+ break;
1069
+ }
1070
+ index = closeIndex + 2;
1071
+ continue;
1072
+ }
1073
+ if (!insideJsxText && ((statementRegexBoundary && char === "/") || isLikelyRegexLiteralStart(code, index))) {
1074
+ regexLiteralStarts.add(index);
1075
+ lastRegexLiteralStart = index;
1076
+ statementRegexBoundary = false;
1077
+ const end = findRegexLiteralEnd(code, index);
1078
+ if (!hasValidRegexLiteral(code, index, end)) {
1079
+ markUnclosedLiteral();
1080
+ break;
1081
+ }
1082
+ lastSignificantEnd = end;
1083
+ index = end;
1084
+ continue;
1085
+ }
1086
+ if (!insideJsxText && (char === '"' || char === "'")) {
1087
+ statementRegexBoundary = false;
1088
+ const end = findStringLiteralEnd(code, index);
1089
+ if (!isClosedQuotedLiteral(code, index, end, char)) {
1090
+ markUnclosedLiteral();
1091
+ break;
1092
+ }
1093
+ lastSignificantEnd = end;
1094
+ index = end;
1095
+ continue;
1096
+ }
1097
+ if (!insideJsxText && char === "`") {
1098
+ statementRegexBoundary = false;
1099
+ const literal = scanTemplateLiteralEnd(code, index);
1100
+ if (!literal.closed) {
1101
+ markUnclosedLiteral();
1102
+ break;
1103
+ }
1104
+ lastSignificantEnd = literal.end;
1105
+ index = literal.end;
1106
+ continue;
1107
+ }
1108
+ if (char === "<" && shouldStartJsxTag(code, index, stack)) {
1109
+ statementRegexBoundary = false;
1110
+ const closeIndex = findJsxTagEnd(code, index);
1111
+ if (closeIndex === -1) {
1112
+ incompleteJsxTagStart = index;
1113
+ break;
1114
+ }
1115
+ updateJsxStackForTag(stack, code.slice(index, closeIndex + 1), index);
1116
+ lastSignificantEnd = closeIndex + 1;
1117
+ index = closeIndex + 1;
1118
+ continue;
1119
+ }
1120
+ if (char in BLOCK_FOLLOWERS) {
1121
+ statementRegexBoundary = false;
1122
+ const inheritedStart = currentStatementStart();
1123
+ const jsxExpression = char === "{" && insideJsxText;
1124
+ const kind = jsxExpression ? undefined : getBlockKind(code, index, inheritedStart);
1125
+ const statement = !jsxExpression && kind !== "other";
1126
+ const statementStart = statement ? findKeywordStart(code, index, inheritedStart, kind ?? "other") : inheritedStart;
1127
+ stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement, open: index, jsxExpression, statementStart, kind });
1128
+ if (statement && char === "{") scopes.push({ statementStart: index + 1 });
1129
+ lastSignificantEnd = index + 1;
1130
+ index += 1;
1131
+ continue;
1132
+ }
1133
+ if (BLOCK_CLOSERS.has(char)) {
1134
+ const frame = stack.at(-1);
1135
+ let closesStatementBlock = false;
1136
+ if (frame?.type === "block" && frame.closer === char) {
1137
+ stack.pop();
1138
+ frame.close = index;
1139
+ closedBlocks.push(frame);
1140
+ if (frame.statement && char === "}") {
1141
+ closesStatementBlock = STATEMENT_BLOCK_KINDS.has(frame.kind ?? "other");
1142
+ scopes.pop();
1143
+ const parent = scopes.at(-1);
1144
+ if (parent) parent.statementStart = index + 1;
1145
+ }
1146
+ }
1147
+ statementRegexBoundary = closesStatementBlock;
1148
+ lastSignificantEnd = index + 1;
1149
+ index += 1;
1150
+ continue;
1151
+ }
1152
+ if (char === ";" && canCommitBoundary(stack)) {
1153
+ const scope = scopes.at(-1);
1154
+ if (scope) scope.statementStart = index + 1;
1155
+ }
1156
+ if (char === "\n" && !insideJsxText && canCommitBoundary(stack)) {
1157
+ const scope = scopes.at(-1);
1158
+ const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
1159
+ if (scope && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(index + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = index + 1;
1160
+ }
1161
+ if (!/\s/.test(char)) {
1162
+ statementRegexBoundary = false;
1163
+ lastSignificantEnd = index + 1;
1164
+ }
1165
+ index += 1;
1166
+ }
1167
+ return { stack, closedBlocks, scopes, regexLiteralStarts, lastSignificantEnd, unclosedLiteralStart, unclosedLiteralStatementStart, incompleteJsxTagStart };
1168
+ };
1169
+
1170
+ const getFrameHeader = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => code.slice(frame.statementStart ?? 0, frame.open ?? 0).trim();
1171
+ const isComponentFunctionFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(FUNCTION_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
1172
+ const isFunctionDeclarationFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => FUNCTION_HEADER.test(getFrameHeader(code, frame));
1173
+ const isComponentClassFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(CLASS_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
1174
+ const isRenderMethodFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => METHOD_HEADER.exec(getFrameHeader(code, frame))?.[1] === "render";
1175
+ const isComponentArrowPrefix = (code: string, end: number) => {
1176
+ const before = code
1177
+ .slice(0, end)
1178
+ .trimEnd()
1179
+ .replace(/\(\s*$/, "")
1180
+ .trimEnd();
1181
+ const match = /(?:^|[;}\n])\s*((?:const|let|var)\s+[A-Z][\s\S]*=>)\s*$/.exec(before);
1182
+ return Boolean(match && COMPONENT_ARROW_HEADER.test(match[1]));
1183
+ };
1184
+ const isComponentArrowFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => COMPONENT_ARROW_HEADER.test(getFrameHeader(code, frame));
1185
+
1186
+ const isCompleteJsxTail = (code: string, scan: StreamingScan) => {
1187
+ const trimmed = code.trimEnd();
1188
+ if (!/(?:\/>|<\/[A-Za-z][\w$.-]*>)\s*;?\s*$/.test(trimmed)) return false;
1189
+ const scopeStart = scan.scopes.at(-1)?.statementStart ?? 0;
1190
+ const tail = trimmed.slice(scopeStart).trim();
1191
+ if (/^(?:return\s*\(?[\s\S]*<|(?:const|let|var)\s+[A-Z][\w$]*\s*(?::[^=]+)?=[\s\S]*=>\s*\(?[\s\S]*<|<)/.test(tail)) return true;
1192
+ const shellFrame = scan.stack.findLast((frame): frame is Extract<CompletionFrame, { type: "block" }> => frame.type === "block" && (frame.kind === "function" || frame.kind === "arrow" || frame.kind === "method"));
1193
+ return shellFrame?.open !== undefined && /\breturn\s*\(?[\s\S]*</.test(trimmed.slice(shellFrame.open + 1));
1194
+ };
1195
+
1196
+ const hasCompletedRenderMethodTail = (code: string, scan: StreamingScan) => {
1197
+ const frame = scan.closedBlocks.findLast((candidate) => candidate.kind === "method" && isRenderMethodFrame(code, candidate));
1198
+ if (frame?.open === undefined || frame.close === undefined || code.slice(frame.close + 1).trim()) return false;
1199
+ return /\breturn\s*\(?[\s\S]*<(?:[A-Za-z]|>)/.test(code.slice(frame.open + 1, frame.close));
1200
+ };
1201
+
1202
+ const isAllowedJsxShellFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>, componentArrow: boolean) => {
1203
+ if (frame.jsxExpression || frame.kind === "control") return false;
1204
+ if (frame.kind === "function") return isComponentFunctionFrame(code, frame);
1205
+ if (frame.kind === "arrow") return componentArrow && isComponentArrowFrame(code, frame);
1206
+ if (frame.kind === "class") return isComponentClassFrame(code, frame);
1207
+ if (frame.kind === "method") return isRenderMethodFrame(code, frame);
1208
+ if (frame.statement) return false;
1209
+ const context = getFrameHeader(code, frame).trimEnd();
1210
+ return /(?:\breturn\s*|=>\s*)\(?$/.test(context);
1211
+ };
1212
+
1213
+ // JSX returned directly by a component is the only open script shell we complete. A nested callback/control frame with JSX is
1214
+ // rolled back with its statement, so `useEffect(() => <Card` can never become an executable synthetic callback.
1215
+ const isJsxShellPreview = (code: string, scan: StreamingScan) => {
1216
+ const jsxFrame = scan.stack.find((frame): frame is Extract<CompletionFrame, { type: "jsx" }> => frame.type === "jsx");
1217
+ if (scan.unclosedLiteralStart !== null) return false;
1218
+ if (scan.stack.some((frame) => frame.type === "block" && frame.jsxExpression)) return false;
1219
+ const allFramesAllowed = (componentArrow: boolean) => scan.stack.every((frame) => frame.type !== "block" || isAllowedJsxShellFrame(code, frame, componentArrow));
1220
+ const rootReturnBefore = (end: number) => {
1221
+ const before = code.slice(0, end);
1222
+ return /\breturn\s*\(?\s*$/.test(before.slice(Math.max(0, before.lastIndexOf(";"), before.lastIndexOf("{"))));
1223
+ };
1224
+ const arrowFrameActive = scan.stack.some((frame) => frame.type === "block" && frame.kind === "arrow" && isComponentArrowFrame(code, frame));
1225
+ if (!jsxFrame) {
1226
+ if (scan.incompleteJsxTagStart === null) {
1227
+ const componentArrow = isComponentArrowPrefix(code, code.length) || arrowFrameActive;
1228
+ return (isCompleteJsxTail(code, scan) || hasCompletedRenderMethodTail(code, scan)) && allFramesAllowed(componentArrow);
1229
+ }
1230
+ const jsxStart = scan.incompleteJsxTagStart;
1231
+ const incompleteComponentArrow = isComponentArrowPrefix(code, jsxStart);
1232
+ if (!rootReturnBefore(jsxStart) && !incompleteComponentArrow && !/^\s*</.test(code)) return false;
1233
+ return allFramesAllowed(incompleteComponentArrow);
1234
+ }
1235
+ const jsxStart = jsxFrame.open ?? code.length;
1236
+ const componentArrow = isComponentArrowPrefix(code, jsxStart) || arrowFrameActive;
1237
+ if (!rootReturnBefore(jsxStart) && !componentArrow && !/^\s*</.test(code)) return false;
1238
+ return allFramesAllowed(componentArrow);
1239
+ };
1240
+
1241
+ const findAsiBoundary = (code: string, start: number, regexLiteralStarts: ReadonlySet<number>) => {
1242
+ if (code.indexOf("\n", start) === -1) return start;
1243
+ let boundary = start;
1244
+ const regexStarts = [...regexLiteralStarts];
1245
+ let regexIndex = 0;
1246
+ let lastRegexStart: number | undefined;
1247
+ const segment = code.slice(start);
1248
+ for (const match of segment.matchAll(/\n/g)) {
1249
+ const index = match.index ?? 0;
1250
+ const absoluteIndex = start + index;
1251
+ while (regexIndex < regexStarts.length && regexStarts[regexIndex] < absoluteIndex) lastRegexStart = regexStarts[regexIndex++];
1252
+ if (isAsiBoundary(segment.slice(0, index), segment.slice(index + 1), isTrailingClosedRegexLiteral(code, absoluteIndex, lastRegexStart))) boundary = absoluteIndex + 1;
1253
+ }
1254
+ return boundary;
1255
+ };
1256
+
1257
+ const hasIncompleteControlTail = (tail: string) => {
1258
+ const trimmed = tail.trim();
1259
+ if (/^(?:if|for|while|switch|catch|with)\b[\s\S]*\)\s*$/.test(trimmed)) return true;
1260
+ if (/^(?:else|finally)\s*$/.test(trimmed)) return true;
1261
+ if (/^(?:if|for|while)\b[\s\S]*\)\s+[^;{][\s\S]*$/.test(trimmed) && !/[;} ]$/.test(trimmed)) return true;
1262
+ if (/^try\b[\s\S]*\}\s*$/.test(trimmed) && !/\}\s*(?:catch|finally)\b/.test(trimmed)) return true;
1263
+ if (/^do\b[\s\S]*\}\s*$/.test(trimmed) && !/\}\s*while\b/.test(trimmed)) return true;
1264
+ return false;
1265
+ };
1266
+
1267
+ const isTypedParameterList = (initializer: string) => {
1268
+ const match = /^(?:async\s+)?(?:<[\s\S]*>\s*)?\(([\s\S]*)\)$/.exec(initializer);
1269
+ if (!match || !match[1].includes(":")) return false;
1270
+ const parameters = maskLiteralSpans(match[1]);
1271
+ let segmentStart = 0;
1272
+ let parenDepth = 0;
1273
+ let bracketDepth = 0;
1274
+ let braceDepth = 0;
1275
+ const isBinding = (segment: string) => /^(?:\.\.\.)?[A-Za-z_$][\w$]*\??$/.test(segment.trim()) || /^(?:\{[\s\S]*\}|\[[\s\S]*\])$/.test(segment.trim());
1276
+ for (let index = 0; index < parameters.length; index += 1) {
1277
+ const char = parameters[index];
1278
+ if (char === "(") parenDepth += 1;
1279
+ else if (char === ")") parenDepth -= 1;
1280
+ else if (char === "[") bracketDepth += 1;
1281
+ else if (char === "]") bracketDepth -= 1;
1282
+ else if (char === "{") braceDepth += 1;
1283
+ else if (char === "}") braceDepth -= 1;
1284
+ else if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) segmentStart = index + 1;
1285
+ else if (char === ":" && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) return isBinding(parameters.slice(segmentStart, index));
1286
+ }
1287
+ return false;
1288
+ };
1289
+
1290
+ const hasUnsafeStreamingTypeArgument = (code: string) => {
1291
+ const masked = maskLiteralSpans(code);
1292
+ if (/(?:^|=)\s*<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)[^>]*$/.test(neutralizeAngleOperators(masked))) return true;
1293
+ if (!hasUnclosedTypeArgument(masked)) return false;
1294
+ const lastOpen = masked.lastIndexOf("<");
1295
+ if (lastOpen > 0 && /\s/.test(masked[lastOpen - 1] ?? "") && /^\s*(?:\[|\(|\{)/.test(masked.slice(lastOpen + 1))) return false;
1296
+ return true;
1297
+ };
1298
+
1299
+ // Bare identifiers, primitives, assignments, and partial operators can all grow on the next chunk. Only real delimiters or a
1300
+ // source boundary make an EOF expression stable; this is why the check intentionally rejects valid-looking `const x = value`.
1301
+ const hasSafeExpressionTail = (tail: string) => {
1302
+ const trimmed = tail.trim();
1303
+ if (!trimmed) return true;
1304
+ if (/^(?:[A-Za-z_$][\w$]*\s*:\s*)+$/.test(trimmed)) return false;
1305
+ if (hasIncompleteControlTail(trimmed) || dropTrailingIncompleteExpressionOperator(trimmed) !== trimmed) return false;
1306
+ if (completeTrailingIncompleteTernary(trimmed) !== trimmed) return false;
1307
+ if (dropTrailingIncompleteFunctionDeclaration(trimmed) !== trimmed) return false;
1308
+ if (hasUnsafeStreamingTypeArgument(trimmed)) return false;
1309
+ if (/^import\b/.test(trimmed) && !/^import\s+(?:["'][\s\S]*["']|[\s\S]+\s+from\s+["'][\s\S]*["'])\s*;?$/.test(trimmed)) return false;
1310
+ 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;
1311
+ if (/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class(?:\s+[A-Za-z_$][\w$]*)?\s*$/.test(trimmed)) return false;
1312
+ if (/^(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\b/.test(trimmed) && !hasTopLevelClassBody(trimmed)) return false;
1313
+ if (FUNCTION_HEADER.test(trimmed) || /^(?:export\s+(?:default\s+)?)?(?:async\s+)?function\b[\s\S]*\)\s*:\s*$/.test(trimmed)) return false;
1314
+ if (/^(?:export\s+)?type\b/.test(trimmed) && !/;\s*$/.test(trimmed)) return false;
1315
+ if (/^(?:const|let|var)\b/.test(trimmed)) {
1316
+ const initializer = /\b(?:const|let|var)\b[\s\S]*?=\s*([\s\S]*)$/.exec(trimmed)?.[1]?.trim() ?? "";
1317
+ // A typed parameter list is not an expression until `=>` arrives; its closing `)` must not commit the declaration early.
1318
+ if (!initializer || /^(?:async\s+)?function\b[\s\S]*\)\s*$/.test(initializer) || /^class(?:\s+[A-Za-z_$][\w$]*)?\s*$/.test(initializer) || /^\(\s*\)$/.test(initializer) || isTypedParameterList(initializer) || /[.?]$/.test(initializer) || !/[)\]}"'`]$/.test(initializer)) return false;
1319
+ }
1320
+ if (/^(?:return|throw|yield|await)\b[\s\S]*$/.test(trimmed)) {
1321
+ const expression = trimmed.replace(/^(?:return|throw|yield|await)\s+/, "").trim();
1322
+ if (!/[)\]}"'`]$/.test(expression)) return false;
1323
+ }
1324
+ if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
1325
+ if (/(?<![\w$])(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
1326
+ if (!/(?:null|true|false)$/.test(trimmed) && /[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
1327
+ if (/^(?:[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[[^\]]+\])*)\s*=/.test(trimmed)) return false;
1328
+ if (/[A-Za-z0-9_$]$/.test(trimmed) && !/[)\]}"'`]$/.test(trimmed)) return false;
1329
+ return true;
1330
+ };
1331
+
1332
+ const findTrailingUnsafeStatementStart = (code: string, scan: StreamingScan) => {
1333
+ const scopeStart = scan.scopes.at(-1)?.statementStart ?? 0;
1334
+ const start = findAsiBoundary(code, scopeStart, scan.regexLiteralStarts);
1335
+ const tail = code.slice(start, Math.max(start, scan.lastSignificantEnd));
1336
+ if (isTrailingClosedRegexLiteral(code, code.length, findLastRegexLiteralStart(scan.regexLiteralStarts, code.length))) return code.length;
1337
+ if (/^\s*while\s*\([\s\S]*\)\s*$/.test(tail) && isCompleteDoWhileStatement(code, scan)) return code.length;
1338
+ return hasSafeExpressionTail(tail) ? code.length : start;
1339
+ };
1340
+
1341
+ const skipMaskedWhitespace = (code: string, index: number) => {
1342
+ while (index < code.length && /\s/.test(code[index])) index += 1;
1343
+ return index;
1344
+ };
1345
+ const findBalancedDelimiterEnd = (code: string, index: number, open: string, close: string) => {
1346
+ if (code[index] !== open) return -1;
1347
+ let depth = 0;
1348
+ for (; index < code.length; index += 1) {
1349
+ if (code[index] === open) depth += 1;
1350
+ else if (code[index] === close && --depth === 0) return index + 1;
1351
+ }
1352
+ return -1;
1353
+ };
1354
+ const hasKeywordAt = (code: string, index: number, keyword: string) => code.slice(index, index + keyword.length) === keyword && !/[\w$]/.test(code[index + keyword.length] ?? "");
1355
+ const isTrailingKeywordPrefix = (code: string, index: number, keywords: string[]) => {
1356
+ const tail = code.slice(index).trim();
1357
+ return !tail || keywords.some((keyword) => tail.length < keyword.length && keyword.startsWith(tail));
1358
+ };
1359
+ const findRequiredBlockEnd = (code: string, index: number) => {
1360
+ index = skipMaskedWhitespace(code, index);
1361
+ return code[index] === "{" ? findBalancedDelimiterEnd(code, index, "{", "}") : -1;
1362
+ };
1363
+ const findSimpleStatementEnd = (code: string, index: number) => {
1364
+ index = skipMaskedWhitespace(code, index);
1365
+ if (code[index] === "{") return findBalancedDelimiterEnd(code, index, "{", "}");
1366
+ const start = index;
1367
+ const stack: string[] = [];
1368
+ for (; index < code.length; index += 1) {
1369
+ const char = code[index];
1370
+ if (char in BLOCK_FOLLOWERS) stack.push(BLOCK_FOLLOWERS[char]);
1371
+ else if (BLOCK_CLOSERS.has(char)) {
1372
+ if (stack.at(-1) === char) stack.pop();
1373
+ else if (stack.length === 0) return index;
1374
+ } else if (char === "\n" && stack.length === 0 && isAsiBoundary(code.slice(start, index), code.slice(index + 1))) return index + 1;
1375
+ else if (char === ";" && stack.length === 0) return index + 1;
1376
+ }
1377
+ return -1;
1378
+ };
1379
+
1380
+ const skipStatementLabels = (code: string, index: number) => {
1381
+ index = skipMaskedWhitespace(code, index);
1382
+ let label: RegExpExecArray | null;
1383
+ while ((label = /^([A-Za-z_$][\w$]*)\s*:\s*/.exec(code.slice(index))) && label[1] !== "case" && label[1] !== "default") index += label[0].length;
1384
+ return index;
1385
+ };
1386
+ function findIfStatementEnd(code: string, index: number) {
1387
+ const conditionEnd = findBalancedDelimiterEnd(code, skipMaskedWhitespace(code, index + "if".length), "(", ")");
1388
+ if (conditionEnd === -1) return -1;
1389
+ const consequentEnd = findStatementEnd(code, conditionEnd);
1390
+ if (consequentEnd === -1) return -1;
1391
+ const cursor = skipMaskedWhitespace(code, consequentEnd);
1392
+ if (isTrailingKeywordPrefix(code, cursor, ["else"])) return -1;
1393
+ return hasKeywordAt(code, cursor, "else") ? findStatementEnd(code, cursor + "else".length) : consequentEnd;
1394
+ }
1395
+ function findTryStatementEnd(code: string, index: number) {
1396
+ let cursor = findRequiredBlockEnd(code, index + "try".length);
1397
+ if (cursor === -1) return -1;
1398
+ cursor = skipMaskedWhitespace(code, cursor);
1399
+ if (isTrailingKeywordPrefix(code, cursor, ["catch", "finally"])) return -1;
1400
+ if (hasKeywordAt(code, cursor, "catch")) {
1401
+ cursor = skipMaskedWhitespace(code, cursor + "catch".length);
1402
+ if (code[cursor] === "(") {
1403
+ cursor = findBalancedDelimiterEnd(code, cursor, "(", ")");
1404
+ if (cursor === -1) return -1;
1405
+ }
1406
+ const catchEnd = findRequiredBlockEnd(code, cursor);
1407
+ if (catchEnd === -1) return -1;
1408
+ cursor = skipMaskedWhitespace(code, catchEnd);
1409
+ const tail = code.slice(cursor).trim();
1410
+ if (tail && tail.length < "finally".length && "finally".startsWith(tail)) return -1;
1411
+ if (!hasKeywordAt(code, cursor, "finally")) return catchEnd;
1412
+ cursor += "finally".length;
1413
+ } else if (hasKeywordAt(code, cursor, "finally")) cursor += "finally".length;
1414
+ else return -1;
1415
+ return findRequiredBlockEnd(code, cursor);
1416
+ }
1417
+ function findDoStatementEnd(code: string, index: number) {
1418
+ const bodyEnd = findStatementEnd(code, index + "do".length);
1419
+ if (bodyEnd === -1) return -1;
1420
+ let cursor = skipMaskedWhitespace(code, bodyEnd);
1421
+ if (!hasKeywordAt(code, cursor, "while")) return -1;
1422
+ cursor = skipMaskedWhitespace(code, cursor + "while".length);
1423
+ const conditionEnd = findBalancedDelimiterEnd(code, cursor, "(", ")");
1424
+ if (conditionEnd === -1) return -1;
1425
+ cursor = skipMaskedWhitespace(code, conditionEnd);
1426
+ return code[cursor] === ";" ? cursor + 1 : conditionEnd;
1427
+ }
1428
+ function findHeaderBodyStatementEnd(code: string, index: number, keyword: "for" | "while" | "with") {
1429
+ let cursor = skipMaskedWhitespace(code, index + keyword.length);
1430
+ if (keyword === "for" && hasKeywordAt(code, cursor, "await")) cursor = skipMaskedWhitespace(code, cursor + "await".length);
1431
+ const conditionEnd = findBalancedDelimiterEnd(code, cursor, "(", ")");
1432
+ return conditionEnd === -1 ? -1 : findStatementEnd(code, conditionEnd);
1433
+ }
1434
+ function findSwitchStatementEnd(code: string, index: number) {
1435
+ const conditionEnd = findBalancedDelimiterEnd(code, skipMaskedWhitespace(code, index + "switch".length), "(", ")");
1436
+ return conditionEnd === -1 ? -1 : findRequiredBlockEnd(code, conditionEnd);
1437
+ }
1438
+ function findStatementEnd(code: string, index: number): number {
1439
+ index = skipStatementLabels(code, index);
1440
+ if (hasKeywordAt(code, index, "if")) return findIfStatementEnd(code, index);
1441
+ if (hasKeywordAt(code, index, "try")) return findTryStatementEnd(code, index);
1442
+ if (hasKeywordAt(code, index, "do")) return findDoStatementEnd(code, index);
1443
+ if (hasKeywordAt(code, index, "switch")) return findSwitchStatementEnd(code, index);
1444
+ for (const keyword of ["for", "while", "with"] as const) if (hasKeywordAt(code, index, keyword)) return findHeaderBodyStatementEnd(code, index, keyword);
1445
+ return findSimpleStatementEnd(code, index);
1446
+ }
1447
+
1448
+ const createControlStatementContextLookup = (scan: StreamingScan) => {
1449
+ const frames = [...scan.closedBlocks, ...scan.stack].filter((frame): frame is Extract<CompletionFrame, { type: "block" }> & { open: number } => frame.type === "block" && frame.closer === "}" && frame.open !== undefined).toSorted((left, right) => left.open - right.open);
1450
+ const active: typeof frames = [];
1451
+ let frameIndex = 0;
1452
+ return (index: number) => {
1453
+ while (frameIndex < frames.length && frames[frameIndex].open < index) {
1454
+ const frame = frames[frameIndex++];
1455
+ while (active.length && (active.at(-1)?.close ?? Number.POSITIVE_INFINITY) <= frame.open) active.pop();
1456
+ active.push(frame);
1457
+ }
1458
+ while (active.length && (active.at(-1)?.close ?? Number.POSITIVE_INFINITY) <= index) active.pop();
1459
+ const container = active.at(-1);
1460
+ return !container || (container.statement && container.kind !== "class" && !container.jsxExpression);
1461
+ };
1462
+ };
1463
+
1464
+ // Optional continuations are the dangerous boundary: `if { … } el`, `try { … } cat`, and `do { … } wh` are each valid-looking
1465
+ // prefixes if their first block is completed in isolation. Keep the whole originating statement pending until its continuation is
1466
+ // either complete or a later token proves that no optional branch follows.
1467
+ const findPendingContinuationStart = (code: string, scan: StreamingScan) => {
1468
+ const masked = maskIgnoredTextForExportScan(code, scan.regexLiteralStarts);
1469
+ const isControlStatementContext = createControlStatementContextLookup(scan);
1470
+ let coveredUntil = -1;
1471
+ for (const match of controlStatementMatches(masked)) {
1472
+ const start = (match.index ?? 0) + match[1].length;
1473
+ if (start < coveredUntil || !isControlStatementContext(start)) continue;
1474
+ const end = findStatementEnd(masked, start);
1475
+ if (end === -1) return start;
1476
+ coveredUntil = end;
1477
+ }
1478
+ return code.length;
1479
+ };
1480
+
1481
+ const trimToCommittedPrefix = (code: string) => {
1482
+ let scan = scanStreamingSource(code);
1483
+ const jsxExpression = scan.stack.find((frame): frame is Extract<CompletionFrame, { type: "block" }> => frame.type === "block" && frame.jsxExpression === true);
1484
+ if (jsxExpression) {
1485
+ code = code.slice(0, jsxExpression.open ?? code.length);
1486
+ // Rolling back the child expression changes the active frames; rescan so its discarded callback/literal cannot invalidate the parent JSX shell.
1487
+ scan = scanStreamingSource(code);
1488
+ }
1489
+ let cut = code.length;
1490
+ if (scan.unclosedLiteralStart !== null) cut = Math.min(cut, scan.unclosedLiteralStatementStart ?? scan.unclosedLiteralStart);
1491
+ const safeJsxShell = isJsxShellPreview(code, scan);
1492
+ const componentArrow = safeJsxShell && (isComponentArrowPrefix(code, code.length) || scan.stack.some((frame) => frame.type === "block" && frame.kind === "arrow" && isComponentArrowFrame(code, frame)));
1493
+ for (const frame of scan.stack) {
1494
+ if (frame.type !== "block" || frame.jsxExpression) continue;
1495
+ if (safeJsxShell && isAllowedJsxShellFrame(code, frame, componentArrow)) continue;
1496
+ if (frame.kind === "function" && isFunctionDeclarationFrame(code, frame)) continue;
1497
+ cut = Math.min(cut, frame.statementStart ?? frame.open ?? code.length);
1498
+ }
1499
+ cut = Math.min(cut, findPendingContinuationStart(code, scan));
1500
+ if (scan.incompleteJsxTagStart !== null && !safeJsxShell) cut = Math.min(cut, scan.scopes.at(-1)?.statementStart ?? scan.incompleteJsxTagStart);
1501
+ if (cut === code.length && !safeJsxShell && scan.incompleteJsxTagStart === null) cut = findTrailingUnsafeStatementStart(code, scan);
1502
+ return { code: code.slice(0, cut), regexLiteralStarts: scan.regexLiteralStarts };
1503
+ };
1504
+
1505
+ export function completePartialTsx(code: string, options: PartialTsxOptions = {}): string {
1506
+ let regexLiteralStarts: ReadonlySet<number> | undefined;
1507
+ if (options.mode === "streaming") {
1508
+ const committed = trimToCommittedPrefix(code);
1509
+ code = committed.code;
1510
+ regexLiteralStarts = committed.regexLiteralStarts;
1511
+ }
830
1512
  let output = "";
831
1513
  const stack: CompletionFrame[] = [];
832
1514
  let index = 0;
833
- let validEnd = code.length;
834
1515
  const push = (value: string) => {
835
1516
  output += value;
836
1517
  };
@@ -854,7 +1535,7 @@ export function completePartialTsx(code: string) {
854
1535
  index = closeIndex + 2;
855
1536
  continue;
856
1537
  }
857
- if (!isInsideJsxText(stack) && isLikelyRegexLiteralStart(code, index)) {
1538
+ if (!isInsideJsxText(stack) && (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index))) {
858
1539
  index = consumeRegexLiteral(code, index, push);
859
1540
  continue;
860
1541
  }
@@ -881,12 +1562,9 @@ export function completePartialTsx(code: string) {
881
1562
  if (closeIndex === -1) {
882
1563
  const completedTag = completeIncompleteJsxTag(code, index);
883
1564
  if (completedTag === null) {
884
- validEnd = index;
885
- output = output.slice(0, validEnd);
886
1565
  break;
887
1566
  }
888
1567
  push(completedTag.tag);
889
- index = code.length;
890
1568
  if (!completedTag.tag.trimEnd().endsWith("/>") && !VOID_TAGS.has(completedTag.tagName)) stack.push({ type: "jsx", tag: completedTag.tagName });
891
1569
  break;
892
1570
  }
@@ -914,19 +1592,24 @@ export function completePartialTsx(code: string) {
914
1592
  push(char);
915
1593
  index += 1;
916
1594
  }
917
- const closingFrames = [...stack].toReversed();
1595
+ const lastRegexLiteralStart = regexLiteralStarts ? findLastRegexLiteralStart(regexLiteralStarts, code.length) : undefined;
1596
+ if (isTrailingClosedRegexLiteral(code, code.length, lastRegexLiteralStart) && canCommitBoundary(stack)) output += ";";
1597
+ const closingFrames = stack.toReversed();
918
1598
  const closers = closingFrames.map(closeFrame).join("");
919
- if (!closers) return completeIncompleteExpressionPrefix(output, true);
1599
+ if (!closers) return completeIncompleteExpressionPrefix(output, true, options.mode === "streaming");
920
1600
  const statementIndex = closingFrames.findIndex((frame) => frame.type === "block" && frame.statement);
921
1601
  if (statementIndex === -1) return closeStackFrames(output, closingFrames);
922
1602
  const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex));
923
- const completedExpression = fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true));
1603
+ const completedExpression = dropTrailingBareLoopHeader(fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true, options.mode === "streaming")));
924
1604
  return `${completedExpression}${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`;
925
1605
  }
926
1606
 
927
- export function normalizeGeneratedTsx(code: string) {
928
- let next = completePartialTsx(stripMarkdownFence(code)).trim();
929
- const exportScanCode = maskIgnoredTextForExportScan(next);
1607
+ export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions = {}) {
1608
+ let next = completePartialTsx(stripMarkdownFence(code), options).trim();
1609
+ let exportScanCode = maskIgnoredTextForExportScan(next);
1610
+ // The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
1611
+ // false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
1612
+ if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
930
1613
  if (!hasDefaultExportDeclaration(exportScanCode)) {
931
1614
  const componentName = findLastExportableComponent(exportScanCode);
932
1615
  if (componentName) next += `\nexport default ${componentName};`;