also looking at this
fix(inference): keep an arrow type in constructor type arguments
#3122
2 files
+110
-3
| @@ -773,6 +773,57 @@ function skipQuotedValue(value: string, start: number, quote: number): number { | ||
| 773 | 773 | return i |
| 774 | 774 | } |
| 775 | 775 | |
| 776 | /** | |
| 777 | * Index just past the string, template literal type or comment that starts at | |
| 778 | * `start` in a type, or `start` when none does. A template literal's `${...}` | |
| 779 | * holds a type and is passed over the same way, nested template literals too. | |
| 780 | */ | |
| 781 | function skipTypeLiteralOrComment(type: string, start: number): number { | |
| 782 | const char = type.charCodeAt(start) | |
| 783 | if (char === 34 || char === 39) | |
| 784 | return skipQuotedValue(type, start, char) | |
| 785 | if (char === 47 && type.charCodeAt(start + 1) === 47) { | |
| 786 | let i = start + 2 | |
| 787 | while (i < type.length) { | |
| 788 | const c = type.charCodeAt(i) | |
| 789 | if (c === 10 || c === 13 || c === 0x2028 || c === 0x2029) break // line terminators | |
| 790 | i++ | |
| 791 | } | |
| 792 | return i | |
| 793 | } | |
| 794 | if (char === 47 && type.charCodeAt(start + 1) === 42) { | |
| 795 | const close = type.indexOf('*/', start + 2) | |
| 796 | return close === -1 ? type.length : close + 2 | |
| 797 | } | |
| 798 | if (char !== 96) | |
| 799 | return start | |
| 800 | // One entry per template literal (-1) or `${` (its open braces) still open. | |
| 801 | const open = [-1] | |
| 802 | let i = start + 1 | |
| 803 | while (i < type.length && open.length > 0) { | |
| 804 | const c = type.charCodeAt(i) | |
| 805 | const top = open.length - 1 | |
| 806 | if (open[top] === -1) { | |
| 807 | if (c === 92) { i += 2 } // backslash | |
| 808 | else if (c === 96) { open.pop(); i++ } | |
| 809 | else if (c === 36 && type.charCodeAt(i + 1) === 123) { open.push(1); i += 2 } // ${ | |
| 810 | else { i++ } | |
| 811 | } | |
| 812 | else if (c === 96) { | |
| 813 | open.push(-1) | |
| 814 | i++ | |
| 815 | } | |
| 816 | else { | |
| 817 | const next = skipTypeLiteralOrComment(type, i) | |
| 818 | if (next !== i) { i = next; continue } | |
| 819 | if (c === 123) open[top]++ | |
| 820 | else if (c === 125 && --open[top] === 0) open.pop() | |
| 821 | i++ | |
| 822 | } | |
| 823 | } | |
| 824 | return i | |
| 825 | } | |
| 826 | ||
| 776 | 827 | /** |
| 777 | 828 | * Infer type from template literal |
| 778 | 829 | */ |
| @@ -809,12 +860,20 @@ function inferNewExpressionType(value: string): string { | ||
| 809 | 860 | // Check for generic type parameters |
| 810 | 861 | let afterGenerics = afterClass |
| 811 | 862 | if (afterClass.startsWith('<')) { |
| 812 | // Extract the generic params by finding the matching '>' | |
| 863 | // Extract the generic params by finding the matching '>'. The `>` of an | |
| 864 | // arrow type does not close them (`new Map<string, () => void>()`), and | |
| 865 | // strings, template literal types and comments are passed over, so a `<` | |
| 866 | // or `>` in one (`new Set<(op: '<') => boolean>()`) is not counted either. | |
| 813 | 867 | let depth = 0 |
| 814 | 868 | let end = -1 |
| 815 | for (let j = 0; j < afterClass.length; j++) { | |
| 869 | for (let j = 0; j < afterClass.length && end === -1; j++) { | |
| 870 | const next = skipTypeLiteralOrComment(afterClass, j) | |
| 871 | if (next !== j) { j = next - 1; continue } | |
| 816 | 872 | if (afterClass[j] === '<') depth++ |
| 817 | else if (afterClass[j] === '>') { depth--; if (depth === 0) { end = j; break } } | |
| 873 | else if (afterClass[j] === '>' && afterClass[j - 1] !== '=') { | |
| 874 | depth-- | |
| 875 | if (depth === 0) end = j | |
| 876 | } | |
| 818 | 877 | } |
| 819 | 878 | if (end !== -1) { |
| 820 | 879 | const generics = afterClass.slice(0, end + 1) |
| @@ -0,0 +1,48 @@ | ||
| 1 | /** | |
| 2 | * An arrow type among a constructor's type arguments ended them early: | |
| 3 | * | |
| 4 | * export const handlers = new Map<string, () => void>() | |
| 5 | * | |
| 6 | * emitted `Map<string, () =>`, which does not parse. `inferNewExpressionType` | |
| 7 | * matched the closing `>` itself and counted the `>` of `=>` as one. Vue's | |
| 8 | * `TransitionGroup` and knip's configuration hints are written this way. | |
| 9 | * A `<` or `>` in a string, template literal type or comment is not counted | |
| 10 | * either, or `(op: '<') => boolean` would leave one `<` open. | |
| 11 | */ | |
| 12 | import { describe, expect, it } from 'bun:test' | |
| 13 | import { inferNarrowType } from '../src/processor' | |
| 14 | import { processCode } from './test-utils' | |
| 15 | ||
| 16 | const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' }) | |
| 17 | ||
| 18 | describe('arrow types in constructor type arguments', () => { | |
| 19 | it.each([ | |
| 20 | ['arrow type argument', 'export const handlers = new Map<string, () => void>()\n', 'handlers: Map<string, () => void>;'], | |
| 21 | ['arrow type nested in another type argument', 'export const listeners = new Map<string, Set<(event: Event) => void>>()\n', 'listeners: Map<string, Set<(event: Event) => void>>;'], | |
| 22 | ['arrow type returning a generic', 'export const cache = new WeakMap<object, (key: string) => Promise<number>>()\n', 'cache: WeakMap<object, (key: string) => Promise<number>>;'], | |
| 23 | ['generic arrow type', 'export const ids = new Map<string, <T>(x: T) => T>()\n', 'ids: Map<string, <T>(x: T) => T>;'], | |
| 24 | ['arrow type with a `<` in a string', 'export const callbacks = new Set<(operator: "<") => boolean>()\n', 'callbacks: Set<(operator: "<") => boolean>;'], | |
| 25 | ['arrow type and a `>` in a constructor argument', 'declare class Box<T> { value: T; constructor(label: string) }\nexport const value = new Box<(operator: "<") => boolean>("x >")\n', 'value: Box<(operator: "<") => boolean>;'], | |
| 26 | ['`<=>` inside a string type', 'export const operators = new Set<"<=>">()\n', 'operators: Set<"<=>">;'], | |
| 27 | ['`>` inside a string type', 'export const closers = new Set<\'>\'>()\n', 'closers: Set<\'>\'>;'], | |
| 28 | ['`<() =>` inside a string type', 'export const marks = new Set<"<() =>">()\n', 'marks: Set<"<() =>">;'], | |
| 29 | ['`<=>` inside a template literal type', 'export const ops = new Set<`${string}<=>${string}`>()\n', 'ops: Set<`${string}<=>${string}`>;'], | |
| 30 | ['`<(a) =>` inside a template literal type', 'export const arrows = new Set<`<(a) =>${string}`>()\n', 'arrows: Set<`<(a) =>${string}`>;'], | |
| 31 | ['nested template literal type', 'export const tags = new Set<`<${`${string}>`}`>()\n', 'tags: Set<`<${`${string}>`}`>;'], | |
| 32 | ['`//` in a nested template literal type', 'export const urls = new Set<`url(${`https://${string}`})`>()\n', 'urls: Set<`url(${`https://${string}`})`>;'], | |
| 33 | ['`>` after a line comment ended by a carriage return', 'export const nums = new Set<number // items\r>()\n', 'nums: Set<number // items\r>;'], | |
| 34 | ['`>` after a line comment ended by U+2028', 'export const nums = new Set<number // items\u2028>()\n', 'nums: Set<number // items\u2028>;'], | |
| 35 | ['`>` after a line comment ended by U+2029', 'export const nums = new Set<number // items\u2029>()\n', 'nums: Set<number // items\u2029>;'], | |
| 36 | ['`<() =>` inside a block comment', 'export const nums = new Set<number /* <() => */>()\n', 'nums: Set<number /* <() => */>;'], | |
| 37 | ['`>` in a constructor argument', 'declare class Box<T> { value: T; constructor(label: string) }\nexport const boxed = new Box<"<=>">("x >")\n', 'boxed: Box<"<=>">;'], | |
| 38 | ])('keeps the whole type: %s', (_label, code, expected) => { | |
| 39 | const output = processCode(code) | |
| 40 | expect(() => transpiler.transformSync(output)).not.toThrow() | |
| 41 | expect(output).toContain(expected) | |
| 42 | }) | |
| 43 | ||
| 44 | it('reads deeply nested template literal types without recursing', () => { | |
| 45 | const type = `\`${'${`'.repeat(50_000)}x${'`}'.repeat(50_000)}\`` | |
| 46 | expect(inferNarrowType(`new Set<${type}>()`)).toBe(`Set<${type}>`) | |
| 47 | }) | |
| 48 | }) | |