inline-enums.js 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. // @ts-check
  2. /**
  3. * We used const enums before, but it caused some issues: #1228, so we
  4. * switched to regular enums. But we still want to keep the zero-cost benefit
  5. * of const enums, and minimize the impact on bundle size as much as possible.
  6. *
  7. * Here we pre-process all the enums in the project and turn them into
  8. * global replacements, and rewrite the original declarations as object literals.
  9. *
  10. * This file is expected to be executed with project root as cwd.
  11. */
  12. import * as assert from 'node:assert'
  13. import {
  14. existsSync,
  15. mkdirSync,
  16. readFileSync,
  17. rmSync,
  18. writeFileSync,
  19. } from 'node:fs'
  20. import * as path from 'node:path'
  21. import { parseSync } from 'oxc-parser'
  22. import { spawnSync } from 'node:child_process'
  23. /**
  24. * @typedef {{ readonly name: string, readonly value: string | number }} EnumMember
  25. * @typedef {{ readonly id: string, readonly range: readonly [start: number, end: number], readonly members: ReadonlyArray<EnumMember>}} EnumDeclaration
  26. * @typedef {{ readonly declarations: { readonly [file: string] : ReadonlyArray<EnumDeclaration>}, readonly defines: { readonly [ id_key: `${string}.${string}`]: string } }} EnumData
  27. */
  28. const ENUM_CACHE_PATH = 'temp/enum.json'
  29. /**
  30. * @param {string} exp
  31. * @returns {string | number}
  32. */
  33. function evaluate(exp) {
  34. return new Function(`return ${exp}`)()
  35. }
  36. /**
  37. * @param {import('oxc-parser').Expression | import('oxc-parser').PrivateIdentifier} exp
  38. * @returns { exp is import('oxc-parser').StringLiteral | import('oxc-parser').NumericLiteral }
  39. */
  40. function isStringOrNumberLiteral(exp) {
  41. return (
  42. exp.type === 'Literal' &&
  43. (typeof exp.value === 'string' || typeof exp.value === 'number')
  44. )
  45. }
  46. // this is called in the build script entry once
  47. // so the data can be shared across concurrent Rolldown processes
  48. export function scanEnums() {
  49. /** @type {{ [file: string]: EnumDeclaration[] }} */
  50. const declarations = Object.create(null)
  51. /** @type {{ [id_key: `${string}.${string}`]: string; }} */
  52. const defines = Object.create(null)
  53. // 1. grep for files with exported enum
  54. const { stdout } = spawnSync('git', ['grep', `enum `])
  55. const files = [
  56. ...new Set(
  57. stdout
  58. .toString()
  59. .trim()
  60. .split('\n')
  61. .map(line => line.split(':')[0]),
  62. ),
  63. ]
  64. // 2. parse matched files to collect enum info
  65. for (const relativeFile of files) {
  66. const file = path.resolve(process.cwd(), relativeFile)
  67. const content = readFileSync(file, 'utf-8')
  68. const res = parseSync(file, content, {
  69. sourceType: 'module',
  70. })
  71. /** @type {Set<string>} */
  72. const enumIds = new Set()
  73. for (const node of res.program.body) {
  74. let decl
  75. if (node.type === 'TSEnumDeclaration') {
  76. decl = node
  77. }
  78. if (
  79. node.type === 'ExportNamedDeclaration' &&
  80. node.declaration &&
  81. node.declaration.type === 'TSEnumDeclaration'
  82. ) {
  83. decl = node.declaration
  84. }
  85. if (decl) {
  86. const id = decl.id.name
  87. if (enumIds.has(id)) {
  88. throw new Error(
  89. `not support declaration merging for enum ${id} in ${file}`,
  90. )
  91. }
  92. enumIds.add(id)
  93. /** @type {string | number | undefined} */
  94. let lastInitialized
  95. /** @type {Array<EnumMember>} */
  96. const members = []
  97. for (let i = 0; i < decl.body.members.length; i++) {
  98. const e = decl.body.members[i]
  99. const key =
  100. e.id.type === 'Identifier'
  101. ? e.id.name
  102. : e.id.type === 'Literal'
  103. ? e.id.value
  104. : ''
  105. if (key === '') continue
  106. const fullKey = /** @type {const} */ (`${id}.${key}`)
  107. const saveValue = (/** @type {string | number} */ value) => {
  108. // We need allow same name enum in different file.
  109. // For example: enum ErrorCodes exist in both @vue/compiler-core and @vue/runtime-core
  110. // But not allow `ErrorCodes.__EXTEND_POINT__` appear in two same name enum
  111. if (fullKey in defines) {
  112. throw new Error(`name conflict for enum ${id} in ${file}`)
  113. }
  114. members.push({
  115. name: key,
  116. value,
  117. })
  118. defines[fullKey] = JSON.stringify(value)
  119. }
  120. const init = e.initializer
  121. if (init) {
  122. /** @type {string | number} */
  123. let value
  124. if (isStringOrNumberLiteral(init)) {
  125. value = init.value
  126. }
  127. // e.g. 1 << 2
  128. else if (init.type === 'BinaryExpression') {
  129. const resolveValue = (
  130. /** @type {import('oxc-parser').Expression | import('oxc-parser').PrivateIdentifier} */ node,
  131. ) => {
  132. assert.ok(typeof node.start === 'number')
  133. assert.ok(typeof node.end === 'number')
  134. if (isStringOrNumberLiteral(node)) {
  135. return node.value
  136. } else if (
  137. node.type === 'MemberExpression' ||
  138. // @ts-expect-error oxc only type
  139. node.type === 'StaticMemberExpression'
  140. ) {
  141. const exp = /** @type {`${string}.${string}`} */ (
  142. content.slice(node.start, node.end)
  143. )
  144. if (!(exp in defines)) {
  145. throw new Error(
  146. `unhandled enum initialization expression ${exp} in ${file}`,
  147. )
  148. }
  149. return defines[exp]
  150. } else {
  151. throw new Error(
  152. `unhandled BinaryExpression operand type ${node.type} in ${file}`,
  153. )
  154. }
  155. }
  156. const exp = `${resolveValue(init.left)}${
  157. init.operator
  158. }${resolveValue(init.right)}`
  159. value = evaluate(exp)
  160. } else if (init.type === 'UnaryExpression') {
  161. if (isStringOrNumberLiteral(init.argument)) {
  162. const exp = `${init.operator}${init.argument.value}`
  163. value = evaluate(exp)
  164. } else {
  165. throw new Error(
  166. `unhandled UnaryExpression argument type ${init.argument.type} in ${file}`,
  167. )
  168. }
  169. } else {
  170. throw new Error(
  171. `unhandled initializer type ${init.type} for ${fullKey} in ${file}`,
  172. )
  173. }
  174. lastInitialized = value
  175. saveValue(lastInitialized)
  176. } else {
  177. if (lastInitialized === undefined) {
  178. // first initialized
  179. lastInitialized = 0
  180. saveValue(lastInitialized)
  181. } else if (typeof lastInitialized === 'number') {
  182. lastInitialized++
  183. saveValue(lastInitialized)
  184. } else {
  185. // should not happen
  186. throw new Error(`wrong enum initialization sequence in ${file}`)
  187. }
  188. }
  189. }
  190. if (!(file in declarations)) {
  191. declarations[file] = []
  192. }
  193. assert.ok(typeof node.start === 'number')
  194. assert.ok(typeof node.end === 'number')
  195. declarations[file].push({
  196. id,
  197. range: [node.start, node.end],
  198. members,
  199. })
  200. }
  201. }
  202. }
  203. // 3. save cache
  204. try {
  205. mkdirSync('temp')
  206. } catch {}
  207. /** @type {EnumData} */
  208. const enumData = {
  209. declarations,
  210. defines,
  211. }
  212. writeFileSync(ENUM_CACHE_PATH, JSON.stringify(enumData))
  213. return () => {
  214. rmSync(ENUM_CACHE_PATH, { force: true })
  215. }
  216. }
  217. /**
  218. * @returns {[import('rolldown').Plugin, Record<string, string>]}
  219. */
  220. export function inlineEnums() {
  221. if (!existsSync(ENUM_CACHE_PATH)) {
  222. throw new Error('enum cache needs to be initialized before creating plugin')
  223. }
  224. /**
  225. * @type {EnumData}
  226. */
  227. const enumData = JSON.parse(readFileSync(ENUM_CACHE_PATH, 'utf-8'))
  228. // 3. during transform:
  229. // 3.1 files w/ enum declaration: rewrite declaration as object literal
  230. // 3.2 files using enum: inject into rolldown define
  231. /**
  232. * @type {import('rolldown').Plugin}
  233. */
  234. const plugin = {
  235. name: 'inline-enum',
  236. transform(code, id, meta) {
  237. if (!meta.magicString) {
  238. throw new Error('meta.magicString is missing')
  239. }
  240. /**
  241. * @type {import('rolldown').BindingMagicString | undefined}
  242. */
  243. let s
  244. if (id in enumData.declarations) {
  245. s = s || meta.magicString
  246. for (const declaration of enumData.declarations[id]) {
  247. const {
  248. range: [start, end],
  249. id,
  250. members,
  251. } = declaration
  252. s.update(
  253. start,
  254. end,
  255. `export const ${id} = {${members
  256. .flatMap(({ name, value }) => {
  257. const forwardMapping =
  258. JSON.stringify(name) + ': ' + JSON.stringify(value)
  259. const reverseMapping =
  260. JSON.stringify(value.toString()) + ': ' + JSON.stringify(name)
  261. // see https://www.typescriptlang.org/docs/handbook/enums.html#reverse-mappings
  262. return typeof value === 'string'
  263. ? [
  264. forwardMapping,
  265. // string enum members do not get a reverse mapping generated at all
  266. ]
  267. : [
  268. forwardMapping,
  269. // other enum members should support enum reverse mapping
  270. reverseMapping,
  271. ]
  272. })
  273. .join(',\n')}}`,
  274. )
  275. }
  276. }
  277. if (s) {
  278. return {
  279. code: s,
  280. }
  281. }
  282. },
  283. }
  284. return [plugin, enumData.defines]
  285. }