mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
feat(semantic): add enum member value evaluation for const enum support (#20602)
## Summary Part of https://github.com/oxc-project/oxc/issues/6073 and #11844 - Add `ConstantValue` type in `oxc_syntax` for representing enum member values (number/string) - Implement enum member value evaluation in `oxc_semantic` supporting numeric auto-increment, string values, binary/unary expressions, cross-member and cross-enum references, template literals, and merged enum declarations - Store evaluated enum member values in `Scoping` via `EnumData` struct with getter/setter APIs and enum body scope tracking - All enum evaluation code is gated behind the `enum_eval` Cargo feature (enabled by `oxc_transformer`) ### Feature gate The `enum_eval` feature controls: - `ts_enum` module (evaluator + data types) - `EnumData` field and enum-related methods on `Scoping` - `oxc_syntax/to_js_string` dependency Consumers that don't need enum values (linter, language server) pay zero cost. ### Babel alignment Unary operators on strings follow Babel's JS-coercion semantics (`+"s"` → `"s"`, `-"s"` → `NaN`, `~"s"` → `-1`), which differs from TypeScript (unevaluated). This is documented in the source. ## Test plan - [x] Integration tests in `crates/oxc_semantic/tests/integration/enum_values.rs` - [x] Semantic TypeScript conformance snapshot updated - [x] `cargo check -p oxc_semantic` passes (without feature) - [x] `cargo check -p oxc_semantic --features enum_eval` passes (with feature) - [x] `cargo check -p oxc_transformer` passes (enables feature transitively)
This commit is contained in:
1 parent
9a0725955f
commit
75663c077d
12 files changed
+615
-2
No files matched your search
@@ -225,7 +225,7 @@ pub trait CompilerInterface {
|
||||
|
||||
if self.transform_options().is_some() {
|
||||
// Estimate transformer will triple scopes, symbols, references
|
||||
builder = builder.with_excess_capacity(2.0);
|
||||
builder = builder.with_excess_capacity(2.0).with_enum_eval(true);
|
||||
}
|
||||
|
||||
builder.with_check_syntax_error(self.check_semantic_error()).build(program)
|
||||
|
||||
@@ -29,7 +29,7 @@ oxc_ecmascript = { workspace = true }
|
||||
oxc_index = { workspace = true }
|
||||
oxc_jsdoc = { workspace = true, optional = true }
|
||||
oxc_span = { workspace = true }
|
||||
oxc_syntax = { workspace = true }
|
||||
oxc_syntax = { workspace = true, features = ["to_js_string"] }
|
||||
|
||||
itertools = { workspace = true }
|
||||
memchr = { workspace = true }
|
||||
|
||||
@@ -105,6 +105,9 @@ pub struct SemanticBuilder<'a> {
|
||||
stats: Option<Stats>,
|
||||
excess_capacity: f64,
|
||||
|
||||
/// Should enum member values be evaluated?
|
||||
enum_eval: bool,
|
||||
|
||||
/// Should additional syntax checks be performed?
|
||||
///
|
||||
/// See: [`crate::checker::check`]
|
||||
@@ -162,6 +165,7 @@ impl<'a> SemanticBuilder<'a> {
|
||||
jsdoc: JSDocBuilder::default(),
|
||||
stats: None,
|
||||
excess_capacity: 0.0,
|
||||
enum_eval: false,
|
||||
check_syntax_error: false,
|
||||
#[cfg(feature = "cfg")]
|
||||
cfg: None,
|
||||
@@ -186,6 +190,19 @@ impl<'a> SemanticBuilder<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable evaluation of TypeScript enum member values.
|
||||
///
|
||||
/// When enabled, enum member values are computed during semantic analysis
|
||||
/// and stored in [`Scoping`], allowing the transformer to inline const enum
|
||||
/// member accesses.
|
||||
///
|
||||
/// By default, this is `false`.
|
||||
#[must_use]
|
||||
pub fn with_enum_eval(mut self, yes: bool) -> Self {
|
||||
self.enum_eval = yes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Enable or disable building a [`ControlFlowGraph`].
|
||||
///
|
||||
/// [`ControlFlowGraph`]: oxc_cfg::ControlFlowGraph
|
||||
@@ -2386,6 +2403,10 @@ impl<'a> Visit<'a> for SemanticBuilder<'a> {
|
||||
self.visit_span(&decl.span);
|
||||
self.visit_binding_identifier(&decl.id);
|
||||
self.visit_ts_enum_body(&decl.body);
|
||||
// Evaluate enum member values after all members are bound
|
||||
if self.enum_eval {
|
||||
crate::ts_enum::eval::evaluate_enum_members(decl, &mut self.scoping);
|
||||
}
|
||||
self.leave_node(kind);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ mod multi_index_vec;
|
||||
mod node;
|
||||
mod scoping;
|
||||
mod stats;
|
||||
pub mod ts_enum;
|
||||
mod unresolved_stack;
|
||||
|
||||
#[cfg(feature = "linter")]
|
||||
|
||||
@@ -6,6 +6,7 @@ use self_cell::self_cell;
|
||||
use oxc_allocator::{Allocator, CloneIn, Vec as ArenaVec};
|
||||
use oxc_index::IndexVec;
|
||||
use oxc_span::{ArenaIdentHashMap, Ident, Span};
|
||||
use oxc_syntax::constant_value::ConstantValue;
|
||||
use oxc_syntax::{
|
||||
node::NodeId,
|
||||
reference::{Reference, ReferenceId},
|
||||
@@ -79,6 +80,8 @@ multi_index_vec! {
|
||||
///
|
||||
/// ## Scope Tree
|
||||
///
|
||||
use crate::ts_enum::EnumData;
|
||||
|
||||
/// The scope tree stores lexical scopes created by a program, and all the
|
||||
/// variable bindings each scope creates.
|
||||
///
|
||||
@@ -94,6 +97,9 @@ pub struct Scoping {
|
||||
/// Function or Variable Symbol IDs that are marked with `@__NO_SIDE_EFFECTS__`.
|
||||
pub(crate) no_side_effects: FxHashSet<SymbolId>,
|
||||
|
||||
/// Pre-computed enum member values and scope mappings.
|
||||
pub(crate) enum_data: EnumData,
|
||||
|
||||
/* Scope Tree - single allocation for all scope-indexed flat fields */
|
||||
scope_table: ScopeTable,
|
||||
|
||||
@@ -112,6 +118,7 @@ impl Default for Scoping {
|
||||
symbol_table: SymbolTable::new(),
|
||||
references: IndexVec::new(),
|
||||
no_side_effects: FxHashSet::default(),
|
||||
enum_data: EnumData::default(),
|
||||
scope_table: ScopeTable::new(),
|
||||
cell: ScopingCell::new(Allocator::default(), |allocator| ScopingInner {
|
||||
symbol_names: ArenaVec::new_in(allocator),
|
||||
@@ -589,6 +596,28 @@ impl Scoping {
|
||||
pub fn no_side_effects(&self) -> &FxHashSet<SymbolId> {
|
||||
&self.no_side_effects
|
||||
}
|
||||
|
||||
/// Get the computed constant value for an enum member symbol.
|
||||
pub fn get_enum_member_value(&self, symbol_id: SymbolId) -> Option<&ConstantValue> {
|
||||
self.enum_data.get_member_value(symbol_id)
|
||||
}
|
||||
|
||||
/// Set a computed constant value for an enum member symbol.
|
||||
pub(crate) fn set_enum_member_value(&mut self, symbol_id: SymbolId, value: ConstantValue) {
|
||||
self.enum_data.set_member_value(symbol_id, value);
|
||||
}
|
||||
|
||||
/// Get the body scopes for an enum declaration symbol.
|
||||
/// Returns multiple scopes for merged enum declarations.
|
||||
pub fn get_enum_body_scopes(&self, symbol_id: SymbolId) -> Option<&[ScopeId]> {
|
||||
self.enum_data.get_body_scopes(symbol_id)
|
||||
}
|
||||
|
||||
/// Add a body scope for an enum declaration symbol.
|
||||
/// Appends to the list (supports merged enum declarations).
|
||||
pub(crate) fn add_enum_body_scope(&mut self, symbol_id: SymbolId, scope_id: ScopeId) {
|
||||
self.enum_data.add_body_scope(symbol_id, scope_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scope Tree Methods
|
||||
@@ -918,6 +947,7 @@ impl Scoping {
|
||||
symbol_table: self.symbol_table.clone(),
|
||||
references: self.references.clone(),
|
||||
no_side_effects: self.no_side_effects.clone(),
|
||||
enum_data: self.enum_data.clone(),
|
||||
scope_table: self.scope_table.clone(),
|
||||
cell: {
|
||||
let allocator = Allocator::with_capacity(used_bytes);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use oxc_syntax::{constant_value::ConstantValue, scope::ScopeId, symbol::SymbolId};
|
||||
|
||||
/// Pre-computed enum member values and declaration-to-scope mappings.
|
||||
///
|
||||
/// Populated during semantic analysis for all enums (const and regular).
|
||||
/// Used by the transformer to inline const enum member accesses.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct EnumData {
|
||||
/// Computed constant values for enum members, keyed by member `SymbolId`.
|
||||
member_values: FxHashMap<SymbolId, ConstantValue>,
|
||||
/// Maps enum declaration `SymbolId` → body `ScopeId`s (one per declaration).
|
||||
body_scopes: FxHashMap<SymbolId, Vec<ScopeId>>,
|
||||
}
|
||||
|
||||
impl EnumData {
|
||||
pub fn get_member_value(&self, symbol_id: SymbolId) -> Option<&ConstantValue> {
|
||||
self.member_values.get(&symbol_id)
|
||||
}
|
||||
|
||||
pub(crate) fn set_member_value(&mut self, symbol_id: SymbolId, value: ConstantValue) {
|
||||
self.member_values.insert(symbol_id, value);
|
||||
}
|
||||
|
||||
pub fn get_body_scopes(&self, symbol_id: SymbolId) -> Option<&[ScopeId]> {
|
||||
self.body_scopes.get(&symbol_id).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
pub(crate) fn add_body_scope(&mut self, symbol_id: SymbolId, scope_id: ScopeId) {
|
||||
self.body_scopes.entry(symbol_id).or_default().push(scope_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
use oxc_ast::ast::{
|
||||
BinaryExpression, Expression, IdentifierReference, TSEnumDeclaration, TSEnumMemberName,
|
||||
UnaryExpression,
|
||||
};
|
||||
use oxc_ecmascript::{ToInt32, ToUint32};
|
||||
use oxc_span::CompactStr;
|
||||
use oxc_syntax::{
|
||||
constant_value::ConstantValue,
|
||||
number::ToJsString,
|
||||
operator::{BinaryOperator, UnaryOperator},
|
||||
scope::ScopeId,
|
||||
symbol::SymbolId,
|
||||
};
|
||||
|
||||
use crate::scoping::Scoping;
|
||||
|
||||
/// Immutable context shared across recursive evaluation calls.
|
||||
struct EnumEvalCtx<'s> {
|
||||
scope_id: ScopeId,
|
||||
enum_symbol_id: Option<SymbolId>,
|
||||
scoping: &'s Scoping,
|
||||
}
|
||||
|
||||
/// Evaluate all enum member values in a `TSEnumDeclaration` and store them in `Scoping`.
|
||||
///
|
||||
/// Runs during semantic analysis so the transformer can look up pre-computed values
|
||||
/// to emit constant literals and decide whether const enum declarations can be removed.
|
||||
///
|
||||
/// ```ts
|
||||
/// enum Color { Red, Green, Blue }
|
||||
/// // Red → 0, Green → 1, Blue → 2 (auto-increment)
|
||||
///
|
||||
/// enum Flags { A = 1 << 0, B = 1 << 1, C = A | B }
|
||||
/// // A → 1, B → 2, C → 3 (constant-folded expressions)
|
||||
///
|
||||
/// enum Mixed { X = "hello", Y }
|
||||
/// // X → "hello", Y → None (can't auto-increment after string)
|
||||
/// ```
|
||||
pub fn evaluate_enum_members(decl: &TSEnumDeclaration<'_>, scoping: &mut Scoping) {
|
||||
let Some(scope_id) = decl.body.scope_id.get() else { return };
|
||||
|
||||
let enum_symbol_id = decl.id.symbol_id.get();
|
||||
if let Some(id) = enum_symbol_id {
|
||||
scoping.add_enum_body_scope(id, scope_id);
|
||||
}
|
||||
|
||||
let mut prev_value: Option<ConstantValue> = None;
|
||||
|
||||
for member in &decl.body.members {
|
||||
let value = if let Some(init) = &member.initializer {
|
||||
let ctx = EnumEvalCtx { scope_id, enum_symbol_id, scoping };
|
||||
evaluate_expression(init, &ctx)
|
||||
} else {
|
||||
match &prev_value {
|
||||
None => Some(ConstantValue::Number(0.0)),
|
||||
Some(ConstantValue::Number(n)) => Some(ConstantValue::Number(n + 1.0)),
|
||||
Some(ConstantValue::String(_)) => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref val) = value {
|
||||
let member_name = match &member.id {
|
||||
TSEnumMemberName::Identifier(ident) => Some(ident.name.as_str()),
|
||||
TSEnumMemberName::String(lit) | TSEnumMemberName::ComputedString(lit) => {
|
||||
Some(lit.value.as_str())
|
||||
}
|
||||
TSEnumMemberName::ComputedTemplateString(_) => None,
|
||||
};
|
||||
if let Some(name) = member_name
|
||||
&& let Some(symbol_id) = scoping.get_binding(scope_id, name.into())
|
||||
{
|
||||
scoping.set_enum_member_value(symbol_id, val.clone());
|
||||
}
|
||||
}
|
||||
|
||||
prev_value = value;
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_expression(expr: &Expression<'_>, ctx: &EnumEvalCtx<'_>) -> Option<ConstantValue> {
|
||||
match expr {
|
||||
Expression::Identifier(_)
|
||||
| Expression::ComputedMemberExpression(_)
|
||||
| Expression::StaticMemberExpression(_)
|
||||
| Expression::PrivateFieldExpression(_) => evaluate_ref(expr, ctx),
|
||||
Expression::BinaryExpression(expr) => eval_binary_expression(expr, ctx),
|
||||
Expression::UnaryExpression(expr) => eval_unary_expression(expr, ctx),
|
||||
Expression::NumericLiteral(lit) => Some(ConstantValue::Number(lit.value)),
|
||||
Expression::StringLiteral(lit) => {
|
||||
Some(ConstantValue::String(CompactStr::from(lit.value.as_str())))
|
||||
}
|
||||
Expression::TemplateLiteral(lit) => {
|
||||
if let Some(quasi) = lit.single_quasi() {
|
||||
Some(ConstantValue::String(CompactStr::from(quasi.as_str())))
|
||||
} else {
|
||||
let mut value = String::new();
|
||||
for (i, quasi) in lit.quasis.iter().enumerate() {
|
||||
let cooked_or_raw = quasi.value.cooked.as_ref().unwrap_or(&quasi.value.raw);
|
||||
value.push_str(cooked_or_raw.as_str());
|
||||
if i < lit.expressions.len() {
|
||||
match evaluate_expression(&lit.expressions[i], ctx)? {
|
||||
ConstantValue::String(s) => value.push_str(&s),
|
||||
ConstantValue::Number(n) => value.push_str(&n.to_js_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ConstantValue::String(CompactStr::from(value.as_str())))
|
||||
}
|
||||
}
|
||||
Expression::ParenthesizedExpression(expr) => evaluate_expression(&expr.expression, ctx),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an identifier or member expression to a previously evaluated enum value.
|
||||
///
|
||||
/// Uses a three-level lookup strategy for bare identifiers:
|
||||
/// 1. Resolved reference — `enum A { X = 1, Y = X }` (X is resolved in the same scope)
|
||||
/// 2. Scope binding fallback — handles cases where references aren't yet resolved
|
||||
/// 3. Sibling enum fallback — `enum A { X = 1 } enum A { Y = X }` (merged declarations)
|
||||
///
|
||||
/// Also handles cross-enum member access:
|
||||
/// ```ts
|
||||
/// enum A { X = 1 }
|
||||
/// enum B { Y = A.X + 1 } // StaticMemberExpression
|
||||
/// enum C { Z = A["X"] + 1 } // ComputedMemberExpression
|
||||
/// ```
|
||||
fn evaluate_ref(expr: &Expression<'_>, ctx: &EnumEvalCtx<'_>) -> Option<ConstantValue> {
|
||||
match expr {
|
||||
Expression::Identifier(ident) => {
|
||||
if ident.name == "Infinity" {
|
||||
return Some(ConstantValue::Number(f64::INFINITY));
|
||||
}
|
||||
if ident.name == "NaN" {
|
||||
return Some(ConstantValue::Number(f64::NAN));
|
||||
}
|
||||
|
||||
if let Some(ref_id) = ident.reference_id.get()
|
||||
&& let Some(symbol_id) = ctx.scoping.get_reference(ref_id).symbol_id()
|
||||
{
|
||||
return ctx.scoping.get_enum_member_value(symbol_id).cloned();
|
||||
}
|
||||
|
||||
// Fallback: look up as a binding in the current enum body scope.
|
||||
if let Some(symbol_id) =
|
||||
ctx.scoping.get_binding(ctx.scope_id, ident.name.as_str().into())
|
||||
&& let Some(value) = ctx.scoping.get_enum_member_value(symbol_id)
|
||||
{
|
||||
return Some(value.clone());
|
||||
}
|
||||
|
||||
// Sibling enum fallback for merged enums.
|
||||
find_in_sibling_enum_scopes(
|
||||
ident.name.as_str(),
|
||||
ctx.scope_id,
|
||||
ctx.enum_symbol_id,
|
||||
ctx.scoping,
|
||||
)
|
||||
}
|
||||
Expression::StaticMemberExpression(member_expr) => {
|
||||
let Expression::Identifier(obj_ident) = &member_expr.object else { return None };
|
||||
let obj_symbol_id = resolve_identifier_symbol(obj_ident, ctx)?;
|
||||
find_in_enum_body_scopes(member_expr.property.name.as_str(), obj_symbol_id, ctx.scoping)
|
||||
}
|
||||
Expression::ComputedMemberExpression(member_expr) => {
|
||||
let Expression::Identifier(obj_ident) = &member_expr.object else { return None };
|
||||
let Expression::StringLiteral(prop_lit) = &member_expr.expression else {
|
||||
return None;
|
||||
};
|
||||
let obj_symbol_id = resolve_identifier_symbol(obj_ident, ctx)?;
|
||||
find_in_enum_body_scopes(prop_lit.value.as_str(), obj_symbol_id, ctx.scoping)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an identifier to its symbol, trying the resolved reference first,
|
||||
/// then falling back to scope binding lookup.
|
||||
fn resolve_identifier_symbol(
|
||||
ident: &IdentifierReference<'_>,
|
||||
ctx: &EnumEvalCtx<'_>,
|
||||
) -> Option<SymbolId> {
|
||||
if let Some(ref_id) = ident.reference_id.get()
|
||||
&& let Some(symbol_id) = ctx.scoping.get_reference(ref_id).symbol_id()
|
||||
{
|
||||
return Some(symbol_id);
|
||||
}
|
||||
ctx.scoping.find_binding(ctx.scope_id, ident.name.as_str().into())
|
||||
}
|
||||
|
||||
fn eval_binary_expression(
|
||||
expr: &BinaryExpression<'_>,
|
||||
ctx: &EnumEvalCtx<'_>,
|
||||
) -> Option<ConstantValue> {
|
||||
let left = evaluate_expression(&expr.left, ctx)?;
|
||||
let right = evaluate_expression(&expr.right, ctx)?;
|
||||
|
||||
if matches!(expr.operator, BinaryOperator::Addition)
|
||||
&& (matches!(left, ConstantValue::String(_)) || matches!(right, ConstantValue::String(_)))
|
||||
{
|
||||
let mut result = match &left {
|
||||
ConstantValue::String(s) => s.to_string(),
|
||||
ConstantValue::Number(v) => v.to_js_string(),
|
||||
};
|
||||
match &right {
|
||||
ConstantValue::String(s) => result.push_str(s),
|
||||
ConstantValue::Number(v) => result.push_str(&v.to_js_string()),
|
||||
}
|
||||
return Some(ConstantValue::String(CompactStr::from(result.as_str())));
|
||||
}
|
||||
|
||||
let left = match left {
|
||||
ConstantValue::Number(v) => v,
|
||||
ConstantValue::String(_) => return None,
|
||||
};
|
||||
let right = match right {
|
||||
ConstantValue::Number(v) => v,
|
||||
ConstantValue::String(_) => return None,
|
||||
};
|
||||
|
||||
match expr.operator {
|
||||
BinaryOperator::ShiftRight => Some(ConstantValue::Number(f64::from(
|
||||
left.to_int_32().wrapping_shr(right.to_uint_32()),
|
||||
))),
|
||||
BinaryOperator::ShiftRightZeroFill => Some(ConstantValue::Number(f64::from(
|
||||
left.to_uint_32().wrapping_shr(right.to_uint_32()),
|
||||
))),
|
||||
BinaryOperator::ShiftLeft => Some(ConstantValue::Number(f64::from(
|
||||
left.to_int_32().wrapping_shl(right.to_uint_32()),
|
||||
))),
|
||||
BinaryOperator::BitwiseXOR => {
|
||||
Some(ConstantValue::Number(f64::from(left.to_int_32() ^ right.to_int_32())))
|
||||
}
|
||||
BinaryOperator::BitwiseOR => {
|
||||
Some(ConstantValue::Number(f64::from(left.to_int_32() | right.to_int_32())))
|
||||
}
|
||||
BinaryOperator::BitwiseAnd => {
|
||||
Some(ConstantValue::Number(f64::from(left.to_int_32() & right.to_int_32())))
|
||||
}
|
||||
BinaryOperator::Multiplication => Some(ConstantValue::Number(left * right)),
|
||||
BinaryOperator::Division => Some(ConstantValue::Number(left / right)),
|
||||
BinaryOperator::Addition => Some(ConstantValue::Number(left + right)),
|
||||
BinaryOperator::Subtraction => Some(ConstantValue::Number(left - right)),
|
||||
BinaryOperator::Remainder => Some(ConstantValue::Number(left % right)),
|
||||
BinaryOperator::Exponential => Some(ConstantValue::Number(left.powf(right))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_unary_expression(
|
||||
expr: &UnaryExpression<'_>,
|
||||
ctx: &EnumEvalCtx<'_>,
|
||||
) -> Option<ConstantValue> {
|
||||
let value = evaluate_expression(&expr.argument, ctx)?;
|
||||
|
||||
// Babel uses JS coercion for unary on strings: `+"s"` → `"s"`, `-"s"` → NaN, `~"s"` → -1.
|
||||
// TypeScript would leave these unevaluated (computed members). We align with Babel.
|
||||
let value = match value {
|
||||
ConstantValue::Number(v) => v,
|
||||
ConstantValue::String(_) => {
|
||||
return match expr.operator {
|
||||
UnaryOperator::UnaryPlus => Some(value),
|
||||
UnaryOperator::UnaryNegation => Some(ConstantValue::Number(f64::NAN)),
|
||||
UnaryOperator::BitwiseNot => Some(ConstantValue::Number(-1.0)),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
match expr.operator {
|
||||
UnaryOperator::UnaryPlus => Some(ConstantValue::Number(value)),
|
||||
UnaryOperator::UnaryNegation => Some(ConstantValue::Number(-value)),
|
||||
UnaryOperator::BitwiseNot => Some(ConstantValue::Number(f64::from(!value.to_int_32()))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Search all body scopes of an enum for a member by name.
|
||||
///
|
||||
/// Handles merged enums where a single enum symbol has multiple body scopes:
|
||||
/// ```ts
|
||||
/// enum A { X = 1 }
|
||||
/// enum A { Y = 2 }
|
||||
/// // The symbol `A` has two body scopes — this searches both.
|
||||
/// ```
|
||||
fn find_in_enum_body_scopes(
|
||||
member_name: &str,
|
||||
enum_symbol_id: SymbolId,
|
||||
scoping: &Scoping,
|
||||
) -> Option<ConstantValue> {
|
||||
let body_scopes = scoping.get_enum_body_scopes(enum_symbol_id)?;
|
||||
for &body_scope in body_scopes {
|
||||
if let Some(member_symbol_id) = scoping.get_binding(body_scope, member_name.into())
|
||||
&& let Some(value) = scoping.get_enum_member_value(member_symbol_id)
|
||||
{
|
||||
return Some(value.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// For merged enum declarations, find a bare identifier in a *sibling* body scope.
|
||||
///
|
||||
/// This handles the case where a later declaration references a member from an
|
||||
/// earlier declaration without qualification:
|
||||
/// ```ts
|
||||
/// enum Foo { A = 1 }
|
||||
/// enum Foo { B = A + 1 } // `A` is in the first Foo's scope, not the current one
|
||||
/// ```
|
||||
fn find_in_sibling_enum_scopes(
|
||||
name: &str,
|
||||
current_scope_id: ScopeId,
|
||||
enum_symbol_id: Option<SymbolId>,
|
||||
scoping: &Scoping,
|
||||
) -> Option<ConstantValue> {
|
||||
let body_scopes = scoping.get_enum_body_scopes(enum_symbol_id?)?;
|
||||
for &body_scope in body_scopes {
|
||||
if body_scope != current_scope_id
|
||||
&& let Some(member_sym) = scoping.get_binding(body_scope, name.into())
|
||||
&& let Some(value) = scoping.get_enum_member_value(member_sym)
|
||||
{
|
||||
return Some(value.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod data;
|
||||
pub(crate) mod eval;
|
||||
|
||||
pub use data::EnumData;
|
||||
@@ -0,0 +1,184 @@
|
||||
use oxc_allocator::Allocator;
|
||||
use oxc_parser::Parser;
|
||||
use oxc_semantic::SemanticBuilder;
|
||||
use oxc_span::SourceType;
|
||||
use oxc_syntax::constant_value::ConstantValue;
|
||||
|
||||
fn get_enum_member_value(source: &str, member_name: &str) -> Option<ConstantValue> {
|
||||
let allocator = Allocator::default();
|
||||
let source_type = SourceType::ts();
|
||||
let parser_ret = Parser::new(&allocator, source, source_type).parse();
|
||||
assert!(parser_ret.errors.is_empty(), "Parse errors: {:?}", parser_ret.errors);
|
||||
let semantic_ret = SemanticBuilder::new().with_enum_eval(true).build(&parser_ret.program);
|
||||
assert!(semantic_ret.errors.is_empty(), "Semantic errors: {:?}", semantic_ret.errors);
|
||||
let scoping = semantic_ret.semantic.into_scoping();
|
||||
|
||||
for symbol_id in scoping.symbol_ids() {
|
||||
if scoping.symbol_name(symbol_id) == member_name
|
||||
&& scoping.symbol_flags(symbol_id).is_enum_member()
|
||||
{
|
||||
return scoping.get_enum_member_value(symbol_id).cloned();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_increment() {
|
||||
let source = "enum A { X, Y, Z }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::Number(0.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), Some(ConstantValue::Number(1.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Z"), Some(ConstantValue::Number(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_numeric() {
|
||||
let source = "enum A { X = 10, Y, Z }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::Number(10.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), Some(ConstantValue::Number(11.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Z"), Some(ConstantValue::Number(12.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_members() {
|
||||
let source = r#"enum A { X = "hello" }"#;
|
||||
debug_assert_eq!(
|
||||
get_enum_member_value(source, "X"),
|
||||
Some(ConstantValue::String("hello".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_expressions() {
|
||||
let source = "enum A { X = 1 + 2, Y = 1 << 3 }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::Number(3.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), Some(ConstantValue::Number(8.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unary_expressions() {
|
||||
let source = "enum A { X = -1, Y = ~0 }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::Number(-1.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), Some(ConstantValue::Number(-1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_member_reference() {
|
||||
let source = "enum A { X = 1, Y = X + 1 }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::Number(1.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), Some(ConstantValue::Number(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infinity_nan() {
|
||||
let source = "enum A { X = Infinity, Y = NaN }";
|
||||
debug_assert_eq!(
|
||||
get_enum_member_value(source, "X"),
|
||||
Some(ConstantValue::Number(f64::INFINITY))
|
||||
);
|
||||
match get_enum_member_value(source, "Y") {
|
||||
Some(ConstantValue::Number(n)) => assert!(n.is_nan(), "Expected NaN, got {n}"),
|
||||
other => panic!("Expected Some(Number(NaN)), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_concat() {
|
||||
let source = r#"enum A { X = "a" + "b" }"#;
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::String("ab".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable() {
|
||||
let source = "enum A { X = foo() }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn const_enum() {
|
||||
let source = "const enum A { X, Y }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::Number(0.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), Some(ConstantValue::Number(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declare_enum() {
|
||||
let source = "declare enum A { X = 1 }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::Number(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_increment_after_string_fails() {
|
||||
let source = r#"enum A { X = "hello", Y }"#;
|
||||
debug_assert_eq!(
|
||||
get_enum_member_value(source, "X"),
|
||||
Some(ConstantValue::String("hello".into()))
|
||||
);
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parenthesized_expression() {
|
||||
let source = "enum A { X = (1 + 2) }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::Number(3.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_literal() {
|
||||
let source = "enum A { X = `hello` }";
|
||||
debug_assert_eq!(
|
||||
get_enum_member_value(source, "X"),
|
||||
Some(ConstantValue::String("hello".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_enum_member_expression() {
|
||||
let source = "enum A { X = 1 } enum B { Y = A.X + 1 }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), Some(ConstantValue::Number(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_enum_computed_member_expression() {
|
||||
let source = r#"enum A { X = 1 } enum B { Y = A["X"] + 1 }"#;
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), Some(ConstantValue::Number(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_enum_string_value() {
|
||||
let source = r#"enum A { X = "hello" } enum B { Y = A.X }"#;
|
||||
debug_assert_eq!(
|
||||
get_enum_member_value(source, "Y"),
|
||||
Some(ConstantValue::String("hello".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bitwise_operations() {
|
||||
let source = "enum A { X = 0xFF & 0x0F, Y = 1 | 2, Z = 1 ^ 3 }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "X"), Some(ConstantValue::Number(15.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Y"), Some(ConstantValue::Number(3.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "Z"), Some(ConstantValue::Number(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_enum_cross_declaration_reference() {
|
||||
let source = "enum Foo { A = 1 } enum Foo { B = A + 1 }";
|
||||
debug_assert_eq!(get_enum_member_value(source, "A"), Some(ConstantValue::Number(1.0)));
|
||||
debug_assert_eq!(get_enum_member_value(source, "B"), Some(ConstantValue::Number(2.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unary_on_string() {
|
||||
// Babel uses JS coercion: +"s" → "s" (identity), -"s" → NaN, ~"s" → -1
|
||||
let source = r#"enum A { X = +"hello", Y = -"hello", Z = ~"hello" }"#;
|
||||
debug_assert_eq!(
|
||||
get_enum_member_value(source, "X"),
|
||||
Some(ConstantValue::String("hello".into()))
|
||||
);
|
||||
match get_enum_member_value(source, "Y") {
|
||||
Some(ConstantValue::Number(n)) => assert!(n.is_nan()),
|
||||
other => panic!("Expected Some(Number(NaN)), got {other:?}"),
|
||||
}
|
||||
debug_assert_eq!(get_enum_member_value(source, "Z"), Some(ConstantValue::Number(-1.0)));
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod cfg;
|
||||
pub mod classes;
|
||||
pub mod enum_values;
|
||||
pub mod modules;
|
||||
pub mod scopes;
|
||||
pub mod symbols;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
use oxc_span::CompactStr;
|
||||
|
||||
/// A compile-time constant value from a TypeScript enum member.
|
||||
/// Per the TypeScript spec, enum members can only evaluate to numbers or strings.
|
||||
///
|
||||
/// Note: Only `PartialEq` is derived — `Eq` is intentionally omitted because
|
||||
/// `Number(NaN) != Number(NaN)` per IEEE 754. Do not use as a map key.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConstantValue {
|
||||
Number(f64),
|
||||
String(CompactStr),
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use oxc_ast_macros::ast;
|
||||
|
||||
pub mod class;
|
||||
pub mod comment_node;
|
||||
pub mod constant_value;
|
||||
pub mod es_target;
|
||||
pub mod identifier;
|
||||
pub mod keyword;
|
||||
|
||||
Reference in new issue
Block a user