also looking at this
fix(inference): read every spelling of a number literal
#3121
3 files
+141
-40
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import type { Declaration } from '../types' |
| 8 | import { inferFunctionBodyReturnType, inferNarrowType } from '../processor/type-inference' | |
| 8 | import { inferFunctionBodyReturnType, inferNarrowType, isNumericLiteral } from '../processor/type-inference' | |
| 9 | 9 | |
| 10 | 10 | // Character codes for fast comparison |
| 11 | 11 | const CH_SPACE = 32 |
| @@ -1897,22 +1897,6 @@ function scanDeclarationsInternal(_source: string, _filename: string, _keepComme | ||
| 1897 | 1897 | return `${name}${optionalMarker}: ${type}` |
| 1898 | 1898 | } |
| 1899 | 1899 | |
| 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 | ||
| 1916 | 1900 | /** Infer type from a default value expression (simple cases) */ |
| 1917 | 1901 | function inferTypeFromDefault(value: string): string { |
| 1918 | 1902 | const v = value.trim() |
| @@ -74,32 +74,87 @@ export function consumeCleanDefault(): string | null { | ||
| 74 | 74 | return val |
| 75 | 75 | } |
| 76 | 76 | |
| 77 | /** Check if a string matches /^-?\d+(\.\d+)?$/ without regex */ | |
| 78 | function 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). */ | |
| 78 | function 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 | |
| 94 | 82 | } |
| 95 | 83 | |
| 96 | /** Check if s (excluding last char 'n') is all digits — matches /^\d+n$/ */ | |
| 97 | function 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. */ | |
| 85 | function digitsEnd(s: string, i: number, radix: number): number { | |
| 86 | const start = i | |
| 87 | while (i < s.length) { | |
| 99 | 88 | 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 | |
| 101 | 93 | } |
| 102 | return true | |
| 94 | return i | |
| 95 | } | |
| 96 | ||
| 97 | /** The radix a `0x`, `0b` or `0o` prefix at `i` sets, or 10 without one. */ | |
| 98 | function 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 | */ | |
| 110 | export 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`. */ | |
| 147 | function 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 | |
| 103 | 158 | } |
| 104 | 159 | |
| 105 | 160 | /** |
| @@ -332,7 +387,7 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio | ||
| 332 | 387 | } |
| 333 | 388 | |
| 334 | 389 | // BigInt literals (digits followed by 'n') |
| 335 | if (trimmed.charCodeAt(trimmed.length - 1) === 110 /* n */ && trimmed.length > 1 && isBigIntDigits(trimmed)) { | |
| 390 | if (isBigIntLiteral(trimmed)) { | |
| 336 | 391 | if (isConst) { |
| 337 | 392 | return trimmed |
| 338 | 393 | } |
| @@ -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 | */ | |
| 13 | import { describe, expect, it } from 'bun:test' | |
| 14 | import { mkdtemp, rm, writeFile } from 'node:fs/promises' | |
| 15 | import { tmpdir } from 'node:os' | |
| 16 | import { join } from 'node:path' | |
| 17 | import { runTsc } from './helpers/tsc' | |
| 18 | import { processCode } from './test-utils' | |
| 19 | ||
| 20 | describe('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 | }) | |