also looking at this
fix(inference): leave defaults out of annotated destructured arrow parameters
#3127
2 files
+248
-2
| @@ -1580,7 +1580,12 @@ export function cleanParameterDefaults(params: string): string { | ||
| 1580 | 1580 | } |
| 1581 | 1581 | } |
| 1582 | 1582 | } |
| 1583 | if (!hasRealEqual) return stripped | |
| 1583 | if (!hasRealEqual) { | |
| 1584 | // No parameter has a default of its own, but a destructuring pattern may | |
| 1585 | // hold some; those are taken out in place. | |
| 1586 | const cleanedInner = stripPatternDefaultsInList(inner) | |
| 1587 | return cleanedInner === inner ? stripped : (hadParens ? `(${cleanedInner})` : cleanedInner) | |
| 1588 | } | |
| 1584 | 1589 | |
| 1585 | 1590 | // Split parameters by comma at depth 0, preserving whitespace around commas |
| 1586 | 1591 | const paramParts: string[] = [] |
| @@ -1728,7 +1733,7 @@ function cleanSingleParam(param: string): string { | ||
| 1728 | 1733 | |
| 1729 | 1734 | if (colonIdx !== -1 && (equalIdx === -1 || colonIdx < equalIdx)) { |
| 1730 | 1735 | // Has type annotation: name[?]: type [= default] |
| 1731 | const name = param.slice(0, colonIdx).trim() | |
| 1736 | const name = stripPatternDefaults(param.slice(0, colonIdx).trim()) | |
| 1732 | 1737 | const type = equalIdx !== -1 |
| 1733 | 1738 | ? param.slice(colonIdx + 1, equalIdx).trim() |
| 1734 | 1739 | : param.slice(colonIdx + 1).trim() |
| @@ -1756,6 +1761,149 @@ function cleanSingleParam(param: string): string { | ||
| 1756 | 1761 | return param |
| 1757 | 1762 | } |
| 1758 | 1763 | |
| 1764 | /** | |
| 1765 | * `params` with the defaults taken out of each annotated destructuring pattern | |
| 1766 | * (`{ a = 1 }: T, b: U` is `{ a }: T, b: U`) and every other character as | |
| 1767 | * written. A list holding a `/` (a comment or a regex, whose text could pass | |
| 1768 | * for parameters) or a template literal is returned as it is. | |
| 1769 | */ | |
| 1770 | function stripPatternDefaultsInList(params: string): string { | |
| 1771 | if (params.includes('/') || params.includes('`')) | |
| 1772 | return params | |
| 1773 | let result = '' | |
| 1774 | let copied = 0 | |
| 1775 | let depth = 0 | |
| 1776 | let patternStart = -1 | |
| 1777 | let atParamStart = true | |
| 1778 | for (let i = 0; i < params.length; i++) { | |
| 1779 | const ch = params.charCodeAt(i) | |
| 1780 | if (ch === 34 || ch === 39) { | |
| 1781 | i = skipQuotedValue(params, i, ch) - 1 | |
| 1782 | atParamStart = false | |
| 1783 | continue | |
| 1784 | } | |
| 1785 | if (atParamStart && ch > 32) { | |
| 1786 | patternStart = depth === 0 && (ch === 91 || ch === 123) ? i : -1 | |
| 1787 | atParamStart = false | |
| 1788 | } | |
| 1789 | if (ch === 40 || ch === 60 || ch === 91 || ch === 123) { | |
| 1790 | depth++ | |
| 1791 | } | |
| 1792 | else if (ch === 41 || ch === 93 || ch === 125 || (ch === 62 && params.charCodeAt(i - 1) !== 61)) { | |
| 1793 | if (--depth === 0 && patternStart !== -1 && (ch === 93 || ch === 125)) { | |
| 1794 | const pattern = params.slice(patternStart, i + 1) | |
| 1795 | const cleaned = stripPatternDefaults(pattern) | |
| 1796 | if (cleaned !== pattern && params.slice(i + 1).trimStart().startsWith(':')) { | |
| 1797 | result += params.slice(copied, patternStart) + cleaned | |
| 1798 | copied = i + 1 | |
| 1799 | } | |
| 1800 | patternStart = -1 | |
| 1801 | } | |
| 1802 | } | |
| 1803 | else if (ch === 44 && depth === 0) { | |
| 1804 | atParamStart = true | |
| 1805 | } | |
| 1806 | } | |
| 1807 | return copied === 0 ? params : result + params.slice(copied) | |
| 1808 | } | |
| 1809 | ||
| 1810 | /** | |
| 1811 | * A parameter's destructuring pattern without its default values, which a | |
| 1812 | * declaration may not have (TS2371): `{ a = 1, b: [c = 'x'] = [] }` is | |
| 1813 | * `{ a, b: [c] }`. Only a pattern of names, string and number keys, nested | |
| 1814 | * patterns and rest elements, whose defaults are each a name, a number, a | |
| 1815 | * string, `[]` or `{}`, is rewritten. Any other text is returned as it is. | |
| 1816 | */ | |
| 1817 | function stripPatternDefaults(name: string): string { | |
| 1818 | let i = 0 | |
| 1819 | let out = '' | |
| 1820 | const space = (): string => { | |
| 1821 | const start = i | |
| 1822 | while (i < name.length && name.charCodeAt(i) <= 32) i++ | |
| 1823 | return name.slice(start, i) | |
| 1824 | } | |
| 1825 | const word = (dots: boolean): string => { | |
| 1826 | const start = i | |
| 1827 | while (i < name.length && (isWordChar(name.charCodeAt(i)) || (dots && name[i] === '.'))) i++ | |
| 1828 | return name.slice(start, i) | |
| 1829 | } | |
| 1830 | const quoted = (): string => { | |
| 1831 | const quote = name.charCodeAt(i) | |
| 1832 | const end = skipQuotedValue(name, i, quote) | |
| 1833 | if (end > name.length || end - 1 === i || name.charCodeAt(end - 1) !== quote) return '' | |
| 1834 | const text = name.slice(i, end) | |
| 1835 | i = end | |
| 1836 | return text | |
| 1837 | } | |
| 1838 | const literal = (): boolean => { | |
| 1839 | if (name[i] === '"' || name[i] === '\'') return quoted() !== '' | |
| 1840 | if (name.startsWith('[]', i) || name.startsWith('{}', i)) { i += 2; return true } | |
| 1841 | if (name[i] === '-') i++ | |
| 1842 | return word(true) !== '' | |
| 1843 | } | |
| 1844 | const target = (): boolean => { | |
| 1845 | if (name[i] === '{' || name[i] === '[') return pattern() | |
| 1846 | const id = word(false) | |
| 1847 | out += id | |
| 1848 | return isIdentifierName(id) | |
| 1849 | } | |
| 1850 | const element = (inObject: boolean): boolean => { | |
| 1851 | if (name.startsWith('...', i)) { | |
| 1852 | out += '...' | |
| 1853 | i += 3 | |
| 1854 | out += space() | |
| 1855 | return target() | |
| 1856 | } | |
| 1857 | if (inObject) { | |
| 1858 | const key = name[i] === '"' || name[i] === '\'' ? quoted() : word(false) | |
| 1859 | if (!key) return false | |
| 1860 | out += key | |
| 1861 | const afterKey = i | |
| 1862 | const gap = space() | |
| 1863 | if (name[i] === ':') { | |
| 1864 | out += `${gap}:` | |
| 1865 | i++ | |
| 1866 | out += space() | |
| 1867 | if (!target()) return false | |
| 1868 | } | |
| 1869 | else { | |
| 1870 | i = afterKey | |
| 1871 | if (!isIdentifierName(key)) return false | |
| 1872 | } | |
| 1873 | } | |
| 1874 | else if (!target()) { | |
| 1875 | return false | |
| 1876 | } | |
| 1877 | const afterTarget = i | |
| 1878 | space() | |
| 1879 | if (name[i] === '=' && name[i + 1] !== '=' && name[i + 1] !== '>') { | |
| 1880 | i++ | |
| 1881 | space() | |
| 1882 | return literal() | |
| 1883 | } | |
| 1884 | i = afterTarget | |
| 1885 | return true | |
| 1886 | } | |
| 1887 | const pattern = (): boolean => { | |
| 1888 | const open = name[i] | |
| 1889 | const close = open === '{' ? '}' : ']' | |
| 1890 | out += open | |
| 1891 | i++ | |
| 1892 | for (;;) { | |
| 1893 | out += space() | |
| 1894 | if (name[i] === close) { out += close; i++; return true } | |
| 1895 | if (open === '[' && name[i] === ',') { out += ','; i++; continue } // a hole | |
| 1896 | if (!element(open === '{')) return false | |
| 1897 | out += space() | |
| 1898 | if (name[i] === ',') { out += ','; i++; continue } | |
| 1899 | if (name[i] === close) { out += close; i++; return true } | |
| 1900 | return false | |
| 1901 | } | |
| 1902 | } | |
| 1903 | if (name[0] !== '{' && name[0] !== '[') return name | |
| 1904 | return pattern() && i === name.length ? out : name | |
| 1905 | } | |
| 1906 | ||
| 1759 | 1907 | /** |
| 1760 | 1908 | * Find the index after any leading block comments and whitespace in a key. |
| 1761 | 1909 | * Used to locate the actual identifier so modifiers like `async` can be |
| @@ -0,0 +1,98 @@ | ||
| 1 | /** | |
| 2 | * An arrow function's destructured parameter kept its default values: | |
| 3 | * | |
| 4 | * export const glob = ({ cwd, gitignore = true }: GlobOptions) => ... | |
| 5 | * | |
| 6 | * emitted `({ cwd, gitignore = true }: GlobOptions) => ...`, and TypeScript | |
| 7 | * rejects an initializer in a declaration (TS2371). knip's `util/glob` came out | |
| 8 | * this way. A function declaration's parameters already lost them. | |
| 9 | */ | |
| 10 | import { describe, expect, it } from 'bun:test' | |
| 11 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 12 | import { tmpdir } from 'node:os' | |
| 13 | import { join } from 'node:path' | |
| 14 | import { runTsc } from './helpers/tsc' | |
| 15 | import { processCode } from './test-utils' | |
| 16 | ||
| 17 | /** Type-check `consumer` against the generated declarations as `./index`. */ | |
| 18 | async function checkConsumer(dts: string, consumer: string): Promise<string> { | |
| 19 | const dir = await mkdtemp(join(tmpdir(), 'dtsx-pattern-defaults-')) | |
| 20 | try { | |
| 21 | await writeFile(join(dir, 'index.d.ts'), dts) | |
| 22 | await writeFile(join(dir, 'use.ts'), consumer) | |
| 23 | return runTsc(dir, ['index.d.ts', 'use.ts']).output | |
| 24 | } | |
| 25 | finally { | |
| 26 | await rm(dir, { recursive: true, force: true }) | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | describe('destructured arrow parameters with defaults', () => { | |
| 31 | it.each([ | |
| 32 | [ | |
| 33 | 'object pattern', | |
| 34 | 'export interface GlobOptions { cwd: string, dir?: string, gitignore?: boolean }\nexport const glob = ({ cwd, dir = cwd, gitignore = true }: GlobOptions): string => dir + gitignore\n', | |
| 35 | '({ cwd, dir, gitignore }: GlobOptions) => string', | |
| 36 | 'import { glob } from \'./index\'\nconst s: string = glob({ cwd: \'.\' })\nexport {}\n', | |
| 37 | ], | |
| 38 | [ | |
| 39 | 'array pattern', | |
| 40 | 'export const first = ([x = 0, y]: [number?, number?]): number => x\n', | |
| 41 | '([x, y]: [number?, number?]) => number', | |
| 42 | 'import { first } from \'./index\'\nconst n: number = first([])\nexport {}\n', | |
| 43 | ], | |
| 44 | [ | |
| 45 | 'nested pattern with its own default', | |
| 46 | 'export const pick = ({ o: { p = \'z\' } = {} }: { o?: { p?: string } }): string => p\n', | |
| 47 | '({ o: { p } }: { o?: { p?: string } }) => string', | |
| 48 | 'import { pick } from \'./index\'\nconst s: string = pick({})\nexport {}\n', | |
| 49 | ], | |
| 50 | [ | |
| 51 | 'pattern with a parameter default', | |
| 52 | 'export const add = (n = 5, { m = 2 }: { m?: number } = {}): number => n + m\n', | |
| 53 | '(n?: number, { m }?: { m?: number }) => number', | |
| 54 | 'import { add } from \'./index\'\nconst a: number = add()\nconst b: number = add(1, { m: 3 })\nexport {}\n', | |
| 55 | ], | |
| 56 | [ | |
| 57 | 'defaults that are names, numbers, strings and empty literals, and a rest element', | |
| 58 | 'export interface Opts { a?: number, b?: number, c?: string, d?: string, e?: string[], f?: object, g?: boolean }\nconst base = { c: \'x\' }\nexport const opts = ({ a = 1.5, b = -2, c = base.c, d = \'q\', e = [], f = {}, ...rest }: Opts): number => a + b\n', | |
| 59 | '({ a, b, c, d, e, f, ...rest }: Opts) => number', | |
| 60 | 'import { opts } from \'./index\'\nconst n: number = opts({})\nexport {}\n', | |
| 61 | ], | |
| 62 | [ | |
| 63 | 'two patterns around a generic type', | |
| 64 | 'export const f = ({ a = 1 }: { a?: number }, b: Map<string, { c: number }>, { d = 2 }: { d?: number }): number => a\n', | |
| 65 | '({ a }: { a?: number }, b: Map<string, { c: number }>, { d }: { d?: number }) => number', | |
| 66 | 'import { f } from \'./index\'\nconst n: number = f({}, new Map(), {})\nexport {}\n', | |
| 67 | ], | |
| 68 | ])('leaves the defaults out: %s', async (_label, code, signature, consumer) => { | |
| 69 | const output = processCode(code) | |
| 70 | expect(output).toContain(signature) | |
| 71 | expect(await checkConsumer(output, consumer)).toBe('') | |
| 72 | }) | |
| 73 | ||
| 74 | it.each([ | |
| 75 | ['a line comment holding `=`', 'export const f = ({ x, // seconds = ms / 1000\n y }: { x: number, y: number }): number => 1\n', '// seconds = ms / 1000\n y }'], | |
| 76 | ['a default with type arguments', 'export const f = ({ map = new Map<string, number>() }: { map?: Map<string, number> }): number => 1\n', '{ map = new Map<string, number>() }'], | |
| 77 | ['a computed key with an assignment', 'let key: string | undefined\nexport const f = ({ [key ??= \'x\']: x }: Record<string, number>): number => x\n', '[key ??= \'x\']: x'], | |
| 78 | ['a default with operators, a string holding `=` and an arrow', 'export const f = ({ s = \'x=y\', eq = 1 === 1, cb = (q: number) => q }: { s?: string, eq?: boolean, cb?: (q: number) => number }): string => s\n', '{ s = \'x=y\', eq = 1 === 1, cb = (q: number) => q }'], | |
| 79 | ['comparisons in two patterns', 'type B = { b?: boolean }\nexport const f = ({ a = 1 < 2 }, { b = 2 > 1 }: B): boolean => !!a && !!b\n', '({ a = 1 < 2 }, { b = 2 > 1 }: B)'], | |
| 80 | ['a computed key with a default', 'let key: \'a\' | \'b\' = \'a\'\nexport const f = ({ [key = \'b\']: value }: { a: string, b: number }, n = 1): typeof value => value\n', '{ [key = \'b\']: value }'], | |
| 81 | ['a regex default holding a comma', 'export const f = ({ re = /a,b/ }: { re?: RegExp }): number => 1\n', '{ re = /a,b/ }'], | |
| 82 | ['a regex default holding `,b=`', 'export const f = ({ re = /a,b=c/ }: { re?: RegExp }): boolean => re.test(\'a\')\n', '{ re = /a,b=c/ }'], | |
| 83 | ['a default holding a block comment', 'export const f = ({ n = 1 /* a,b=c */ }: { n?: number }): number => n\n', '{ n = 1 /* a,b=c */ }'], | |
| 84 | ['a default holding a nested template literal', 'export const f = ({ t = `${`a,b=c`}` }: { t?: string }): string => t\n', '{ t = `${`a,b=c`}` }'], | |
| 85 | ['a pattern without a type, whose defaults give its type', 'export const f = ({ verbose = false }): boolean => verbose\n', '{ verbose = false }'], | |
| 86 | ['a parameter beside it with a block comment', 'export const f = ({ a = 1 }: { a?: number }, /* note */ b: string): number => a\n', '({ a = 1 }: { a?: number }, /* note */ b: string)'], | |
| 87 | ['a regex that looks like another parameter', 'export const f = ({ re = /}, { b = 2 }:B/ }: { re?: RegExp }): number => 1\n', '{ re = /}, { b = 2 }:B/ }'], | |
| 88 | ['a parameter beside it with a nested template literal type', 'export const f = ({ x = 1 }: { x?: number }, query: `?${`id=${number}`}`): number => x\n', '({ x = 1 }: { x?: number }, query: `?${`id=${number}`}`)'], | |
| 89 | ['a parameter beside it with `//` in a comment', 'export const f = ({ x = 1 }: { x?: number }, /** See https://example.com */ y: number): number => x + y\n', '/** See https://example.com */ y: number'], | |
| 90 | ])('leaves a pattern it cannot cut cleanly as written: %s', (_label, code, kept) => { | |
| 91 | expect(processCode(code)).toContain(kept) | |
| 92 | }) | |
| 93 | ||
| 94 | it('keeps an unannotated pattern beside it as written', () => { | |
| 95 | expect(processCode('export const f = ({ op = \'<\' }: { op?: string }, { valid = 1 > 0, verbose = false }): string => op\n')) | |
| 96 | .toContain('({ op }: { op?: string }, { valid = 1 > 0, verbose = false }) => string') | |
| 97 | }) | |
| 98 | }) | |