diff --git a/src/core/enum.ts b/src/core/enum.ts index af79f81..a27c65b 100644 --- a/src/core/enum.ts +++ b/src/core/enum.ts @@ -52,6 +52,11 @@ function evaluate(exp: string): string | number { return new Function(`return ${exp}`)() } +/** + * A function that resolves to a value + */ +type Resolver = () => T + /** * Scans the specified directory for enums based on the provided options. * @param options - The scan options for the enum. @@ -61,8 +66,9 @@ export function scanEnums(options: ScanOptions): EnumData { const declarations: { [file: string]: EnumDeclaration[] } = Object.create(null) - const defines: { [id_key: `${string}.${string}`]: string } = - Object.create(null) + const defines: { + [id_key: `${string}.${string}`]: Resolver + } = Object.create(null) // 1. grep for files with exported enum const files = scanFiles(options) @@ -91,13 +97,13 @@ export function scanEnums(options: ScanOptions): EnumData { } enumIds.add(id) - let lastInitialized: string | number | undefined + let lastInitialized: Resolver = () => -1 const members: Array = [] for (const e of decl.members) { const key = e.id.type === 'Identifier' ? e.id.name : e.id.value const fullKey = `${id}.${key}` as const - const saveValue = (value: string | number) => { + const saveValue = (resolver: Resolver) => { // We need allow same name enum in different file. // For example: enum ErrorCodes exist in both @vue/compiler-core and @vue/runtime-core // But not allow `ErrorCodes.__EXTEND_POINT__` appear in two same name enum @@ -106,86 +112,84 @@ export function scanEnums(options: ScanOptions): EnumData { } members.push({ name: key, - value, + get value() { + return defines[fullKey]() + }, }) - defines[fullKey] = JSON.stringify(value) + + let resolved: number | string | undefined + let resolving = false + defines[fullKey] = () => { + if (resolved !== undefined) return resolved + if (resolving) + throw new Error( + `circular reference evaluating ${fullKey} in ${file}`, + ) + resolving = true + resolved = resolver() + return resolved + } } const init = e.initializer if (init) { - let value: string | number - switch (init.type) { - case 'StringLiteral': - case 'NumericLiteral': { - value = init.value - - break - } - case 'BinaryExpression': { - const resolveValue = (node: Expression | PrivateName) => { - assert.ok(typeof node.start === 'number') - assert.ok(typeof node.end === 'number') - if ( - node.type === 'NumericLiteral' || - node.type === 'StringLiteral' - ) { - return node.value - } else if (node.type === 'MemberExpression') { - const exp = content.slice( - node.start, - node.end, - ) as `${string}.${string}` - if (!(exp in defines)) { - throw new Error( - `unhandled enum initialization expression ${exp} in ${file}`, - ) - } - return defines[exp] - } else { - throw new Error( - `unhandled BinaryExpression operand type ${node.type} in ${file}`, - ) + const resolveValue = ( + node: Expression | PrivateName, + ): Resolver => { + assert.ok(typeof node.start === 'number') + assert.ok(typeof node.end === 'number') + + switch (node.type) { + case 'NumericLiteral': + case 'StringLiteral': + return () => node.value + + case 'MemberExpression': { + const exp = content.slice( + node.start, + node.end, + ) as `${string}.${string}` + return () => { + if (defines[exp]) return defines[exp]() + throw new Error(`unresolved expression ${exp} in ${file}`) } } - const exp = `${resolveValue(init.left)}${ - init.operator - }${resolveValue(init.right)}` - value = evaluate(exp) - - break - } - case 'UnaryExpression': { - if ( - init.argument.type === 'StringLiteral' || - init.argument.type === 'NumericLiteral' - ) { - const exp = `${init.operator}${init.argument.value}` - value = evaluate(exp) - } else { + case 'Identifier': { + const exp = `${id}.${node.name}` as const + return () => { + if (defines[exp]) return defines[exp]() + throw new Error(`unresolved expression ${exp} in ${file}`) + } + } + case 'BinaryExpression': { + const left = resolveValue(node.left) + const right = resolveValue(node.right) + return () => + evaluate( + `${JSON.stringify(left())}${node.operator}${JSON.stringify(right())}`, + ) + } + case 'UnaryExpression': { + const arg = resolveValue(node.argument) + return () => + evaluate(`${node.operator}${JSON.stringify(arg())}`) + } + default: throw new Error( - `unhandled UnaryExpression argument type ${init.argument.type} in ${file}`, + `unhandled expression type ${node.type} in ${file}`, ) - } - - break - } - default: { - throw new Error( - `unhandled initializer type ${init.type} for ${fullKey} in ${file}`, - ) } } - lastInitialized = value - saveValue(lastInitialized) - } else if (lastInitialized === undefined) { - // first initialized - lastInitialized = 0 - saveValue(lastInitialized) - } else if (typeof lastInitialized === 'number') { - lastInitialized++ + lastInitialized = resolveValue(init) saveValue(lastInitialized) } else { - // should not happen - throw new TypeError(`wrong enum initialization sequence in ${file}`) + const prev = lastInitialized + lastInitialized = () => { + const previous = prev() + if (typeof previous === 'string') + throw new Error(`wrong enum initialization sequence in ${file}`) + return previous + 1 + } + saveValue(lastInitialized) } } @@ -205,7 +209,12 @@ export function scanEnums(options: ScanOptions): EnumData { const enumData: EnumData = { declarations, - defines, + defines: Object.fromEntries( + Object.entries(defines).map(([key, value]) => [ + key, + JSON.stringify(value()), + ]), + ), } return enumData } diff --git a/src/index.ts b/src/index.ts index 2a74787..d762e07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,15 @@ const InlineEnum: UnpluginInstance = createUnplugin< id, members, } = declaration + // For numeric members with duplicate values, only the last one + // gets a reverse mapping to avoid duplicate keys in the object literal. + // This matches TypeScript's runtime behavior where later assignments overwrite earlier ones. + const lastForValue = new Map() + for (const { name, value } of members) { + if (typeof value === 'number') { + lastForValue.set(value, name) + } + } s.update( start, end, @@ -68,11 +77,13 @@ const InlineEnum: UnpluginInstance = createUnplugin< forwardMapping, // string enum members do not get a reverse mapping generated at all ] - : [ - forwardMapping, - // other enum members should support enum reverse mapping - reverseMapping, - ] + : lastForValue.get(value) === name + ? [ + forwardMapping, + // numeric enum members get a reverse mapping (last wins) + reverseMapping, + ] + : [forwardMapping] }) .join(',\n')}}`, ) diff --git a/tests/__snapshots__/scan-enums.spec.ts.snap b/tests/__snapshots__/scan-enums.spec.ts.snap index 69d4bab..abee5eb 100644 --- a/tests/__snapshots__/scan-enums.spec.ts.snap +++ b/tests/__snapshots__/scan-enums.spec.ts.snap @@ -23,10 +23,113 @@ exports[`scanEnums > scanMode: fs 1`] = ` "name": "D", "value": 3.14, }, + { + "name": "E", + "value": -1, + }, + { + "name": "F", + "value": -1, + }, ], "range": [ - 0, - 74, + 35, + 129, + ], + }, + { + "id": "Flags", + "members": [ + { + "name": "None", + "value": 0, + }, + { + "name": "A", + "value": 1, + }, + { + "name": "B", + "value": 2, + }, + { + "name": "C", + "value": 4, + }, + { + "name": "D", + "value": 8, + }, + { + "name": "E", + "value": 16, + }, + { + "name": "AB", + "value": 3, + }, + { + "name": "ABC", + "value": 7, + }, + { + "name": "ABCD", + "value": 15, + }, + { + "name": "All", + "value": 15, + }, + { + "name": "NotAll", + "value": -16, + }, + ], + "range": [ + 223, + 456, + ], + }, + { + "id": "Items", + "members": [ + { + "name": "First", + "value": 0, + }, + { + "name": "Second", + "value": 1, + }, + { + "name": "Third", + "value": 2, + }, + { + "name": "Last", + "value": 2, + }, + { + "name": "Next", + "value": 3, + }, + ], + "range": [ + 535, + 608, + ], + }, + { + "id": "CrossFileRef", + "members": [ + { + "name": "X", + "value": 100, + }, + ], + "range": [ + 610, + 657, ], }, ], @@ -59,10 +162,29 @@ exports[`scanEnums > scanMode: fs 1`] = ` ], }, "defines": { + "CrossFileRef.X": "100", + "Flags.A": "1", + "Flags.AB": "3", + "Flags.ABC": "7", + "Flags.ABCD": "15", + "Flags.All": "15", + "Flags.B": "2", + "Flags.C": "4", + "Flags.D": "8", + "Flags.E": "16", + "Flags.None": "0", + "Flags.NotAll": "-16", + "Items.First": "0", + "Items.Last": "2", + "Items.Next": "3", + "Items.Second": "1", + "Items.Third": "2", "TestEnum.A": ""foo"", "TestEnum.B": "100", "TestEnum.C": "4", "TestEnum.D": "3.14", + "TestEnum.E": "-1", + "TestEnum.F": "-1", "TestEnum2.A": ""foo"", "TestEnum2.B": "100", "TestEnum2.C": "4", diff --git a/tests/enums/ts.ts b/tests/enums/ts.ts index 62b1ed6..32843bd 100644 --- a/tests/enums/ts.ts +++ b/tests/enums/ts.ts @@ -1,6 +1,38 @@ +import { TestEnum2 } from './tsx' + export enum TestEnum { A = 'foo', B = 100, C = 1 << 2, D = 3.14, + E = -1, + F = ~0, +} + +// nested BinaryExpression, self-referencing compound members, MemberExpression initializer +export enum Flags { + None = 0x00, + A = 0x01, + B = 0x02, + C = 0x04, + D = 0x08, + E = 0x10, + AB = Flags.A | Flags.B, + ABC = Flags.AB | Flags.C, + ABCD = Flags.AB | Flags.C | Flags.D, + All = Flags.ABCD, + NotAll = ~Flags.ABCD, +} + +// bare Identifier referencing sibling enum member, duplicate numeric values +export enum Items { + First, + Second, + Third, + Last = Third, + Next, +} + +export enum CrossFileRef { + X = TestEnum2.B, } diff --git a/tsconfig.json b/tsconfig.json index 380a7c1..b84267f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["es2023"], "moduleDetection": "force", "module": "preserve", + "jsx": "preserve", "moduleResolution": "bundler", "resolveJsonModule": true, "types": ["node"], @@ -11,7 +12,7 @@ "noUnusedLocals": true, "declaration": true, "esModuleInterop": true, - "isolatedDeclarations": true, + "isolatedDeclarations": false, "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true