Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 82 additions & 73 deletions src/core/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = () => T

/**
* Scans the specified directory for enums based on the provided options.
* @param options - The scan options for the enum.
Expand All @@ -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<string | number>
} = Object.create(null)

// 1. grep for files with exported enum
const files = scanFiles(options)
Expand Down Expand Up @@ -91,13 +97,13 @@ export function scanEnums(options: ScanOptions): EnumData {
}
enumIds.add(id)

let lastInitialized: string | number | undefined
let lastInitialized: Resolver<string | number> = () => -1
const members: Array<EnumMember> = []

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<string | number>) => {
// 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
Expand All @@ -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<string | number> => {
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)
}
}

Expand All @@ -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
}
Expand Down
21 changes: 16 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ const InlineEnum: UnpluginInstance<Options | undefined, true> = 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<number, string>()
for (const { name, value } of members) {
if (typeof value === 'number') {
lastForValue.set(value, name)
}
}
s.update(
start,
end,
Expand All @@ -68,11 +77,13 @@ const InlineEnum: UnpluginInstance<Options | undefined, true> = 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')}}`,
)
Expand Down
126 changes: 124 additions & 2 deletions tests/__snapshots__/scan-enums.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
},
],
Expand Down Expand Up @@ -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",
Expand Down
Loading