also looking at this
fix(inference): keep a function-typed parameter's type whole
#3128
2 files
+114
-1
| @@ -638,7 +638,7 @@ function collectParameterTypes(parameters: string): Map<string, string> { | ||
| 638 | 638 | if (colon !== -1) { |
| 639 | 639 | const name = parameter.slice(0, colon).trim().replace(/^\.\.\./, '').replace(/\?$/, '') |
| 640 | 640 | let type = parameter.slice(colon + 1).trim() |
| 641 | const defaultValue = type.indexOf('=') | |
| 641 | const defaultValue = findTypeDefault(type) | |
| 642 | 642 | if (defaultValue !== -1) type = type.slice(0, defaultValue).trim() |
| 643 | 643 | if (isIdentifierName(name) && type) types.set(name, type) |
| 644 | 644 | } |
| @@ -647,6 +647,34 @@ function collectParameterTypes(parameters: string): Map<string, string> { | ||
| 647 | 647 | return types |
| 648 | 648 | } |
| 649 | 649 | |
| 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 | */ | |
| 657 | function 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 | ||
| 650 | 678 | function inferBodyExpressionType(expression: string, parameterTypes: ReadonlyMap<string, string>): string { |
| 651 | 679 | let value = expression.trim() |
| 652 | 680 | while (hasBalancedOuterParentheses(value)) value = value.slice(1, -1).trim() |
| @@ -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 | */ | |
| 11 | import { describe, expect, it } from 'bun:test' | |
| 12 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 13 | import { tmpdir } from 'node:os' | |
| 14 | import { join } from 'node:path' | |
| 15 | import { runTsc } from './helpers/tsc' | |
| 16 | import { processCode } from './test-utils' | |
| 17 | ||
| 18 | const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' }) | |
| 19 | ||
| 20 | /** Type-check `consumer` against the generated declarations as `./index`. */ | |
| 21 | async 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 | ||
| 33 | describe('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 | }) | |