ReviewOS

also looking at this

stacks/dtsx

fix(inference): read every spelling of a number literal

#3121
Open mkantautas wants to merge fix/numeric-literal-spellings into main
3 files +141 -40
packages/dtsx/src/extractor/scanner.tsmodified+1-17
Changes to packages/dtsx/src/extractor/scanner.ts
@@ -5,7 +5,7 @@
55 */
66
77import type { Declaration } from '../types'
8import { inferFunctionBodyReturnType, inferNarrowType } from '../processor/type-inference'
8import { inferFunctionBodyReturnType, inferNarrowType, isNumericLiteral } from '../processor/type-inference'
99
1010// Character codes for fast comparison
1111const CH_SPACE = 32
@@ -1897,22 +1897,6 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme
18971897 return `${name}${optionalMarker}: ${type}`
18981898 }
18991899
1900 /** Check if string is a numeric literal (integer or decimal, optionally negative) */
1901 function isNumericLiteral(v: string): boolean {
1902 let i = 0
1903 if (i < v.length && v.charCodeAt(i) === 45 /* - */) i++
1904 if (i >= v.length) return false
1905 const c = v.charCodeAt(i)
1906 if (c < 48 || c > 57) return false // not a digit
1907 while (i < v.length && v.charCodeAt(i) >= 48 && v.charCodeAt(i) <= 57) i++
1908 if (i < v.length && v.charCodeAt(i) === 46 /* . */) {
1909 i++
1910 if (i >= v.length || v.charCodeAt(i) < 48 || v.charCodeAt(i) > 57) return false
1911 while (i < v.length && v.charCodeAt(i) >= 48 && v.charCodeAt(i) <= 57) i++
1912 }
1913 return i === v.length
1914 }
1915
19161900 /** Infer type from a default value expression (simple cases) */
19171901 function inferTypeFromDefault(value: string): string {
19181902 const v = value.trim()
packages/dtsx/src/processor/type-inference.tsmodified+78-23
Changes to packages/dtsx/src/processor/type-inference.ts
@@ -74,32 +74,87 @@ export function consumeCleanDefault(): string | null {
7474 return val
7575}
7676
77/** Check if a string matches /^-?\d+(\.\d+)?$/ without regex */
78function isNumericLiteral(s: string): boolean {
79 const len = s.length
80 if (len === 0) return false
81 let i = 0
82 if (s.charCodeAt(i) === 45 /* - */) i++
83 if (i >= len) return false
84 const digitStart = i
85 while (i < len && s.charCodeAt(i) >= 48 && s.charCodeAt(i) <= 57) i++
86 if (i === digitStart) return false // no digits
87 if (i < len && s.charCodeAt(i) === 46 /* . */) {
88 i++
89 const fracStart = i
90 while (i < len && s.charCodeAt(i) >= 48 && s.charCodeAt(i) <= 57) i++
91 if (i === fracStart) return false // no digits after dot
92 }
93 return i === len
77/** Whether char code `c` is a digit in `radix` (2, 8, 10 or 16). */
78function isDigitIn(c: number, radix: number): boolean {
79 if (radix === 16)
80 return (c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70)
81 return c >= 48 && c < 48 + radix
9482}
9583
96/** Check if s (excluding last char 'n') is all digits — matches /^\d+n$/ */
97function isBigIntDigits(s: string): boolean {
98 for (let i = 0, end = s.length - 1; i < end; i++) {
84/** Index after the digits of `radix` from `i`, with `_` allowed between two digits. */
85function digitsEnd(s: string, i: number, radix: number): number {
86 const start = i
87 while (i < s.length) {
9988 const c = s.charCodeAt(i)
100 if (c < 48 || c > 57) return false
89 if (isDigitIn(c, radix) || (c === 95 /* _ */ && i > start && isDigitIn(s.charCodeAt(i + 1), radix)))
90 i++
91 else
92 break
10193 }
102 return true
94 return i
95}
96
97/** The radix a `0x`, `0b` or `0o` prefix at `i` sets, or 10 without one. */
98function prefixRadix(s: string, i: number): number {
99 if (s.charCodeAt(i) !== 48 /* 0 */)
100 return 10
101 const p = s.charCodeAt(i + 1) | 32
102 return p === 120 /* x */ ? 16 : p === 98 /* b */ ? 2 : p === 111 /* o */ ? 8 : 10
103}
104
105/**
106 * Whether `s` is a number literal as written, optionally negative: decimal
107 * (`1`, `1.5`, `.5`, `1e3`, `1_000`), hexadecimal, binary or octal (`0xff`,
108 * `0b101`, `0o17`). Each spelling is also valid as a literal type.
109 */
110export function isNumericLiteral(s: string): boolean {
111 let i = s.charCodeAt(0) === 45 /* - */ ? 1 : 0
112 const radix = prefixRadix(s, i)
113 if (radix !== 10) {
114 const end = digitsEnd(s, i + 2, radix)
115 return end > i + 2 && end === s.length
116 }
117 let end = digitsEnd(s, i, 10)
118 // A leading zero followed by more digits (`07`, `08`) is a legacy spelling,
119 // read as digits alone as before; a separator, fraction or exponent after it
120 // (`0_1`, `00e0`) is not a number literal at all.
121 if (s.charCodeAt(i) === 48 /* 0 */ && end > i + 1)
122 return end === s.length && !s.includes('_', i)
123 const hasInteger = end > i
124 i = end
125 if (s.charCodeAt(i) === 46 /* . */) {
126 end = digitsEnd(s, i + 1, 10)
127 if (!hasInteger && end === i + 1)
128 return false
129 i = end
130 }
131 else if (!hasInteger) {
132 return false
133 }
134 if ((s.charCodeAt(i) | 32) === 101 /* e */) {
135 i++
136 if (s.charCodeAt(i) === 43 /* + */ || s.charCodeAt(i) === 45 /* - */)
137 i++
138 end = digitsEnd(s, i, 10)
139 if (end === i)
140 return false
141 i = end
142 }
143 return i === s.length
144}
145
146/** Whether `s` is a BigInt literal as written, optionally negative: `10n`, `-10n`, `1_000n`, `0xffn`. */
147function isBigIntLiteral(s: string): boolean {
148 if (s.length < 2 || s.charCodeAt(s.length - 1) !== 110 /* n */)
149 return false
150 const i = s.charCodeAt(0) === 45 /* - */ ? 1 : 0
151 const radix = prefixRadix(s, i)
152 const start = radix === 10 ? i : i + 2
153 const end = digitsEnd(s, start, radix)
154 // A decimal BigInt has no leading zero (`0n`, but not `00n` or `0_1n`).
155 if (radix === 10 && s.charCodeAt(start) === 48 /* 0 */ && end > start + 1)
156 return false
157 return end > start && end === s.length - 1
103158}
104159
105160/**
@@ -332,7 +387,7 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio
332387 }
333388
334389 // BigInt literals (digits followed by 'n')
335 if (trimmed.charCodeAt(trimmed.length - 1) === 110 /* n */ && trimmed.length > 1 && isBigIntDigits(trimmed)) {
390 if (isBigIntLiteral(trimmed)) {
336391 if (isConst) {
337392 return trimmed
338393 }
packages/dtsx/test/numeric-literal-spellings.test.tsadded+62-0
Changes to packages/dtsx/test/numeric-literal-spellings.test.ts
@@ -0,0 +1,62 @@
1/**
2 * A number written any way but plain decimal was typed `unknown`:
3 *
4 * export const TIMEOUT_MS = 30_000
5 * export const MASK = 0xff
6 * export const EPSILON = 1e-9
7 *
8 * all came out `unknown`, and so did a `let`, an object property, a parameter
9 * default or a class field initialized that way. Both copies of
10 * `isNumericLiteral` accepted only `-?\d+(\.\d+)?`, and the BigInt check only
11 * decimal digits. The Zig port already reads every spelling below.
12 */
13import { describe, expect, it } from 'bun:test'
14import { mkdtemp, rm, writeFile } from 'node:fs/promises'
15import { tmpdir } from 'node:os'
16import { join } from 'node:path'
17import { runTsc } from './helpers/tsc'
18import { processCode } from './test-utils'
19
20describe('numeric literal spellings', () => {
21 it.each([
22 ['separators', 'export const a = 30_000\n', 'const a: 30_000;'],
23 ['hexadecimal', 'export const a = 0xff\n', 'const a: 0xff;'],
24 ['binary', 'export const a = 0b101\n', 'const a: 0b101;'],
25 ['octal', 'export const a = 0o17\n', 'const a: 0o17;'],
26 ['exponent', 'export const a = -2.5e-3\n', 'const a: -2.5e-3;'],
27 ['leading dot', 'export const a = .5\n', 'const a: .5;'],
28 ['let', 'export let a = 1e3\n', 'let a: number;'],
29 ['object property', 'export let a = { max: 0x10 }\n', 'max: number'],
30 ['parameter default', 'export function f(ms = 1_000): number { return ms }\n', 'f(ms?: number): number;'],
31 ['class field', 'export class C { size = 0x10 }\n', 'size: number;'],
32 ['BigInt', 'export const a = 0xffn\n', 'const a: 0xffn;'],
33 ['trailing dot', 'export const a = 3.\n', 'const a: 3.;'],
34 ['negative separated BigInt', 'export const a = -1_000n\n', 'const a: -1_000n;'],
35 ['static readonly class constant', 'export class C { static readonly MASK = -0x10 }\n', 'static readonly MASK: -0x10;'],
36 ])('types it: %s', (_label, code, expected) => {
37 const output = processCode(code)
38 expect(output).toContain(expected)
39 expect(output).not.toContain('unknown')
40 })
41
42 it.each([
43 ['separator after a leading zero', 'export const a = 0_1\n'],
44 ['doubled separator', 'export const a = 1__0\n'],
45 ['BigInt with a leading zero', 'export const a = 0_1n\n'],
46 ])('leaves a malformed spelling alone: %s', (_label, code) => {
47 expect(processCode(code)).toContain('const a: unknown;')
48 })
49
50 it('gives a consumer the values it was written with', async () => {
51 const output = processCode('export const TIMEOUT_MS = 30_000\nexport const MASK = 0xff\nexport const EPSILON = 1e-9\nexport const BIG = 0xffn\nexport class Flags { static readonly ALL = 0b111 }\n')
52 const dir = await mkdtemp(join(tmpdir(), 'dtsx-numbers-'))
53 try {
54 await writeFile(join(dir, 'index.d.ts'), output)
55 await writeFile(join(dir, 'use.ts'), 'import { TIMEOUT_MS, MASK, EPSILON, BIG, Flags } from \'./index\'\nconst a: 30000 = TIMEOUT_MS\nconst b: 255 = MASK\nconst c: number = EPSILON\nconst d: 255n = BIG\nconst e: 7 = Flags.ALL\nexport {}\n')
56 expect(runTsc(dir, ['index.d.ts', 'use.ts']).output).toBe('')
57 }
58 finally {
59 await rm(dir, { recursive: true, force: true })
60 }
61 })
62})