const-enum.mjs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. // @ts-check
  2. /**
  3. * We use rollup-plugin-esbuild for faster builds, but esbuild in insolation
  4. * mode compiles const enums into runtime enums, bloating bundle size.
  5. *
  6. * Here we pre-process all the const enums in the project and turn them into
  7. * global replacements, and remove the original declarations and re-exports.
  8. *
  9. * This erases the const enums before the esbuild transform so that we can
  10. * leverage esbuild's speed while retaining the DX and bundle size benefits
  11. * of const enums.
  12. *
  13. * This file is expected to be executed with project root as cwd.
  14. */
  15. import execa from 'execa'
  16. import { readFileSync } from 'node:fs'
  17. import { parse } from '@babel/parser'
  18. import path from 'node:path'
  19. import MagicString from 'magic-string'
  20. function evaluate(exp) {
  21. return new Function(`return ${exp}`)()
  22. }
  23. /**
  24. * @returns {Promise<[import('rollup').Plugin, Record<string, string>]>}
  25. */
  26. export async function constEnum() {
  27. /**
  28. * @type {{ ranges: Record<string, [number, number][]>, defines: Record<string, string> }}
  29. */
  30. const enumData = {
  31. ranges: {},
  32. defines: {}
  33. }
  34. const knowEnums = new Set()
  35. // 1. grep for files with exported const enum
  36. const { stdout } = await execa('git', ['grep', `export const enum`])
  37. const files = [...new Set(stdout.split('\n').map(line => line.split(':')[0]))]
  38. // 2. parse matched files to collect enum info
  39. for (const relativeFile of files) {
  40. const file = path.resolve(process.cwd(), relativeFile)
  41. const content = readFileSync(file, 'utf-8')
  42. const ast = parse(content, {
  43. plugins: ['typescript'],
  44. sourceType: 'module'
  45. })
  46. for (const node of ast.program.body) {
  47. if (
  48. node.type === 'ExportNamedDeclaration' &&
  49. node.declaration &&
  50. node.declaration.type === 'TSEnumDeclaration'
  51. ) {
  52. if (file in enumData.ranges) {
  53. // @ts-ignore
  54. enumData.ranges[file].push([node.start, node.end])
  55. } else {
  56. // @ts-ignore
  57. enumData.ranges[file] = [[node.start, node.end]]
  58. }
  59. const decl = node.declaration
  60. let lastInitialized
  61. for (let i = 0; i < decl.members.length; i++) {
  62. const e = decl.members[i]
  63. const id = decl.id.name
  64. knowEnums.add(id)
  65. const key = e.id.type === 'Identifier' ? e.id.name : e.id.value
  66. const fullKey = `${id}.${key}`
  67. const init = e.initializer
  68. if (init) {
  69. let value
  70. if (
  71. init.type === 'StringLiteral' ||
  72. init.type === 'NumericLiteral'
  73. ) {
  74. value = init.value
  75. }
  76. // e.g. 1 << 2
  77. if (init.type === 'BinaryExpression') {
  78. const resolveValue = node => {
  79. if (
  80. node.type === 'NumericLiteral' ||
  81. node.type === 'StringLiteral'
  82. ) {
  83. return node.value
  84. } else if (node.type === 'MemberExpression') {
  85. const exp = content.slice(node.start, node.end)
  86. if (!(exp in enumData.defines)) {
  87. throw new Error(
  88. `unhandled enum initialization expression ${exp} in ${file}`
  89. )
  90. }
  91. return enumData.defines[exp]
  92. } else {
  93. throw new Error(
  94. `unhandled BinaryExpression operand type ${node.type} in ${file}`
  95. )
  96. }
  97. }
  98. const exp = `${resolveValue(init.left)}${
  99. init.operator
  100. }${resolveValue(init.right)}`
  101. value = evaluate(exp)
  102. }
  103. if (init.type === 'UnaryExpression') {
  104. // @ts-ignore assume all operands are literals
  105. const exp = `${init.operator}${init.argument.value}`
  106. value = evaluate(exp)
  107. }
  108. if (value === undefined) {
  109. throw new Error(
  110. `unhandled initializer type ${init.type} for ${fullKey} in ${file}`
  111. )
  112. }
  113. enumData.defines[fullKey] = JSON.stringify(value)
  114. lastInitialized = value
  115. } else {
  116. if (lastInitialized === undefined) {
  117. // first initialized
  118. enumData.defines[fullKey] = `0`
  119. lastInitialized = 0
  120. } else if (typeof lastInitialized === 'number') {
  121. enumData.defines[fullKey] = String(++lastInitialized)
  122. } else {
  123. // should not happen
  124. throw new Error(`wrong enum initialization sequence in ${file}`)
  125. }
  126. }
  127. }
  128. }
  129. }
  130. }
  131. // construct a regex for matching re-exports of known const enums
  132. const reExportsRE = new RegExp(
  133. `export {[^}]*?\\b(${[...knowEnums].join('|')})\\b[^]*?}`
  134. )
  135. // 3. during transform:
  136. // 3.1 files w/ const enum declaration: remove delcaration
  137. // 3.2 files using const enum: inject into esbuild define
  138. /**
  139. * @type {import('rollup').Plugin}
  140. */
  141. const plugin = {
  142. name: 'remove-const-enum',
  143. transform(code, id) {
  144. let s
  145. if (id in enumData.ranges) {
  146. s = s || new MagicString(code)
  147. for (const [start, end] of enumData.ranges[id]) {
  148. s.remove(start, end)
  149. }
  150. }
  151. // check for const enum re-exports that must be removed
  152. if (reExportsRE.test(code)) {
  153. s = s || new MagicString(code)
  154. const ast = parse(code, {
  155. plugins: ['typescript'],
  156. sourceType: 'module'
  157. })
  158. for (const node of ast.program.body) {
  159. if (
  160. node.type === 'ExportNamedDeclaration' &&
  161. node.exportKind !== 'type' &&
  162. node.source
  163. ) {
  164. for (let i = 0; i < node.specifiers.length; i++) {
  165. const spec = node.specifiers[i]
  166. if (
  167. spec.type === 'ExportSpecifier' &&
  168. spec.exportKind !== 'type' &&
  169. knowEnums.has(spec.local.name)
  170. ) {
  171. if (i === 0) {
  172. // first
  173. const next = node.specifiers[i + 1]
  174. // @ts-ignore
  175. s.remove(spec.start, next ? next.start : spec.end)
  176. } else {
  177. // locate the end of prev
  178. // @ts-ignore
  179. s.remove(node.specifiers[i - 1].end, spec.end)
  180. }
  181. }
  182. }
  183. }
  184. }
  185. }
  186. if (s) {
  187. return {
  188. code: s.toString(),
  189. map: s.generateMap()
  190. }
  191. }
  192. }
  193. }
  194. return [plugin, enumData.defines]
  195. }