partial-tsx 0.0.1 → 0.0.3
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 +773 -50
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,22 @@ 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("=>");
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// `null` when `index` isn't a comment start, `-1` when the comment is unterminated, else the index just past it.
|
|
282
|
+
// The three tag/expression scanners below all need this and drifted apart once already — #1600 was one of them missing it.
|
|
283
|
+
const findCommentEnd = (code: string, index: number) => {
|
|
284
|
+
if (code[index] !== "/") return null;
|
|
285
|
+
if (code[index + 1] === "/") {
|
|
286
|
+
const end = code.indexOf("\n", index + 2);
|
|
287
|
+
return end === -1 ? -1 : end + 1;
|
|
288
|
+
}
|
|
289
|
+
if (code[index + 1] === "*") {
|
|
290
|
+
const end = code.indexOf("*/", index + 2);
|
|
291
|
+
return end === -1 ? -1 : end + 2;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
268
294
|
};
|
|
269
295
|
|
|
270
296
|
const findJsxTagEnd = (code: string, start: number) => {
|
|
@@ -277,6 +303,20 @@ const findJsxTagEnd = (code: string, start: number) => {
|
|
|
277
303
|
else if (char === quote) quote = null;
|
|
278
304
|
continue;
|
|
279
305
|
}
|
|
306
|
+
// A regex ending in an escaped slash (`/\//`) puts a literal `//` in the source, so it must be consumed before the
|
|
307
|
+
// comment scan below mistakes it for one — same ordering `findJsxAttributeExpressionEnd` uses.
|
|
308
|
+
if (stack.length > 0 && isLikelyRegexLiteralStart(code, index)) {
|
|
309
|
+
index = findRegexLiteralEnd(code, index) - 1;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
// Comments are legal anywhere in a tag, including its own attribute list — not just inside an attribute's `{…}`.
|
|
313
|
+
// Skipping them keeps an apostrophe in `// framework's Link` from opening a string that swallows the rest of the tag (#1600).
|
|
314
|
+
const commentEnd = findCommentEnd(code, index);
|
|
315
|
+
if (commentEnd !== null) {
|
|
316
|
+
if (commentEnd === -1) return -1;
|
|
317
|
+
index = commentEnd - 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
280
320
|
if (char === '"' || char === "'" || char === "`") {
|
|
281
321
|
quote = char;
|
|
282
322
|
continue;
|
|
@@ -296,14 +336,9 @@ const findJsxTagEnd = (code: string, start: number) => {
|
|
|
296
336
|
const findJsxAttributeExpressionEnd = (code: string, index: number) => {
|
|
297
337
|
let depth = 1;
|
|
298
338
|
while (index < code.length) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
index =
|
|
302
|
-
continue;
|
|
303
|
-
}
|
|
304
|
-
if (code[index] === "/" && code[index + 1] === "*") {
|
|
305
|
-
const close = code.indexOf("*/", index + 2);
|
|
306
|
-
index = close === -1 ? code.length : close + 2;
|
|
339
|
+
const commentEnd = findCommentEnd(code, index);
|
|
340
|
+
if (commentEnd !== null) {
|
|
341
|
+
index = commentEnd === -1 ? code.length : commentEnd;
|
|
307
342
|
continue;
|
|
308
343
|
}
|
|
309
344
|
if (isLikelyRegexLiteralStart(code, index)) {
|
|
@@ -338,6 +373,18 @@ const completeIncompleteJsxTag = (code: string, start: number) => {
|
|
|
338
373
|
let tag = "";
|
|
339
374
|
while (index < code.length) {
|
|
340
375
|
const char = code[index];
|
|
376
|
+
const commentEnd = findCommentEnd(code, index);
|
|
377
|
+
if (commentEnd !== null) {
|
|
378
|
+
// A still-streaming comment has to be dropped, not carried: emitting it would bury the `>` we synthesize below
|
|
379
|
+
// inside the comment. A closed one is kept verbatim — comments are legal in a JSX attribute list (#1600).
|
|
380
|
+
if (commentEnd === -1) {
|
|
381
|
+
index = code.length;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
tag += code.slice(index, commentEnd);
|
|
385
|
+
index = commentEnd;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
341
388
|
if (char === "`") {
|
|
342
389
|
// 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.
|
|
343
390
|
tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
|
|
@@ -351,7 +398,7 @@ const completeIncompleteJsxTag = (code: string, start: number) => {
|
|
|
351
398
|
// 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
399
|
if (!closed) {
|
|
353
400
|
// 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)
|
|
401
|
+
if ((/\\*$/.exec(literal)?.[0].length ?? 0) % 2) literal = literal.slice(0, -1);
|
|
355
402
|
literal += char;
|
|
356
403
|
}
|
|
357
404
|
tag += literal;
|
|
@@ -385,7 +432,11 @@ const completeIncompleteJsxTag = (code: string, start: number) => {
|
|
|
385
432
|
if (/=\s*$/.test(tag)) tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
|
|
386
433
|
// Otherwise a trailing name with no following space is still streaming (`valu` may become `value`); a trailing space commits it as a boolean attr.
|
|
387
434
|
else if (!/\s$/.test(tag)) tag = tag.replace(/\s+[A-Za-z_$][\w$:-]*$/, "");
|
|
388
|
-
|
|
435
|
+
// That trim can eat the newline terminating a trailing comment. The closer synthesized below must stay clear of it:
|
|
436
|
+
// after `//` it would be buried inside the comment, after `*/` it would fuse into a spurious self-closing `/>`.
|
|
437
|
+
if (/\/\/[^\n]*$|\*\/$/.test(tag)) tag += "\n";
|
|
438
|
+
// The `\/` strip below must not mistake a block comment's terminating slash for a stray self-closing one.
|
|
439
|
+
const completedTag = VOID_TAGS.has(tagName) ? `${tag.replace(/\s*(?<!\*)\/\s*$/, "")} />` : `${tag}>`;
|
|
389
440
|
return { tag: completedTag, tagName };
|
|
390
441
|
};
|
|
391
442
|
|
|
@@ -467,6 +518,11 @@ const endsWithJsxTagHead = (value: string) => {
|
|
|
467
518
|
const tail = value.slice(lastOpen);
|
|
468
519
|
return /^<\/?(?:[A-Za-z][\w$.-]*|$)/.test(tail) && findJsxTagEnd(value, lastOpen) === -1;
|
|
469
520
|
};
|
|
521
|
+
const endsWithCompleteJsxTag = (value: string) => {
|
|
522
|
+
const trimmed = value.trimEnd();
|
|
523
|
+
const lastOpen = trimmed.lastIndexOf("<");
|
|
524
|
+
return lastOpen !== -1 && shouldStartJsxTag(trimmed, lastOpen, collectCompletionStackBefore(trimmed, lastOpen)) && findJsxTagEnd(trimmed, lastOpen) === trimmed.length - 1;
|
|
525
|
+
};
|
|
470
526
|
const hasCompleteRegexLiteralEndingAt = (value: string, endIndex: number) => {
|
|
471
527
|
for (let index = 0; index <= endIndex; index += 1) {
|
|
472
528
|
if (value[index] !== "/" || !isLikelyRegexLiteralStart(value, index)) continue;
|
|
@@ -476,18 +532,30 @@ const hasCompleteRegexLiteralEndingAt = (value: string, endIndex: number) => {
|
|
|
476
532
|
}
|
|
477
533
|
return false;
|
|
478
534
|
};
|
|
479
|
-
|
|
480
|
-
|
|
535
|
+
// Window = longest operator `>>>=` (4) + the 1 char the `(?<![.\d])\.` lookbehind inspects, rounded up.
|
|
536
|
+
const TRAILING_OPERATOR_WINDOW = 8;
|
|
537
|
+
const endsWithPostfixUpdate = (value: string) => /[\w$)\]](?:\+\+|--)$/.test(value.trimEnd());
|
|
538
|
+
const dropTrailingOperator = (value: string, pattern: RegExp, preservePostfixUpdate = false) => {
|
|
539
|
+
// `$`-anchored ⇒ the operator is pinned to the trimmed suffix, so a fixed tail window yields the identical match but
|
|
540
|
+
// turns an O(n)-per-call (O(n²) over a char-by-char stream) scan into O(1) in prefix length.
|
|
541
|
+
const trimmed = value.trimEnd();
|
|
542
|
+
const windowStart = Math.max(0, trimmed.length - TRAILING_OPERATOR_WINDOW);
|
|
543
|
+
const match = pattern.exec(trimmed.slice(windowStart));
|
|
481
544
|
if (!match) return value;
|
|
482
545
|
const operator = match[1];
|
|
483
|
-
const operatorIndex =
|
|
484
|
-
|
|
546
|
+
const operatorIndex = trimmed.length - operator.length;
|
|
547
|
+
// Trimming the operator's leading-whitespace run reproduces the unwindowed scan's `value.slice(0, match.index)`
|
|
548
|
+
// byte-for-byte (the pattern's `\s*` is greedy, so `match.index` sat at the start of that run).
|
|
549
|
+
const before = value.slice(0, operatorIndex).trimEnd();
|
|
485
550
|
if (operator === "=>") return `${before} => null`;
|
|
551
|
+
if (preservePostfixUpdate && (operator === "+" || operator === "-") && endsWithPostfixUpdate(value)) return value;
|
|
552
|
+
if (operator === "/" && before.endsWith("*") && before.lastIndexOf("/*") > before.lastIndexOf("*/")) return value;
|
|
486
553
|
if (operator === "/" && hasCompleteRegexLiteralEndingAt(value, operatorIndex)) return value;
|
|
554
|
+
if (operator === ">" && /\/\s*$/.test(before)) return value;
|
|
487
555
|
if ((operator === ">" || operator === ">=") && endsWithJsxTagHead(before)) return value;
|
|
488
|
-
return
|
|
556
|
+
return before;
|
|
489
557
|
};
|
|
490
|
-
const dropTrailingIncompleteExpressionOperator = (value: string) => dropTrailingOperator(value, /\s*(>>>=?|>>=?|<<=?|===|!==|=>|\*\*=|\?\?=|&&=|\|\|=|[+\-*/%&|^]=|\?\?|\?\.|\*\*|[=!<>]=?|&&|\|\||[+\-*/%&|^~]|(?<![.\d])\.)\s*$/s);
|
|
558
|
+
const dropTrailingIncompleteExpressionOperator = (value: string, preservePostfixUpdate = false) => dropTrailingOperator(value, /\s*(>>>=?|>>=?|<<=?|===|!==|=>|\*\*=|\?\?=|&&=|\|\|=|[+\-*/%&|^]=|\?\?|\?\.|\*\*|[=!<>]=?|&&|\|\||[+\-*/%&|^~]|(?<![.\d])\.)\s*$/s, preservePostfixUpdate);
|
|
491
559
|
// Blanks out string/template/regex/comment spans (but not JSX) so angle-bracket counting in `dropTrailingIncompleteStatement`
|
|
492
560
|
// only sees real `<`/`>` operators and type arguments, never a literal `<` (`"a<b"`) or a `</div>` closer.
|
|
493
561
|
const maskLiteralSpans = (body: string) => {
|
|
@@ -566,7 +634,7 @@ const hasUnclosedTypeArgument = (body: string) => {
|
|
|
566
634
|
// the latest one that isn't closed by a depth-0 `;` or by a `)`/`]`/`}` that pops out of its enclosing block.
|
|
567
635
|
const locateTrailingDeclaration = (value: string) => {
|
|
568
636
|
const masked = maskLiteralSpans(value);
|
|
569
|
-
const re = /(?:^|[{};\n]|=>)\s*(const|let|var)\b/g;
|
|
637
|
+
const re = /(?:^|[{};\n]|=>)\s*(const(?!\s+enum\b)|let|var)\b/g;
|
|
570
638
|
let match: RegExpExecArray | null;
|
|
571
639
|
let start = -1;
|
|
572
640
|
let kind = "";
|
|
@@ -617,7 +685,9 @@ const dropTrailingIncompleteStatement = (value: string) => {
|
|
|
617
685
|
};
|
|
618
686
|
const completeTrailingIncompleteTernary = (value: string) => {
|
|
619
687
|
const masked = maskIgnoredTextForExportScan(value);
|
|
620
|
-
|
|
688
|
+
// `masked` blanks a string/template else-branch (or regex contents) to spaces, so a *complete* `? a : "x"` looks like it ends at `:`.
|
|
689
|
+
// A genuine trailing `:` is never inside a literal, so confirm against the raw text before synthesizing the else-branch.
|
|
690
|
+
if (/(?<!\?)\?(?![.?])[^?;{}]*:\s*$/s.test(masked) && /:\s*$/s.test(value)) return `${value} null`;
|
|
621
691
|
const match = /(?<!\?)\?(?![.?])(?<tail>[^?:;{}]*)$/s.exec(masked);
|
|
622
692
|
if (!match?.groups) return value;
|
|
623
693
|
return value.slice(match.index + 1).trim() ? `${value} : null` : `${value} null : null`;
|
|
@@ -629,11 +699,56 @@ const dropTrailingIncompleteFunctionDeclaration = (value: string) => {
|
|
|
629
699
|
const match = /(?:^|[}\n;])\s*((?:export\s+(?:default\s+)?)?(?:async\s+)?function\b\s*\*?\s*[A-Za-z_$]?[\w$]*\s*)$/.exec(value);
|
|
630
700
|
return match ? value.slice(0, match.index + (match[0].length - match[1].length)) : value;
|
|
631
701
|
};
|
|
632
|
-
const completeIncompleteExpressionPrefix = (value: string, dropStatement = false
|
|
702
|
+
const completeIncompleteExpressionPrefix = (value: string, dropStatement = false, preservePostfixUpdate = false) =>
|
|
703
|
+
completeTrailingIncompleteTernary(dropTrailingIncompleteExpressionOperator(dropStatement ? dropTrailingIncompleteFunctionDeclaration(dropTrailingIncompleteStatement(value)) : value, preservePostfixUpdate));
|
|
633
704
|
const fillEmptyReturnExpression = (value: string) => {
|
|
634
705
|
if (/\breturn\s*$/.test(value)) return `${value} null`;
|
|
635
706
|
return value.replace(/(\breturn\s*\(\s*)\)$/s, "$1null)");
|
|
636
707
|
};
|
|
708
|
+
// Walk back from a string ending in `close` to the index of the `open` that matches it, or -1 if it never balances.
|
|
709
|
+
const findMatchingOpener = (text: string, open: string, close: string) => {
|
|
710
|
+
let depth = 0;
|
|
711
|
+
for (let index = text.length - 1; index >= 0; index -= 1) {
|
|
712
|
+
if (text[index] === close) depth += 1;
|
|
713
|
+
else if (text[index] === open && --depth === 0) return index;
|
|
714
|
+
}
|
|
715
|
+
return -1;
|
|
716
|
+
};
|
|
717
|
+
// A loop whose header has streamed but whose body has not ends the completed prefix as a bare `for (...)` / `while (...)`.
|
|
718
|
+
// Finalization would give it an empty `;` body, and a header that never advances its counter (`for (let i = 0; i < n;)`)
|
|
719
|
+
// then spins forever and freezes the tab. Loops only live in statement position (JSX iterates via `.map`), so a body-less
|
|
720
|
+
// header renders nothing regardless — drop it back to before the keyword.
|
|
721
|
+
const controlStatementMatches = (code: string) => code.matchAll(/(^|[;{}\n):])(\s*)((?:[A-Za-z_$][\w$]*\s*:\s*)*)(if|for|while|with|try|do)\b/g);
|
|
722
|
+
const isCompleteDoWhileStatement = (value: string, scan = scanStreamingSource(value)) => {
|
|
723
|
+
const masked = maskIgnoredTextForExportScan(value, scan.regexLiteralStarts);
|
|
724
|
+
const isControlStatementContext = createControlStatementContextLookup(scan);
|
|
725
|
+
for (const match of controlStatementMatches(masked)) {
|
|
726
|
+
const start = (match.index ?? 0) + match[1].length;
|
|
727
|
+
if (match[4] !== "do" || !isControlStatementContext(start)) continue;
|
|
728
|
+
const doStart = skipStatementLabels(masked, start);
|
|
729
|
+
const end = findStatementEnd(masked, doStart);
|
|
730
|
+
if (end !== -1 && !masked.slice(end).trim()) return true;
|
|
731
|
+
}
|
|
732
|
+
return false;
|
|
733
|
+
};
|
|
734
|
+
const dropTrailingBareLoopHeader = (value: string) => {
|
|
735
|
+
const masked = maskLiteralSpans(value).trimEnd();
|
|
736
|
+
if (!masked.endsWith(")")) return value;
|
|
737
|
+
const index = findMatchingOpener(masked, "(", ")");
|
|
738
|
+
if (index <= 0) return value;
|
|
739
|
+
const head = masked.slice(0, index).trimEnd();
|
|
740
|
+
const match = /\b(for|while)$/.exec(head);
|
|
741
|
+
if (!match) return value;
|
|
742
|
+
const before = head.slice(0, match.index);
|
|
743
|
+
// A reserved word used as a member name or identifier suffix is a reference, not a loop: `obj.for(x)`, `arr?.while(p)`,
|
|
744
|
+
// `a$for(1)` must survive intact. `\b` treats `.` and `$` as boundaries, so guard both — `$` is the only identifier-
|
|
745
|
+
// continuation char that can precede the keyword (letters/digits/`_` are word chars `\b` would not break on).
|
|
746
|
+
if (/[.$]$/.test(before.trimEnd())) return value;
|
|
747
|
+
// `do { ... } while (cond)` is a complete statement that needs no body — keep it. Tell it apart from a plain `while` that
|
|
748
|
+
// merely follows another block (`while(a){...} while(b`) by parsing the source and checking this trailing `while` closes a `do`.
|
|
749
|
+
if (match[1] === "while" && isCompleteDoWhileStatement(value)) return value;
|
|
750
|
+
return value.slice(0, match.index).trimEnd();
|
|
751
|
+
};
|
|
637
752
|
// A non-statement bracket frame gets its expression prefix completed (and possibly dropped). Completing can shorten the output
|
|
638
753
|
// two different ways: a trailing operator/dot trim (the frame's own `{`/`[` survives, so we still close it) or a whole-declaration
|
|
639
754
|
// rollback that removes the frame's own opening bracket plus every nested opener — e.g. `useState<{ nested: { count` or an array
|
|
@@ -654,6 +769,9 @@ const completeStatementPrefixFrame = (output: string, frame: CompletionFrame): {
|
|
|
654
769
|
if (frame.type !== "block") return { text: `${output}${closeFrame(frame)}`, dropped: false };
|
|
655
770
|
if (frame.closer === ")") {
|
|
656
771
|
const completed = completeIncompleteExpressionPrefix(output);
|
|
772
|
+
// A paren whose entire body is a balanced empty `()` (a double paren `(()`) can only continue as an arrow head — an
|
|
773
|
+
// empty parenthesized expression is rejected by the compiler. Complete the forced arrow rather than emit illegal `(())`.
|
|
774
|
+
if (frame.open !== undefined && completed.slice(frame.open + 1).trim() === "()") return { text: `${completed} => null${frame.closer}`, dropped: false };
|
|
657
775
|
return { text: `${completed.endsWith("(") && !isCallArgumentParen(completed) ? `${completed}null` : completed}${frame.closer}`, dropped: false };
|
|
658
776
|
}
|
|
659
777
|
return completeBlockFrame(output, frame);
|
|
@@ -702,7 +820,12 @@ function findRegexLiteralEnd(code: string, index: number) {
|
|
|
702
820
|
if (next === "[" && !inClass) inClass = true;
|
|
703
821
|
else if (next === "]" && inClass) inClass = false;
|
|
704
822
|
else if (next === "/" && !inClass) {
|
|
705
|
-
while (index < code.length
|
|
823
|
+
while (index < code.length) {
|
|
824
|
+
const point = code.codePointAt(index) ?? 0;
|
|
825
|
+
const char = String.fromCodePoint(point);
|
|
826
|
+
if (!/[A-Za-z0-9_$\\]/.test(char) && (point <= 0x7f || /\s/u.test(char))) break;
|
|
827
|
+
index += point > 0xffff ? 2 : 1;
|
|
828
|
+
}
|
|
706
829
|
return index;
|
|
707
830
|
}
|
|
708
831
|
}
|
|
@@ -746,10 +869,11 @@ const isJsxTagHead = (code: string, index: number) => {
|
|
|
746
869
|
return match !== null && /^(?:$|[\s/>])/.test(tail.slice(match[0].length));
|
|
747
870
|
};
|
|
748
871
|
|
|
749
|
-
// A
|
|
750
|
-
// followed by whitespace, `/`, `>`, or end, never
|
|
751
|
-
// misread as an open `<T>` element and a
|
|
752
|
-
|
|
872
|
+
// A `,`, `extends`, or `=` right after the tag name (`<T,`, `<T extends U`, `<T = U`) is a TSX generic type-param
|
|
873
|
+
// list, not JSX — a JSX tag name is only ever followed by whitespace, `/`, `>`, or end, never these tokens. Without
|
|
874
|
+
// this, generic arrows like `<T,>(x:T)=>x` or `<T extends {}>(x:T)=>x` are misread as an open `<T>` element and a
|
|
875
|
+
// synthetic `</T>` is appended, breaking the module.
|
|
876
|
+
const isGenericTypeParamList = (code: string, index: number) => /^<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)/.test(code.slice(index));
|
|
753
877
|
const shouldStartJsxTag = (code: string, index: number, stack: CompletionFrame[]) => {
|
|
754
878
|
if (isGenericTypeParamList(code, index)) return false;
|
|
755
879
|
if (stack.at(-1)?.type === "jsx") return isJsxTagHead(code, index);
|
|
@@ -759,6 +883,7 @@ const isInsideJsxText = (stack: CompletionFrame[]) => stack.at(-1)?.type === "js
|
|
|
759
883
|
|
|
760
884
|
const consumeTemplateExpression = (code: string, index: number, push: PushOutput): number => {
|
|
761
885
|
let depth = 1;
|
|
886
|
+
const startIndex = index;
|
|
762
887
|
while (index < code.length && depth > 0) {
|
|
763
888
|
const expressionChar = code[index];
|
|
764
889
|
if (expressionChar === "/" && code[index + 1] === "/") {
|
|
@@ -795,7 +920,9 @@ const consumeTemplateExpression = (code: string, index: number, push: PushOutput
|
|
|
795
920
|
if (expressionChar === "{") depth += 1;
|
|
796
921
|
else if (expressionChar === "}") depth -= 1;
|
|
797
922
|
}
|
|
798
|
-
|
|
923
|
+
// An interpolation that never streamed an expression (`${}` once we synthesize the `}`) is illegal; fill `null` so the
|
|
924
|
+
// streamed frame parses, mirroring the empty-paren and empty-return synthesis.
|
|
925
|
+
if (depth > 0) push((code.slice(startIndex, index).trim() ? "" : "null") + "}".repeat(depth));
|
|
799
926
|
return index;
|
|
800
927
|
};
|
|
801
928
|
|
|
@@ -826,11 +953,605 @@ const consumeTemplateLiteral = (code: string, index: number, push: PushOutput):
|
|
|
826
953
|
return index;
|
|
827
954
|
};
|
|
828
955
|
|
|
829
|
-
|
|
956
|
+
type StreamingScope = { statementStart: number };
|
|
957
|
+
type StreamingScan = {
|
|
958
|
+
stack: CompletionFrame[];
|
|
959
|
+
closedBlocks: Array<Extract<CompletionFrame, { type: "block" }>>;
|
|
960
|
+
scopes: StreamingScope[];
|
|
961
|
+
regexLiteralStarts: Set<number>;
|
|
962
|
+
lastSignificantEnd: number;
|
|
963
|
+
unclosedLiteralStart: number | null;
|
|
964
|
+
unclosedLiteralStatementStart: number | null;
|
|
965
|
+
incompleteJsxTagStart: number | null;
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const FUNCTION_HEADER = /^(?:export\s+(?:default\s+)?)?(?:async\s+)?function(?:\s*\*)?\s*([A-Za-z_$][\w$]*)?(?:\s*<[\s\S]*>)?\s*\([^)]*\)\s*(?::\s*[\s\S]+)?$/;
|
|
969
|
+
const CLASS_HEADER = /^(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)(?:\s+extends[\s\S]+)?(?:\s+implements[\s\S]+)?$/;
|
|
970
|
+
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]+)?$/;
|
|
971
|
+
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*=>$/;
|
|
972
|
+
const hasTopLevelClassBody = (value: string) => {
|
|
973
|
+
let angleDepth = 0;
|
|
974
|
+
for (const char of maskLiteralSpans(value)) {
|
|
975
|
+
if (char === "<") angleDepth += 1;
|
|
976
|
+
else if (char === ">" && angleDepth > 0) angleDepth -= 1;
|
|
977
|
+
else if (char === "{" && angleDepth === 0) return true;
|
|
978
|
+
}
|
|
979
|
+
return false;
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
const getBlockKind = (code: string, index: number, statementStart: number): BlockKind => {
|
|
983
|
+
const before = code.slice(statementStart, index).trim();
|
|
984
|
+
if (!before || /^(?:[A-Za-z_$][\w$]*\s*:\s*)+$/.test(before) || before === "static") return "block";
|
|
985
|
+
if (FUNCTION_HEADER.test(before)) return "function";
|
|
986
|
+
if (/^(?:if|for|while|switch|with)\s*\([^)]*\)$/.test(before) || /^catch(?:\s*\([^)]*\))?$/.test(before) || /^(?:else|try|finally|do)$/.test(before)) return "control";
|
|
987
|
+
if (before.endsWith("=>")) return "arrow";
|
|
988
|
+
if (CLASS_HEADER.test(before) && !hasUnclosedTypeArgument(maskLiteralSpans(before))) return "class";
|
|
989
|
+
if (METHOD_HEADER.test(before)) return "method";
|
|
990
|
+
return "other";
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
const findKeywordStart = (code: string, index: number, scopeStart: number, kind: BlockKind) => {
|
|
994
|
+
if (kind !== "function" && kind !== "control") return scopeStart;
|
|
995
|
+
const segment = code.slice(scopeStart, index);
|
|
996
|
+
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;
|
|
997
|
+
let start = scopeStart;
|
|
998
|
+
let match: RegExpExecArray | null;
|
|
999
|
+
while ((match = pattern.exec(segment))) start = scopeStart + match.index + match[0].lastIndexOf(match[1]);
|
|
1000
|
+
return start;
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const canCommitBoundary = (stack: CompletionFrame[]) => !stack.some((frame) => frame.type === "block" && (frame.closer !== "}" || !frame.statement));
|
|
1004
|
+
const LINE_CONTINUING_KEYWORD_OPERATOR = /(?<![.\w$])(?:as|await|delete|in|instanceof|new|satisfies|throw|typeof|void)$/;
|
|
1005
|
+
const isAsiBoundary = (before: string, after: string, leftEndsWithRegexLiteral = false) => {
|
|
1006
|
+
const left = before.trimEnd();
|
|
1007
|
+
const right = after.trimStart();
|
|
1008
|
+
const endsWithContinuingPunctuator = /[=+\-*/%&|^~?!<>.:,([]$/.test(left) && !endsWithPostfixUpdate(left) && !(left.endsWith("/") && leftEndsWithRegexLiteral) && !(left.endsWith(">") && endsWithCompleteJsxTag(left));
|
|
1009
|
+
if (!left || !right || endsWithContinuingPunctuator || right.startsWith(".") || LINE_CONTINUING_KEYWORD_OPERATOR.test(left)) return false;
|
|
1010
|
+
if (FUNCTION_HEADER.test(left) || (/(?:^|\n)\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\b/.test(left) && !hasTopLevelClassBody(left))) return false;
|
|
1011
|
+
return /^(?:const|let|var|return|throw|if|else|for|while|switch|try|catch|finally|do|function|class|import|export)\b|^[A-Za-z_$][\w$]*(?:\s*(?:[=(]|$))/.test(right);
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
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;
|
|
1015
|
+
const findRegexLiteralClosingSlash = (code: string, start: number, end: number) => {
|
|
1016
|
+
let inClass = false;
|
|
1017
|
+
for (let index = start + 1; index < end; index += 1) {
|
|
1018
|
+
const char = code[index];
|
|
1019
|
+
if (/\r|\n|\u2028|\u2029/.test(char)) return -1;
|
|
1020
|
+
if (char === "\\") {
|
|
1021
|
+
if (/\r|\n|\u2028|\u2029/.test(code[index + 1] ?? "")) return -1;
|
|
1022
|
+
index += 1;
|
|
1023
|
+
} else if (char === "[" && !inClass) inClass = true;
|
|
1024
|
+
else if (char === "]" && inClass) inClass = false;
|
|
1025
|
+
else if (char === "/" && !inClass) return index;
|
|
1026
|
+
}
|
|
1027
|
+
return -1;
|
|
1028
|
+
};
|
|
1029
|
+
const hasValidRegexLiteral = (code: string, start: number, end: number) => {
|
|
1030
|
+
const closingSlash = findRegexLiteralClosingSlash(code, start, end);
|
|
1031
|
+
if (closingSlash === -1) return false;
|
|
1032
|
+
const flags = code.slice(closingSlash + 1, end);
|
|
1033
|
+
return /^[dgimsuvy]*$/.test(flags) && new Set(flags).size === flags.length && !(flags.includes("u") && flags.includes("v"));
|
|
1034
|
+
};
|
|
1035
|
+
const isOnlyJsTrivia = (code: string, start: number, end: number) => {
|
|
1036
|
+
while (start < end) {
|
|
1037
|
+
if (/\s/.test(code[start])) start += 1;
|
|
1038
|
+
else if (code[start] === "/" && code[start + 1] === "/") {
|
|
1039
|
+
const newline = code.indexOf("\n", start + 2);
|
|
1040
|
+
start = newline === -1 || newline >= end ? end : newline + 1;
|
|
1041
|
+
} else if (code[start] === "/" && code[start + 1] === "*") {
|
|
1042
|
+
const close = code.indexOf("*/", start + 2);
|
|
1043
|
+
if (close === -1 || close + 2 > end) return false;
|
|
1044
|
+
start = close + 2;
|
|
1045
|
+
} else return false;
|
|
1046
|
+
}
|
|
1047
|
+
return true;
|
|
1048
|
+
};
|
|
1049
|
+
const isTrailingClosedRegexLiteral = (code: string, end: number, start: number | null | undefined) => {
|
|
1050
|
+
if (start === null || start === undefined || start >= end) return false;
|
|
1051
|
+
const regexEnd = findRegexLiteralEnd(code, start);
|
|
1052
|
+
return regexEnd <= end && hasValidRegexLiteral(code, start, regexEnd) && isOnlyJsTrivia(code, regexEnd, end);
|
|
1053
|
+
};
|
|
1054
|
+
const findLastRegexLiteralStart = (starts: ReadonlySet<number>, end: number) => {
|
|
1055
|
+
let last: number | undefined;
|
|
1056
|
+
for (const start of starts) {
|
|
1057
|
+
if (start >= end) break;
|
|
1058
|
+
last = start;
|
|
1059
|
+
}
|
|
1060
|
+
return last;
|
|
1061
|
+
};
|
|
1062
|
+
const STATEMENT_BLOCK_KINDS = new Set<BlockKind>(["block", "class", "control", "function"]);
|
|
1063
|
+
|
|
1064
|
+
// Keep source provenance separate from the legacy completer: JSX escaping and synthetic closers change output offsets, while
|
|
1065
|
+
// rollback must always cut the raw stream. The committed prefix is completed only after this source-only pass returns.
|
|
1066
|
+
const scanStreamingSource = (code: string): StreamingScan => {
|
|
1067
|
+
const stack: CompletionFrame[] = [];
|
|
1068
|
+
const closedBlocks: Array<Extract<CompletionFrame, { type: "block" }>> = [];
|
|
1069
|
+
const scopes: StreamingScope[] = [{ statementStart: 0 }];
|
|
1070
|
+
const regexLiteralStarts = new Set<number>();
|
|
1071
|
+
let unclosedLiteralStart: number | null = null;
|
|
1072
|
+
let unclosedLiteralStatementStart: number | null = null;
|
|
1073
|
+
let incompleteJsxTagStart: number | null = null;
|
|
1074
|
+
let statementRegexBoundary = false;
|
|
1075
|
+
let lastRegexLiteralStart: number | null = null;
|
|
1076
|
+
let lastSignificantEnd = 0;
|
|
1077
|
+
let index = 0;
|
|
1078
|
+
const currentStatementStart = () => scopes.at(-1)?.statementStart ?? 0;
|
|
1079
|
+
const markUnclosedLiteral = () => {
|
|
1080
|
+
unclosedLiteralStart = index;
|
|
1081
|
+
unclosedLiteralStatementStart = currentStatementStart();
|
|
1082
|
+
};
|
|
1083
|
+
const markDisposableComment = () => {
|
|
1084
|
+
const statementStart = currentStatementStart();
|
|
1085
|
+
const statement = code.slice(statementStart, index);
|
|
1086
|
+
unclosedLiteralStart = index;
|
|
1087
|
+
unclosedLiteralStatementStart = isTrailingClosedRegexLiteral(code, index, lastRegexLiteralStart) || (Boolean(statement.trim()) && hasSafeExpressionTail(statement)) ? index : statementStart;
|
|
1088
|
+
};
|
|
1089
|
+
while (index < code.length) {
|
|
1090
|
+
const insideJsxText = stack.at(-1)?.type === "jsx";
|
|
1091
|
+
const char = code[index];
|
|
1092
|
+
if (!insideJsxText && char === "/" && code[index + 1] === "/") {
|
|
1093
|
+
const newlineIndex = code.indexOf("\n", index + 2);
|
|
1094
|
+
if (newlineIndex === -1) {
|
|
1095
|
+
markDisposableComment(); // the comment itself is disposable; preserve only a complete statement before it
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
const scope = scopes.at(-1);
|
|
1099
|
+
const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
|
|
1100
|
+
if (scope && canCommitBoundary(stack) && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(newlineIndex + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = newlineIndex + 1;
|
|
1101
|
+
index = newlineIndex + 1;
|
|
1102
|
+
continue;
|
|
1103
|
+
}
|
|
1104
|
+
if (!insideJsxText && char === "/" && code[index + 1] === "*") {
|
|
1105
|
+
const closeIndex = code.indexOf("*/", index + 2);
|
|
1106
|
+
if (closeIndex === -1) {
|
|
1107
|
+
markDisposableComment();
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
index = closeIndex + 2;
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
if (!insideJsxText && ((statementRegexBoundary && char === "/") || isLikelyRegexLiteralStart(code, index))) {
|
|
1114
|
+
regexLiteralStarts.add(index);
|
|
1115
|
+
lastRegexLiteralStart = index;
|
|
1116
|
+
statementRegexBoundary = false;
|
|
1117
|
+
const end = findRegexLiteralEnd(code, index);
|
|
1118
|
+
if (!hasValidRegexLiteral(code, index, end)) {
|
|
1119
|
+
markUnclosedLiteral();
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
lastSignificantEnd = end;
|
|
1123
|
+
index = end;
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
if (!insideJsxText && (char === '"' || char === "'")) {
|
|
1127
|
+
statementRegexBoundary = false;
|
|
1128
|
+
const end = findStringLiteralEnd(code, index);
|
|
1129
|
+
if (!isClosedQuotedLiteral(code, index, end, char)) {
|
|
1130
|
+
markUnclosedLiteral();
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
lastSignificantEnd = end;
|
|
1134
|
+
index = end;
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
if (!insideJsxText && char === "`") {
|
|
1138
|
+
statementRegexBoundary = false;
|
|
1139
|
+
const literal = scanTemplateLiteralEnd(code, index);
|
|
1140
|
+
if (!literal.closed) {
|
|
1141
|
+
markUnclosedLiteral();
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
lastSignificantEnd = literal.end;
|
|
1145
|
+
index = literal.end;
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
if (char === "<" && shouldStartJsxTag(code, index, stack)) {
|
|
1149
|
+
statementRegexBoundary = false;
|
|
1150
|
+
const closeIndex = findJsxTagEnd(code, index);
|
|
1151
|
+
if (closeIndex === -1) {
|
|
1152
|
+
incompleteJsxTagStart = index;
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
updateJsxStackForTag(stack, code.slice(index, closeIndex + 1), index);
|
|
1156
|
+
lastSignificantEnd = closeIndex + 1;
|
|
1157
|
+
index = closeIndex + 1;
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
if (char in BLOCK_FOLLOWERS) {
|
|
1161
|
+
statementRegexBoundary = false;
|
|
1162
|
+
const inheritedStart = currentStatementStart();
|
|
1163
|
+
const jsxExpression = char === "{" && insideJsxText;
|
|
1164
|
+
const kind = jsxExpression ? undefined : getBlockKind(code, index, inheritedStart);
|
|
1165
|
+
const statement = !jsxExpression && kind !== "other";
|
|
1166
|
+
const statementStart = statement ? findKeywordStart(code, index, inheritedStart, kind ?? "other") : inheritedStart;
|
|
1167
|
+
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement, open: index, jsxExpression, statementStart, kind });
|
|
1168
|
+
if (statement && char === "{") scopes.push({ statementStart: index + 1 });
|
|
1169
|
+
lastSignificantEnd = index + 1;
|
|
1170
|
+
index += 1;
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
if (BLOCK_CLOSERS.has(char)) {
|
|
1174
|
+
const frame = stack.at(-1);
|
|
1175
|
+
let closesStatementBlock = false;
|
|
1176
|
+
if (frame?.type === "block" && frame.closer === char) {
|
|
1177
|
+
stack.pop();
|
|
1178
|
+
frame.close = index;
|
|
1179
|
+
closedBlocks.push(frame);
|
|
1180
|
+
if (frame.statement && char === "}") {
|
|
1181
|
+
closesStatementBlock = STATEMENT_BLOCK_KINDS.has(frame.kind ?? "other");
|
|
1182
|
+
scopes.pop();
|
|
1183
|
+
const parent = scopes.at(-1);
|
|
1184
|
+
if (parent) parent.statementStart = index + 1;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
statementRegexBoundary = closesStatementBlock;
|
|
1188
|
+
lastSignificantEnd = index + 1;
|
|
1189
|
+
index += 1;
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
if (char === ";" && canCommitBoundary(stack)) {
|
|
1193
|
+
const scope = scopes.at(-1);
|
|
1194
|
+
if (scope) scope.statementStart = index + 1;
|
|
1195
|
+
}
|
|
1196
|
+
if (char === "\n" && !insideJsxText && canCommitBoundary(stack)) {
|
|
1197
|
+
const scope = scopes.at(-1);
|
|
1198
|
+
const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
|
|
1199
|
+
if (scope && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(index + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = index + 1;
|
|
1200
|
+
}
|
|
1201
|
+
if (!/\s/.test(char)) {
|
|
1202
|
+
statementRegexBoundary = false;
|
|
1203
|
+
lastSignificantEnd = index + 1;
|
|
1204
|
+
}
|
|
1205
|
+
index += 1;
|
|
1206
|
+
}
|
|
1207
|
+
return { stack, closedBlocks, scopes, regexLiteralStarts, lastSignificantEnd, unclosedLiteralStart, unclosedLiteralStatementStart, incompleteJsxTagStart };
|
|
1208
|
+
};
|
|
1209
|
+
|
|
1210
|
+
const getFrameHeader = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => code.slice(frame.statementStart ?? 0, frame.open ?? 0).trim();
|
|
1211
|
+
const isComponentFunctionFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(FUNCTION_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
|
|
1212
|
+
const isFunctionDeclarationFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => FUNCTION_HEADER.test(getFrameHeader(code, frame));
|
|
1213
|
+
const isComponentClassFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => /^[A-Z]/.test(CLASS_HEADER.exec(getFrameHeader(code, frame))?.[1] ?? "");
|
|
1214
|
+
const isRenderMethodFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => METHOD_HEADER.exec(getFrameHeader(code, frame))?.[1] === "render";
|
|
1215
|
+
const isComponentArrowPrefix = (code: string, end: number) => {
|
|
1216
|
+
const before = code
|
|
1217
|
+
.slice(0, end)
|
|
1218
|
+
.trimEnd()
|
|
1219
|
+
.replace(/\(\s*$/, "")
|
|
1220
|
+
.trimEnd();
|
|
1221
|
+
const match = /(?:^|[;}\n])\s*((?:const|let|var)\s+[A-Z][\s\S]*=>)\s*$/.exec(before);
|
|
1222
|
+
return Boolean(match && COMPONENT_ARROW_HEADER.test(match[1]));
|
|
1223
|
+
};
|
|
1224
|
+
const isComponentArrowFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>) => COMPONENT_ARROW_HEADER.test(getFrameHeader(code, frame));
|
|
1225
|
+
|
|
1226
|
+
const isCompleteJsxTail = (code: string, scan: StreamingScan) => {
|
|
1227
|
+
const trimmed = code.trimEnd();
|
|
1228
|
+
if (!/(?:\/>|<\/[A-Za-z][\w$.-]*>)\s*;?\s*$/.test(trimmed)) return false;
|
|
1229
|
+
const scopeStart = scan.scopes.at(-1)?.statementStart ?? 0;
|
|
1230
|
+
const tail = trimmed.slice(scopeStart).trim();
|
|
1231
|
+
if (/^(?:return\s*\(?[\s\S]*<|(?:const|let|var)\s+[A-Z][\w$]*\s*(?::[^=]+)?=[\s\S]*=>\s*\(?[\s\S]*<|<)/.test(tail)) return true;
|
|
1232
|
+
const shellFrame = scan.stack.findLast((frame): frame is Extract<CompletionFrame, { type: "block" }> => frame.type === "block" && (frame.kind === "function" || frame.kind === "arrow" || frame.kind === "method"));
|
|
1233
|
+
return shellFrame?.open !== undefined && /\breturn\s*\(?[\s\S]*</.test(trimmed.slice(shellFrame.open + 1));
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
const hasCompletedRenderMethodTail = (code: string, scan: StreamingScan) => {
|
|
1237
|
+
const frame = scan.closedBlocks.findLast((candidate) => candidate.kind === "method" && isRenderMethodFrame(code, candidate));
|
|
1238
|
+
if (frame?.open === undefined || frame.close === undefined || code.slice(frame.close + 1).trim()) return false;
|
|
1239
|
+
return /\breturn\s*\(?[\s\S]*<(?:[A-Za-z]|>)/.test(code.slice(frame.open + 1, frame.close));
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
const isAllowedJsxShellFrame = (code: string, frame: Extract<CompletionFrame, { type: "block" }>, componentArrow: boolean) => {
|
|
1243
|
+
if (frame.jsxExpression || frame.kind === "control") return false;
|
|
1244
|
+
if (frame.kind === "function") return isComponentFunctionFrame(code, frame);
|
|
1245
|
+
if (frame.kind === "arrow") return componentArrow && isComponentArrowFrame(code, frame);
|
|
1246
|
+
if (frame.kind === "class") return isComponentClassFrame(code, frame);
|
|
1247
|
+
if (frame.kind === "method") return isRenderMethodFrame(code, frame);
|
|
1248
|
+
if (frame.statement) return false;
|
|
1249
|
+
const context = getFrameHeader(code, frame).trimEnd();
|
|
1250
|
+
return /(?:\breturn\s*|=>\s*)\(?$/.test(context);
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
// JSX returned directly by a component is the only open script shell we complete. A nested callback/control frame with JSX is
|
|
1254
|
+
// rolled back with its statement, so `useEffect(() => <Card` can never become an executable synthetic callback.
|
|
1255
|
+
const isJsxShellPreview = (code: string, scan: StreamingScan) => {
|
|
1256
|
+
const jsxFrame = scan.stack.find((frame): frame is Extract<CompletionFrame, { type: "jsx" }> => frame.type === "jsx");
|
|
1257
|
+
if (scan.unclosedLiteralStart !== null) return false;
|
|
1258
|
+
if (scan.stack.some((frame) => frame.type === "block" && frame.jsxExpression)) return false;
|
|
1259
|
+
const allFramesAllowed = (componentArrow: boolean) => scan.stack.every((frame) => frame.type !== "block" || isAllowedJsxShellFrame(code, frame, componentArrow));
|
|
1260
|
+
const rootReturnBefore = (end: number) => {
|
|
1261
|
+
const before = code.slice(0, end);
|
|
1262
|
+
return /\breturn\s*\(?\s*$/.test(before.slice(Math.max(0, before.lastIndexOf(";"), before.lastIndexOf("{"))));
|
|
1263
|
+
};
|
|
1264
|
+
const arrowFrameActive = scan.stack.some((frame) => frame.type === "block" && frame.kind === "arrow" && isComponentArrowFrame(code, frame));
|
|
1265
|
+
if (!jsxFrame) {
|
|
1266
|
+
if (scan.incompleteJsxTagStart === null) {
|
|
1267
|
+
const componentArrow = isComponentArrowPrefix(code, code.length) || arrowFrameActive;
|
|
1268
|
+
return (isCompleteJsxTail(code, scan) || hasCompletedRenderMethodTail(code, scan)) && allFramesAllowed(componentArrow);
|
|
1269
|
+
}
|
|
1270
|
+
const jsxStart = scan.incompleteJsxTagStart;
|
|
1271
|
+
const incompleteComponentArrow = isComponentArrowPrefix(code, jsxStart);
|
|
1272
|
+
if (!rootReturnBefore(jsxStart) && !incompleteComponentArrow && !/^\s*</.test(code)) return false;
|
|
1273
|
+
return allFramesAllowed(incompleteComponentArrow);
|
|
1274
|
+
}
|
|
1275
|
+
const jsxStart = jsxFrame.open ?? code.length;
|
|
1276
|
+
const componentArrow = isComponentArrowPrefix(code, jsxStart) || arrowFrameActive;
|
|
1277
|
+
if (!rootReturnBefore(jsxStart) && !componentArrow && !/^\s*</.test(code)) return false;
|
|
1278
|
+
return allFramesAllowed(componentArrow);
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
const findAsiBoundary = (code: string, start: number, regexLiteralStarts: ReadonlySet<number>) => {
|
|
1282
|
+
if (code.indexOf("\n", start) === -1) return start;
|
|
1283
|
+
let boundary = start;
|
|
1284
|
+
const regexStarts = [...regexLiteralStarts];
|
|
1285
|
+
let regexIndex = 0;
|
|
1286
|
+
let lastRegexStart: number | undefined;
|
|
1287
|
+
const segment = code.slice(start);
|
|
1288
|
+
for (const match of segment.matchAll(/\n/g)) {
|
|
1289
|
+
const index = match.index ?? 0;
|
|
1290
|
+
const absoluteIndex = start + index;
|
|
1291
|
+
while (regexIndex < regexStarts.length && regexStarts[regexIndex] < absoluteIndex) lastRegexStart = regexStarts[regexIndex++];
|
|
1292
|
+
if (isAsiBoundary(segment.slice(0, index), segment.slice(index + 1), isTrailingClosedRegexLiteral(code, absoluteIndex, lastRegexStart))) boundary = absoluteIndex + 1;
|
|
1293
|
+
}
|
|
1294
|
+
return boundary;
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
const hasIncompleteControlTail = (tail: string) => {
|
|
1298
|
+
const trimmed = tail.trim();
|
|
1299
|
+
if (/^(?:if|for|while|switch|catch|with)\b[\s\S]*\)\s*$/.test(trimmed)) return true;
|
|
1300
|
+
if (/^(?:else|finally)\s*$/.test(trimmed)) return true;
|
|
1301
|
+
if (/^(?:if|for|while)\b[\s\S]*\)\s+[^;{][\s\S]*$/.test(trimmed) && !/[;} ]$/.test(trimmed)) return true;
|
|
1302
|
+
if (/^try\b[\s\S]*\}\s*$/.test(trimmed) && !/\}\s*(?:catch|finally)\b/.test(trimmed)) return true;
|
|
1303
|
+
if (/^do\b[\s\S]*\}\s*$/.test(trimmed) && !/\}\s*while\b/.test(trimmed)) return true;
|
|
1304
|
+
return false;
|
|
1305
|
+
};
|
|
1306
|
+
|
|
1307
|
+
const isTypedParameterList = (initializer: string) => {
|
|
1308
|
+
const match = /^(?:async\s+)?(?:<[\s\S]*>\s*)?\(([\s\S]*)\)$/.exec(initializer);
|
|
1309
|
+
if (!match || !match[1].includes(":")) return false;
|
|
1310
|
+
const parameters = maskLiteralSpans(match[1]);
|
|
1311
|
+
let segmentStart = 0;
|
|
1312
|
+
let parenDepth = 0;
|
|
1313
|
+
let bracketDepth = 0;
|
|
1314
|
+
let braceDepth = 0;
|
|
1315
|
+
const isBinding = (segment: string) => /^(?:\.\.\.)?[A-Za-z_$][\w$]*\??$/.test(segment.trim()) || /^(?:\{[\s\S]*\}|\[[\s\S]*\])$/.test(segment.trim());
|
|
1316
|
+
for (let index = 0; index < parameters.length; index += 1) {
|
|
1317
|
+
const char = parameters[index];
|
|
1318
|
+
if (char === "(") parenDepth += 1;
|
|
1319
|
+
else if (char === ")") parenDepth -= 1;
|
|
1320
|
+
else if (char === "[") bracketDepth += 1;
|
|
1321
|
+
else if (char === "]") bracketDepth -= 1;
|
|
1322
|
+
else if (char === "{") braceDepth += 1;
|
|
1323
|
+
else if (char === "}") braceDepth -= 1;
|
|
1324
|
+
else if (char === "," && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) segmentStart = index + 1;
|
|
1325
|
+
else if (char === ":" && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) return isBinding(parameters.slice(segmentStart, index));
|
|
1326
|
+
}
|
|
1327
|
+
return false;
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
const hasUnsafeStreamingTypeArgument = (code: string) => {
|
|
1331
|
+
const masked = maskLiteralSpans(code);
|
|
1332
|
+
if (/(?:^|=)\s*<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)[^>]*$/.test(neutralizeAngleOperators(masked))) return true;
|
|
1333
|
+
if (!hasUnclosedTypeArgument(masked)) return false;
|
|
1334
|
+
const lastOpen = masked.lastIndexOf("<");
|
|
1335
|
+
if (lastOpen > 0 && /\s/.test(masked[lastOpen - 1] ?? "") && /^\s*(?:\[|\(|\{)/.test(masked.slice(lastOpen + 1))) return false;
|
|
1336
|
+
return true;
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
// Bare identifiers, primitives, assignments, and partial operators can all grow on the next chunk. Only real delimiters or a
|
|
1340
|
+
// source boundary make an EOF expression stable; this is why the check intentionally rejects valid-looking `const x = value`.
|
|
1341
|
+
const hasSafeExpressionTail = (tail: string) => {
|
|
1342
|
+
const trimmed = tail.trim();
|
|
1343
|
+
if (!trimmed) return true;
|
|
1344
|
+
if (/^(?:[A-Za-z_$][\w$]*\s*:\s*)+$/.test(trimmed)) return false;
|
|
1345
|
+
if (hasIncompleteControlTail(trimmed) || dropTrailingIncompleteExpressionOperator(trimmed) !== trimmed) return false;
|
|
1346
|
+
if (completeTrailingIncompleteTernary(trimmed) !== trimmed) return false;
|
|
1347
|
+
if (dropTrailingIncompleteFunctionDeclaration(trimmed) !== trimmed) return false;
|
|
1348
|
+
if (hasUnsafeStreamingTypeArgument(trimmed)) return false;
|
|
1349
|
+
if (/^import\b/.test(trimmed) && !/^import\s+(?:["'][\s\S]*["']|[\s\S]+\s+from\s+["'][\s\S]*["'])\s*;?$/.test(trimmed)) return false;
|
|
1350
|
+
if (/^export\s+(?:d(?:e(?:f(?:a(?:u(?:l(?:t)?)?)?)?)?)?)?\s*$/.test(trimmed) || /^export\s+default\s+[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
|
|
1351
|
+
if (/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class(?:\s+[A-Za-z_$][\w$]*)?\s*$/.test(trimmed)) return false;
|
|
1352
|
+
if (/^(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\b/.test(trimmed) && !hasTopLevelClassBody(trimmed)) return false;
|
|
1353
|
+
if (FUNCTION_HEADER.test(trimmed) || /^(?:export\s+(?:default\s+)?)?(?:async\s+)?function\b[\s\S]*\)\s*:\s*$/.test(trimmed)) return false;
|
|
1354
|
+
if (/^(?:export\s+)?type\b/.test(trimmed) && !/;\s*$/.test(trimmed)) return false;
|
|
1355
|
+
if (/^(?:const|let|var)\b/.test(trimmed)) {
|
|
1356
|
+
const initializer = /\b(?:const|let|var)\b[\s\S]*?=\s*([\s\S]*)$/.exec(trimmed)?.[1]?.trim() ?? "";
|
|
1357
|
+
// A typed parameter list is not an expression until `=>` arrives; its closing `)` must not commit the declaration early.
|
|
1358
|
+
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;
|
|
1359
|
+
}
|
|
1360
|
+
if (/^(?:return|throw|yield|await)\b[\s\S]*$/.test(trimmed)) {
|
|
1361
|
+
const expression = trimmed.replace(/^(?:return|throw|yield|await)\s+/, "").trim();
|
|
1362
|
+
if (!/[)\]}"'`]$/.test(expression)) return false;
|
|
1363
|
+
}
|
|
1364
|
+
if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
|
|
1365
|
+
if (/(?<![\w$])(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
|
|
1366
|
+
if (!/(?:null|true|false)$/.test(trimmed) && /[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
|
|
1367
|
+
if (/^(?:[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\[[^\]]+\])*)\s*=/.test(trimmed)) return false;
|
|
1368
|
+
if (/[A-Za-z0-9_$]$/.test(trimmed) && !/[)\]}"'`]$/.test(trimmed)) return false;
|
|
1369
|
+
return true;
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
const findTrailingUnsafeStatementStart = (code: string, scan: StreamingScan) => {
|
|
1373
|
+
const scopeStart = scan.scopes.at(-1)?.statementStart ?? 0;
|
|
1374
|
+
const start = findAsiBoundary(code, scopeStart, scan.regexLiteralStarts);
|
|
1375
|
+
const tail = code.slice(start, Math.max(start, scan.lastSignificantEnd));
|
|
1376
|
+
if (isTrailingClosedRegexLiteral(code, code.length, findLastRegexLiteralStart(scan.regexLiteralStarts, code.length))) return code.length;
|
|
1377
|
+
if (/^\s*while\s*\([\s\S]*\)\s*$/.test(tail) && isCompleteDoWhileStatement(code, scan)) return code.length;
|
|
1378
|
+
return hasSafeExpressionTail(tail) ? code.length : start;
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
const skipMaskedWhitespace = (code: string, index: number) => {
|
|
1382
|
+
while (index < code.length && /\s/.test(code[index])) index += 1;
|
|
1383
|
+
return index;
|
|
1384
|
+
};
|
|
1385
|
+
const findBalancedDelimiterEnd = (code: string, index: number, open: string, close: string) => {
|
|
1386
|
+
if (code[index] !== open) return -1;
|
|
1387
|
+
let depth = 0;
|
|
1388
|
+
for (; index < code.length; index += 1) {
|
|
1389
|
+
if (code[index] === open) depth += 1;
|
|
1390
|
+
else if (code[index] === close && --depth === 0) return index + 1;
|
|
1391
|
+
}
|
|
1392
|
+
return -1;
|
|
1393
|
+
};
|
|
1394
|
+
const hasKeywordAt = (code: string, index: number, keyword: string) => code.slice(index, index + keyword.length) === keyword && !/[\w$]/.test(code[index + keyword.length] ?? "");
|
|
1395
|
+
const isTrailingKeywordPrefix = (code: string, index: number, keywords: string[]) => {
|
|
1396
|
+
const tail = code.slice(index).trim();
|
|
1397
|
+
return !tail || keywords.some((keyword) => tail.length < keyword.length && keyword.startsWith(tail));
|
|
1398
|
+
};
|
|
1399
|
+
const findRequiredBlockEnd = (code: string, index: number) => {
|
|
1400
|
+
index = skipMaskedWhitespace(code, index);
|
|
1401
|
+
return code[index] === "{" ? findBalancedDelimiterEnd(code, index, "{", "}") : -1;
|
|
1402
|
+
};
|
|
1403
|
+
const findSimpleStatementEnd = (code: string, index: number) => {
|
|
1404
|
+
index = skipMaskedWhitespace(code, index);
|
|
1405
|
+
if (code[index] === "{") return findBalancedDelimiterEnd(code, index, "{", "}");
|
|
1406
|
+
const start = index;
|
|
1407
|
+
const stack: string[] = [];
|
|
1408
|
+
for (; index < code.length; index += 1) {
|
|
1409
|
+
const char = code[index];
|
|
1410
|
+
if (char in BLOCK_FOLLOWERS) stack.push(BLOCK_FOLLOWERS[char]);
|
|
1411
|
+
else if (BLOCK_CLOSERS.has(char)) {
|
|
1412
|
+
if (stack.at(-1) === char) stack.pop();
|
|
1413
|
+
else if (stack.length === 0) return index;
|
|
1414
|
+
} else if (char === "\n" && stack.length === 0 && isAsiBoundary(code.slice(start, index), code.slice(index + 1))) return index + 1;
|
|
1415
|
+
else if (char === ";" && stack.length === 0) return index + 1;
|
|
1416
|
+
}
|
|
1417
|
+
return -1;
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
const skipStatementLabels = (code: string, index: number) => {
|
|
1421
|
+
index = skipMaskedWhitespace(code, index);
|
|
1422
|
+
let label: RegExpExecArray | null;
|
|
1423
|
+
while ((label = /^([A-Za-z_$][\w$]*)\s*:\s*/.exec(code.slice(index))) && label[1] !== "case" && label[1] !== "default") index += label[0].length;
|
|
1424
|
+
return index;
|
|
1425
|
+
};
|
|
1426
|
+
function findIfStatementEnd(code: string, index: number) {
|
|
1427
|
+
const conditionEnd = findBalancedDelimiterEnd(code, skipMaskedWhitespace(code, index + "if".length), "(", ")");
|
|
1428
|
+
if (conditionEnd === -1) return -1;
|
|
1429
|
+
const consequentEnd = findStatementEnd(code, conditionEnd);
|
|
1430
|
+
if (consequentEnd === -1) return -1;
|
|
1431
|
+
const cursor = skipMaskedWhitespace(code, consequentEnd);
|
|
1432
|
+
if (isTrailingKeywordPrefix(code, cursor, ["else"])) return -1;
|
|
1433
|
+
return hasKeywordAt(code, cursor, "else") ? findStatementEnd(code, cursor + "else".length) : consequentEnd;
|
|
1434
|
+
}
|
|
1435
|
+
function findTryStatementEnd(code: string, index: number) {
|
|
1436
|
+
let cursor = findRequiredBlockEnd(code, index + "try".length);
|
|
1437
|
+
if (cursor === -1) return -1;
|
|
1438
|
+
cursor = skipMaskedWhitespace(code, cursor);
|
|
1439
|
+
if (isTrailingKeywordPrefix(code, cursor, ["catch", "finally"])) return -1;
|
|
1440
|
+
if (hasKeywordAt(code, cursor, "catch")) {
|
|
1441
|
+
cursor = skipMaskedWhitespace(code, cursor + "catch".length);
|
|
1442
|
+
if (code[cursor] === "(") {
|
|
1443
|
+
cursor = findBalancedDelimiterEnd(code, cursor, "(", ")");
|
|
1444
|
+
if (cursor === -1) return -1;
|
|
1445
|
+
}
|
|
1446
|
+
const catchEnd = findRequiredBlockEnd(code, cursor);
|
|
1447
|
+
if (catchEnd === -1) return -1;
|
|
1448
|
+
cursor = skipMaskedWhitespace(code, catchEnd);
|
|
1449
|
+
const tail = code.slice(cursor).trim();
|
|
1450
|
+
if (tail && tail.length < "finally".length && "finally".startsWith(tail)) return -1;
|
|
1451
|
+
if (!hasKeywordAt(code, cursor, "finally")) return catchEnd;
|
|
1452
|
+
cursor += "finally".length;
|
|
1453
|
+
} else if (hasKeywordAt(code, cursor, "finally")) cursor += "finally".length;
|
|
1454
|
+
else return -1;
|
|
1455
|
+
return findRequiredBlockEnd(code, cursor);
|
|
1456
|
+
}
|
|
1457
|
+
function findDoStatementEnd(code: string, index: number) {
|
|
1458
|
+
const bodyEnd = findStatementEnd(code, index + "do".length);
|
|
1459
|
+
if (bodyEnd === -1) return -1;
|
|
1460
|
+
let cursor = skipMaskedWhitespace(code, bodyEnd);
|
|
1461
|
+
if (!hasKeywordAt(code, cursor, "while")) return -1;
|
|
1462
|
+
cursor = skipMaskedWhitespace(code, cursor + "while".length);
|
|
1463
|
+
const conditionEnd = findBalancedDelimiterEnd(code, cursor, "(", ")");
|
|
1464
|
+
if (conditionEnd === -1) return -1;
|
|
1465
|
+
cursor = skipMaskedWhitespace(code, conditionEnd);
|
|
1466
|
+
return code[cursor] === ";" ? cursor + 1 : conditionEnd;
|
|
1467
|
+
}
|
|
1468
|
+
function findHeaderBodyStatementEnd(code: string, index: number, keyword: "for" | "while" | "with") {
|
|
1469
|
+
let cursor = skipMaskedWhitespace(code, index + keyword.length);
|
|
1470
|
+
if (keyword === "for" && hasKeywordAt(code, cursor, "await")) cursor = skipMaskedWhitespace(code, cursor + "await".length);
|
|
1471
|
+
const conditionEnd = findBalancedDelimiterEnd(code, cursor, "(", ")");
|
|
1472
|
+
return conditionEnd === -1 ? -1 : findStatementEnd(code, conditionEnd);
|
|
1473
|
+
}
|
|
1474
|
+
function findSwitchStatementEnd(code: string, index: number) {
|
|
1475
|
+
const conditionEnd = findBalancedDelimiterEnd(code, skipMaskedWhitespace(code, index + "switch".length), "(", ")");
|
|
1476
|
+
return conditionEnd === -1 ? -1 : findRequiredBlockEnd(code, conditionEnd);
|
|
1477
|
+
}
|
|
1478
|
+
function findStatementEnd(code: string, index: number): number {
|
|
1479
|
+
index = skipStatementLabels(code, index);
|
|
1480
|
+
if (hasKeywordAt(code, index, "if")) return findIfStatementEnd(code, index);
|
|
1481
|
+
if (hasKeywordAt(code, index, "try")) return findTryStatementEnd(code, index);
|
|
1482
|
+
if (hasKeywordAt(code, index, "do")) return findDoStatementEnd(code, index);
|
|
1483
|
+
if (hasKeywordAt(code, index, "switch")) return findSwitchStatementEnd(code, index);
|
|
1484
|
+
for (const keyword of ["for", "while", "with"] as const) if (hasKeywordAt(code, index, keyword)) return findHeaderBodyStatementEnd(code, index, keyword);
|
|
1485
|
+
return findSimpleStatementEnd(code, index);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const createControlStatementContextLookup = (scan: StreamingScan) => {
|
|
1489
|
+
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);
|
|
1490
|
+
const active: typeof frames = [];
|
|
1491
|
+
let frameIndex = 0;
|
|
1492
|
+
return (index: number) => {
|
|
1493
|
+
while (frameIndex < frames.length && frames[frameIndex].open < index) {
|
|
1494
|
+
const frame = frames[frameIndex++];
|
|
1495
|
+
while (active.length && (active.at(-1)?.close ?? Number.POSITIVE_INFINITY) <= frame.open) active.pop();
|
|
1496
|
+
active.push(frame);
|
|
1497
|
+
}
|
|
1498
|
+
while (active.length && (active.at(-1)?.close ?? Number.POSITIVE_INFINITY) <= index) active.pop();
|
|
1499
|
+
const container = active.at(-1);
|
|
1500
|
+
return !container || (container.statement && container.kind !== "class" && !container.jsxExpression);
|
|
1501
|
+
};
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
// Optional continuations are the dangerous boundary: `if { … } el`, `try { … } cat`, and `do { … } wh` are each valid-looking
|
|
1505
|
+
// prefixes if their first block is completed in isolation. Keep the whole originating statement pending until its continuation is
|
|
1506
|
+
// either complete or a later token proves that no optional branch follows.
|
|
1507
|
+
const findPendingContinuationStart = (code: string, scan: StreamingScan) => {
|
|
1508
|
+
const masked = maskIgnoredTextForExportScan(code, scan.regexLiteralStarts);
|
|
1509
|
+
const isControlStatementContext = createControlStatementContextLookup(scan);
|
|
1510
|
+
let coveredUntil = -1;
|
|
1511
|
+
for (const match of controlStatementMatches(masked)) {
|
|
1512
|
+
const start = (match.index ?? 0) + match[1].length;
|
|
1513
|
+
if (start < coveredUntil || !isControlStatementContext(start)) continue;
|
|
1514
|
+
const end = findStatementEnd(masked, start);
|
|
1515
|
+
if (end === -1) return start;
|
|
1516
|
+
coveredUntil = end;
|
|
1517
|
+
}
|
|
1518
|
+
return code.length;
|
|
1519
|
+
};
|
|
1520
|
+
|
|
1521
|
+
const trimToCommittedPrefix = (code: string) => {
|
|
1522
|
+
let scan = scanStreamingSource(code);
|
|
1523
|
+
const jsxExpression = scan.stack.find((frame): frame is Extract<CompletionFrame, { type: "block" }> => frame.type === "block" && frame.jsxExpression === true);
|
|
1524
|
+
if (jsxExpression) {
|
|
1525
|
+
code = code.slice(0, jsxExpression.open ?? code.length);
|
|
1526
|
+
// Rolling back the child expression changes the active frames; rescan so its discarded callback/literal cannot invalidate the parent JSX shell.
|
|
1527
|
+
scan = scanStreamingSource(code);
|
|
1528
|
+
}
|
|
1529
|
+
let cut = code.length;
|
|
1530
|
+
if (scan.unclosedLiteralStart !== null) cut = Math.min(cut, scan.unclosedLiteralStatementStart ?? scan.unclosedLiteralStart);
|
|
1531
|
+
const safeJsxShell = isJsxShellPreview(code, scan);
|
|
1532
|
+
const componentArrow = safeJsxShell && (isComponentArrowPrefix(code, code.length) || scan.stack.some((frame) => frame.type === "block" && frame.kind === "arrow" && isComponentArrowFrame(code, frame)));
|
|
1533
|
+
for (const frame of scan.stack) {
|
|
1534
|
+
if (frame.type !== "block" || frame.jsxExpression) continue;
|
|
1535
|
+
if (safeJsxShell && isAllowedJsxShellFrame(code, frame, componentArrow)) continue;
|
|
1536
|
+
if (frame.kind === "function" && isFunctionDeclarationFrame(code, frame)) continue;
|
|
1537
|
+
cut = Math.min(cut, frame.statementStart ?? frame.open ?? code.length);
|
|
1538
|
+
}
|
|
1539
|
+
cut = Math.min(cut, findPendingContinuationStart(code, scan));
|
|
1540
|
+
if (scan.incompleteJsxTagStart !== null && !safeJsxShell) cut = Math.min(cut, scan.scopes.at(-1)?.statementStart ?? scan.incompleteJsxTagStart);
|
|
1541
|
+
if (cut === code.length && !safeJsxShell && scan.incompleteJsxTagStart === null) cut = findTrailingUnsafeStatementStart(code, scan);
|
|
1542
|
+
return { code: code.slice(0, cut), regexLiteralStarts: scan.regexLiteralStarts };
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
export function completePartialTsx(code: string, options: PartialTsxOptions = {}): string {
|
|
1546
|
+
let regexLiteralStarts: ReadonlySet<number> | undefined;
|
|
1547
|
+
if (options.mode === "streaming") {
|
|
1548
|
+
const committed = trimToCommittedPrefix(code);
|
|
1549
|
+
code = committed.code;
|
|
1550
|
+
regexLiteralStarts = committed.regexLiteralStarts;
|
|
1551
|
+
}
|
|
830
1552
|
let output = "";
|
|
831
1553
|
const stack: CompletionFrame[] = [];
|
|
832
1554
|
let index = 0;
|
|
833
|
-
let validEnd = code.length;
|
|
834
1555
|
const push = (value: string) => {
|
|
835
1556
|
output += value;
|
|
836
1557
|
};
|
|
@@ -854,7 +1575,7 @@ export function completePartialTsx(code: string) {
|
|
|
854
1575
|
index = closeIndex + 2;
|
|
855
1576
|
continue;
|
|
856
1577
|
}
|
|
857
|
-
if (!isInsideJsxText(stack) && isLikelyRegexLiteralStart(code, index)) {
|
|
1578
|
+
if (!isInsideJsxText(stack) && (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index))) {
|
|
858
1579
|
index = consumeRegexLiteral(code, index, push);
|
|
859
1580
|
continue;
|
|
860
1581
|
}
|
|
@@ -881,12 +1602,9 @@ export function completePartialTsx(code: string) {
|
|
|
881
1602
|
if (closeIndex === -1) {
|
|
882
1603
|
const completedTag = completeIncompleteJsxTag(code, index);
|
|
883
1604
|
if (completedTag === null) {
|
|
884
|
-
validEnd = index;
|
|
885
|
-
output = output.slice(0, validEnd);
|
|
886
1605
|
break;
|
|
887
1606
|
}
|
|
888
1607
|
push(completedTag.tag);
|
|
889
|
-
index = code.length;
|
|
890
1608
|
if (!completedTag.tag.trimEnd().endsWith("/>") && !VOID_TAGS.has(completedTag.tagName)) stack.push({ type: "jsx", tag: completedTag.tagName });
|
|
891
1609
|
break;
|
|
892
1610
|
}
|
|
@@ -914,19 +1632,24 @@ export function completePartialTsx(code: string) {
|
|
|
914
1632
|
push(char);
|
|
915
1633
|
index += 1;
|
|
916
1634
|
}
|
|
917
|
-
const
|
|
1635
|
+
const lastRegexLiteralStart = regexLiteralStarts ? findLastRegexLiteralStart(regexLiteralStarts, code.length) : undefined;
|
|
1636
|
+
if (isTrailingClosedRegexLiteral(code, code.length, lastRegexLiteralStart) && canCommitBoundary(stack)) output += ";";
|
|
1637
|
+
const closingFrames = stack.toReversed();
|
|
918
1638
|
const closers = closingFrames.map(closeFrame).join("");
|
|
919
|
-
if (!closers) return completeIncompleteExpressionPrefix(output, true);
|
|
1639
|
+
if (!closers) return completeIncompleteExpressionPrefix(output, true, options.mode === "streaming");
|
|
920
1640
|
const statementIndex = closingFrames.findIndex((frame) => frame.type === "block" && frame.statement);
|
|
921
1641
|
if (statementIndex === -1) return closeStackFrames(output, closingFrames);
|
|
922
1642
|
const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex));
|
|
923
|
-
const completedExpression = fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true));
|
|
1643
|
+
const completedExpression = dropTrailingBareLoopHeader(fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true, options.mode === "streaming")));
|
|
924
1644
|
return `${completedExpression}${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`;
|
|
925
1645
|
}
|
|
926
1646
|
|
|
927
|
-
export function normalizeGeneratedTsx(code: string) {
|
|
928
|
-
let next = completePartialTsx(stripMarkdownFence(code)).trim();
|
|
929
|
-
|
|
1647
|
+
export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions = {}) {
|
|
1648
|
+
let next = completePartialTsx(stripMarkdownFence(code), options).trim();
|
|
1649
|
+
let exportScanCode = maskIgnoredTextForExportScan(next);
|
|
1650
|
+
// The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
|
|
1651
|
+
// false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
|
|
1652
|
+
if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
|
|
930
1653
|
if (!hasDefaultExportDeclaration(exportScanCode)) {
|
|
931
1654
|
const componentName = findLastExportableComponent(exportScanCode);
|
|
932
1655
|
if (componentName) next += `\nexport default ${componentName};`;
|