inline-enums.js 9.3 KB

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