partial-tsx 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/partial.ts +192 -51
package/package.json
CHANGED
package/src/partial.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type PartialTsxOptions = { mode?: PartialTsxMode };
|
|
|
6
6
|
type BlockKind = "function" | "control" | "arrow" | "class" | "method" | "block" | "other";
|
|
7
7
|
type CompletionFrame = { type: "block"; closer: string; statement: boolean; open?: number; close?: number; jsxExpression?: boolean; statementStart?: number; kind?: BlockKind } | { type: "jsx"; tag: string; open?: number };
|
|
8
8
|
|
|
9
|
+
// Single source of truth for the JSX-text fast path in the four lexer loops below: only `<` (child tag) and `{` (child
|
|
10
|
+
// expression) leave text mode, so none of them reads `(`/`[`/quotes/`//` in ordinary text as unfinished JavaScript.
|
|
11
|
+
const isJsxTextBoundary = (char: string) => char === "<" || char === "{";
|
|
12
|
+
|
|
9
13
|
const stripMarkdownFence = (code: string) => {
|
|
10
14
|
let next = code.trim();
|
|
11
15
|
next = next.replace(/^```(?:[^\r\n]*)?(?:\r?\n|$)/i, "");
|
|
@@ -78,7 +82,7 @@ const maskIgnoredTextForExportScan = (code: string, regexLiteralStarts?: Readonl
|
|
|
78
82
|
const stack: CompletionFrame[] = [];
|
|
79
83
|
for (let index = 0; index < code.length; index += 1) {
|
|
80
84
|
const char = code[index];
|
|
81
|
-
if (isInsideJsxText(stack) && char
|
|
85
|
+
if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) {
|
|
82
86
|
if (chars[index] !== "\n") chars[index] = " ";
|
|
83
87
|
continue;
|
|
84
88
|
}
|
|
@@ -157,14 +161,28 @@ const findWrappedComponentCallOpen = (code: string, index: number) => {
|
|
|
157
161
|
return code[index] === "(" ? index : -1;
|
|
158
162
|
};
|
|
159
163
|
const isWrappedImplementationFunction = (code: string, index: number) => /\b(?:memo|forwardRef)\s*(?:<[\s\S]*>)?\s*\($/.test(code.slice(0, index).trimEnd());
|
|
164
|
+
const isAmbientDeclaration = (code: string, index: number) => /\bdeclare\s+(?:abstract\s+)?$/.test(code.slice(0, index));
|
|
165
|
+
// A class/function expression's own name is bound only inside its body, so the enclosing binding it is assigned to is the
|
|
166
|
+
// exportable one. Without a binding (argument, object value, `return`) nothing is exportable and the candidate is dropped.
|
|
167
|
+
// A lone HOC call (`withTheme(class Inner …)`) is skipped so arbitrary wrappers resolve like the `memo`/`forwardRef` loop below.
|
|
168
|
+
const findExpressionBindingName = (code: string, index: number) => /\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$.]*\s*(?:<[^<>()]*>)?\s*\(\s*)?$/.exec(code.slice(0, index))?.[1] ?? null;
|
|
169
|
+
const isExpressionPosition = (code: string, index: number) => !/(?:^|[;}]|(?<!\$)\{|\n)\s*(?:(?:export\s+)?(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?)$/.test(code.slice(0, index));
|
|
160
170
|
|
|
161
171
|
const findLastExportableComponent = (code: string) => {
|
|
162
172
|
const candidates: Array<{ name: string; index: number }> = [];
|
|
163
173
|
for (const match of code.matchAll(/\bfunction\s+([A-Z][\w$]*)\s*\(/g)) {
|
|
164
|
-
|
|
174
|
+
const index = match.index ?? 0;
|
|
175
|
+
if (isWrappedImplementationFunction(code, index) || isAmbientDeclaration(code, index)) continue;
|
|
176
|
+
const name = isExpressionPosition(code, index) ? findExpressionBindingName(code, index) : match[1];
|
|
177
|
+
if (name) candidates.push({ name, index });
|
|
178
|
+
}
|
|
179
|
+
for (const match of code.matchAll(/\bclass\s+([A-Z][\w$]*)\s+extends\s+(?:[A-Za-z_$][\w$]*\.)?(?:Pure)?Component\b/g)) {
|
|
180
|
+
const index = match.index ?? 0;
|
|
181
|
+
if (isAmbientDeclaration(code, index)) continue;
|
|
182
|
+
const name = isExpressionPosition(code, index) ? findExpressionBindingName(code, index) : match[1];
|
|
183
|
+
if (name) candidates.push({ name, index });
|
|
165
184
|
}
|
|
166
|
-
for (const match of code.matchAll(/\
|
|
167
|
-
for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*=>/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
|
|
185
|
+
for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)?\s*(?::[^=;\n]+)?=>/g)) candidates.push({ name: match[1], index: match.index ?? 0 });
|
|
168
186
|
for (const match of code.matchAll(/\b(?:const|let|var)\s+([A-Z][\w$]*)\s*(?::[^=]+)?=\s*(?:[A-Za-z_$][\w$]*\.)?(?:memo|forwardRef)\s*/g)) {
|
|
169
187
|
if (findWrappedComponentCallOpen(code, (match.index ?? 0) + match[0].length) !== -1) candidates.push({ name: match[1], index: match.index ?? 0 });
|
|
170
188
|
}
|
|
@@ -174,6 +192,37 @@ const findLastExportableComponent = (code: string) => {
|
|
|
174
192
|
// so the scan does not miss an existing default and append a second one (`Duplicate export of 'default'`).
|
|
175
193
|
const hasDefaultExportDeclaration = (code: string) => /(?:^|[\n;}])\s*export\s+default\s+(?:async\s+)?(?:function|class|[A-Za-z_$]|\(|<)/m.test(code) || /(?:^|[\n;}])\s*export\s*\{[^}]*\b(?:as\s+default|default\s*(?:,|}))[^}]*\}/m.test(code);
|
|
176
194
|
|
|
195
|
+
const maskAmbientBlocks = (scanned: string) => {
|
|
196
|
+
const matches = [...scanned.matchAll(/\bdeclare\s+(?:namespace|module|global)\b[^{;]*\{/g)];
|
|
197
|
+
if (!matches.length) return scanned;
|
|
198
|
+
const chars = scanned.split("");
|
|
199
|
+
// Ambient blocks can contain component-shaped declarations, but none survive into the runtime module.
|
|
200
|
+
for (const match of matches) {
|
|
201
|
+
let depth = 1;
|
|
202
|
+
let end = match.index + match[0].length;
|
|
203
|
+
while (end < scanned.length && depth) {
|
|
204
|
+
if (scanned[end] === "{") depth += 1;
|
|
205
|
+
else if (scanned[end] === "}") depth -= 1;
|
|
206
|
+
end += 1;
|
|
207
|
+
}
|
|
208
|
+
maskExportScanRange(chars, match.index, end);
|
|
209
|
+
}
|
|
210
|
+
return chars.join("");
|
|
211
|
+
};
|
|
212
|
+
// Reuse the normalizer's lexical scan: component declarations inside comments or strings are not preview entry points.
|
|
213
|
+
export function hasReactComponent(code: string) {
|
|
214
|
+
const scanned = maskAmbientBlocks(maskIgnoredTextForExportScan(code));
|
|
215
|
+
if (findLastExportableComponent(scanned)) return true;
|
|
216
|
+
for (const match of scanned.matchAll(/(?:^|[\n;}])\s*export\s+default\s+(?:async\s+)?/g)) {
|
|
217
|
+
const start = match.index + match[0].length;
|
|
218
|
+
const expression = scanned.slice(start);
|
|
219
|
+
if (/^(?:function\b|class\s+(?:[A-Za-z_$][\w$]*\s+)?extends\s+(?:[A-Za-z_$][\w$]*\.)?(?:Pure)?Component\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*(?::[^=;\n]+)?=>)/.test(expression)) return true;
|
|
220
|
+
const wrapper = /^(?:[A-Za-z_$][\w$]*\.)?(?:memo|forwardRef)\b/.exec(expression);
|
|
221
|
+
if (wrapper && findWrappedComponentCallOpen(scanned, start + wrapper[0].length) !== -1) return true;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
177
226
|
// React hooks the model commonly forgets to import. Stay conservative: anything outside this set (custom hooks, react-dom hooks like useFormStatus) is left alone.
|
|
178
227
|
const REACT_HOOK_NAMES = new Set([
|
|
179
228
|
"useState",
|
|
@@ -272,12 +321,28 @@ const isLikelyJsxTagStart = (code: string, index: number) => {
|
|
|
272
321
|
return previousChar !== ">";
|
|
273
322
|
};
|
|
274
323
|
|
|
275
|
-
const isLikelyStatementBlock = (code: string, index: number) => {
|
|
276
|
-
|
|
324
|
+
const isLikelyStatementBlock = (code: string, index: number, stack: CompletionFrame[]) => {
|
|
325
|
+
// A JSX child expression stays an expression even when the preceding prose looks like a JavaScript block header.
|
|
326
|
+
if (code[index] !== "{" || isInsideJsxText(stack)) return false;
|
|
277
327
|
const before = code.slice(0, index).trimEnd();
|
|
278
328
|
return /\bfunction(?:\s+[\w$]+)?\s*\([^)]*\)$/.test(before) || /\b(?:if|for|while|switch|with)\s*\([^)]*\)$/.test(before) || /\bcatch(?:\s*\([^)]*\))?$/.test(before) || /(?:^|[^\w$])(?:else|try|finally|do)$/.test(before) || before.endsWith("=>");
|
|
279
329
|
};
|
|
280
330
|
|
|
331
|
+
// `null` when `index` isn't a comment start, `-1` when the comment is unterminated, else the index just past it.
|
|
332
|
+
// The three tag/expression scanners below all need this and drifted apart once already — #1600 was one of them missing it.
|
|
333
|
+
const findCommentEnd = (code: string, index: number) => {
|
|
334
|
+
if (code[index] !== "/") return null;
|
|
335
|
+
if (code[index + 1] === "/") {
|
|
336
|
+
const end = code.indexOf("\n", index + 2);
|
|
337
|
+
return end === -1 ? -1 : end + 1;
|
|
338
|
+
}
|
|
339
|
+
if (code[index + 1] === "*") {
|
|
340
|
+
const end = code.indexOf("*/", index + 2);
|
|
341
|
+
return end === -1 ? -1 : end + 2;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
};
|
|
345
|
+
|
|
281
346
|
const findJsxTagEnd = (code: string, start: number) => {
|
|
282
347
|
const stack: string[] = [];
|
|
283
348
|
let quote: string | null = null;
|
|
@@ -288,6 +353,20 @@ const findJsxTagEnd = (code: string, start: number) => {
|
|
|
288
353
|
else if (char === quote) quote = null;
|
|
289
354
|
continue;
|
|
290
355
|
}
|
|
356
|
+
// A regex ending in an escaped slash (`/\//`) puts a literal `//` in the source, so it must be consumed before the
|
|
357
|
+
// comment scan below mistakes it for one — same ordering `findJsxAttributeExpressionEnd` uses.
|
|
358
|
+
if (stack.length > 0 && isLikelyRegexLiteralStart(code, index)) {
|
|
359
|
+
index = findRegexLiteralEnd(code, index) - 1;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// Comments are legal anywhere in a tag, including its own attribute list — not just inside an attribute's `{…}`.
|
|
363
|
+
// Skipping them keeps an apostrophe in `// framework's Link` from opening a string that swallows the rest of the tag (#1600).
|
|
364
|
+
const commentEnd = findCommentEnd(code, index);
|
|
365
|
+
if (commentEnd !== null) {
|
|
366
|
+
if (commentEnd === -1) return -1;
|
|
367
|
+
index = commentEnd - 1;
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
291
370
|
if (char === '"' || char === "'" || char === "`") {
|
|
292
371
|
quote = char;
|
|
293
372
|
continue;
|
|
@@ -307,14 +386,9 @@ const findJsxTagEnd = (code: string, start: number) => {
|
|
|
307
386
|
const findJsxAttributeExpressionEnd = (code: string, index: number) => {
|
|
308
387
|
let depth = 1;
|
|
309
388
|
while (index < code.length) {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
index =
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
if (code[index] === "/" && code[index + 1] === "*") {
|
|
316
|
-
const close = code.indexOf("*/", index + 2);
|
|
317
|
-
index = close === -1 ? code.length : close + 2;
|
|
389
|
+
const commentEnd = findCommentEnd(code, index);
|
|
390
|
+
if (commentEnd !== null) {
|
|
391
|
+
index = commentEnd === -1 ? code.length : commentEnd;
|
|
318
392
|
continue;
|
|
319
393
|
}
|
|
320
394
|
if (isLikelyRegexLiteralStart(code, index)) {
|
|
@@ -349,6 +423,18 @@ const completeIncompleteJsxTag = (code: string, start: number) => {
|
|
|
349
423
|
let tag = "";
|
|
350
424
|
while (index < code.length) {
|
|
351
425
|
const char = code[index];
|
|
426
|
+
const commentEnd = findCommentEnd(code, index);
|
|
427
|
+
if (commentEnd !== null) {
|
|
428
|
+
// A still-streaming comment has to be dropped, not carried: emitting it would bury the `>` we synthesize below
|
|
429
|
+
// inside the comment. A closed one is kept verbatim — comments are legal in a JSX attribute list (#1600).
|
|
430
|
+
if (commentEnd === -1) {
|
|
431
|
+
index = code.length;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
tag += code.slice(index, commentEnd);
|
|
435
|
+
index = commentEnd;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
352
438
|
if (char === "`") {
|
|
353
439
|
// A backtick is illegal as a JSX attribute value (`title=`x`` → "JSX value should be either an expression or a quoted JSX text"); template literals are only valid inside `{...}`. Drop the attribute.
|
|
354
440
|
tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
|
|
@@ -396,7 +482,11 @@ const completeIncompleteJsxTag = (code: string, start: number) => {
|
|
|
396
482
|
if (/=\s*$/.test(tag)) tag = tag.replace(/\s*[\w$:-]+\s*=\s*$/, "");
|
|
397
483
|
// Otherwise a trailing name with no following space is still streaming (`valu` may become `value`); a trailing space commits it as a boolean attr.
|
|
398
484
|
else if (!/\s$/.test(tag)) tag = tag.replace(/\s+[A-Za-z_$][\w$:-]*$/, "");
|
|
399
|
-
|
|
485
|
+
// That trim can eat the newline terminating a trailing comment. The closer synthesized below must stay clear of it:
|
|
486
|
+
// after `//` it would be buried inside the comment, after `*/` it would fuse into a spurious self-closing `/>`.
|
|
487
|
+
if (/\/\/[^\n]*$|\*\/$/.test(tag)) tag += "\n";
|
|
488
|
+
// The `\/` strip below must not mistake a block comment's terminating slash for a stray self-closing one.
|
|
489
|
+
const completedTag = VOID_TAGS.has(tagName) ? `${tag.replace(/\s*(?<!\*)\/\s*$/, "")} />` : `${tag}>`;
|
|
400
490
|
return { tag: completedTag, tagName };
|
|
401
491
|
};
|
|
402
492
|
|
|
@@ -435,25 +525,26 @@ const collectCompletionStackBefore = (code: string, end: number) => {
|
|
|
435
525
|
const stack: CompletionFrame[] = [];
|
|
436
526
|
for (let index = 0; index < end; index += 1) {
|
|
437
527
|
const char = code[index];
|
|
438
|
-
if (
|
|
528
|
+
if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) continue;
|
|
529
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
439
530
|
const newlineIndex = code.indexOf("\n", index + 2);
|
|
440
531
|
index = (newlineIndex === -1 ? end : Math.min(newlineIndex, end)) - 1;
|
|
441
532
|
continue;
|
|
442
533
|
}
|
|
443
|
-
if (
|
|
534
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
444
535
|
const closeIndex = code.indexOf("*/", index + 2);
|
|
445
536
|
index = (closeIndex === -1 ? end : Math.min(closeIndex + 2, end)) - 1;
|
|
446
537
|
continue;
|
|
447
538
|
}
|
|
448
|
-
if (
|
|
539
|
+
if (isLikelyRegexLiteralStart(code, index)) {
|
|
449
540
|
index = Math.min(findRegexLiteralEnd(code, index), end) - 1;
|
|
450
541
|
continue;
|
|
451
542
|
}
|
|
452
|
-
if (
|
|
543
|
+
if (char === '"' || char === "'") {
|
|
453
544
|
index = Math.min(findStringLiteralEnd(code, index), end) - 1;
|
|
454
545
|
continue;
|
|
455
546
|
}
|
|
456
|
-
if (
|
|
547
|
+
if (char === "`") {
|
|
457
548
|
index = Math.min(findTemplateLiteralEnd(code, index), end) - 1;
|
|
458
549
|
continue;
|
|
459
550
|
}
|
|
@@ -464,7 +555,7 @@ const collectCompletionStackBefore = (code: string, end: number) => {
|
|
|
464
555
|
index = closeIndex;
|
|
465
556
|
continue;
|
|
466
557
|
}
|
|
467
|
-
if (char in BLOCK_FOLLOWERS) stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement:
|
|
558
|
+
if (char in BLOCK_FOLLOWERS) stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: false });
|
|
468
559
|
else if (BLOCK_CLOSERS.has(char)) {
|
|
469
560
|
const frame = stack.at(-1);
|
|
470
561
|
if (frame?.type === "block" && frame.closer === char) stack.pop();
|
|
@@ -589,11 +680,13 @@ const hasUnclosedTypeArgument = (body: string) => {
|
|
|
589
680
|
return true;
|
|
590
681
|
};
|
|
591
682
|
// Locate the last `const`/`let`/`var` whose declaration runs to EOF without a depth-0 `;` terminator, scanning the
|
|
592
|
-
// literal-masked text so a `;`/`{`/`<`
|
|
683
|
+
// JSX/literal-masked text so prose declarations and a `;`/`{`/`<` inside a string or comment (`const s = "a; const x = useState<T"`) are never
|
|
593
684
|
// mistaken for a statement boundary. A declaration can begin at the buffer start or right after `{`, `}`, `;`, or `=>`; we keep
|
|
594
685
|
// the latest one that isn't closed by a depth-0 `;` or by a `)`/`]`/`}` that pops out of its enclosing block.
|
|
595
686
|
const locateTrailingDeclaration = (value: string) => {
|
|
596
|
-
|
|
687
|
+
// Masking cannot create a keyword, so skip the JSX scan on keyword-free streaming prefixes.
|
|
688
|
+
if (!/\b(?:const|let|var)\b/.test(value)) return null;
|
|
689
|
+
const masked = maskIgnoredTextForExportScan(value);
|
|
597
690
|
const re = /(?:^|[{};\n]|=>)\s*(const(?!\s+enum\b)|let|var)\b/g;
|
|
598
691
|
let match: RegExpExecArray | null;
|
|
599
692
|
let start = -1;
|
|
@@ -692,18 +785,25 @@ const isCompleteDoWhileStatement = (value: string, scan = scanStreamingSource(va
|
|
|
692
785
|
return false;
|
|
693
786
|
};
|
|
694
787
|
const dropTrailingBareLoopHeader = (value: string) => {
|
|
695
|
-
|
|
788
|
+
// Masking only ever blanks spans, so it can neither synthesize a `)` nor the letters of the keyword: a value missing
|
|
789
|
+
// either can never yield a droppable header, and skipping the mask here saves it on most calls of a stream. This does
|
|
790
|
+
// NOT license hoisting the `endsWith(")")` check below — blanking trailing trivia changes *which* `)` is last, so
|
|
791
|
+
// `while (a) // note` masks to a droppable header the raw value does not end with.
|
|
792
|
+
if (!value.includes(")") || !/\b(?:for|while)\b/.test(value)) return value;
|
|
793
|
+
// Mask JSX too, not just literals: prose like `<p>run for (i` ends in a synthesized `)` closer whose matching `(` sits in
|
|
794
|
+
// committed text, and `maskLiteralSpans` would let the preceding word read as a real `for`/`while` keyword — rolling the
|
|
795
|
+
// already-rendered JSX back to `<p>run`.
|
|
796
|
+
const masked = maskIgnoredTextForExportScan(value).trimEnd();
|
|
696
797
|
if (!masked.endsWith(")")) return value;
|
|
697
798
|
const index = findMatchingOpener(masked, "(", ")");
|
|
698
799
|
if (index <= 0) return value;
|
|
699
800
|
const head = masked.slice(0, index).trimEnd();
|
|
700
|
-
const match = /\b(for
|
|
801
|
+
const match = /\b(for(?:\s+await)?|while)$/.exec(head);
|
|
701
802
|
if (!match) return value;
|
|
702
803
|
const before = head.slice(0, match.index);
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
if (/[.$]$/.test(before.trimEnd())) return value;
|
|
804
|
+
// `\b` only understands ASCII word characters: member names and Unicode/escaped identifier suffixes are not loops.
|
|
805
|
+
// Keep whitespace for the identifier check so a separate keyword after an identifier still gets dropped.
|
|
806
|
+
if (/[.#]$/.test(before.trimEnd()) || /[$\p{ID_Continue}\u200c\u200d]$|\\u\{[\da-f]+\}$/iu.test(before)) return value;
|
|
707
807
|
// `do { ... } while (cond)` is a complete statement that needs no body — keep it. Tell it apart from a plain `while` that
|
|
708
808
|
// merely follows another block (`while(a){...} while(b`) by parsing the source and checking this trailing `while` closes a `do`.
|
|
709
809
|
if (match[1] === "while" && isCompleteDoWhileStatement(value)) return value;
|
|
@@ -717,7 +817,9 @@ const dropTrailingBareLoopHeader = (value: string) => {
|
|
|
717
817
|
// inner frames whose openers were inside the rolled-back span.
|
|
718
818
|
const completeBlockFrame = (output: string, frame: Extract<CompletionFrame, { type: "block" }>) => {
|
|
719
819
|
if (frame.statement) return { text: `${output}${frame.closer}`, dropped: false };
|
|
720
|
-
|
|
820
|
+
// Clean JSX child expressions before `}` hides their loop header; other frames can contain valid type methods `for(...)`.
|
|
821
|
+
// Drop the header before completing its prefix: it can expose an operator or be hidden by a synthesized ternary branch.
|
|
822
|
+
const completed = completeIncompleteExpressionPrefix(frame.jsxExpression ? dropTrailingBareLoopHeader(output) : output, true);
|
|
721
823
|
const openerGone = frame.open !== undefined ? completed.length <= frame.open : completed.length < output.length;
|
|
722
824
|
if (openerGone) return { text: completed, dropped: true };
|
|
723
825
|
return { text: `${completed}${frame.closer}`, dropped: false };
|
|
@@ -739,12 +841,16 @@ const completeStatementPrefixFrame = (output: string, frame: CompletionFrame): {
|
|
|
739
841
|
// Close frames from the stack top down. A frame whose own opening bracket was rolled back by a declaration drop must not emit a
|
|
740
842
|
// closer — `completeBlockFrame` detects that and reports `open: number` for the bracket position it would have closed, so once
|
|
741
843
|
// a drop happens we know which later frames lost their opener (those whose recorded bracket offset is past the drop point).
|
|
742
|
-
const closeStackFrames = (output: string, frames: CompletionFrame[]) => {
|
|
844
|
+
const closeStackFrames = (output: string, frames: CompletionFrame[], beforeCloser?: (text: string, closer: string) => string) => {
|
|
743
845
|
let text = output;
|
|
744
846
|
let dropPoint = Infinity;
|
|
745
847
|
for (const frame of frames) {
|
|
746
848
|
if (frame.type === "block" && frame.open !== undefined && frame.open >= dropPoint) continue;
|
|
747
849
|
const result = completeStatementPrefixFrame(text, frame);
|
|
850
|
+
if (beforeCloser && !result.dropped) {
|
|
851
|
+
const closer = closeFrame(frame);
|
|
852
|
+
result.text = beforeCloser(result.text.slice(0, -closer.length), closer);
|
|
853
|
+
}
|
|
748
854
|
if (result.dropped) dropPoint = Math.min(dropPoint, result.text.length);
|
|
749
855
|
text = result.text;
|
|
750
856
|
}
|
|
@@ -968,7 +1074,7 @@ const isAsiBoundary = (before: string, after: string, leftEndsWithRegexLiteral =
|
|
|
968
1074
|
const endsWithContinuingPunctuator = /[=+\-*/%&|^~?!<>.:,([]$/.test(left) && !endsWithPostfixUpdate(left) && !(left.endsWith("/") && leftEndsWithRegexLiteral) && !(left.endsWith(">") && endsWithCompleteJsxTag(left));
|
|
969
1075
|
if (!left || !right || endsWithContinuingPunctuator || right.startsWith(".") || LINE_CONTINUING_KEYWORD_OPERATOR.test(left)) return false;
|
|
970
1076
|
if (FUNCTION_HEADER.test(left) || (/(?:^|\n)\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\b/.test(left) && !hasTopLevelClassBody(left))) return false;
|
|
971
|
-
return /^(?:const|let|var|return|throw|if|else|for|while|switch|try|catch|finally|do|function|class|import|export)\b|^[A-Za-z_$][\w$]*(?:\s*(?:[=(]|$))/.test(right);
|
|
1077
|
+
return /^(?:const|let|var|return|throw|if|else|for|while|switch|try|catch|finally|do|function|class|import|export|type)\b|^[A-Za-z_$][\w$]*(?:\s*(?:[=(]|$))/.test(right);
|
|
972
1078
|
};
|
|
973
1079
|
|
|
974
1080
|
const isClosedQuotedLiteral = (code: string, start: number, end: number, quote: string) => end > start + 1 && code[end - 1] === quote && (code.slice(start, end - 1).match(/\\+$/)?.[0].length ?? 0) % 2 === 0;
|
|
@@ -1047,9 +1153,22 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1047
1153
|
unclosedLiteralStatementStart = isTrailingClosedRegexLiteral(code, index, lastRegexLiteralStart) || (Boolean(statement.trim()) && hasSafeExpressionTail(statement)) ? index : statementStart;
|
|
1048
1154
|
};
|
|
1049
1155
|
while (index < code.length) {
|
|
1050
|
-
const insideJsxText = stack
|
|
1156
|
+
const insideJsxText = isInsideJsxText(stack);
|
|
1051
1157
|
const char = code[index];
|
|
1052
|
-
|
|
1158
|
+
// A label such as "shape (E, r" is committed JSX text, not an unfinished JS call that can roll back the whole return.
|
|
1159
|
+
// Text advances `lastSignificantEnd` but must NOT reach the `;`/`\n` boundary gates below: committing a statement at a
|
|
1160
|
+
// `;` in prose would move the rollback cut into the label. Kept parallel to the shared tail rather than falling through
|
|
1161
|
+
// to it for that reason. `statementRegexBoundary` is only ever set by a statement `}` pop, which leaves a block frame on
|
|
1162
|
+
// top, so it is already false here — mirroring the tail costs one line and keeps the two blocks diffable.
|
|
1163
|
+
if (insideJsxText && !isJsxTextBoundary(char)) {
|
|
1164
|
+
if (!/\s/.test(char)) {
|
|
1165
|
+
statementRegexBoundary = false;
|
|
1166
|
+
lastSignificantEnd = index + 1;
|
|
1167
|
+
}
|
|
1168
|
+
index += 1;
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
1053
1172
|
const newlineIndex = code.indexOf("\n", index + 2);
|
|
1054
1173
|
if (newlineIndex === -1) {
|
|
1055
1174
|
markDisposableComment(); // the comment itself is disposable; preserve only a complete statement before it
|
|
@@ -1061,7 +1180,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1061
1180
|
index = newlineIndex + 1;
|
|
1062
1181
|
continue;
|
|
1063
1182
|
}
|
|
1064
|
-
if (
|
|
1183
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
1065
1184
|
const closeIndex = code.indexOf("*/", index + 2);
|
|
1066
1185
|
if (closeIndex === -1) {
|
|
1067
1186
|
markDisposableComment();
|
|
@@ -1070,7 +1189,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1070
1189
|
index = closeIndex + 2;
|
|
1071
1190
|
continue;
|
|
1072
1191
|
}
|
|
1073
|
-
if (
|
|
1192
|
+
if ((statementRegexBoundary && char === "/") || isLikelyRegexLiteralStart(code, index)) {
|
|
1074
1193
|
regexLiteralStarts.add(index);
|
|
1075
1194
|
lastRegexLiteralStart = index;
|
|
1076
1195
|
statementRegexBoundary = false;
|
|
@@ -1083,7 +1202,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1083
1202
|
index = end;
|
|
1084
1203
|
continue;
|
|
1085
1204
|
}
|
|
1086
|
-
if (
|
|
1205
|
+
if (char === '"' || char === "'") {
|
|
1087
1206
|
statementRegexBoundary = false;
|
|
1088
1207
|
const end = findStringLiteralEnd(code, index);
|
|
1089
1208
|
if (!isClosedQuotedLiteral(code, index, end, char)) {
|
|
@@ -1094,7 +1213,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1094
1213
|
index = end;
|
|
1095
1214
|
continue;
|
|
1096
1215
|
}
|
|
1097
|
-
if (
|
|
1216
|
+
if (char === "`") {
|
|
1098
1217
|
statementRegexBoundary = false;
|
|
1099
1218
|
const literal = scanTemplateLiteralEnd(code, index);
|
|
1100
1219
|
if (!literal.closed) {
|
|
@@ -1153,7 +1272,7 @@ const scanStreamingSource = (code: string): StreamingScan => {
|
|
|
1153
1272
|
const scope = scopes.at(-1);
|
|
1154
1273
|
if (scope) scope.statementStart = index + 1;
|
|
1155
1274
|
}
|
|
1156
|
-
if (char === "\n" &&
|
|
1275
|
+
if (char === "\n" && canCommitBoundary(stack)) {
|
|
1157
1276
|
const scope = scopes.at(-1);
|
|
1158
1277
|
const boundaryEnd = Math.max(scope?.statementStart ?? 0, lastSignificantEnd);
|
|
1159
1278
|
if (scope && isAsiBoundary(code.slice(scope.statementStart, boundaryEnd), code.slice(index + 1), isTrailingClosedRegexLiteral(code, boundaryEnd, lastRegexLiteralStart))) scope.statementStart = index + 1;
|
|
@@ -1288,7 +1407,7 @@ const isTypedParameterList = (initializer: string) => {
|
|
|
1288
1407
|
};
|
|
1289
1408
|
|
|
1290
1409
|
const hasUnsafeStreamingTypeArgument = (code: string) => {
|
|
1291
|
-
const masked =
|
|
1410
|
+
const masked = maskIgnoredTextForExportScan(code);
|
|
1292
1411
|
if (/(?:^|=)\s*<\s*[A-Za-z_$][\w$]*\s*(?:,|extends\b|=)[^>]*$/.test(neutralizeAngleOperators(masked))) return true;
|
|
1293
1412
|
if (!hasUnclosedTypeArgument(masked)) return false;
|
|
1294
1413
|
const lastOpen = masked.lastIndexOf("<");
|
|
@@ -1305,7 +1424,12 @@ const hasSafeExpressionTail = (tail: string) => {
|
|
|
1305
1424
|
if (hasIncompleteControlTail(trimmed) || dropTrailingIncompleteExpressionOperator(trimmed) !== trimmed) return false;
|
|
1306
1425
|
if (completeTrailingIncompleteTernary(trimmed) !== trimmed) return false;
|
|
1307
1426
|
if (dropTrailingIncompleteFunctionDeclaration(trimmed) !== trimmed) return false;
|
|
1308
|
-
|
|
1427
|
+
// Strip JSX prefix before checking for unclosed type arguments: `<div>text</div>\ntype Foo` should only examine `type Foo`.
|
|
1428
|
+
// `maskIgnoredTextForExportScan` blanks JSX content but preserves `<`/`>` delimiters, which `hasUnclosedTypeArgument` would
|
|
1429
|
+
// misread as type-argument brackets. Find the last complete JSX tag and check only the tail after it.
|
|
1430
|
+
const lastJsxTagEnd = trimmed.lastIndexOf(">");
|
|
1431
|
+
const tailAfterJsx = lastJsxTagEnd >= 0 && endsWithCompleteJsxTag(trimmed.slice(0, lastJsxTagEnd + 1)) ? trimmed.slice(lastJsxTagEnd + 1).trimStart() : trimmed;
|
|
1432
|
+
if (tailAfterJsx && hasUnsafeStreamingTypeArgument(tailAfterJsx)) return false;
|
|
1309
1433
|
if (/^import\b/.test(trimmed) && !/^import\s+(?:["'][\s\S]*["']|[\s\S]+\s+from\s+["'][\s\S]*["'])\s*;?$/.test(trimmed)) return false;
|
|
1310
1434
|
if (/^export\s+(?:d(?:e(?:f(?:a(?:u(?:l(?:t)?)?)?)?)?)?)?\s*$/.test(trimmed) || /^export\s+default\s+[A-Za-z_$][\w$]*$/.test(trimmed)) return false;
|
|
1311
1435
|
if (/^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class(?:\s+[A-Za-z_$][\w$]*)?\s*$/.test(trimmed)) return false;
|
|
@@ -1510,6 +1634,12 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1510
1634
|
regexLiteralStarts = committed.regexLiteralStarts;
|
|
1511
1635
|
}
|
|
1512
1636
|
let output = "";
|
|
1637
|
+
let pendingTrivia = "";
|
|
1638
|
+
const finish = (text: string, suffix = "") => {
|
|
1639
|
+
const trivia = pendingTrivia;
|
|
1640
|
+
pendingTrivia = "";
|
|
1641
|
+
return `${text}${trivia}${suffix}`;
|
|
1642
|
+
};
|
|
1513
1643
|
const stack: CompletionFrame[] = [];
|
|
1514
1644
|
let index = 0;
|
|
1515
1645
|
const push = (value: string) => {
|
|
@@ -1517,14 +1647,23 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1517
1647
|
};
|
|
1518
1648
|
while (index < code.length) {
|
|
1519
1649
|
const char = code[index];
|
|
1520
|
-
|
|
1650
|
+
// Keep completion and committed-prefix scanning in the same lexical mode; never append ')' or ']' to plain text.
|
|
1651
|
+
if (isInsideJsxText(stack) && !isJsxTextBoundary(char)) {
|
|
1652
|
+
push(char);
|
|
1653
|
+
index += 1;
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
if (char === "/" && code[index + 1] === "/") {
|
|
1521
1657
|
const newlineIndex = code.indexOf("\n", index + 2);
|
|
1522
1658
|
const end = newlineIndex === -1 ? code.length : newlineIndex;
|
|
1523
|
-
|
|
1659
|
+
// A newline alone is not enough: expression cleanup can strip an operator from the comment and eat that newline.
|
|
1660
|
+
// Hold EOF trivia apart until the first surviving frame is complete, then restore it before its synthetic closer.
|
|
1661
|
+
if (newlineIndex === -1 && stack.length) pendingTrivia = `${code.slice(index, end)}\n`;
|
|
1662
|
+
else push(code.slice(index, end));
|
|
1524
1663
|
index = end;
|
|
1525
1664
|
continue;
|
|
1526
1665
|
}
|
|
1527
|
-
if (
|
|
1666
|
+
if (char === "/" && code[index + 1] === "*") {
|
|
1528
1667
|
const closeIndex = code.indexOf("*/", index + 2);
|
|
1529
1668
|
if (closeIndex === -1) {
|
|
1530
1669
|
push(`${code.slice(index)}*/`);
|
|
@@ -1535,15 +1674,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1535
1674
|
index = closeIndex + 2;
|
|
1536
1675
|
continue;
|
|
1537
1676
|
}
|
|
1538
|
-
if (
|
|
1677
|
+
if (regexLiteralStarts?.has(index) || isLikelyRegexLiteralStart(code, index)) {
|
|
1539
1678
|
index = consumeRegexLiteral(code, index, push);
|
|
1540
1679
|
continue;
|
|
1541
1680
|
}
|
|
1542
|
-
if (
|
|
1681
|
+
if (char === '"' || char === "'") {
|
|
1543
1682
|
index = consumeStringLiteral(code, index, push);
|
|
1544
1683
|
continue;
|
|
1545
1684
|
}
|
|
1546
|
-
if (
|
|
1685
|
+
if (char === "`") {
|
|
1547
1686
|
index = consumeTemplateLiteral(code, index, push);
|
|
1548
1687
|
continue;
|
|
1549
1688
|
}
|
|
@@ -1577,7 +1716,7 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1577
1716
|
continue;
|
|
1578
1717
|
}
|
|
1579
1718
|
if (char in BLOCK_FOLLOWERS) {
|
|
1580
|
-
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index), open: output.length });
|
|
1719
|
+
stack.push({ type: "block", closer: BLOCK_FOLLOWERS[char], statement: isLikelyStatementBlock(code, index, stack), open: output.length, jsxExpression: char === "{" && isInsideJsxText(stack) });
|
|
1581
1720
|
push(char);
|
|
1582
1721
|
index += 1;
|
|
1583
1722
|
continue;
|
|
@@ -1594,14 +1733,15 @@ export function completePartialTsx(code: string, options: PartialTsxOptions = {}
|
|
|
1594
1733
|
}
|
|
1595
1734
|
const lastRegexLiteralStart = regexLiteralStarts ? findLastRegexLiteralStart(regexLiteralStarts, code.length) : undefined;
|
|
1596
1735
|
if (isTrailingClosedRegexLiteral(code, code.length, lastRegexLiteralStart) && canCommitBoundary(stack)) output += ";";
|
|
1736
|
+
const beforeCloser = pendingTrivia ? finish : undefined;
|
|
1597
1737
|
const closingFrames = stack.toReversed();
|
|
1598
1738
|
const closers = closingFrames.map(closeFrame).join("");
|
|
1599
1739
|
if (!closers) return completeIncompleteExpressionPrefix(output, true, options.mode === "streaming");
|
|
1600
1740
|
const statementIndex = closingFrames.findIndex((frame) => frame.type === "block" && frame.statement);
|
|
1601
|
-
if (statementIndex === -1) return closeStackFrames(output, closingFrames);
|
|
1602
|
-
const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex));
|
|
1741
|
+
if (statementIndex === -1) return finish(closeStackFrames(output, closingFrames, beforeCloser));
|
|
1742
|
+
const beforeStatement = closeStackFrames(output, closingFrames.slice(0, statementIndex), beforeCloser);
|
|
1603
1743
|
const completedExpression = dropTrailingBareLoopHeader(fillEmptyReturnExpression(completeIncompleteExpressionPrefix(beforeStatement, true, options.mode === "streaming")));
|
|
1604
|
-
return `${
|
|
1744
|
+
return finish(completedExpression, `${isExpressionEnd(completedExpression) ? ";" : ""}${closingFrames.slice(statementIndex).map(closeFrame).join("")}`);
|
|
1605
1745
|
}
|
|
1606
1746
|
|
|
1607
1747
|
export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions = {}) {
|
|
@@ -1610,6 +1750,7 @@ export function normalizeGeneratedTsx(code: string, options: PartialTsxOptions =
|
|
|
1610
1750
|
// The ordinary lexer recognizes every regex position except a new statement after a closed block. The first mask removes
|
|
1611
1751
|
// false candidates from JSX/templates/comments, so only the ambiguous `} /* trivia */ /regex/` shape pays for provenance.
|
|
1612
1752
|
if (/}\s*\/(?![/*])/.test(exportScanCode)) exportScanCode = maskIgnoredTextForExportScan(next, scanStreamingSource(next).regexLiteralStarts);
|
|
1753
|
+
exportScanCode = maskAmbientBlocks(exportScanCode);
|
|
1613
1754
|
if (!hasDefaultExportDeclaration(exportScanCode)) {
|
|
1614
1755
|
const componentName = findLastExportableComponent(exportScanCode);
|
|
1615
1756
|
if (componentName) next += `\nexport default ${componentName};`;
|