also looking at this
fix(emit): skip a satisfies type that is cut from inside the initializer
#3123
3 files
+109
-3
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | |
| 5 | 5 | import type { Declaration } from '../types' |
| 6 | 6 | import { formatComments } from './comments' |
| 7 | import { consumeCleanDefault, enableCleanDefaultCollection, extractSatisfiesType, inferNarrowType, isGenericType } from './type-inference' | |
| 7 | import { consumeCleanDefault, enableCleanDefaultCollection, extractSatisfiesType, inferNarrowType, isBalancedType, isGenericType } from './type-inference' | |
| 8 | 8 | |
| 9 | 9 | /** |
| 10 | 10 | * Format a @defaultValue tag as a standalone JSDoc block |
| @@ -109,10 +109,13 @@ export function processVariableDeclaration(decl: Declaration, keepComments: bool | ||
| 109 | 109 | let typeAnnotation = decl.typeAnnotation |
| 110 | 110 | const value = decl.value as string | undefined |
| 111 | 111 | |
| 112 | // Check for 'satisfies' operator - extract the type from the satisfies clause | |
| 112 | // Check for 'satisfies' operator - extract the type from the satisfies clause. | |
| 113 | // The last ` satisfies ` can sit in a comment, a string or a nested element | |
| 114 | // (`[{ id: 1 } satisfies Item, y]`); the text after it then closes a bracket | |
| 115 | // or leaves a string open, and is not a type to declare. | |
| 113 | 116 | if (value && value.includes(' satisfies ')) { |
| 114 | 117 | const satisfiesType = extractSatisfiesType(value) |
| 115 | if (satisfiesType) { | |
| 118 | if (satisfiesType && isBalancedType(satisfiesType)) { | |
| 116 | 119 | typeAnnotation = satisfiesType |
| 117 | 120 | } |
| 118 | 121 | } |
| @@ -2741,6 +2741,48 @@ export function extractSatisfiesType(value: string): string | null { | ||
| 2741 | 2741 | return typeStr || null |
| 2742 | 2742 | } |
| 2743 | 2743 | |
| 2744 | /** | |
| 2745 | * Whether `type` closes every bracket, string, template literal and comment it | |
| 2746 | * opens, in order, and opens every bracket it closes. | |
| 2747 | */ | |
| 2748 | export function isBalancedType(type: string): boolean { | |
| 2749 | // The closing character each open bracket expects; -1 for template literal text. | |
| 2750 | const open: number[] = [] | |
| 2751 | let i = 0 | |
| 2752 | while (i < type.length) { | |
| 2753 | const c = type.charCodeAt(i) | |
| 2754 | if (open[open.length - 1] === -1) { | |
| 2755 | if (c === 92) i += 2 // backslash | |
| 2756 | else if (c === 96) { open.pop(); i++ } | |
| 2757 | else if (c === 36 && type.charCodeAt(i + 1) === 123) { open.push(125); i += 2 } // ${ | |
| 2758 | else i++ | |
| 2759 | } | |
| 2760 | else if (c === 34 || c === 39) { // " ' | |
| 2761 | i++ | |
| 2762 | while (i < type.length && type.charCodeAt(i) !== c) i += type.charCodeAt(i) === 92 ? 2 : 1 | |
| 2763 | if (i >= type.length) return false | |
| 2764 | i++ | |
| 2765 | } | |
| 2766 | else if (c === 47 && type.charCodeAt(i + 1) === 47) { // // | |
| 2767 | while (i < type.length && type.charCodeAt(i) !== 10 && type.charCodeAt(i) !== 13 && type.charCodeAt(i) !== 0x2028 && type.charCodeAt(i) !== 0x2029) i++ | |
| 2768 | } | |
| 2769 | else if (c === 47 && type.charCodeAt(i + 1) === 42) { // /* | |
| 2770 | const close = type.indexOf('*/', i + 2) | |
| 2771 | if (close === -1) return false | |
| 2772 | i = close + 2 | |
| 2773 | } | |
| 2774 | else { | |
| 2775 | if (c === 96) open.push(-1) | |
| 2776 | else if (c === 40) open.push(41) // ( ) | |
| 2777 | else if (c === 91) open.push(93) // [ ] | |
| 2778 | else if (c === 123) open.push(125) // { } | |
| 2779 | else if ((c === 41 || c === 93 || c === 125) && open.pop() !== c) return false | |
| 2780 | i++ | |
| 2781 | } | |
| 2782 | } | |
| 2783 | return open.length === 0 | |
| 2784 | } | |
| 2785 | ||
| 2744 | 2786 | /** |
| 2745 | 2787 | * Infer mapped type from type expression |
| 2746 | 2788 | * Handles patterns like { [K in keyof T]: V } |
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * The text after the last ` satisfies ` in an initializer was taken as the | |
| 3 | * declared type, wherever it stood: | |
| 4 | * | |
| 5 | * export const list = [ | |
| 6 | * 'a', // b satisfies T | |
| 7 | * ] | |
| 8 | * | |
| 9 | * emitted `export declare const list: T\n];`. A `satisfies` in a comment, a | |
| 10 | * string, a nested element or a function body is not about the declaration, | |
| 11 | * and the text after it closes a bracket or leaves a string open that it never | |
| 12 | * began. Vue's compiler-core `babelUtils` and three of knip's plugins came out | |
| 13 | * unparsable this way. | |
| 14 | */ | |
| 15 | import { describe, expect, it } from 'bun:test' | |
| 16 | import { processCode } from './test-utils' | |
| 17 | ||
| 18 | const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' }) | |
| 19 | ||
| 20 | describe('satisfies outside the top level of an initializer', () => { | |
| 21 | it.each([ | |
| 22 | ['comment', 'export const list = [\n \'a\', // b satisfies T\n]\nexport const after = 1\n', 'const list: T'], | |
| 23 | ['string', 'export const s = \'x satisfies Y\'\nexport const after = 1\n', 'const s: Y'], | |
| 24 | ['nested element', 'type Item = { id: number }\nexport const items = [{ id: 1 } satisfies Item, { id: 2 } satisfies Item]\nexport const after = 1\n', 'const items: Item'], | |
| 25 | ['function body', 'type Cfg = { a: number }\nexport const plugin = {\n setup() { const c = { a: 1 } satisfies Cfg; return c },\n}\nexport const after = 1\n', 'const plugin: Cfg'], | |
| 26 | ['string after a regex', 'export const rule = { pattern: /\'/, message: \'x satisfies Y\' }\nexport const after = 1\n', 'const rule: Y'], | |
| 27 | ])('is not taken as the declared type: %s', (_label, code, wrong) => { | |
| 28 | const output = processCode(code) | |
| 29 | expect(() => transpiler.transformSync(output)).not.toThrow() | |
| 30 | expect(output).not.toContain(wrong) | |
| 31 | expect(output).toContain('export declare const after: 1;') | |
| 32 | }) | |
| 33 | ||
| 34 | it.each([ | |
| 35 | ['plain', 'type Cfg = { port: number }\nexport const config = { port: 3000 } satisfies Cfg\n', 'export declare const config: Cfg;'], | |
| 36 | ['function type', 'export const f = (() => {}) satisfies () => void\n', 'export declare const f: () => void;'], | |
| 37 | ['template literal type', 'export const id = `a-1` satisfies `a-${number}`\n', 'export declare const id: `a-${number}`;'], | |
| 38 | ['brackets in a string and a comment', 'export const m = { \'(\': 1 } satisfies Record<\'(\', 1 /* ) */>\n', 'export declare const m: Record<\'(\', 1 /* ) */>;'], | |
| 39 | ['regex with a quote', 'export const re = /\'/ satisfies RegExp\n', 'export declare const re: RegExp;'], | |
| 40 | ['regex with a bracket and slashes', 'export const re = /[)//]/ satisfies RegExp\n', 'export declare const re: RegExp;'], | |
| 41 | ['division after a non-null assertion', 'declare const n: number | undefined\nexport const ratio = n! / 2 satisfies number\n', 'export declare const ratio: number;'], | |
| 42 | ['nested template literal with a URL', 'export const url = `${`https://example.com`}` satisfies string\n', 'export declare const url: string;'], | |
| 43 | ])('still types a declaration from a top-level satisfies: %s', (_label, code, expected) => { | |
| 44 | expect(processCode(code)).toContain(expected) | |
| 45 | }) | |
| 46 | ||
| 47 | it('still types JSX from a top-level satisfies', () => { | |
| 48 | expect(processCode('export const link = (<a>https://example.com</a>) satisfies JSX.Element\n', 'link.tsx')).toContain('export declare const link: JSX.Element;') | |
| 49 | }) | |
| 50 | ||
| 51 | it('leaves a comment cut from inside a regex out', () => { | |
| 52 | const output = processCode('export const x = /=>[ satisfies RegExp /*]/\n/** docs */\nexport const after = 1\n') | |
| 53 | expect(() => transpiler.transformSync(output)).not.toThrow() | |
| 54 | expect(output).toContain('/** docs */') | |
| 55 | }) | |
| 56 | ||
| 57 | it('leaves a value whose last satisfies has nothing after it as before', () => { | |
| 58 | const output = processCode('export const x = 1 // => satisfies ;\n') | |
| 59 | expect(() => transpiler.transformSync(output)).not.toThrow() | |
| 60 | }) | |
| 61 | }) | |