ReviewOS

also looking at this

stacks/dtsx

fix(emit): skip a satisfies type that is cut from inside the initializer

#3123
Open mkantautas wants to merge fix/satisfies-top-level into main
3 files +109 -3
packages/dtsx/src/processor/declarations.tsmodified+6-3
Changes to packages/dtsx/src/processor/declarations.ts
@@ -4,7 +4,7 @@
44
55import type { Declaration } from '../types'
66import { formatComments } from './comments'
7import { consumeCleanDefault, enableCleanDefaultCollection, extractSatisfiesType, inferNarrowType, isGenericType } from './type-inference'
7import { consumeCleanDefault, enableCleanDefaultCollection, extractSatisfiesType, inferNarrowType, isBalancedType, isGenericType } from './type-inference'
88
99/**
1010 * Format a @defaultValue tag as a standalone JSDoc block
@@ -109,10 +109,13 @@ export function processVariableDeclaration(decl: Declaration, keepComments: bool
109109 let typeAnnotation = decl.typeAnnotation
110110 const value = decl.value as string | undefined
111111
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.
113116 if (value && value.includes(' satisfies ')) {
114117 const satisfiesType = extractSatisfiesType(value)
115 if (satisfiesType) {
118 if (satisfiesType && isBalancedType(satisfiesType)) {
116119 typeAnnotation = satisfiesType
117120 }
118121 }
packages/dtsx/src/processor/type-inference.tsmodified+42-0
Changes to packages/dtsx/src/processor/type-inference.ts
@@ -2741,6 +2741,48 @@ export function extractSatisfiesType(value: string): string | null {
27412741 return typeStr || null
27422742}
27432743
2744/**
2745 * Whether `type` closes every bracket, string, template literal and comment it
2746 * opens, in order, and opens every bracket it closes.
2747 */
2748export 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
27442786/**
27452787 * Infer mapped type from type expression
27462788 * Handles patterns like { [K in keyof T]: V }
packages/dtsx/test/satisfies-top-level.test.tsadded+61-0
Changes to packages/dtsx/test/satisfies-top-level.test.ts
@@ -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 */
15import { describe, expect, it } from 'bun:test'
16import { processCode } from './test-utils'
17
18const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' })
19
20describe('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})