ReviewOS

also looking at this

stacks/dtsx

fix(inference): keep an arrow type in constructor type arguments

#3122
Open mkantautas wants to merge fix/new-expression-arrow-type into main
2 files +110 -3
packages/dtsx/src/processor/type-inference.tsmodified+62-3
Changes to packages/dtsx/src/processor/type-inference.ts
@@ -773,6 +773,57 @@ function skipQuotedValue(value: string, start: number, quote: number): number {
773773 return i
774774}
775775
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 */
781function 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
776827/**
777828 * Infer type from template literal
778829 */
@@ -809,12 +860,20 @@ function inferNewExpressionType(value: string): string {
809860 // Check for generic type parameters
810861 let afterGenerics = afterClass
811862 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.
813867 let depth = 0
814868 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 }
816872 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 }
818877 }
819878 if (end !== -1) {
820879 const generics = afterClass.slice(0, end + 1)
packages/dtsx/test/new-expression-type-arguments.test.tsadded+48-0
Changes to packages/dtsx/test/new-expression-type-arguments.test.ts
@@ -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 */
12import { describe, expect, it } from 'bun:test'
13import { inferNarrowType } from '../src/processor'
14import { processCode } from './test-utils'
15
16const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' })
17
18describe('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})