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.
- package/README.md +39 -0
- package/package.json +1 -1
- 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
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
|
|
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
|
-
|
|
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
|
-
|
|
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] === "`")
|
|
46
|
-
|
|
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
|
|
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
|
|
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|
|
|
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)
|
|
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
|
-
|
|
480
|
-
|
|
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 =
|
|
484
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
750
|
-
// followed by whitespace, `/`, `>`, or end, never
|
|
751
|
-
// misread as an open `<T>` element and a
|
|
752
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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};`;
|