partial-tsx 0.0.2 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/partial.ts +192 -51
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.2",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
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 !== "<" && 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
- if (!isWrappedImplementationFunction(code, match.index ?? 0)) candidates.push({ name: match[1], index: match.index ?? 0 });
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 });
178
+ }
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 });
165
184
  }
166
- for (const match of code.matchAll(/\bclass\s+([A-Z][\w$]*)\s+extends\s+(?:(?:React\.)?(?:Pure)?Component|[A-Za-z_$][\w$]*\.Component)\b/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
167
- for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*=>/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
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,12 +321,28 @@ const isLikelyJsxTagStart = (code: string, index: number) => {
272
321
  return previousChar !== ">";
273
322
  };
274
323
 
275
- const isLikelyStatementBlock = (code: string, index: number) => {
276
- if (code[index] !== "{") return false;
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
  };
280
330
 
331
+ // `null` when `index` isn't a comment start, `-1` when the comment is unterminated, else the index just past it.
332
+ // The three tag/expression scanners below all need this and drifted apart once already — #1600 was one of them missing it.
333
+ const findCommentEnd = (code: string, index: number) => {
334
+ if (code[index] !== "/") return null;
335
+ if (code[index + 1] === "/") {
336
+ const end = code.indexOf("\n", index + 2);
337
+ return end === -1 ? -1 : end + 1;
338
+ }
339
+ if (code[index + 1] === "*") {
340
+ const end = code.indexOf("*/", index + 2);
341
+ return end === -1 ? -1 : end + 2;
342
+ }
343
+ return null;
344
+ };
345
+
281
346
  const findJsxTagEnd = (code: string, start: number) => {
282
347
  const stack: string[] = [];
283
348
  let quote: string | null = null;
@@ -288,6 +353,20 @@ const findJsxTagEnd = (code: string, start: number) => {
288
353
  else if (char === quote) quote = null;
289
354
  continue;
290
355
  }
356
+ // A regex ending in an escaped slash (`/\//`) puts a literal `//` in the source, so it must be consumed before the
357
+ // comment scan below mistakes it for one — same ordering `findJsxAttributeExpressionEnd` uses.
358
+ if (stack.length > 0 && isLikelyRegexLiteralStart(code, index)) {
359
+ index = findRegexLiteralEnd(code, index) - 1;
360
+ continue;
361
+ }
362
+ // Comments are legal anywhere in a tag, including its own attribute list — not just inside an attribute's `{…}`.
363
+ // Skipping them keeps an apostrophe in `// framework's Link` from opening a string that swallows the rest of the tag (#1600).
364
+ const commentEnd = findCommentEnd(code, index);
365
+ if (commentEnd !== null) {
366
+ if (commentEnd === -1) return -1;
367
+ index = commentEnd - 1;
368
+ continue;
369
+ }
291
370
  if (char === '"' || char === "'" || char === "`") {
292
371
  quote = char;
293
372
  continue;
@@ -307,14 +386,9 @@ const findJsxTagEnd = (code: string, start: number) => {
307
386
  const findJsxAttributeExpressionEnd = (code: string, index: number) => {
308
387
  let depth = 1;
309
388
  while (index < code.length) {
310
- if (code[index] === "/" && code[index + 1] === "/") {
311
- const end = code.indexOf("\n", index + 2);
312
- index = end === -1 ? code.length : end;
313
- continue;
314
- }
315
- if (code[index] === "/" && code[index + 1] === "*") {
316
- const close = code.indexOf("*/", index + 2);
317
- index = close === -1 ? code.length : close + 2;
389
+ const commentEnd = findCommentEnd(code, index);
390
+ if (commentEnd !== null) {
391
+ index = commentEnd === -1 ? code.length : commentEnd;
318
392
  continue;
319
393
  }
320
394
  if (isLikelyRegexLiteralStart(code, index)) {
@@ -349,6 +423,18 @@ const completeIncompleteJsxTag = (code: string, start: number) => {
349
423
  let tag = "";
350
424
  while (index < code.length) {
351
425
  const char = code[index];
426
+ const commentEnd = findCommentEnd(code, index);
427
+ if (commentEnd !== null) {
428
+ // A still-streaming comment has to be dropped, not carried: emitting it would bury the `>` we synthesize below
429
+ // inside the comment. A closed one is kept verbatim — comments are legal in a JSX attribute list (#1600).
430
+ if (commentEnd === -1) {
431
+ index = code.length;
432
+ continue;
433
+ }
434
+ tag += code.slice(index, commentEnd);
435
+ index = commentEnd;
436
+ continue;
437
+ }
352
438
  if (char === "`") {
353
439
  // A backtick is illegal as a JSX attribute value (`title=`x`` → "JSX value should be either an expression or a quoted JSX text"); template literals are only valid inside `{...}`. Drop the attribute.
354
440
  tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
@@ -396,7 +482,11 @@ const completeIncompleteJsxTag = (code: string, start: number) => {
396
482
  if (/=\s*$/.test(tag)) tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
397
483
  // Otherwise a trailing name with no following space is still streaming (`valu` may become `value`); a trailing space commits it as a boolean attr.
398
484
  else if (!/\s$/.test(tag)) tag = tag.replace(/\s+[A-Za-z_$][\w$:-]*$/, "");
399
- const completedTag = VOID_TAGS.has(tagName) ? `${tag.replace(/\s*\/\s*$/, "")} />` : `${tag}>`;
485
+ // That trim can eat the newline terminating a trailing comment. The closer synthesized below must stay clear of it:
486
+ // after `//` it would be buried inside the comment, after `*/` it would fuse into a spurious self-closing `/>`.
487
+ if (/\/\/[^\n]*$|\*\/$/.test(tag)) tag += "\n";
488
+ // The `\/` strip below must not mistake a block comment's terminating slash for a stray self-closing one.
489
+ const completedTag = VOID_TAGS.has(tagName) ? `${tag.replace(/\s*(?<!\*)\/\s*$/, "")} />` : `${tag}>`;
400
490
  return { tag: completedTag, tagName };
401
491
  };
402
492
 
@@ -435,25 +525,26 @@ const collectCompletionStackBefore = (code: string, end: number) => {
435
525
  const stack: CompletionFrame[] = [];
436
526
  for (let index = 0; index < end; index += 1) {
437
527
  const char = code[index];
438
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "/") {
528
+ if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) continue;
529
+ if (char === "/" && code[index + 1] === "/") {
439
530
  const newlineIndex = code.indexOf("\n", index + 2);
440
531
  index = (newlineIndex === -1 ? end : Math.min(newlineIndex, end)) - 1;
441
532
  continue;
442
533
  }
443
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "*") {
534
+ if (char === "/" && code[index + 1] === "*") {
444
535
  const closeIndex = code.indexOf("*/", index + 2);
445
536
  index = (closeIndex === -1 ? end : Math.min(closeIndex + 2, end)) - 1;
446
537
  continue;
447
538
  }
448
- if (!isInsideJsxText(stack) && isLikelyRegexLiteralStart(code, index)) {
539
+ if (isLikelyRegexLiteralStart(code, index)) {
449
540
  index = Math.min(findRegexLiteralEnd(code, index), end) - 1;
450
541
  continue;
451
542
  }
452
- if (!isInsideJsxText(stack) && (char === '"' || char === "'")) {
543
+ if (char === '"' || char === "'") {
453
544
  index = Math.min(findStringLiteralEnd(code, index), end) - 1;
454
545
  continue;
455
546
  }
456
- if (!isInsideJsxText(stack) && char === "`") {
547
+ if (char === "`") {
457
548
  index = Math.min(findTemplateLiteralEnd(code, index), end) - 1;
458
549
  continue;
459
550
  }
@@ -464,7 +555,7 @@ const collectCompletionStackBefore = (code: string, end: number) => {
464
555
  index = closeIndex;
465
556
  continue;
466
557
  }
467
- if (char in BLOCK_FOLLOWERS) stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index) });
558
+ if (char in BLOCK_FOLLOWERS) stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: false });
468
559
  else if (BLOCK_CLOSERS.has(char)) {
469
560
  const frame = stack.at(-1);
470
561
  if (frame?.type === "block" && frame.closer === char) stack.pop();
@@ -589,11 +680,13 @@ const hasUnclosedTypeArgument = (body: string) => {
589
680
  return true;
590
681
  };
591
682
  // Locate the last `const`/`let`/`var` whose declaration runs to EOF without a depth-0 `;` terminator, scanning the
592
- // literal-masked text so a `;`/`{`/`<` sitting inside a string or comment (`const s = "a; const x = useState<T"`) is never
683
+ // JSX/literal-masked text so prose declarations and a `;`/`{`/`<` inside a string or comment (`const s = "a; const x = useState<T"`) are never
593
684
  // mistaken for a statement boundary. A declaration can begin at the buffer start or right after `{`, `}`, `;`, or `=>`; we keep
594
685
  // the latest one that isn't closed by a depth-0 `;` or by a `)`/`]`/`}` that pops out of its enclosing block.
595
686
  const locateTrailingDeclaration = (value: string) => {
596
- const masked = maskLiteralSpans(value);
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);
597
690
  const re = /(?:^|[{};\n]|=>)\s*(const(?!\s+enum\b)|let|var)\b/g;
598
691
  let match: RegExpExecArray | null;
599
692
  let start = -1;
@@ -692,18 +785,25 @@ const isCompleteDoWhileStatement = (value: string, scan = scanStreamingSource(va
692
785
  return false;
693
786
  };
694
787
  const dropTrailingBareLoopHeader = (value: string) => {
695
- const masked = maskLiteralSpans(value).trimEnd();
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();
696
797
  if (!masked.endsWith(")")) return value;
697
798
  const index = findMatchingOpener(masked, "(", ")");
698
799
  if (index <= 0) return value;
699
800
  const head = masked.slice(0, index).trimEnd();
700
- const match = /\b(for|while)$/.exec(head);
801
+ const match = /\b(for(?:\s+await)?|while)$/.exec(head);
701
802
  if (!match) return value;
702
803
  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;
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;
707
807
  // `do { ... } while (cond)` is a complete statement that needs no body — keep it. Tell it apart from a plain `while` that
708
808
  // merely follows another block (`while(a){...} while(b`) by parsing the source and checking this trailing `while` closes a `do`.
709
809
  if (match[1] === "while" && isCompleteDoWhileStatement(value)) return value;
@@ -717,7 +817,9 @@ const dropTrailingBareLoopHeader = (value: string) => {
717
817
  // inner frames whose openers were inside the rolled-back span.
718
818
  const completeBlockFrame = (output: string, frame: Extract<CompletionFrame, { type: "block" }>) => {
719
819
  if (frame.statement) return { text: `${output}${frame.closer}`, dropped: false };
720
- const completed = completeIncompleteExpressionPrefix(output, true);
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);
721
823
  const openerGone = frame.open !== undefined ? completed.length <= frame.open : completed.length < output.length;
722
824
  if (openerGone) return { text: completed, dropped: true };
723
825
  return { text: `${completed}${frame.closer}`, dropped: false };
@@ -739,12 +841,16 @@ const completeStatementPrefixFrame = (output: string, frame: CompletionFrame): {
739
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
740
842
  // closer — `completeBlockFrame` detects that and reports `open: number` for the bracket position it would have closed, so once
741
843
  // a drop happens we know which later frames lost their opener (those whose recorded bracket offset is past the drop point).
742
- const closeStackFrames = (output: string, frames: CompletionFrame[]) => {
844
+ const closeStackFrames = (output: string, frames: CompletionFrame[], beforeCloser?: (text: string, closer: string) => string) => {
743
845
  let text = output;
744
846
  let dropPoint = Infinity;
745
847
  for (const frame of frames) {
746
848
  if (frame.type === "block" && frame.open !== undefined && frame.open >= dropPoint) continue;
747
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
+ }
748
854
  if (result.dropped) dropPoint = Math.min(dropPoint, result.text.length);
749
855
  text = result.text;
750
856
  }
@@ -968,7 +1074,7 @@ const isAsiBoundary = (before: string, after: string, leftEndsWithRegexLiteral =
968
1074
  const endsWithContinuingPunctuator = /[=+\-*/%&|^~?!<>.:,([]$/.test(left) && !endsWithPostfixUpdate(left) && !(left.endsWith("/") && leftEndsWithRegexLiteral) && !(left.endsWith(">") && endsWithCompleteJsxTag(left));
969
1075
  if (!left || !right || endsWithContinuingPunctuator || right.startsWith(".") || LINE_CONTINUING_KEYWORD_OPERATOR.test(left)) return false;
970
1076
  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);
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);
972
1078
  };
973
1079
 
974
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;
@@ -1047,9 +1153,22 @@ const scanStreamingSource = (code: string): StreamingScan => {
1047
1153
  unclosedLiteralStatementStart = isTrailingClosedRegexLiteral(code, index, lastRegexLiteralStart) || (Boolean(statement.trim()) && hasSafeExpressionTail(statement)) ? index : statementStart;
1048
1154
  };
1049
1155
  while (index < code.length) {
1050
- const insideJsxText = stack.at(-1)?.type === "jsx";
1156
+ const insideJsxText = isInsideJsxText(stack);
1051
1157
  const char = code[index];
1052
- if (!insideJsxText && char === "/" && code[index + 1] === "/") {
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] === "/") {
1053
1172
  const newlineIndex = code.indexOf("\n", index + 2);
1054
1173
  if (newlineIndex === -1) {
1055
1174
  markDisposableComment(); // the comment itself is disposable; preserve only a complete statement before it
@@ -1061,7 +1180,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1061
1180
  index = newlineIndex + 1;
1062
1181
  continue;
1063
1182
  }
1064
- if (!insideJsxText && char === "/" && code[index + 1] === "*") {
1183
+ if (char === "/" && code[index + 1] === "*") {
1065
1184
  const closeIndex = code.indexOf("*/", index + 2);
1066
1185
  if (closeIndex === -1) {
1067
1186
  markDisposableComment();
@@ -1070,7 +1189,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1070
1189
  index = closeIndex + 2;
1071
1190
  continue;
1072
1191
  }
1073
- if (!insideJsxText && ((statementRegexBoundary && char === "/") || isLikelyRegexLiteralStart(code, index))) {
1192
+ if ((statementRegexBoundary && char === "/") || isLikelyRegexLiteralStart(code, index)) {
1074
1193
  regexLiteralStarts.add(index);
1075
1194
  lastRegexLiteralStart = index;
1076
1195
  statementRegexBoundary = false;
@@ -1083,7 +1202,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1083
1202
  index = end;
1084
1203
  continue;
1085
1204
  }
1086
- if (!insideJsxText && (char === '"' || char === "'")) {
1205
+ if (char === '"' || char === "'") {
1087
1206
  statementRegexBoundary = false;
1088
1207
  const end = findStringLiteralEnd(code, index);
1089
1208
  if (!isClosedQuotedLiteral(code, index, end, char)) {
@@ -1094,7 +1213,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1094
1213
  index = end;
1095
1214
  continue;
1096
1215
  }
1097
- if (!insideJsxText && char === "`") {
1216
+ if (char === "`") {
1098
1217
  statementRegexBoundary = false;
1099
1218
  const literal = scanTemplateLiteralEnd(code, index);
1100
1219
  if (!literal.closed) {
@@ -1153,7 +1272,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
1153
1272
  const scope = scopes.at(-1);
1154
1273
  if (scope) scope.statementStart = index + 1;
1155
1274
  }
1156
- if (char === "\n" && !insideJsxText && canCommitBoundary(stack)) {
1275
+ if (char === "\n" && canCommitBoundary(stack)) {
1157
1276
  const scope = scopes.at(-1);
1158
1277
  const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
1159
1278
  if (scope && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(index + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = index + 1;
@@ -1288,7 +1407,7 @@ const isTypedParameterList = (initializer: string) => {
1288
1407
  };
1289
1408
 
1290
1409
  const hasUnsafeStreamingTypeArgument = (code: string) => {
1291
- const masked = maskLiteralSpans(code);
1410
+ const masked = maskIgnoredTextForExportScan(code);
1292
1411
  if (/(?:^|=)\s*<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)[^>]*$/.test(neutralizeAngleOperators(masked))) return true;
1293
1412
  if (!hasUnclosedTypeArgument(masked)) return false;
1294
1413
  const lastOpen = masked.lastIndexOf("<");
@@ -1305,7 +1424,12 @@ const hasSafeExpressionTail = (tail: string) => {
1305
1424
  if (hasIncompleteControlTail(trimmed) || dropTrailingIncompleteExpressionOperator(trimmed) !== trimmed) return false;
1306
1425
  if (completeTrailingIncompleteTernary(trimmed) !== trimmed) return false;
1307
1426
  if (dropTrailingIncompleteFunctionDeclaration(trimmed) !== trimmed) return false;
1308
- if (hasUnsafeStreamingTypeArgument(trimmed)) return false;
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;
1309
1433
  if (/^import\b/.test(trimmed) && !/^import\s+(?:["'][\s\S]*["']|[\s\S]+\s+from\s+["'][\s\S]*["'])\s*;?$/.test(trimmed)) return false;
1310
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;
1311
1435
  if (/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class(?:\s+[A-Za-z_$][\w$]*)?\s*$/.test(trimmed)) return false;
@@ -1510,6 +1634,12 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1510
1634
  regexLiteralStarts = committed.regexLiteralStarts;
1511
1635
  }
1512
1636
  let output = "";
1637
+ let pendingTrivia = "";
1638
+ const finish = (text: string, suffix = "") => {
1639
+ const trivia = pendingTrivia;
1640
+ pendingTrivia = "";
1641
+ return `${text}${trivia}${suffix}`;
1642
+ };
1513
1643
  const stack: CompletionFrame[] = [];
1514
1644
  let index = 0;
1515
1645
  const push = (value: string) => {
@@ -1517,14 +1647,23 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1517
1647
  };
1518
1648
  while (index < code.length) {
1519
1649
  const char = code[index];
1520
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "/") {
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] === "/") {
1521
1657
  const newlineIndex = code.indexOf("\n", index + 2);
1522
1658
  const end = newlineIndex === -1 ? code.length : newlineIndex;
1523
- push(code.slice(index, end));
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));
1524
1663
  index = end;
1525
1664
  continue;
1526
1665
  }
1527
- if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "*") {
1666
+ if (char === "/" && code[index + 1] === "*") {
1528
1667
  const closeIndex = code.indexOf("*/", index + 2);
1529
1668
  if (closeIndex === -1) {
1530
1669
  push(`${code.slice(index)}*/`);
@@ -1535,15 +1674,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1535
1674
  index = closeIndex + 2;
1536
1675
  continue;
1537
1676
  }
1538
- if (!isInsideJsxText(stack) && (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index))) {
1677
+ if (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index)) {
1539
1678
  index = consumeRegexLiteral(code, index, push);
1540
1679
  continue;
1541
1680
  }
1542
- if (!isInsideJsxText(stack) && (char === '"' || char === "'")) {
1681
+ if (char === '"' || char === "'") {
1543
1682
  index = consumeStringLiteral(code, index, push);
1544
1683
  continue;
1545
1684
  }
1546
- if (!isInsideJsxText(stack) && char === "`") {
1685
+ if (char === "`") {
1547
1686
  index = consumeTemplateLiteral(code, index, push);
1548
1687
  continue;
1549
1688
  }
@@ -1577,7 +1716,7 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1577
1716
  continue;
1578
1717
  }
1579
1718
  if (char in BLOCK_FOLLOWERS) {
1580
- 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) });
1581
1720
  push(char);
1582
1721
  index += 1;
1583
1722
  continue;
@@ -1594,14 +1733,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
1594
1733
  }
1595
1734
  const lastRegexLiteralStart = regexLiteralStarts ? findLastRegexLiteralStart(regexLiteralStarts, code.length) : undefined;
1596
1735
  if (isTrailingClosedRegexLiteral(code, code.length, lastRegexLiteralStart) && canCommitBoundary(stack)) output += ";";
1736
+ const beforeCloser = pendingTrivia ? finish : undefined;
1597
1737
  const closingFrames = stack.toReversed();
1598
1738
  const closers = closingFrames.map(closeFrame).join("");
1599
1739
  if (!closers) return completeIncompleteExpressionPrefix(output, true, options.mode === "streaming");
1600
1740
  const statementIndex = closingFrames.findIndex((frame) => frame.type === "block" && frame.statement);
1601
- if (statementIndex === -1) return closeStackFrames(output, closingFrames);
1602
- 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);
1603
1743
  const completedExpression = dropTrailingBareLoopHeader(fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true, options.mode === "streaming")));
1604
- return `${completedExpression}${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`;
1744
+ return finish(completedExpression, `${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`);
1605
1745
  }
1606
1746
 
1607
1747
  export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions = {}) {
@@ -1610,6 +1750,7 @@ export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions =
1610
1750
  // The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
1611
1751
  // false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
1612
1752
  if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
1753
+ exportScanCode = maskAmbientBlocks(exportScanCode);
1613
1754
  if (!hasDefaultExportDeclaration(exportScanCode)) {
1614
1755
  const componentName = findLastExportableComponent(exportScanCode);
1615
1756
  if (componentName) next += `\nexport default ${componentName};`;