ReviewOS

also looking at this

stacks/dtsx

fix(inference): keep a function-typed parameter's type whole

#3128
Open mkantautas wants to merge fix/function-typed-parameter-default into main
2 files +114 -1

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/dtsx/src/processor/type-inference.tsmodified+29-1
Changes to packages/dtsx/src/processor/type-inference.ts
@@ -638,7 +638,7 @@ function collectParameterTypes(parameters: string): Map<string, string> {
638638 if (colon !== -1) {
639639 const name = parameter.slice(0, colon).trim().replace(/^\.\.\./, '').replace(/\?$/, '')
640640 let type = parameter.slice(colon + 1).trim()
641 const defaultValue = type.indexOf('=')
641 const defaultValue = findTypeDefault(type)
642642 if (defaultValue !== -1) type = type.slice(0, defaultValue).trim()
643643 if (isIdentifierName(name) && type) types.set(name, type)
644644 }
@@ -647,6 +647,34 @@ function collectParameterTypes(parameters: string): Map<string, string> {
647647 return types
648648}
649649
650/**
651 * Index of the `=` that starts a parameter's default after its type, or -1.
652 * The `=` of an arrow in the type (`(x: number) => void = noop`) is not one.
653 * A type holding a comment or a template literal, or text that is not one
654 * type (a top-level `,`, an unopened bracket or an unclosed string, from a
655 * parameter list split elsewhere), is read as before, up to its first `=`.
656 */
657function findTypeDefault(type: string): number {
658 if (type.includes('/') || type.includes('`'))
659 return type.indexOf('=')
660 let depth = 0
661 for (let i = 0; i < type.length; i++) {
662 const char = type.charCodeAt(i)
663 if (char === 34 || char === 39) {
664 let close = i + 1
665 while (close < type.length && type.charCodeAt(close) !== char) close += type.charCodeAt(close) === 92 ? 2 : 1
666 if (close >= type.length) return type.indexOf('=')
667 i = close
668 continue
669 }
670 if (char === 40 || char === 60 || char === 91 || char === 123) depth++
671 else if (char === 41 || char === 93 || char === 125 || (char === 62 && type.charCodeAt(i - 1) !== 61)) depth--
672 else if (char === 61 && depth === 0 && type.charCodeAt(i + 1) !== 62) return i
673 if (depth < 0 || (char === 44 && depth === 0)) return type.indexOf('=')
674 }
675 return -1
676}
677
650678function inferBodyExpressionType(expression: string, parameterTypes: ReadonlyMap<string, string>): string {
651679 let value = expression.trim()
652680 while (hasBalancedOuterParentheses(value)) value = value.slice(1, -1).trim()
packages/dtsx/test/function-typed-parameter-return.test.tsadded+85-0
Changes to packages/dtsx/test/function-typed-parameter-return.test.ts
@@ -0,0 +1,85 @@
1/**
2 * An arrow function returning a parameter whose type is a function type got a
3 * cut-off return type:
4 *
5 * export const run = (g: () => void) => g
6 *
7 * emitted `(g: () => void) => ();`, which does not parse. The parameter types
8 * were read up to their first `=`, taken as the start of a default, and the
9 * first `=` of `() => void` belongs to the arrow.
10 */
11import { describe, expect, it } from 'bun:test'
12import { mkdtemp, rm, writeFile } from 'node:fs/promises'
13import { tmpdir } from 'node:os'
14import { join } from 'node:path'
15import { runTsc } from './helpers/tsc'
16import { processCode } from './test-utils'
17
18const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' })
19
20/** Type-check `consumer` against the generated declarations as `./index`. */
21async function checkConsumer(dts: string, consumer: string): Promise<string> {
22 const dir = await mkdtemp(join(tmpdir(), 'dtsx-function-typed-params-'))
23 try {
24 await writeFile(join(dir, 'index.d.ts'), dts)
25 await writeFile(join(dir, 'use.ts'), consumer)
26 return runTsc(dir, ['index.d.ts', 'use.ts']).output
27 }
28 finally {
29 await rm(dir, { recursive: true, force: true })
30 }
31}
32
33describe('returning a parameter with a function type', () => {
34 it.each([
35 [
36 'function type',
37 'export const run = (g: () => void) => g\n',
38 'run: (g: () => void) => () => void;',
39 'import { run } from \'./index\'\nconst g: () => void = run(() => {})\nexport {}\n',
40 ],
41 [
42 'function type in an object type with a default',
43 'export const options = (opts: { onDone?: (x: number) => void } = {}) => opts\n',
44 'options: (opts?: { onDone?: (x: number) => void }) => { onDone?: (x: number) => void };',
45 'import { options } from \'./index\'\nconst o: { onDone?: (x: number) => void } = options()\nexport {}\n',
46 ],
47 [
48 'function type beside a defaulted parameter',
49 'export const wrap = (fn: (a: string) => number, n = 1) => fn\n',
50 'wrap: (fn: (a: string) => number, n?: number) => (a: string) => number;',
51 'import { wrap } from \'./index\'\nconst n: number = wrap(a => a.length)(\'x\')\nexport {}\n',
52 ],
53 [
54 'function type returning a type parameter',
55 'export const make = <T>(create: () => T) => create\n',
56 'make: <T>(create: () => T) => () => T;',
57 'import { make } from \'./index\'\nconst n: number = make(() => 1)()\nexport {}\n',
58 ],
59 [
60 'function type inside a type argument with a default',
61 'export const handlers = (list: Array<() => void> = []) => list\n',
62 'handlers: (list?: Array<() => void>) => Array<() => void>;',
63 'import { handlers } from \'./index\'\nconst l: Array<() => void> = handlers()\nexport {}\n',
64 ],
65 ])('keeps the whole type: %s', async (_label, code, signature, consumer) => {
66 const output = processCode(code)
67 expect(output).toContain(signature)
68 expect(await checkConsumer(output, consumer)).toBe('')
69 })
70
71 it.each([
72 ['class method returning a function-typed parameter', 'export class K {\n m(cb: () => void = () => {}) { return cb }\n}\n', 'm(cb?: () => void): () => void;'],
73 ['a comment holding a quote', 'export function run(x: number /* user\'s choice */ = 1) { return x }\n', 'run(x?: number): number;'],
74 ['a comment holding `>`', 'export function gt(x: number /* > 0 */ = 1) { return x }\n', 'gt(x?: number): number;'],
75 ['a comment holding a comma', 'export function run(x: number /* = 1, default */ = 1) { return x }\n', 'run(x?: number): number;'],
76 ['a quoted `<` that the parameter splitter counts', 'export function run(x: ({}) => \"<\", y: number = 1) { return x }\n', 'export declare function run('],
77 ['a string literal type holding a comma', 'export function run(x: ({}) => \"a,b\") { return x }\n', 'export declare function run('],
78 ['a string literal type holding an escaped quote and a comma', 'export function run(x: ({}) => \"a\\\",b\", y: \'c\\\',d\') { return x }\n', 'export declare function run('],
79 ['a nested template literal type with a URL', 'export function run(x: `${`http://x`}` = \'http://x\') { return x }\n', 'export declare function run('],
80 ])('reads the type up to its default: %s', (_label, code, signature) => {
81 const output = processCode(code)
82 expect(() => transpiler.transformSync(output)).not.toThrow()
83 expect(output).toContain(signature)
84 })
85})