partial-tsx 0.0.0 → 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 +17 -9
- package/src/partial.ts +1619 -0
- package/index.js +0 -1
package/src/partial.ts
ADDED
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
2
|
+
const BLOCK_FOLLOWERS: Record<string, string> = { "{": "}", "(": ")", "[": "]" };
|
|
3
|
+
const BLOCK_CLOSERS = new Set(Object.values(BLOCK_FOLLOWERS));
|
|
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 };
|
|
8
|
+
|
|
9
|
+
const stripMarkdownFence = (code: string) => {
|
|
10
|
+
let next = code.trim();
|
|
11
|
+
next = next.replace(/^```(?:[^\r\n]*)?(?:\r?\n|$)/i, "");
|
|
12
|
+
next = next.replace(/\s*```$/i, "");
|
|
13
|
+
return next;
|
|
14
|
+
};
|
|
15
|
+
const maskExportScanRange = (chars: string[], start: number, end: number) => {
|
|
16
|
+
for (let index = start; index < end; index += 1) if (chars[index] !== "\n") chars[index] = " ";
|
|
17
|
+
};
|
|
18
|
+
const findStringLiteralEnd = (code: string, index: number) => {
|
|
19
|
+
const quote = code[index];
|
|
20
|
+
index += 1;
|
|
21
|
+
while (index < code.length) {
|
|
22
|
+
if (code[index] === "\\") index += 2;
|
|
23
|
+
else {
|
|
24
|
+
if (code[index] === quote) return index + 1;
|
|
25
|
+
index += 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return code.length;
|
|
29
|
+
};
|
|
30
|
+
type TemplateLiteralEnd = { end: number; closed: boolean };
|
|
31
|
+
const scanTemplateLiteralEnd = (code: string, index: number): TemplateLiteralEnd => {
|
|
32
|
+
index += 1;
|
|
33
|
+
while (index < code.length) {
|
|
34
|
+
if (code[index] === "\\") index += 2;
|
|
35
|
+
else if (code[index] === "`") return { end: index + 1, closed: true };
|
|
36
|
+
// A `${ … }` substitution can hold nested template literals (`${`inner`}`); recurse into it so an inner backtick is not
|
|
37
|
+
// mistaken for the outer literal's terminator, which would leave the substitution body unmasked in the export scan. Skip
|
|
38
|
+
// strings/templates inside the substitution so their braces (`${"{"}`) do not throw off the brace-depth tracking.
|
|
39
|
+
else if (code[index] === "$" && code[index + 1] === "{") {
|
|
40
|
+
index += 2;
|
|
41
|
+
let depth = 1;
|
|
42
|
+
for (; index < code.length && depth > 0; index += 1) {
|
|
43
|
+
if (code[index] === "/" && code[index + 1] === "/") {
|
|
44
|
+
const next = code.indexOf("\n", index + 2);
|
|
45
|
+
index = next === -1 ? code.length : next;
|
|
46
|
+
} else if (code[index] === "/" && code[index + 1] === "*") {
|
|
47
|
+
const next = code.indexOf("*/", index + 2);
|
|
48
|
+
index = next === -1 ? code.length : next + 1;
|
|
49
|
+
} else if (isLikelyRegexLiteralStart(code, index)) index = findRegexLiteralEnd(code, index) - 1;
|
|
50
|
+
else if (code[index] === "`") {
|
|
51
|
+
const nested = scanTemplateLiteralEnd(code, index);
|
|
52
|
+
if (!nested.closed) return nested;
|
|
53
|
+
index = nested.end - 1;
|
|
54
|
+
} else if (code[index] === '"' || code[index] === "'") index = findStringLiteralEnd(code, index) - 1;
|
|
55
|
+
else if (code[index] === "{") depth += 1;
|
|
56
|
+
else if (code[index] === "}") depth -= 1;
|
|
57
|
+
}
|
|
58
|
+
if (depth > 0) return { end: code.length, closed: false };
|
|
59
|
+
} else index += 1;
|
|
60
|
+
}
|
|
61
|
+
return { end: code.length, closed: false };
|
|
62
|
+
};
|
|
63
|
+
const findTemplateLiteralEnd = (code: string, index: number) => scanTemplateLiteralEnd(code, index).end;
|
|
64
|
+
const updateJsxStackForTag = (stack: CompletionFrame[], tag: string, open?: number) => {
|
|
65
|
+
const match = /^<\/?\s*([A-Za-z][\w$.-]*)/.exec(tag);
|
|
66
|
+
const tagName = match?.[1] ?? (tag === "<>" || tag === "</>" ? "" : null);
|
|
67
|
+
if (tagName === null) return;
|
|
68
|
+
if (tag.startsWith("</")) {
|
|
69
|
+
const frame = stack.at(-1);
|
|
70
|
+
if (frame?.type === "jsx" && frame.tag === tagName) stack.pop();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!tag.endsWith("/>") && !VOID_TAGS.has(tagName)) stack.push({ type: "jsx", tag: tagName, open });
|
|
74
|
+
};
|
|
75
|
+
const maskIgnoredTextForExportScan = (code: string, regexLiteralStarts?: ReadonlySet<number>) => {
|
|
76
|
+
// split("") keeps code-unit indexing aligned with code[index]; spread would split astral chars (emoji) by code point and desync the two.
|
|
77
|
+
const chars = code.split("");
|
|
78
|
+
const stack: CompletionFrame[] = [];
|
|
79
|
+
for (let index = 0; index < code.length; index += 1) {
|
|
80
|
+
const char = code[index];
|
|
81
|
+
if (isInsideJsxText(stack) && char !== "<" && char !== "{") {
|
|
82
|
+
if (chars[index] !== "\n") chars[index] = " ";
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
86
|
+
const end = code.indexOf("\n", index + 2);
|
|
87
|
+
const next = end === -1 ? code.length : end;
|
|
88
|
+
maskExportScanRange(chars, index, next);
|
|
89
|
+
index = next;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
93
|
+
const close = code.indexOf("*/", index + 2);
|
|
94
|
+
const next = close === -1 ? code.length : close + 2;
|
|
95
|
+
maskExportScanRange(chars, index, next);
|
|
96
|
+
index = next - 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index)) {
|
|
100
|
+
const next = findRegexLiteralEnd(code, index);
|
|
101
|
+
maskExportScanRange(chars, index, next);
|
|
102
|
+
chars[index] = "/"; // preserve the token boundary while hiding keyword/bracket-shaped regex contents
|
|
103
|
+
index = next - 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (char === '"' || char === "'") {
|
|
107
|
+
const next = findStringLiteralEnd(code, index);
|
|
108
|
+
maskExportScanRange(chars, index, next);
|
|
109
|
+
index = next - 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (char === "`") {
|
|
113
|
+
const next = findTemplateLiteralEnd(code, index);
|
|
114
|
+
maskExportScanRange(chars, index, next);
|
|
115
|
+
index = next - 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (char === "<" && shouldStartJsxTag(code, index, stack)) {
|
|
119
|
+
const closeIndex = findJsxTagEnd(code, index);
|
|
120
|
+
if (closeIndex === -1) break;
|
|
121
|
+
const tag = code.slice(index, closeIndex + 1);
|
|
122
|
+
maskExportScanRange(chars, index, closeIndex + 1);
|
|
123
|
+
chars[index] = "<"; // keep the leading `<` so `export default <Card/>` is still detectable after masking
|
|
124
|
+
index = closeIndex;
|
|
125
|
+
updateJsxStackForTag(stack, tag);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (char in BLOCK_FOLLOWERS) {
|
|
129
|
+
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: false });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (BLOCK_CLOSERS.has(char)) {
|
|
133
|
+
const frame = stack.at(-1);
|
|
134
|
+
if (frame?.type === "block" && frame.closer === char) stack.pop();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return chars.join("");
|
|
138
|
+
};
|
|
139
|
+
const findBalancedTypeArgumentsEnd = (code: string, index: number) => {
|
|
140
|
+
if (code[index] !== "<") return index;
|
|
141
|
+
let depth = 0;
|
|
142
|
+
for (; index < code.length; index += 1) {
|
|
143
|
+
const char = code[index];
|
|
144
|
+
if (char === "<") depth += 1;
|
|
145
|
+
else if (char === ">" && code[index - 1] === "=") continue;
|
|
146
|
+
else if (char === ">") {
|
|
147
|
+
depth -= 1;
|
|
148
|
+
if (depth === 0) return index + 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return -1;
|
|
152
|
+
};
|
|
153
|
+
const findWrappedComponentCallOpen = (code: string, index: number) => {
|
|
154
|
+
index = findBalancedTypeArgumentsEnd(code, index);
|
|
155
|
+
if (index === -1) return -1;
|
|
156
|
+
while (/\s/.test(code[index] ?? "")) index += 1;
|
|
157
|
+
return code[index] === "(" ? index : -1;
|
|
158
|
+
};
|
|
159
|
+
const isWrappedImplementationFunction = (code: string, index: number) => /\b(?:memo|forwardRef)\s*(?:<[\s\S]*>)?\s*\($/.test(code.slice(0, index).trimEnd());
|
|
160
|
+
|
|
161
|
+
const findLastExportableComponent = (code: string) => {
|
|
162
|
+
const candidates: Array<{ name: string; index: number }> = [];
|
|
163
|
+
for (const match of code.matchAll(/\bfunction\s+([A-Z][\w$]*)\s*\(/g)) {
|
|
164
|
+
if (!isWrappedImplementationFunction(code, match.index ?? 0)) candidates.push({ name: match[1], index: match.index ?? 0 });
|
|
165
|
+
}
|
|
166
|
+
for (const match of code.matchAll(/\bclass\s+([A-Z][\w$]*)\s+extends\s+(?:(?:React\.)?(?:Pure)?Component|[A-Za-z_$][\w$]*\.Component)\b/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
|
|
167
|
+
for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*=>/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
|
|
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)) {
|
|
169
|
+
if (findWrappedComponentCallOpen(code, (match.index ?? 0) + match[0].length) !== -1) candidates.push({ name: match[1], index: match.index ?? 0 });
|
|
170
|
+
}
|
|
171
|
+
return candidates.toSorted((left, right) => left.index - right.index).at(-1)?.name ?? null;
|
|
172
|
+
};
|
|
173
|
+
// `export default` is also valid mid-line after a `;` or block-closing `}` (`;export default …`, `if(a){} export default …`); match those
|
|
174
|
+
// so the scan does not miss an existing default and append a second one (`Duplicate export of 'default'`).
|
|
175
|
+
const hasDefaultExportDeclaration = (code: string) => /(?:^|[\n;}])\s*export\s+default\s+(?:async\s+)?(?:function|class|[A-Za-z_$]|\(|<)/m.test(code) || /(?:^|[\n;}])\s*export\s*\{[^}]*\b(?:as\s+default|default\s*(?:,|}))[^}]*\}/m.test(code);
|
|
176
|
+
|
|
177
|
+
// React hooks the model commonly forgets to import. Stay conservative: anything outside this set (custom hooks, react-dom hooks like useFormStatus) is left alone.
|
|
178
|
+
const REACT_HOOK_NAMES = new Set([
|
|
179
|
+
"useState",
|
|
180
|
+
"useEffect",
|
|
181
|
+
"useRef",
|
|
182
|
+
"useMemo",
|
|
183
|
+
"useCallback",
|
|
184
|
+
"useReducer",
|
|
185
|
+
"useContext",
|
|
186
|
+
"useLayoutEffect",
|
|
187
|
+
"useInsertionEffect",
|
|
188
|
+
"useTransition",
|
|
189
|
+
"useDeferredValue",
|
|
190
|
+
"useId",
|
|
191
|
+
"useImperativeHandle",
|
|
192
|
+
"useDebugValue",
|
|
193
|
+
"useSyncExternalStore",
|
|
194
|
+
"useActionState",
|
|
195
|
+
"useOptimistic",
|
|
196
|
+
"useEffectEvent",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
const isUnmaskedImportMatch = (code: string, maskedCode: string, match: RegExpExecArray) => {
|
|
200
|
+
const importIndex = (match.index ?? 0) + match[0].indexOf("import");
|
|
201
|
+
return code.slice(importIndex, importIndex + "import".length) === maskedCode.slice(importIndex, importIndex + "import".length);
|
|
202
|
+
};
|
|
203
|
+
const collectImportedBindings = (code: string, maskedCode: string) => {
|
|
204
|
+
const bindings = new Set<string>();
|
|
205
|
+
for (const match of code.matchAll(/(?:^|[;\n])\s*import\s+([\s\S]*?)\s+from\s+["'][^"']*["']/g)) {
|
|
206
|
+
if (!isUnmaskedImportMatch(code, maskedCode, match)) continue;
|
|
207
|
+
const clause = match[1].trim();
|
|
208
|
+
if (!clause.startsWith("{") && !clause.startsWith("*")) {
|
|
209
|
+
const defaultMatch = /^([A-Za-z_$][\w$]*)/.exec(clause);
|
|
210
|
+
if (defaultMatch) bindings.add(defaultMatch[1]);
|
|
211
|
+
}
|
|
212
|
+
const namespaceMatch = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause);
|
|
213
|
+
if (namespaceMatch) bindings.add(namespaceMatch[1]);
|
|
214
|
+
const namedMatch = /\{([\s\S]*?)\}/.exec(clause);
|
|
215
|
+
if (!namedMatch) continue;
|
|
216
|
+
for (const raw of namedMatch[1].split(",")) {
|
|
217
|
+
const item = raw.trim().replace(/^type\s+/, "");
|
|
218
|
+
if (!item) continue;
|
|
219
|
+
const aliased = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)\s*$/.exec(item);
|
|
220
|
+
if (aliased) {
|
|
221
|
+
// Track both the original name and the alias so `useState as useS` blocks us from re-importing `useState`.
|
|
222
|
+
bindings.add(aliased[1]);
|
|
223
|
+
bindings.add(aliased[2]);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const name = /^([A-Za-z_$][\w$]*)/.exec(item)?.[1];
|
|
227
|
+
if (name) bindings.add(name);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return bindings;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const injectMissingReactHookImports = (code: string, maskedCode: string) => {
|
|
234
|
+
if (!/\buse[A-Z]/.test(maskedCode)) return code;
|
|
235
|
+
const bindings = collectImportedBindings(code, maskedCode);
|
|
236
|
+
const missing = new Set<string>();
|
|
237
|
+
for (const match of maskedCode.matchAll(/\b(use[A-Z][\w$]*)\b/g)) {
|
|
238
|
+
const name = match[1];
|
|
239
|
+
if (!REACT_HOOK_NAMES.has(name) || bindings.has(name)) continue;
|
|
240
|
+
if (maskedCode[(match.index ?? 0) - 1] === ".") continue;
|
|
241
|
+
missing.add(name);
|
|
242
|
+
}
|
|
243
|
+
if (missing.size === 0) return code;
|
|
244
|
+
const additions = [...missing].join(", ");
|
|
245
|
+
// Skips `import type { ... } from "react"` so we don't merge value hooks into a type-only import; supports mixed default+named imports like `import React, { useState } from "react"`.
|
|
246
|
+
const existing = /(?:^|[;\n])\s*import\s+(?:[A-Za-z_$][\w$]*\s*,\s*)?\{([^}]*)\}\s+from\s+["']react["']/.exec(code);
|
|
247
|
+
if (existing) {
|
|
248
|
+
if (!isUnmaskedImportMatch(code, maskedCode, existing)) return `import { ${additions} } from "react";\n${code}`;
|
|
249
|
+
const inner = existing[1].trim().replace(/,\s*$/, "");
|
|
250
|
+
const merged = inner ? `${inner}, ${additions}` : additions;
|
|
251
|
+
const rewritten = existing[0].replace(/\{[^}]*\}/, `{ ${merged} }`);
|
|
252
|
+
return `${code.slice(0, existing.index)}${rewritten}${code.slice(existing.index + existing[0].length)}`;
|
|
253
|
+
}
|
|
254
|
+
return `import { ${additions} } from "react";\n${code}`;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const isLikelyJsxTagStart = (code: string, index: number) => {
|
|
258
|
+
const next = code[index + 1] ?? "";
|
|
259
|
+
if (!/[A-Za-z/>]/.test(next)) return false;
|
|
260
|
+
let previous = index - 1;
|
|
261
|
+
while (previous >= 0 && /\s/.test(code[previous])) previous -= 1;
|
|
262
|
+
if (previous < 0) return true;
|
|
263
|
+
const previousChar = code[previous];
|
|
264
|
+
if ("([{=:?!,&|^~*/%+-;".includes(previousChar)) return true;
|
|
265
|
+
if (previousChar === ">" && code[previous - 1] === "=") return true;
|
|
266
|
+
if (/[)\]}"'`]/.test(previousChar)) return false;
|
|
267
|
+
if (/[A-Za-z0-9_$]/.test(previousChar)) {
|
|
268
|
+
let word = "";
|
|
269
|
+
for (let cursor = previous; cursor >= 0 && /[A-Za-z0-9_$]/.test(code[cursor]); cursor -= 1) word = code[cursor] + word;
|
|
270
|
+
return new Set(["return", "throw", "await", "yield", "case", "default", "else", "typeof", "void", "delete", "new"]).has(word);
|
|
271
|
+
}
|
|
272
|
+
return previousChar !== ">";
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const isLikelyStatementBlock = (code: string, index: number) => {
|
|
276
|
+
if (code[index] !== "{") return false;
|
|
277
|
+
const before = code.slice(0, index).trimEnd();
|
|
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
|
+
const findJsxTagEnd = (code: string, start: number) => {
|
|
282
|
+
const stack: string[] = [];
|
|
283
|
+
let quote: string | null = null;
|
|
284
|
+
for (let index = start + 1; index < code.length; index += 1) {
|
|
285
|
+
const char = code[index];
|
|
286
|
+
if (quote) {
|
|
287
|
+
if (char === "\\") index += 1;
|
|
288
|
+
else if (char === quote) quote = null;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
292
|
+
quote = char;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (char in BLOCK_FOLLOWERS) {
|
|
296
|
+
stack.push(BLOCK_FOLLOWERS[char]);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (BLOCK_CLOSERS.has(char)) {
|
|
300
|
+
if (stack.at(-1) === char) stack.pop();
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (char === ">" && stack.length === 0) return index;
|
|
304
|
+
}
|
|
305
|
+
return -1;
|
|
306
|
+
};
|
|
307
|
+
const findJsxAttributeExpressionEnd = (code: string, index: number) => {
|
|
308
|
+
let depth = 1;
|
|
309
|
+
while (index < code.length) {
|
|
310
|
+
if (code[index] === "/" && code[index + 1] === "/") {
|
|
311
|
+
const end = code.indexOf("\n", index + 2);
|
|
312
|
+
index = end === -1 ? code.length : end;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (code[index] === "/" && code[index + 1] === "*") {
|
|
316
|
+
const close = code.indexOf("*/", index + 2);
|
|
317
|
+
index = close === -1 ? code.length : close + 2;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (isLikelyRegexLiteralStart(code, index)) {
|
|
321
|
+
index = findRegexLiteralEnd(code, index);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (code[index] === '"' || code[index] === "'") {
|
|
325
|
+
index = findStringLiteralEnd(code, index);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (code[index] === "`") {
|
|
329
|
+
index = findTemplateLiteralEnd(code, index);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (code[index] === "{") depth += 1;
|
|
333
|
+
else if (code[index] === "}") {
|
|
334
|
+
depth -= 1;
|
|
335
|
+
if (depth === 0) return index;
|
|
336
|
+
}
|
|
337
|
+
index += 1;
|
|
338
|
+
}
|
|
339
|
+
return -1;
|
|
340
|
+
};
|
|
341
|
+
const completeIncompleteJsxTag = (code: string, start: number) => {
|
|
342
|
+
if (code[start + 1] === "/") return null;
|
|
343
|
+
const tagMatch = /^<\s*([A-Za-z][\w$.-]*)/.exec(code.slice(start));
|
|
344
|
+
if (!tagMatch) return null;
|
|
345
|
+
const [matched, tagName] = tagMatch;
|
|
346
|
+
// Tag name may still be streaming (`inp` may become `input`, `Sur` may become `Surface`); completing now would render the wrong element or throw `Sur is not defined` and remount. Wait for a separator (space/`>`/attr).
|
|
347
|
+
if (start + matched.length >= code.length) return null;
|
|
348
|
+
let index = start;
|
|
349
|
+
let tag = "";
|
|
350
|
+
while (index < code.length) {
|
|
351
|
+
const char = code[index];
|
|
352
|
+
if (char === "`") {
|
|
353
|
+
// A backtick is illegal as a JSX attribute value (`title=`x`` → "JSX value should be either an expression or a quoted JSX text"); template literals are only valid inside `{...}`. Drop the attribute.
|
|
354
|
+
tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
|
|
355
|
+
index = findTemplateLiteralEnd(code, index);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (char === '"' || char === "'") {
|
|
359
|
+
const end = findStringLiteralEnd(code, index);
|
|
360
|
+
let literal = code.slice(index, end);
|
|
361
|
+
const closed = literal.length > 1 && literal.endsWith(char);
|
|
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.
|
|
363
|
+
if (!closed) {
|
|
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.
|
|
365
|
+
if ((/\\*$/.exec(literal)?.[0].length ?? 0) % 2) literal = literal.slice(0, -1);
|
|
366
|
+
literal += char;
|
|
367
|
+
}
|
|
368
|
+
tag += literal;
|
|
369
|
+
index = end;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (char === "{") {
|
|
373
|
+
const expressionEnd = findJsxAttributeExpressionEnd(code, index + 1);
|
|
374
|
+
// An unterminated expression value must not be completed: mid-stream `{x` may still become `x.y`, `xyz`, or `x + 1`, so synthesizing `}` binds the frame to the wrong (or undefined) value and can throw. Drop the attribute, keep the tag. Host and component alike.
|
|
375
|
+
if (expressionEnd === -1) {
|
|
376
|
+
tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
|
|
377
|
+
index = code.length;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const expression = code.slice(index + 1, expressionEnd);
|
|
381
|
+
const completedExpression = completeIncompleteExpressionPrefix(completePartialTsx(expression), true);
|
|
382
|
+
if (!expression.trim() || !completedExpression.trim() || /^(?:const|let|var)\b/.test(completedExpression.trim())) {
|
|
383
|
+
tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
|
|
384
|
+
index = expressionEnd + 1;
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
tag += `{${completedExpression}}`;
|
|
388
|
+
index = expressionEnd + 1;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
tag += char;
|
|
392
|
+
index += 1;
|
|
393
|
+
if (char === ">") return { tag: VOID_TAGS.has(tagName) ? tag.replace(/\s*\/?\s*>$/, " />") : tag, tagName };
|
|
394
|
+
}
|
|
395
|
+
// A dangling `prop=` would close as `prop=>` (SWC reads `=>` as an arrow function), breaking the whole module.
|
|
396
|
+
if (/=\s*$/.test(tag)) tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
|
|
397
|
+
// Otherwise a trailing name with no following space is still streaming (`valu` may become `value`); a trailing space commits it as a boolean attr.
|
|
398
|
+
else if (!/\s$/.test(tag)) tag = tag.replace(/\s+[A-Za-z_$][\w$:-]*$/, "");
|
|
399
|
+
const completedTag = VOID_TAGS.has(tagName) ? `${tag.replace(/\s*\/\s*$/, "")} />` : `${tag}>`;
|
|
400
|
+
return { tag: completedTag, tagName };
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
type PushOutput = (value: string) => void;
|
|
404
|
+
|
|
405
|
+
const consumeStringLiteral = (code: string, index: number, push: PushOutput) => {
|
|
406
|
+
const quote = code[index];
|
|
407
|
+
push(quote);
|
|
408
|
+
index += 1;
|
|
409
|
+
let closed = false;
|
|
410
|
+
while (index < code.length) {
|
|
411
|
+
const next = code[index];
|
|
412
|
+
if (next === "\\") {
|
|
413
|
+
push(code.slice(index, index + 2));
|
|
414
|
+
index += 2;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
push(next);
|
|
418
|
+
index += 1;
|
|
419
|
+
if (next === quote) {
|
|
420
|
+
closed = true;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (!closed) push(quote);
|
|
425
|
+
return index;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const closeFrame = (frame: CompletionFrame) => {
|
|
429
|
+
if (frame.type === "jsx") return frame.tag ? `</${frame.tag}>` : "</>";
|
|
430
|
+
return frame.closer;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const isExpressionEnd = (value: string) => /(?:<\/[\w$.-]+>|<\/>|\/>|[>`'")\]\w])$/.test(value.trimEnd());
|
|
434
|
+
const collectCompletionStackBefore = (code: string, end: number) => {
|
|
435
|
+
const stack: CompletionFrame[] = [];
|
|
436
|
+
for (let index = 0; index < end; index += 1) {
|
|
437
|
+
const char = code[index];
|
|
438
|
+
if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "/") {
|
|
439
|
+
const newlineIndex = code.indexOf("\n", index + 2);
|
|
440
|
+
index = (newlineIndex === -1 ? end : Math.min(newlineIndex, end)) - 1;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "*") {
|
|
444
|
+
const closeIndex = code.indexOf("*/", index + 2);
|
|
445
|
+
index = (closeIndex === -1 ? end : Math.min(closeIndex + 2, end)) - 1;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (!isInsideJsxText(stack) && isLikelyRegexLiteralStart(code, index)) {
|
|
449
|
+
index = Math.min(findRegexLiteralEnd(code, index), end) - 1;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (!isInsideJsxText(stack) && (char === '"' || char === "'")) {
|
|
453
|
+
index = Math.min(findStringLiteralEnd(code, index), end) - 1;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (!isInsideJsxText(stack) && char === "`") {
|
|
457
|
+
index = Math.min(findTemplateLiteralEnd(code, index), end) - 1;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (char === "<" && shouldStartJsxTag(code, index, stack)) {
|
|
461
|
+
const closeIndex = findJsxTagEnd(code, index);
|
|
462
|
+
if (closeIndex === -1 || closeIndex >= end) break;
|
|
463
|
+
updateJsxStackForTag(stack, code.slice(index, closeIndex + 1));
|
|
464
|
+
index = closeIndex;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (char in BLOCK_FOLLOWERS) stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index) });
|
|
468
|
+
else if (BLOCK_CLOSERS.has(char)) {
|
|
469
|
+
const frame = stack.at(-1);
|
|
470
|
+
if (frame?.type === "block" && frame.closer === char) stack.pop();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return stack;
|
|
474
|
+
};
|
|
475
|
+
const endsWithJsxTagHead = (value: string) => {
|
|
476
|
+
const lastOpen = value.lastIndexOf("<");
|
|
477
|
+
if (lastOpen === -1 || !shouldStartJsxTag(value, lastOpen, collectCompletionStackBefore(value, lastOpen))) return false;
|
|
478
|
+
const tail = value.slice(lastOpen);
|
|
479
|
+
return /^<\/?(?:[A-Za-z][\w$.-]*|$)/.test(tail) && findJsxTagEnd(value, lastOpen) === -1;
|
|
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
|
+
};
|
|
486
|
+
const hasCompleteRegexLiteralEndingAt = (value: string, endIndex: number) => {
|
|
487
|
+
for (let index = 0; index <= endIndex; index += 1) {
|
|
488
|
+
if (value[index] !== "/" || !isLikelyRegexLiteralStart(value, index)) continue;
|
|
489
|
+
const end = findRegexLiteralEnd(value, index);
|
|
490
|
+
if (end === endIndex + 1) return true;
|
|
491
|
+
if (end > index) index = end - 1;
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
};
|
|
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));
|
|
504
|
+
if (!match) return value;
|
|
505
|
+
const operator = match[1];
|
|
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();
|
|
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;
|
|
513
|
+
if (operator === "/" && hasCompleteRegexLiteralEndingAt(value, operatorIndex)) return value;
|
|
514
|
+
if (operator === ">" && /\/\s*$/.test(before)) return value;
|
|
515
|
+
if ((operator === ">" || operator === ">=") && endsWithJsxTagHead(before)) return value;
|
|
516
|
+
return before;
|
|
517
|
+
};
|
|
518
|
+
const dropTrailingIncompleteExpressionOperator = (value: string, preservePostfixUpdate = false) => dropTrailingOperator(value, /\s*(>>>=?|>>=?|<<=?|===|!==|=>|\*\*=|\?\?=|&&=|\|\|=|[+\-*/%&|^]=|\?\?|\?\.|\*\*|[=!<>]=?|&&|\|\||[+\-*/%&|^~]|(?<![.\d])\.)\s*$/s, preservePostfixUpdate);
|
|
519
|
+
// Blanks out string/template/regex/comment spans (but not JSX) so angle-bracket counting in `dropTrailingIncompleteStatement`
|
|
520
|
+
// only sees real `<`/`>` operators and type arguments, never a literal `<` (`"a<b"`) or a `</div>` closer.
|
|
521
|
+
const maskLiteralSpans = (body: string) => {
|
|
522
|
+
let out = "";
|
|
523
|
+
let index = 0;
|
|
524
|
+
while (index < body.length) {
|
|
525
|
+
const char = body[index];
|
|
526
|
+
if (char === "/" && body[index + 1] === "/") {
|
|
527
|
+
const end = body.indexOf("\n", index + 2);
|
|
528
|
+
const stop = end === -1 ? body.length : end;
|
|
529
|
+
out += " ".repeat(stop - index);
|
|
530
|
+
index = stop;
|
|
531
|
+
} else if (char === "/" && body[index + 1] === "*") {
|
|
532
|
+
const end = body.indexOf("*/", index + 2);
|
|
533
|
+
const stop = end === -1 ? body.length : end + 2;
|
|
534
|
+
out += " ".repeat(stop - index);
|
|
535
|
+
index = stop;
|
|
536
|
+
} else if (char === '"' || char === "'") {
|
|
537
|
+
const stop = findStringLiteralEnd(body, index);
|
|
538
|
+
out += " ".repeat(stop - index);
|
|
539
|
+
index = stop;
|
|
540
|
+
} else if (char === "`") {
|
|
541
|
+
const stop = findTemplateLiteralEnd(body, index);
|
|
542
|
+
out += " ".repeat(stop - index);
|
|
543
|
+
index = stop;
|
|
544
|
+
} else if (isLikelyRegexLiteralStart(body, index)) {
|
|
545
|
+
const stop = findRegexLiteralEnd(body, index);
|
|
546
|
+
out += " ".repeat(stop - index);
|
|
547
|
+
index = stop;
|
|
548
|
+
} else {
|
|
549
|
+
out += char;
|
|
550
|
+
index += 1;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return out;
|
|
554
|
+
};
|
|
555
|
+
// Blank out operators that share the `<` glyph so the angle-bracket counter never mistakes them for type brackets: `=>` (arrow,
|
|
556
|
+
// whose `>` would otherwise close a generic early in `useCallback<(x) => void`), `<<`/`<<=` (left-shift, whose `<`s would read as
|
|
557
|
+
// phantom openers in `1 << 2`), and `<=` (a `<` that can't open a generic, sparing `a <= b`). `>>`/`>>>`/`>=` are left alone so
|
|
558
|
+
// nested generic closers (`Map<K, Array<V>>`) still count.
|
|
559
|
+
const neutralizeAngleOperators = (masked: string) => masked.replace(/=>|<<=?|<=/g, (op) => " ".repeat(op.length));
|
|
560
|
+
// True when the masked body has a `<` that never closes AND sits in value position (after an identifier / `)` / `]`) with no
|
|
561
|
+
// bracket past it that would close out the surrounding expression — i.e. a type-argument list whose `>` hasn't streamed yet
|
|
562
|
+
// (`useState<number | null`). Excludes a `<` right after `=` (a generic-arrow `<T,>(x)=>x`, completed elsewhere) and any `<`
|
|
563
|
+
// followed by a `)`/`]`/`}` that ends the initializer (`items.filter((i) => i < 3)` — a real comparison, already terminated).
|
|
564
|
+
const hasUnclosedTypeArgument = (body: string) => {
|
|
565
|
+
const masked = neutralizeAngleOperators(body);
|
|
566
|
+
let depth = 0;
|
|
567
|
+
let firstUnclosed = -1;
|
|
568
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
569
|
+
const char = masked[index];
|
|
570
|
+
if (char === "<") {
|
|
571
|
+
if (depth === 0) firstUnclosed = index;
|
|
572
|
+
depth += 1;
|
|
573
|
+
} else if (char === ">") {
|
|
574
|
+
if (depth > 0) depth -= 1;
|
|
575
|
+
if (depth === 0) firstUnclosed = -1;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (firstUnclosed < 0) return false;
|
|
579
|
+
if (!/[\w$)\]]$/.test(masked.slice(0, firstUnclosed).trimEnd())) return false;
|
|
580
|
+
let bracketDepth = 0;
|
|
581
|
+
for (let index = firstUnclosed + 1; index < masked.length; index += 1) {
|
|
582
|
+
const char = masked[index];
|
|
583
|
+
if (char === "(" || char === "[" || char === "{") bracketDepth += 1;
|
|
584
|
+
else if (char === ")" || char === "]" || char === "}") {
|
|
585
|
+
bracketDepth -= 1;
|
|
586
|
+
if (bracketDepth < 0) return false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return true;
|
|
590
|
+
};
|
|
591
|
+
// Locate the last `const`/`let`/`var` whose declaration runs to EOF without a depth-0 `;` terminator, scanning the
|
|
592
|
+
// literal-masked text so a `;`/`{`/`<` sitting inside a string or comment (`const s = "a; const x = useState<T"`) is never
|
|
593
|
+
// mistaken for a statement boundary. A declaration can begin at the buffer start or right after `{`, `}`, `;`, or `=>`; we keep
|
|
594
|
+
// the latest one that isn't closed by a depth-0 `;` or by a `)`/`]`/`}` that pops out of its enclosing block.
|
|
595
|
+
const locateTrailingDeclaration = (value: string) => {
|
|
596
|
+
const masked = maskLiteralSpans(value);
|
|
597
|
+
const re = /(?:^|[{};\n]|=>)\s*(const(?!\s+enum\b)|let|var)\b/g;
|
|
598
|
+
let match: RegExpExecArray | null;
|
|
599
|
+
let start = -1;
|
|
600
|
+
let kind = "";
|
|
601
|
+
while ((match = re.exec(masked))) {
|
|
602
|
+
const declarationStart = match.index + match[0].length - match[1].length;
|
|
603
|
+
let depth = 0;
|
|
604
|
+
let terminated = false;
|
|
605
|
+
for (let index = declarationStart + match[1].length; index < masked.length; index += 1) {
|
|
606
|
+
const char = masked[index];
|
|
607
|
+
if (char === "(" || char === "[" || char === "{") depth += 1;
|
|
608
|
+
else if (char === ")" || char === "]" || char === "}") {
|
|
609
|
+
if (depth === 0) {
|
|
610
|
+
terminated = true;
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
depth -= 1;
|
|
614
|
+
} else if (char === ";" && depth === 0) {
|
|
615
|
+
terminated = true;
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (!terminated) {
|
|
620
|
+
start = declarationStart;
|
|
621
|
+
kind = match[1];
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return start === -1 ? null : { start, kind, body: value.slice(start + kind.length) };
|
|
625
|
+
};
|
|
626
|
+
const dropTrailingIncompleteStatement = (value: string) => {
|
|
627
|
+
const declaration = locateTrailingDeclaration(value);
|
|
628
|
+
if (!declaration) return value;
|
|
629
|
+
const { start, kind, body } = declaration;
|
|
630
|
+
// Net bracket depth of the initializer: a trailing `=` only truncates the declaration's own initializer when we're
|
|
631
|
+
// back at top level. Inside an already-open arrow/function body (`() => { ...; x =`) the `=` belongs to a nested
|
|
632
|
+
// statement the block-frame completer will close, so rolling back the whole declaration would over-strip and over-close.
|
|
633
|
+
let initializerDepth = 0;
|
|
634
|
+
for (const char of maskLiteralSpans(body)) {
|
|
635
|
+
if (char === "(" || char === "[" || char === "{") initializerDepth += 1;
|
|
636
|
+
else if (char === ")" || char === "]" || char === "}") initializerDepth -= 1;
|
|
637
|
+
}
|
|
638
|
+
const incompleteInitializer = initializerDepth <= 0 && /=\s*$/.test(body);
|
|
639
|
+
// An initializer truncated mid-generic (`useState<number | null`) is valid-looking JS where SWC reads `<` as a comparison
|
|
640
|
+
// and keeps `number` as a bare value reference, crashing at runtime; drop the statement until its `>` closer streams in.
|
|
641
|
+
const unclosedGenericInitializer = body.includes("=") && hasUnclosedTypeArgument(maskLiteralSpans(body));
|
|
642
|
+
const missingDeclaration = !body.trim() || (!body.includes("=") && (kind === "const" || /^[\s,]*(?:\[|\{)/.test(body)));
|
|
643
|
+
if (!incompleteInitializer && !missingDeclaration && !unclosedGenericInitializer) return value;
|
|
644
|
+
return value.slice(0, start);
|
|
645
|
+
};
|
|
646
|
+
const completeTrailingIncompleteTernary = (value: string) => {
|
|
647
|
+
const masked = maskIgnoredTextForExportScan(value);
|
|
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`;
|
|
651
|
+
const match = /(?<!\?)\?(?![.?])(?<tail>[^?:;{}]*)$/s.exec(masked);
|
|
652
|
+
if (!match?.groups) return value;
|
|
653
|
+
return value.slice(match.index + 1).trim() ? `${value} : null` : `${value} null : null`;
|
|
654
|
+
};
|
|
655
|
+
// A second `function` declaration that has only streamed up to its keyword/name (no parameter list yet) leaves a dangling
|
|
656
|
+
// `function`/`function Name` that SWC rejects with `Expected ident`/`Expected '('`, blanking the already-rendered prior
|
|
657
|
+
// declaration. Drop it back to the preceding complete statement until its `(` arrives.
|
|
658
|
+
const dropTrailingIncompleteFunctionDeclaration = (value: string) => {
|
|
659
|
+
const match = /(?:^|[}\n;])\s*((?:export\s+(?:default\s+)?)?(?:async\s+)?function\b\s*\*?\s*[A-Za-z_$]?[\w$]*\s*)$/.exec(value);
|
|
660
|
+
return match ? value.slice(0, match.index + (match[0].length - match[1].length)) : value;
|
|
661
|
+
};
|
|
662
|
+
const completeIncompleteExpressionPrefix = (value: string, dropStatement = false, preservePostfixUpdate = false) =>
|
|
663
|
+
completeTrailingIncompleteTernary(dropTrailingIncompleteExpressionOperator(dropStatement ? dropTrailingIncompleteFunctionDeclaration(dropTrailingIncompleteStatement(value)) : value, preservePostfixUpdate));
|
|
664
|
+
const fillEmptyReturnExpression = (value: string) => {
|
|
665
|
+
if (/\breturn\s*$/.test(value)) return `${value} null`;
|
|
666
|
+
return value.replace(/(\breturn\s*\(\s*)\)$/s, "$1null)");
|
|
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
|
+
};
|
|
712
|
+
// A non-statement bracket frame gets its expression prefix completed (and possibly dropped). Completing can shorten the output
|
|
713
|
+
// two different ways: a trailing operator/dot trim (the frame's own `{`/`[` survives, so we still close it) or a whole-declaration
|
|
714
|
+
// rollback that removes the frame's own opening bracket plus every nested opener — e.g. `useState<{ nested: { count` or an array
|
|
715
|
+
// initializer `[{ foo: useState<number`. We tell them apart with `frame.open` (the opener's offset): if the completed text no
|
|
716
|
+
// longer reaches that offset, the opener is gone, so we emit no closer and report `dropped` for the caller to skip orphaned
|
|
717
|
+
// inner frames whose openers were inside the rolled-back span.
|
|
718
|
+
const completeBlockFrame = (output: string, frame: Extract<CompletionFrame, { type: "block" }>) => {
|
|
719
|
+
if (frame.statement) return { text: `${output}${frame.closer}`, dropped: false };
|
|
720
|
+
const completed = completeIncompleteExpressionPrefix(output, true);
|
|
721
|
+
const openerGone = frame.open !== undefined ? completed.length <= frame.open : completed.length < output.length;
|
|
722
|
+
if (openerGone) return { text: completed, dropped: true };
|
|
723
|
+
return { text: `${completed}${frame.closer}`, dropped: false };
|
|
724
|
+
};
|
|
725
|
+
// An empty `(` is a legal empty argument list only when it directly follows a callee; as a grouping or
|
|
726
|
+
// arrow body it must hold an expression, so fill `null` rather than closing into an illegal empty `()`.
|
|
727
|
+
const isCallArgumentParen = (value: string) => /(?:[\w$)\]]|\?\.)$/.test(value.slice(0, -1).trimEnd());
|
|
728
|
+
const completeStatementPrefixFrame = (output: string, frame: CompletionFrame): { text: string; dropped: boolean } => {
|
|
729
|
+
if (frame.type !== "block") return { text: `${output}${closeFrame(frame)}`, dropped: false };
|
|
730
|
+
if (frame.closer === ")") {
|
|
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 };
|
|
735
|
+
return { text: `${completed.endsWith("(") && !isCallArgumentParen(completed) ? `${completed}null` : completed}${frame.closer}`, dropped: false };
|
|
736
|
+
}
|
|
737
|
+
return completeBlockFrame(output, frame);
|
|
738
|
+
};
|
|
739
|
+
// Close frames from the stack top down. A frame whose own opening bracket was rolled back by a declaration drop must not emit a
|
|
740
|
+
// closer — `completeBlockFrame` detects that and reports `open: number` for the bracket position it would have closed, so once
|
|
741
|
+
// a drop happens we know which later frames lost their opener (those whose recorded bracket offset is past the drop point).
|
|
742
|
+
const closeStackFrames = (output: string, frames: CompletionFrame[]) => {
|
|
743
|
+
let text = output;
|
|
744
|
+
let dropPoint = Infinity;
|
|
745
|
+
for (const frame of frames) {
|
|
746
|
+
if (frame.type === "block" && frame.open !== undefined && frame.open >= dropPoint) continue;
|
|
747
|
+
const result = completeStatementPrefixFrame(text, frame);
|
|
748
|
+
if (result.dropped) dropPoint = Math.min(dropPoint, result.text.length);
|
|
749
|
+
text = result.text;
|
|
750
|
+
}
|
|
751
|
+
return text;
|
|
752
|
+
};
|
|
753
|
+
const REGEX_PREFIX_WORDS = new Set(["return", "throw", "case", "delete", "typeof", "void", "new", "in", "of", "await", "yield"]);
|
|
754
|
+
const JSX_CLOSING_TAG_START = /^<\/\s*(?:[A-Za-z][\w$.-]*\s*)?>/;
|
|
755
|
+
|
|
756
|
+
function isLikelyRegexLiteralStart(code: string, index: number) {
|
|
757
|
+
if (code[index] !== "/" || code[index + 1] === "/" || code[index + 1] === "*") return false;
|
|
758
|
+
if (code[index - 1] === "<" && JSX_CLOSING_TAG_START.test(code.slice(index - 1))) return false;
|
|
759
|
+
let previous = index - 1;
|
|
760
|
+
while (previous >= 0 && /\s/.test(code[previous])) previous -= 1;
|
|
761
|
+
if (previous < 0) return true;
|
|
762
|
+
const previousChar = code[previous];
|
|
763
|
+
if ("([{=,:;!&|?+-*~^<>".includes(previousChar)) return true;
|
|
764
|
+
if (!/[A-Za-z_$]/.test(previousChar)) return false;
|
|
765
|
+
let word = "";
|
|
766
|
+
for (let cursor = previous; cursor >= 0 && /[A-Za-z0-9_$]/.test(code[cursor]); cursor -= 1) word = code[cursor] + word;
|
|
767
|
+
return REGEX_PREFIX_WORDS.has(word);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function findRegexLiteralEnd(code: string, index: number) {
|
|
771
|
+
index += 1;
|
|
772
|
+
let inClass = false;
|
|
773
|
+
while (index < code.length) {
|
|
774
|
+
const next = code[index];
|
|
775
|
+
if (next === "\\") {
|
|
776
|
+
index += 2;
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
index += 1;
|
|
780
|
+
if (next === "[" && !inClass) inClass = true;
|
|
781
|
+
else if (next === "]" && inClass) inClass = false;
|
|
782
|
+
else if (next === "/" && !inClass) {
|
|
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
|
+
}
|
|
789
|
+
return index;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return code.length;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const consumeRegexLiteral = (code: string, index: number, push: PushOutput) => {
|
|
796
|
+
push("/");
|
|
797
|
+
index += 1;
|
|
798
|
+
let inClass = false;
|
|
799
|
+
let closed = false;
|
|
800
|
+
while (index < code.length) {
|
|
801
|
+
const next = code[index];
|
|
802
|
+
if (next === "\\") {
|
|
803
|
+
push(code.slice(index, index + 2));
|
|
804
|
+
index += 2;
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
push(next);
|
|
808
|
+
index += 1;
|
|
809
|
+
if (next === "[" && !inClass) inClass = true;
|
|
810
|
+
else if (next === "]" && inClass) inClass = false;
|
|
811
|
+
else if (next === "/" && !inClass) {
|
|
812
|
+
closed = true;
|
|
813
|
+
break;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
while (closed && index < code.length && /[A-Za-z]/.test(code[index])) {
|
|
817
|
+
push(code[index]);
|
|
818
|
+
index += 1;
|
|
819
|
+
}
|
|
820
|
+
if (!closed) push("/");
|
|
821
|
+
return index;
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const isJsxTagHead = (code: string, index: number) => {
|
|
825
|
+
const tail = code.slice(index);
|
|
826
|
+
if (tail.startsWith("<>") || tail.startsWith("</>") || tail === "</") return true;
|
|
827
|
+
if (tail.startsWith("</")) return /^<\/\s*[A-Za-z][\w$.-]*(?:\s*>|>|$)/.test(tail);
|
|
828
|
+
const match = /^<[A-Za-z][\w$.-]*/.exec(tail);
|
|
829
|
+
return match !== null && /^(?:$|[\s/>])/.test(tail.slice(match[0].length));
|
|
830
|
+
};
|
|
831
|
+
|
|
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));
|
|
837
|
+
const shouldStartJsxTag = (code: string, index: number, stack: CompletionFrame[]) => {
|
|
838
|
+
if (isGenericTypeParamList(code, index)) return false;
|
|
839
|
+
if (stack.at(-1)?.type === "jsx") return isJsxTagHead(code, index);
|
|
840
|
+
return isLikelyJsxTagStart(code, index);
|
|
841
|
+
};
|
|
842
|
+
const isInsideJsxText = (stack: CompletionFrame[]) => stack.at(-1)?.type === "jsx";
|
|
843
|
+
|
|
844
|
+
const consumeTemplateExpression = (code: string, index: number, push: PushOutput): number => {
|
|
845
|
+
let depth = 1;
|
|
846
|
+
const startIndex = index;
|
|
847
|
+
while (index < code.length && depth > 0) {
|
|
848
|
+
const expressionChar = code[index];
|
|
849
|
+
if (expressionChar === "/" && code[index + 1] === "/") {
|
|
850
|
+
const newlineIndex = code.indexOf("\n", index + 2);
|
|
851
|
+
const end = newlineIndex === -1 ? code.length : newlineIndex;
|
|
852
|
+
push(code.slice(index, end));
|
|
853
|
+
index = end;
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (expressionChar === "/" && code[index + 1] === "*") {
|
|
857
|
+
const closeIndex = code.indexOf("*/", index + 2);
|
|
858
|
+
if (closeIndex === -1) {
|
|
859
|
+
push(`${code.slice(index)}*/`);
|
|
860
|
+
return code.length;
|
|
861
|
+
}
|
|
862
|
+
push(code.slice(index, closeIndex + 2));
|
|
863
|
+
index = closeIndex + 2;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (isLikelyRegexLiteralStart(code, index)) {
|
|
867
|
+
index = consumeRegexLiteral(code, index, push);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (expressionChar === '"' || expressionChar === "'") {
|
|
871
|
+
index = consumeStringLiteral(code, index, push);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (expressionChar === "`") {
|
|
875
|
+
index = consumeTemplateLiteral(code, index, push);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
push(expressionChar);
|
|
879
|
+
index += 1;
|
|
880
|
+
if (expressionChar === "{") depth += 1;
|
|
881
|
+
else if (expressionChar === "}") depth -= 1;
|
|
882
|
+
}
|
|
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));
|
|
886
|
+
return index;
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
const consumeTemplateLiteral = (code: string, index: number, push: PushOutput): number => {
|
|
890
|
+
push("`");
|
|
891
|
+
index += 1;
|
|
892
|
+
let closed = false;
|
|
893
|
+
while (index < code.length) {
|
|
894
|
+
const next = code[index];
|
|
895
|
+
if (next === "\\") {
|
|
896
|
+
push(code.slice(index, index + 2));
|
|
897
|
+
index += 2;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (next === "$" && code[index + 1] === "{") {
|
|
901
|
+
push("${");
|
|
902
|
+
index = consumeTemplateExpression(code, index + 2, push);
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
push(next);
|
|
906
|
+
index += 1;
|
|
907
|
+
if (next === "`") {
|
|
908
|
+
closed = true;
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (!closed) push("`");
|
|
913
|
+
return index;
|
|
914
|
+
};
|
|
915
|
+
|
|
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
|
+
}
|
|
1512
|
+
let output = "";
|
|
1513
|
+
const stack: CompletionFrame[] = [];
|
|
1514
|
+
let index = 0;
|
|
1515
|
+
const push = (value: string) => {
|
|
1516
|
+
output += value;
|
|
1517
|
+
};
|
|
1518
|
+
while (index < code.length) {
|
|
1519
|
+
const char = code[index];
|
|
1520
|
+
if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "/") {
|
|
1521
|
+
const newlineIndex = code.indexOf("\n", index + 2);
|
|
1522
|
+
const end = newlineIndex === -1 ? code.length : newlineIndex;
|
|
1523
|
+
push(code.slice(index, end));
|
|
1524
|
+
index = end;
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
if (!isInsideJsxText(stack) && char === "/" && code[index + 1] === "*") {
|
|
1528
|
+
const closeIndex = code.indexOf("*/", index + 2);
|
|
1529
|
+
if (closeIndex === -1) {
|
|
1530
|
+
push(`${code.slice(index)}*/`);
|
|
1531
|
+
index = code.length;
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
push(code.slice(index, closeIndex + 2));
|
|
1535
|
+
index = closeIndex + 2;
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
if (!isInsideJsxText(stack) && (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index))) {
|
|
1539
|
+
index = consumeRegexLiteral(code, index, push);
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
if (!isInsideJsxText(stack) && (char === '"' || char === "'")) {
|
|
1543
|
+
index = consumeStringLiteral(code, index, push);
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
if (!isInsideJsxText(stack) && char === "`") {
|
|
1547
|
+
index = consumeTemplateLiteral(code, index, push);
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
if (char === "<" && isInsideJsxText(stack) && !shouldStartJsxTag(code, index, stack) && index < code.length - 1) {
|
|
1551
|
+
push("<");
|
|
1552
|
+
index += 1;
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (char === "<" && !shouldStartJsxTag(code, index, stack) && index === code.length - 1) {
|
|
1556
|
+
const frame = stack.at(-1);
|
|
1557
|
+
if (frame?.type === "block" && frame.closer === "}" && !frame.statement) output = output.trimEnd();
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
if (char === "<" && shouldStartJsxTag(code, index, stack)) {
|
|
1561
|
+
const closeIndex = findJsxTagEnd(code, index);
|
|
1562
|
+
if (closeIndex === -1) {
|
|
1563
|
+
const completedTag = completeIncompleteJsxTag(code, index);
|
|
1564
|
+
if (completedTag === null) {
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
push(completedTag.tag);
|
|
1568
|
+
if (!completedTag.tag.trimEnd().endsWith("/>") && !VOID_TAGS.has(completedTag.tagName)) stack.push({ type: "jsx", tag: completedTag.tagName });
|
|
1569
|
+
break;
|
|
1570
|
+
}
|
|
1571
|
+
const tag = code.slice(index, closeIndex + 1);
|
|
1572
|
+
const match = /^<\/?\s*([A-Za-z][\w$.-]*)/.exec(tag);
|
|
1573
|
+
const tagName = match?.[1] ?? (tag === "<>" || tag === "</>" ? "" : null);
|
|
1574
|
+
if (!(tagName && VOID_TAGS.has(tagName) && tag.startsWith("</"))) push(tagName && VOID_TAGS.has(tagName) && !tag.endsWith("/>") ? tag.replace(/\s*\/?\s*>$/, " />") : tag);
|
|
1575
|
+
index = closeIndex + 1;
|
|
1576
|
+
updateJsxStackForTag(stack, tag);
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
if (char in BLOCK_FOLLOWERS) {
|
|
1580
|
+
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index), open: output.length });
|
|
1581
|
+
push(char);
|
|
1582
|
+
index += 1;
|
|
1583
|
+
continue;
|
|
1584
|
+
}
|
|
1585
|
+
if (BLOCK_CLOSERS.has(char)) {
|
|
1586
|
+
const frame = stack.at(-1);
|
|
1587
|
+
if (frame?.type === "block" && frame.closer === char) stack.pop();
|
|
1588
|
+
push(char);
|
|
1589
|
+
index += 1;
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
push(char);
|
|
1593
|
+
index += 1;
|
|
1594
|
+
}
|
|
1595
|
+
const lastRegexLiteralStart = regexLiteralStarts ? findLastRegexLiteralStart(regexLiteralStarts, code.length) : undefined;
|
|
1596
|
+
if (isTrailingClosedRegexLiteral(code, code.length, lastRegexLiteralStart) && canCommitBoundary(stack)) output += ";";
|
|
1597
|
+
const closingFrames = stack.toReversed();
|
|
1598
|
+
const closers = closingFrames.map(closeFrame).join("");
|
|
1599
|
+
if (!closers) return completeIncompleteExpressionPrefix(output, true, options.mode === "streaming");
|
|
1600
|
+
const statementIndex = closingFrames.findIndex((frame) => frame.type === "block" && frame.statement);
|
|
1601
|
+
if (statementIndex === -1) return closeStackFrames(output, closingFrames);
|
|
1602
|
+
const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex));
|
|
1603
|
+
const completedExpression = dropTrailingBareLoopHeader(fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true, options.mode === "streaming")));
|
|
1604
|
+
return `${completedExpression}${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
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);
|
|
1613
|
+
if (!hasDefaultExportDeclaration(exportScanCode)) {
|
|
1614
|
+
const componentName = findLastExportableComponent(exportScanCode);
|
|
1615
|
+
if (componentName) next += `\nexport default ${componentName};`;
|
|
1616
|
+
}
|
|
1617
|
+
next = injectMissingReactHookImports(next, exportScanCode);
|
|
1618
|
+
return next;
|
|
1619
|
+
}
|