feat(minifier): add property name mangling (#24740)

Oxc can shorten variable names and private class names, but it cannot shorten property names such as `obj._field`. This change adds optional property mangling. It replaces #23772 and gives bundlers a way to use the same property names across several files.

### Feature

- `include` selects names to mangle. `exclude` and `reserved` keep names unchanged.
- The cache can give a property a fixed output name or keep it unchanged.
- The mangler supports member access, object and class keys, destructuring, optional chains, the left side of `in`, and JSX property names.
- `mangle_quoted` also handles strings and template literals used directly as property keys. `/* @__KEY__ */` and `/* #__KEY__ */` can mark one other string or template literal as a property key.
- Short names are assigned in a stable order. More common properties get shorter names. Existing and reserved names are avoided.
- Bundlers can collect names from several programs, create one shared map, and apply it to every program.
- Numeric keys, `__proto__`, `constructor`, `prototype`, TypeScript-only keys, and private names are never changed by this pass.

### Design

Property names are part of JavaScript syntax, not local scope. For this reason, the code lives in `oxc_minifier`. It only reuses the short-name generator from `oxc_mangler`.

For one program, the order is:

```text
collect names → assign names → rewrite once → compress → mangle variables and private names
```

Property names are rewritten before compression because compression can remove information about quotes and comments.

For several programs, a bundler collects names from each program, merges the results, assigns one shared map, and rewrites each program once. Collection can run in parallel. All programs must use the same options.

The shared map keeps property names consistent inside one build. The cache has a different job: it keeps fixed names and reservations across separate minify calls.

### Example

With `include: "^_"` and `mangle_quoted: true`:

```js
obj[`_field`] = 1;
obj._field = 2;
```

becomes:

```js
obj[`e`] = 1;
obj.e = 2;
```

### Limitation

- Property mangling is unsafe when other code still uses the old property name. Public APIs and names shared with external code, host APIs, imported namespace objects, or separate builds must be excluded or reserved.
- Quote handling is per use. Without `mangle_quoted`, `obj._field` may change while `obj["_field"]` does not.
- The mangler cannot find names built at runtime or stored in arbitrary strings. Code that uses `eval`, `Function`, or `with` must reserve any affected names.
- Compression may create a matching property after this pass has finished. For example, `'f' + 'oo_'` may later become `foo_` and will not be mangled.
- TypeScript enum reverse mappings are not supported because the transform creates quoted strings.
- Multi-program support is a caller API. The caller must collect every program that shares mangled properties before assigning names.

The public `oxc-minify` API is stacked in #24741. The downstream multi-chunk integration is shown in rolldown/rolldown#10374.

Supersedes #23772.

AI assistance: OpenAI Codex and Claude were used for research, implementation, review, and testing. The contributor is responsible for reviewing and understanding the changes before submission.
This commit is contained in:
Dunqing committed 2026-08-17 07:50:51 +00:00
1 parent 794891d93a
commit 2f5cdb1231
17 files changed
+1668 -41

No files matched your search

Generated
+1
View File
@@ -2352,6 +2352,7 @@ dependencies = [
"insta",
"itoa",
"javascript-globals",
"lazy-regex",
"oxc_allocator",
"oxc_ast",
"oxc_ast_visit",
+1 -1
View File
@@ -9,7 +9,7 @@ use oxc_index::IndexVec;
use oxc_syntax::class::ClassId;
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
use base54::base54;
pub use base54::base54;
use oxc_allocator::{Allocator, ArenaHashSet, ArenaVec, BitSet};
use oxc_ast::ast::{Declaration, Program, Statement};
use oxc_data_structures::inline_string::InlineString;
+1
View File
@@ -36,6 +36,7 @@ oxc_syntax = { workspace = true }
oxc_traverse = { workspace = true }
cow-utils = { workspace = true }
itoa = { workspace = true }
lazy-regex = { workspace = true }
rustc-hash = { workspace = true }
[dev-dependencies]
+29
View File
@@ -187,6 +187,35 @@ serialize((x) => {
## Optional Assumptions
### Property names selected for mangling are not accessed dynamically
When property-name mangling is enabled, code does not access selected properties through an
arbitrary runtime string. Quoted syntax is handled per occurrence: it remains unchanged unless
`mangle_quoted` is enabled, while an unquoted occurrence of the same spelling may be renamed.
No-substitution template keys follow the same quoted rule; interpolated templates are not
rewritten.
```javascript
obj._field; // eligible when `include` matches
obj["_field"]; // kept unless quoted mangling is enabled
obj[`_field`]; // also kept unless quoted mangling is enabled
obj[keyFromNetwork]; // cannot be updated safely
```
Use `exclude`, `reserved`, or a `false` cache entry for public/reflected names. Properties owned by
unminified code, imported module namespaces, globals, DOM objects, or other host APIs must also be
excluded or reserved. A leading `/* @__KEY__ */` or `/* #__KEY__ */` annotation marks a string or
no-substitution template as a property name. Numeric spellings, `__proto__`, `constructor`, and
`prototype` are never mangled.
Property mangling does not inspect source strings evaluated by direct `eval` or the `Function`
constructor, and it does not bail out inside a `with` statement. Matching names reached through
these dynamic mechanisms must be excluded or reserved explicitly.
Property rewriting runs before compression. Computed keys that compression later materializes,
such as `['f' + 'oo_']`, are not revisited and can diverge from an eligible `obj.foo_` occurrence.
Exclude or reserve property names that are constructed this way.
### No Reliance on Function.prototype.name
Code doesn't depend on function names being preserved. This assumption is held by default. This can be changed by settings `keepNames` option.
+1
View File
@@ -95,6 +95,7 @@ fn minify<'a>(
let mut program = ret.program;
let options = MinifierOptions {
mangle: mangle.then(MangleOptions::default),
mangle_properties: None,
compress: Some(CompressOptions { max_iterations, ..CompressOptions::smallest() }),
};
let ret = Minifier::new(options).minify(allocator, &mut program);
+36 -8
View File
@@ -52,6 +52,7 @@ mod keep_var;
mod minifier_traverse;
mod options;
mod peephole;
mod property_mangler;
mod state;
mod symbol_liveness;
mod symbol_metadata;
@@ -71,6 +72,11 @@ use rustc_hash::FxHashMap;
use crate::state::CompressionMode;
pub use oxc_mangler::{MangleOptions, MangleOptionsKeepNames};
pub use property_mangler::{
InvalidManglePropertyCacheTarget, ManglePropertiesOptions, ManglePropertyCache,
PropertyMangleCollection, PropertyMangler, PropertyMapping,
is_valid_property_mangle_cache_target,
};
pub(crate) use crate::generated::traverse::Traverse;
#[doc(hidden)]
@@ -81,12 +87,22 @@ pub use crate::{compressor::Compressor, options::*};
#[derive(Debug, Clone)]
pub struct MinifierOptions {
pub mangle: Option<MangleOptions>,
/// Property-name mangling for the current [`Program`].
///
/// This option does not coordinate mappings across programs. Bundlers that need cross-file
/// consistency must collect all programs with [`PropertyMangler`], assign once, and rewrite
/// each program exactly once before emitting chunks.
pub mangle_properties: Option<ManglePropertiesOptions>,
pub compress: Option<CompressOptions>,
}
impl Default for MinifierOptions {
fn default() -> Self {
Self { mangle: Some(MangleOptions::default()), compress: Some(CompressOptions::default()) }
Self {
mangle: Some(MangleOptions::default()),
mangle_properties: None,
compress: Some(CompressOptions::default()),
}
}
}
@@ -97,6 +113,9 @@ pub struct MinifierReturn {
/// Each element is a mapping from original private member names to their mangled names.
pub class_private_mappings: Option<IndexVec<ClassId, FxHashMap<String, CompactStr>>>,
/// Updated property-name cache when property mangling ran.
pub property_mangle_cache: Option<ManglePropertyCache>,
/// Total number of iterations ran. Useful for debugging performance issues.
pub iterations: u8,
}
@@ -124,9 +143,20 @@ impl<'a> Minifier {
allocator: &'a Allocator,
program: &mut Program<'a>,
) -> MinifierReturn {
let (stats, iterations) = self
.options
.compress
let Self { options } = self;
let MinifierOptions { mangle, mangle_properties, compress } = options;
// Run property mangling first because compression can change which strings are property
// keys.
let property_mangle_cache = mangle_properties.map(|options| {
let mut mangler = PropertyMangler::new(options);
mangler.collect(program);
mangler.assign();
mangler.rewrite(program, allocator);
mangler.into_cache()
});
let (stats, iterations) = compress
.map(|options| {
let semantic = SemanticBuilder::new().build(program).semantic;
let stats = semantic.stats();
@@ -145,9 +175,7 @@ impl<'a> Minifier {
(Some(stats), iterations)
})
.unwrap_or_default();
let (scoping, class_private_mappings) = self
.options
.mangle
let (scoping, class_private_mappings) = mangle
.map(|options| {
let mut builder =
SemanticBuilder::new().with_build_nodes(true).with_class_table(true);
@@ -161,6 +189,6 @@ impl<'a> Minifier {
(semantic.into_scoping(), class_private_mappings)
})
.map_or((None, None), |(scoping, mappings)| (Some(scoping), Some(mappings)));
MinifierReturn { scoping, class_private_mappings, iterations }
MinifierReturn { scoping, class_private_mappings, property_mangle_cache, iterations }
}
}
+953
View File
@@ -0,0 +1,953 @@
//! Property-name mangling.
//!
//! The flow has three steps:
//!
//! 1. Collect property names without changing the AST.
//! 2. Assign one output name to each selected property name.
//! 3. Rewrite each program once.
//!
//! Keeping these steps separate lets bundlers share one mapping across programs.
use oxc_allocator::{Allocator, TakeIn};
use oxc_ast::{ast::*, builder::AstBuilder};
use oxc_ast_visit::{Visit, VisitMut, walk, walk_mut};
use oxc_ecmascript::StringToNumber;
use oxc_mangler::base54;
use oxc_span::Span;
use oxc_str::{CompactStr, Ident, Str};
use oxc_syntax::{identifier::is_identifier_name, number::ToJsString};
use rustc_hash::{FxHashMap, FxHashSet};
/// A property-mangle cache. `Some(name)` pins the output name and `None` keeps the property.
///
/// Pinned output names are authoritative and may intentionally be shared by multiple input
/// names. Automatically generated names never share an output or collide with a cache key or
/// pinned output.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ManglePropertyCache(FxHashMap<CompactStr, Option<CompactStr>>);
impl ManglePropertyCache {
/// Add a cache entry after validating that its target is safe in every rewritten position.
///
/// # Errors
///
/// Returns an error when `target` is not an `IdentifierName` or is a hard-reserved property
/// name.
pub fn insert(
&mut self,
original: CompactStr,
target: Option<CompactStr>,
) -> Result<(), InvalidManglePropertyCacheTarget> {
if let Some(target) = &target
&& !is_valid_property_mangle_cache_target(target)
{
return Err(InvalidManglePropertyCacheTarget { original, target: target.clone() });
}
self.0.insert(original, target);
Ok(())
}
/// Return the pinned target or reservation for an original property name.
pub fn get(&self, original: &str) -> Option<&Option<CompactStr>> {
self.0.get(original)
}
/// Iterate over original property names and their pinned targets or reservations.
pub fn iter(&self) -> impl Iterator<Item = (&CompactStr, &Option<CompactStr>)> {
self.0.iter()
}
/// Return whether the cache contains no mappings or reservations.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
fn insert_generated(&mut self, original: CompactStr, target: Option<CompactStr>) {
self.0.insert(original, target);
}
}
impl IntoIterator for ManglePropertyCache {
type Item = (CompactStr, Option<CompactStr>);
type IntoIter = <FxHashMap<CompactStr, Option<CompactStr>> as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
/// An invalid property-mangle cache target.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidManglePropertyCacheTarget {
pub original: CompactStr,
pub target: CompactStr,
}
impl std::fmt::Display for InvalidManglePropertyCacheTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"invalid property-mangle cache target for '{}': '{}' must be an IdentifierName other than '__proto__', 'constructor', or 'prototype'",
self.original, self.target
)
}
}
impl std::error::Error for InvalidManglePropertyCacheTarget {}
/// A single-application mapping from original property names to their final names.
///
/// Applying a mapping more than once is not generally idempotent: a cache target may also be
/// another cache key or source candidate. Each program must therefore be rewritten exactly once.
pub type PropertyMapping = FxHashMap<CompactStr, CompactStr>;
/// Options for opt-in property-name mangling.
#[derive(Debug, Clone)]
pub struct ManglePropertiesOptions {
/// Property names must match this expression to be mangled.
pub include: lazy_regex::Regex,
/// Matching property names are excluded after `include` is evaluated.
pub exclude: Option<lazy_regex::Regex>,
/// Exact property names that must remain unchanged.
pub reserved: FxHashSet<CompactStr>,
/// Also mangle quoted property occurrences.
pub mangle_quoted: bool,
/// Generate readable property names instead of base54 names.
pub debug: bool,
/// Stable input mappings and explicit `false`-equivalent reservations.
pub cache: ManglePropertyCache,
}
impl ManglePropertiesOptions {
pub fn new(include: lazy_regex::Regex) -> Self {
Self {
include,
exclude: None,
reserved: FxHashSet::default(),
mangle_quoted: false,
debug: false,
cache: ManglePropertyCache::default(),
}
}
/// Compile an `include` pattern using Rust's regex syntax.
///
/// # Errors
///
/// Returns the regex parser's message when `include` is invalid.
pub fn from_pattern(include: &str) -> Result<Self, String> {
lazy_regex::Regex::new(include).map(Self::new).map_err(|error| error.to_string())
}
}
/// Returns whether a user-provided cache target can be emitted in every property position.
pub fn is_valid_property_mangle_cache_target(name: &str) -> bool {
!is_hard_reserved(name) && is_identifier_name(name)
}
fn is_hard_reserved(name: &str) -> bool {
matches!(name, "__proto__" | "constructor" | "prototype")
}
fn numeric_key_string(value: f64) -> String {
if value == 0.0 { "0".to_string() } else { value.to_js_string() }
}
fn is_canonical_numeric_string(name: &str) -> bool {
let value = name.string_to_number();
if value.is_nan() {
return name == "NaN";
}
numeric_key_string(value) == name
}
fn is_key_annotation(comment: &Comment, source_text: &str) -> bool {
let text = comment.content_span().source_text(source_text).trim();
matches!(text.strip_prefix(['@', '#']), Some("__KEY__"))
}
fn key_annotated_spans(program: &Program<'_>) -> FxHashSet<u32> {
program
.comments
.iter()
.filter(|comment| {
comment.position == CommentPosition::Leading
&& is_key_annotation(comment, program.source_text)
})
.map(|comment| comment.attached_to)
.collect()
}
/// Data gathered by the read-only collection phase.
#[derive(Debug, Default)]
struct PropertyCollectState {
/// Number of candidate occurrences before eligibility filtering.
frequencies: FxHashMap<CompactStr, u32>,
/// Spellings that remain in the output and therefore cannot be automatic output names.
occupied: FxHashSet<CompactStr>,
}
/// Property candidates and occupied names collected from one program.
///
/// This opaque value lets parallel callers collect with a shared options reference, then merge
/// the results into one [`PropertyMangler`] without cloning caches or reserved-name sets per
/// program.
#[derive(Debug)]
pub struct PropertyMangleCollection(PropertyCollectState);
impl PropertyMangleCollection {
/// Collect property candidates from one program without mutating its AST.
pub fn from_program(options: &ManglePropertiesOptions, program: &Program<'_>) -> Self {
let key_annotated = key_annotated_spans(program);
Self(collect(options, program, &key_annotated))
}
}
struct PropertyCollector<'o> {
options: &'o ManglePropertiesOptions,
key_annotated: &'o FxHashSet<u32>,
state: PropertyCollectState,
}
fn is_eligible(options: &ManglePropertiesOptions, name: &str) -> bool {
if is_hard_reserved(name)
|| is_canonical_numeric_string(name)
|| options.reserved.contains(name)
|| !options.include.is_match(name)
|| options.exclude.as_ref().is_some_and(|regex| regex.is_match(name))
{
return false;
}
if let Some(cached) = options.cache.get(name) {
return cached.is_some();
}
true
}
impl<'o> PropertyCollector<'o> {
fn new(options: &'o ManglePropertiesOptions, key_annotated: &'o FxHashSet<u32>) -> Self {
Self { options, key_annotated, state: PropertyCollectState::default() }
}
fn candidate(&mut self, name: &str) {
// Avoid constructing an owned key for repeated occurrences.
if let Some(count) = self.state.frequencies.get_mut(name) {
*count = count.saturating_add(1);
return;
}
let count = self.state.frequencies.entry(CompactStr::from(name)).or_default();
*count = count.saturating_add(1);
}
fn quoted(&mut self, name: &str) {
if self.options.mangle_quoted {
self.candidate(name);
} else {
self.occupy(name);
}
}
fn occupy(&mut self, name: &str) {
self.state.occupied.insert(CompactStr::from(name));
}
fn special_literal(&self, span: Span) -> bool {
self.key_annotated.contains(&span.start)
}
fn observe_literal(&mut self, span: Span, name: &str) {
if self.special_literal(span) {
self.candidate(name);
} else {
// Compression can propagate or fold an arbitrary string into a property key after
// property names have already been assigned. Keep every literal spelling out of the
// generated namespace so that later materialization cannot merge distinct keys.
self.occupy(name);
}
}
fn classify_key_expression(&mut self, expression: &Expression<'_>) {
match expression.get_inner_expression() {
Expression::StringLiteral(literal) if self.special_literal(literal.span) => {}
Expression::StringLiteral(literal) => self.quoted(literal.value.as_str()),
Expression::TemplateLiteral(template)
if template.expressions.is_empty() && self.special_literal(template.span) => {}
Expression::TemplateLiteral(template) if template.expressions.is_empty() => {
if let [quasi] = template.quasis.as_slice()
&& let Some(cooked) = quasi.value.cooked
{
self.quoted(cooked.as_str());
}
}
Expression::ConditionalExpression(expression) => {
self.classify_key_expression(&expression.consequent);
self.classify_key_expression(&expression.alternate);
}
Expression::SequenceExpression(expression) => {
if let Some(last) = expression.expressions.last() {
self.classify_key_expression(last);
}
}
Expression::NumericLiteral(literal) => self.occupy(&numeric_key_string(literal.value)),
_ => {}
}
}
fn classify_property_key(&mut self, key: &PropertyKey<'_>) {
match key {
PropertyKey::StaticIdentifier(identifier) => self.candidate(identifier.name.as_str()),
PropertyKey::PrivateIdentifier(_) => {}
PropertyKey::NumericLiteral(literal) => {
self.occupy(&numeric_key_string(literal.value));
}
key => {
if let Some(expression) = key.as_expression() {
self.classify_key_expression(expression);
}
}
}
}
}
impl<'a> Visit<'a> for PropertyCollector<'_> {
fn visit_directive(&mut self, directive: &Directive<'a>) {
// Directives are not property-name positions.
self.occupy(directive.expression.value.as_str());
}
fn visit_ts_type(&mut self, _ty: &TSType<'a>) {}
fn visit_ts_property_signature(&mut self, _signature: &TSPropertySignature<'a>) {}
fn visit_ts_method_signature(&mut self, _signature: &TSMethodSignature<'a>) {}
fn visit_static_member_expression(&mut self, expression: &StaticMemberExpression<'a>) {
self.candidate(expression.property.name.as_str());
walk::walk_static_member_expression(self, expression);
}
fn visit_computed_member_expression(&mut self, expression: &ComputedMemberExpression<'a>) {
self.classify_key_expression(&expression.expression);
walk::walk_computed_member_expression(self, expression);
}
fn visit_property_key(&mut self, key: &PropertyKey<'a>) {
self.classify_property_key(key);
walk::walk_property_key(self, key);
}
fn visit_assignment_target_property_identifier(
&mut self,
property: &AssignmentTargetPropertyIdentifier<'a>,
) {
self.candidate(property.binding.name.as_str());
walk::walk_assignment_target_property_identifier(self, property);
}
fn visit_jsx_attribute_name(&mut self, name: &JSXAttributeName<'a>) {
match name {
JSXAttributeName::Identifier(identifier) => self.candidate(identifier.name.as_str()),
JSXAttributeName::NamespacedName(name) => {
self.occupy(name.namespace.name.as_str());
self.occupy(name.name.name.as_str());
}
}
walk::walk_jsx_attribute_name(self, name);
}
fn visit_jsx_member_expression(&mut self, expression: &JSXMemberExpression<'a>) {
self.candidate(expression.property.name.as_str());
walk::walk_jsx_member_expression(self, expression);
}
fn visit_binary_expression(&mut self, expression: &BinaryExpression<'a>) {
if expression.operator == BinaryOperator::In {
self.classify_key_expression(&expression.left);
}
walk::walk_binary_expression(self, expression);
}
fn visit_string_literal(&mut self, literal: &StringLiteral<'a>) {
self.observe_literal(literal.span, literal.value.as_str());
}
fn visit_template_literal(&mut self, template: &TemplateLiteral<'a>) {
if template.expressions.is_empty()
&& let [quasi] = template.quasis.as_slice()
&& let Some(cooked) = quasi.value.cooked
{
self.observe_literal(template.span, cooked.as_str());
}
walk::walk_template_literal(self, template);
}
}
fn collect(
options: &ManglePropertiesOptions,
program: &Program<'_>,
key_annotated: &FxHashSet<u32>,
) -> PropertyCollectState {
let mut collector = PropertyCollector::new(options, key_annotated);
collector.visit_program(program);
collector.state
}
fn debug_name(original: &str, attempt: u32) -> CompactStr {
if is_identifier_name(original) {
if attempt == 0 {
CompactStr::from(format!("_${original}$_"))
} else {
CompactStr::from(format!("_${original}${attempt}$_"))
}
} else {
CompactStr::from(format!("_$property{attempt}$_"))
}
}
fn assign(
options: &ManglePropertiesOptions,
state: &PropertyCollectState,
) -> (PropertyMapping, ManglePropertyCache) {
// Eligibility depends only on the name, so evaluate it once after collection.
let mut names: Vec<_> =
state.frequencies.iter().filter(|(name, _)| is_eligible(options, name.as_str())).collect();
names.sort_unstable_by(|(name_a, count_a), (name_b, count_b)| {
count_b.cmp(count_a).then_with(|| name_a.as_str().cmp(name_b.as_str()))
});
// Generated names must avoid collected names, reservations, cache keys, and pinned targets.
let mut occupied = state.occupied.clone();
occupied.extend(state.frequencies.keys().cloned());
occupied.extend(options.reserved.iter().cloned());
occupied.extend(options.cache.0.keys().cloned());
occupied.extend(options.cache.0.values().flatten().cloned());
let mut mapping = PropertyMapping::default();
let mut cache = options.cache.clone();
let mut counter = 0u32;
for (original, _) in names {
if let Some(Some(target)) = options.cache.get(original.as_str()) {
mapping.insert(original.clone(), target.clone());
continue;
}
let mut debug_attempt = 0u32;
let target = loop {
let candidate = if options.debug {
let candidate = debug_name(original.as_str(), debug_attempt);
debug_attempt =
debug_attempt.checked_add(1).expect("debug property name space exhausted");
candidate
} else {
let candidate = CompactStr::from(base54(counter).as_str());
counter = counter.checked_add(1).expect("property name space exhausted");
candidate
};
if !occupied.contains(candidate.as_str()) && !is_hard_reserved(candidate.as_str()) {
break candidate;
}
};
occupied.insert(target.clone());
cache.insert_generated(original.clone(), Some(target.clone()));
mapping.insert(original.clone(), target);
}
(mapping, cache)
}
// Property keys can share syntax with local bindings. Expand shorthand when needed so only the
// property key changes.
struct PropertyRewriter<'a, 'm> {
mapping: &'m PropertyMapping,
mangle_quoted: bool,
key_annotated: &'m FxHashSet<u32>,
ast: AstBuilder<'a>,
}
impl<'a> PropertyRewriter<'a, '_> {
fn special_literal(&self, span: Span) -> bool {
self.key_annotated.contains(&span.start)
}
fn should_rewrite_literal(&self, span: Span) -> bool {
self.key_annotated.contains(&span.start) || self.mangle_quoted
}
fn target(&self, original: &str) -> Option<&CompactStr> {
self.mapping.get(original)
}
fn rename_string_literal(&self, literal: &mut StringLiteral<'a>) {
if !self.should_rewrite_literal(literal.span) {
return;
}
if let Some(target) = self.target(literal.value.as_str()) {
literal.value = Str::from_str_in(target.as_str(), &self.ast);
literal.raw = None;
}
}
fn rename_template_literal(&self, template: &mut TemplateLiteral<'a>) {
if !self.should_rewrite_literal(template.span) || !template.expressions.is_empty() {
return;
}
if let [quasi] = template.quasis.as_mut_slice()
&& let Some(cooked) = quasi.value.cooked
&& let Some(target) = self.target(cooked.as_str())
{
let target = Str::from_str_in(target.as_str(), &self.ast);
quasi.value.cooked = Some(target);
quasi.value.raw = target;
}
}
fn rename_key_expression(&self, expression: &mut Expression<'a>) {
match expression.get_inner_expression_mut() {
// Annotated literals were already handled during traversal.
Expression::StringLiteral(literal) if !self.special_literal(literal.span) => {
self.rename_string_literal(literal);
}
Expression::TemplateLiteral(template) if !self.special_literal(template.span) => {
self.rename_template_literal(template);
}
Expression::ConditionalExpression(expression) => {
self.rename_key_expression(&mut expression.consequent);
self.rename_key_expression(&mut expression.alternate);
}
Expression::SequenceExpression(expression) => {
if let Some(last) = expression.expressions.last_mut() {
self.rename_key_expression(last);
}
}
_ => {}
}
}
fn direct_string_key(key: &PropertyKey<'a>) -> Option<(CompactStr, Span)> {
if let PropertyKey::StringLiteral(literal) = key {
Some((CompactStr::from(literal.value.as_str()), literal.span))
} else {
None
}
}
fn rewrite_key_position(
&self,
key: &mut PropertyKey<'a>,
computed: &mut bool,
original_string: Option<(CompactStr, Span)>,
) {
if let Some((original, span)) = original_string
&& self.should_rewrite_literal(span)
&& let Some(target) = self.target(original.as_str())
{
*key = PropertyKey::StaticIdentifier(IdentifierName::boxed(
span,
Ident::from_str_in(target.as_str(), &self.ast),
&self.ast,
));
*computed = false;
return;
}
if *computed && let Some(expression) = key.as_expression_mut() {
self.rename_key_expression(expression);
}
}
fn rename_static_key(&self, key: &mut PropertyKey<'a>) {
if let PropertyKey::StaticIdentifier(identifier) = key
&& let Some(target) = self.target(identifier.name.as_str())
{
identifier.name = Ident::from_str_in(target.as_str(), &self.ast);
}
}
}
impl<'a> VisitMut<'a> for PropertyRewriter<'a, '_> {
// Codegen emits the directive's separate raw value, so leave directives unchanged.
fn visit_directive(&mut self, _directive: &mut Directive<'a>) {}
fn visit_ts_type(&mut self, _ty: &mut TSType<'a>) {}
fn visit_ts_property_signature(&mut self, _signature: &mut TSPropertySignature<'a>) {}
fn visit_ts_method_signature(&mut self, _signature: &mut TSMethodSignature<'a>) {}
fn visit_static_member_expression(&mut self, expression: &mut StaticMemberExpression<'a>) {
let original = CompactStr::from(expression.property.name.as_str());
walk_mut::walk_static_member_expression(self, expression);
if let Some(target) = self.target(original.as_str()) {
expression.property.name = Ident::from_str_in(target.as_str(), &self.ast);
}
}
fn visit_expression(&mut self, expression: &mut Expression<'a>) {
if let Expression::ComputedMemberExpression(member) = expression
&& let Expression::StringLiteral(literal) = &member.expression
{
let original = CompactStr::from(literal.value.as_str());
let property_span = literal.span;
if self.should_rewrite_literal(property_span)
&& let Some(target) = self.target(original.as_str())
{
let property = IdentifierName::new(
property_span,
Ident::from_str_in(target.as_str(), &self.ast),
&self.ast,
);
let replacement = StaticMemberExpression::boxed(
member.span,
member.object.take_in(&self.ast),
property,
member.optional,
&self.ast,
);
*expression = Expression::StaticMemberExpression(replacement);
if let Expression::StaticMemberExpression(member) = expression {
self.visit_expression(&mut member.object);
}
return;
}
}
walk_mut::walk_expression(self, expression);
}
fn visit_computed_member_expression(&mut self, expression: &mut ComputedMemberExpression<'a>) {
walk_mut::walk_computed_member_expression(self, expression);
self.rename_key_expression(&mut expression.expression);
}
fn visit_property_key(&mut self, key: &mut PropertyKey<'a>) {
self.rename_static_key(key);
walk_mut::walk_property_key(self, key);
}
fn visit_object_property(&mut self, property: &mut ObjectProperty<'a>) {
let original_string = Self::direct_string_key(&property.key);
let expands_shorthand = property.shorthand
&& matches!(&property.key, PropertyKey::StaticIdentifier(identifier) if self.target(identifier.name.as_str()).is_some());
walk_mut::walk_object_property(self, property);
if expands_shorthand {
property.shorthand = false;
}
self.rewrite_key_position(&mut property.key, &mut property.computed, original_string);
}
fn visit_binding_property(&mut self, property: &mut BindingProperty<'a>) {
let original_string = Self::direct_string_key(&property.key);
let expands_shorthand = property.shorthand
&& matches!(&property.key, PropertyKey::StaticIdentifier(identifier) if self.target(identifier.name.as_str()).is_some());
walk_mut::walk_binding_property(self, property);
if expands_shorthand {
property.shorthand = false;
}
self.rewrite_key_position(&mut property.key, &mut property.computed, original_string);
}
fn visit_assignment_target_property(&mut self, property: &mut AssignmentTargetProperty<'a>) {
if let AssignmentTargetProperty::AssignmentTargetPropertyIdentifier(identifier) = property {
let original = CompactStr::from(identifier.binding.name.as_str());
let target = self.target(original.as_str()).cloned();
walk_mut::walk_assignment_target_property(self, property);
if let Some(target) = target
&& let AssignmentTargetProperty::AssignmentTargetPropertyIdentifier(identifier) =
property
{
let span = identifier.span;
let binding_span = identifier.binding.span;
let binding_name = identifier.binding.name;
let reference_id = identifier.binding.reference_id.get();
let init = identifier.init.take();
let assignment_target = reference_id.map_or_else(
|| {
AssignmentTarget::new_assignment_target_identifier(
binding_span,
binding_name,
&self.ast,
)
},
|reference_id| {
AssignmentTarget::new_assignment_target_identifier_with_reference_id(
binding_span,
binding_name,
reference_id,
&self.ast,
)
},
);
let binding = if let Some(init) = init {
AssignmentTargetMaybeDefault::new_assignment_target_with_default(
span,
assignment_target,
init,
&self.ast,
)
} else {
match assignment_target {
AssignmentTarget::AssignmentTargetIdentifier(identifier) => {
AssignmentTargetMaybeDefault::AssignmentTargetIdentifier(identifier)
}
_ => unreachable!(),
}
};
let name = PropertyKey::StaticIdentifier(IdentifierName::boxed(
binding_span,
Ident::from_str_in(target.as_str(), &self.ast),
&self.ast,
));
*property = AssignmentTargetProperty::new_assignment_target_property_property(
span, name, binding, false, &self.ast,
);
}
return;
}
walk_mut::walk_assignment_target_property(self, property);
}
fn visit_assignment_target_property_property(
&mut self,
property: &mut AssignmentTargetPropertyProperty<'a>,
) {
let original_string = Self::direct_string_key(&property.name);
walk_mut::walk_assignment_target_property_property(self, property);
self.rewrite_key_position(&mut property.name, &mut property.computed, original_string);
}
fn visit_property_definition(&mut self, property: &mut PropertyDefinition<'a>) {
let original_string = Self::direct_string_key(&property.key);
walk_mut::walk_property_definition(self, property);
self.rewrite_key_position(&mut property.key, &mut property.computed, original_string);
}
fn visit_accessor_property(&mut self, property: &mut AccessorProperty<'a>) {
let original_string = Self::direct_string_key(&property.key);
walk_mut::walk_accessor_property(self, property);
self.rewrite_key_position(&mut property.key, &mut property.computed, original_string);
}
fn visit_method_definition(&mut self, method: &mut MethodDefinition<'a>) {
let original_string = Self::direct_string_key(&method.key);
walk_mut::walk_method_definition(self, method);
self.rewrite_key_position(&mut method.key, &mut method.computed, original_string);
}
fn visit_binary_expression(&mut self, expression: &mut BinaryExpression<'a>) {
walk_mut::walk_binary_expression(self, expression);
if expression.operator == BinaryOperator::In {
self.rename_key_expression(&mut expression.left);
}
}
fn visit_jsx_attribute_name(&mut self, name: &mut JSXAttributeName<'a>) {
if let JSXAttributeName::Identifier(identifier) = name {
let original = CompactStr::from(identifier.name.as_str());
walk_mut::walk_jsx_attribute_name(self, name);
if let JSXAttributeName::Identifier(identifier) = name
&& let Some(target) = self.target(original.as_str())
{
identifier.name = Str::from_str_in(target.as_str(), &self.ast);
}
} else {
walk_mut::walk_jsx_attribute_name(self, name);
}
}
fn visit_jsx_member_expression(&mut self, expression: &mut JSXMemberExpression<'a>) {
let original = CompactStr::from(expression.property.name.as_str());
walk_mut::walk_jsx_member_expression(self, expression);
if let Some(target) = self.target(original.as_str()) {
expression.property.name = Str::from_str_in(target.as_str(), &self.ast);
}
}
fn visit_string_literal(&mut self, literal: &mut StringLiteral<'a>) {
if self.key_annotated.contains(&literal.span.start) {
self.rename_string_literal(literal);
}
}
fn visit_template_literal(&mut self, template: &mut TemplateLiteral<'a>) {
if self.key_annotated.contains(&template.span.start) {
self.rename_template_literal(template);
}
walk_mut::walk_template_literal(self, template);
}
}
/// Three-phase property-name mangler.
pub struct PropertyMangler {
options: ManglePropertiesOptions,
state: PropertyCollectState,
mapping: PropertyMapping,
cache: ManglePropertyCache,
}
impl PropertyMangler {
pub fn new(options: ManglePropertiesOptions) -> Self {
Self {
cache: ManglePropertyCache::default(),
options,
state: PropertyCollectState::default(),
mapping: PropertyMapping::default(),
}
}
/// Collect property occurrences and occupied spellings without mutating the AST.
pub fn collect(&mut self, program: &Program<'_>) {
let collection = PropertyMangleCollection::from_program(&self.options, program);
self.merge_collected(collection);
}
/// Merge candidates collected independently with equivalent options.
///
/// This allows callers to collect programs in parallel, merge the results, and assign one
/// mapping.
pub fn merge_collected(&mut self, collection: PropertyMangleCollection) {
for (name, frequency) in collection.0.frequencies {
let total = self.state.frequencies.entry(name).or_default();
*total = total.saturating_add(frequency);
}
self.state.occupied.extend(collection.0.occupied);
}
/// Assign deterministic names by descending occurrence frequency and lexical tie-break.
pub fn assign(&mut self) -> &PropertyMapping {
let (mapping, cache) = assign(&self.options, &self.state);
self.mapping = mapping;
self.cache = cache;
&self.mapping
}
/// Rewrite every selected occurrence once.
///
/// Do not apply this mapping to already-rewritten code. Cache targets are allowed to equal
/// other cache keys, so a second application can incorrectly follow a mapping chain.
pub fn rewrite<'a>(&self, program: &mut Program<'a>, allocator: &'a Allocator) {
if self.mapping.is_empty() {
return;
}
let key_annotated = key_annotated_spans(program);
let mut rewriter = PropertyRewriter {
mapping: &self.mapping,
mangle_quoted: self.options.mangle_quoted,
key_annotated: &key_annotated,
ast: AstBuilder::new(allocator),
};
rewriter.visit_program(program);
}
pub fn mapping(&self) -> &PropertyMapping {
&self.mapping
}
pub fn cache(&self) -> &ManglePropertyCache {
&self.cache
}
pub fn into_cache(self) -> ManglePropertyCache {
self.cache
}
}
#[cfg(test)]
mod tests {
use super::*;
fn options(pattern: &str) -> ManglePropertiesOptions {
ManglePropertiesOptions::new(lazy_regex::Regex::new(pattern).unwrap())
}
#[test]
fn assignment_prefers_frequency_then_name() {
let options = options("^_");
let state = PropertyCollectState {
frequencies: FxHashMap::from_iter([
(CompactStr::from("_rare"), 1),
(CompactStr::from("_often"), 3),
(CompactStr::from("_also_often"), 3),
]),
occupied: FxHashSet::default(),
};
let (mapping, _) = assign(&options, &state);
assert_eq!(mapping["_also_often"].as_str(), "e");
assert_eq!(mapping["_often"].as_str(), "t");
assert_eq!(mapping["_rare"].as_str(), "n");
}
#[test]
fn assignment_honors_cache_and_allows_duplicate_targets() {
let mut options = options("^_");
options.cache.insert("_a".into(), Some("A".into())).unwrap();
options.cache.insert("_b".into(), Some("A".into())).unwrap();
options.cache.insert("_keep".into(), None).unwrap();
let state = PropertyCollectState {
frequencies: FxHashMap::from_iter([
(CompactStr::from("_a"), 1),
(CompactStr::from("_b"), 1),
(CompactStr::from("_auto"), 1),
]),
occupied: FxHashSet::from_iter([CompactStr::from("_keep")]),
};
let (mapping, cache) = assign(&options, &state);
assert_eq!(mapping["_a"].as_str(), "A");
assert_eq!(mapping["_b"].as_str(), "A");
assert_ne!(mapping["_auto"].as_str(), "A");
assert_eq!(cache.get("_keep"), Some(&None));
}
#[test]
fn assignment_filters_ineligible_collected_names() {
let mut options = options("^(?:_|1$|constructor$)");
options.exclude = Some(lazy_regex::Regex::new("^_excluded(?:One|Two)$").unwrap());
options.reserved.insert("_reserved".into());
options.cache.insert("_cached".into(), None).unwrap();
let state = PropertyCollectState {
frequencies: FxHashMap::from_iter([
(CompactStr::from("_mangle"), 1),
(CompactStr::from("_excludedOne"), 10),
(CompactStr::from("_reserved"), 10),
(CompactStr::from("_cached"), 10),
(CompactStr::from("public"), 10),
(CompactStr::from("1"), 10),
(CompactStr::from("constructor"), 10),
]),
occupied: FxHashSet::default(),
};
let (mapping, cache) = assign(&options, &state);
assert_eq!(mapping.len(), 1);
assert!(mapping.contains_key("_mangle"));
assert_eq!(cache.get("_cached"), Some(&None));
}
#[test]
fn hard_reservations_are_exact() {
assert!(is_hard_reserved("__proto__"));
assert!(is_hard_reserved("constructor"));
assert!(is_hard_reserved("prototype"));
assert!(!is_hard_reserved("toString"));
assert!(!is_hard_reserved("then"));
}
#[test]
fn cache_targets_are_identifier_names() {
assert!(is_valid_property_mangle_cache_target("valid$name"));
assert!(!is_valid_property_mangle_cache_target("not-valid"));
assert!(!is_valid_property_mangle_cache_target("__proto__"));
assert!(!is_valid_property_mangle_cache_target("constructor"));
assert!(!is_valid_property_mangle_cache_target("prototype"));
}
#[test]
fn invalid_cache_targets_cannot_enter_the_rust_engine() {
let mut cache = ManglePropertyCache::default();
for target in ["a-b", "__proto__", "constructor", "prototype", ""] {
let error = cache.insert("_field".into(), Some(target.into())).unwrap_err();
assert_eq!(error.original.as_str(), "_field");
assert_eq!(error.target.as_str(), target);
assert!(cache.is_empty());
}
cache.insert("_field".into(), Some("valid$name".into())).unwrap();
assert_eq!(cache.get("_field").and_then(Option::as_deref), Some("valid$name"));
}
}
+2
View File
@@ -6,6 +6,8 @@ use oxc_mangler::{MangleOptions, MangleOptionsKeepNames, Mangler};
use oxc_parser::Parser;
use oxc_span::SourceType;
mod property_mangler;
fn mangle_with_source_type(
source_text: &str,
options: &MangleOptions,
@@ -0,0 +1,533 @@
use oxc_allocator::Allocator;
use oxc_ast_visit::VisitMut;
use oxc_codegen::Codegen;
use oxc_minifier::{
CompressOptions, MangleOptions, ManglePropertiesOptions, ManglePropertyCache, Minifier,
MinifierOptions, PropertyMangleCollection, PropertyMangler,
};
use oxc_parser::Parser;
use oxc_span::{SourceType, Span};
fn options(pattern: &str) -> ManglePropertiesOptions {
ManglePropertiesOptions::new(lazy_regex::Regex::new(pattern).unwrap())
}
fn codegen(source: &str, source_type: SourceType) -> String {
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, source, source_type).parse();
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
Codegen::new().build(&parsed.program).code
}
fn mangle_with(
source: &str,
source_type: SourceType,
options: ManglePropertiesOptions,
) -> (String, ManglePropertyCache) {
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, source, source_type).parse();
assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
let mut program = parsed.program;
let mut mangler = PropertyMangler::new(options);
mangler.collect(&program);
mangler.assign();
mangler.rewrite(&mut program, &allocator);
let code = Codegen::new().build(&program).code;
(code, mangler.into_cache())
}
#[track_caller]
fn test(source: &str, expected: &str, options: ManglePropertiesOptions) {
let source_type = SourceType::mjs();
let (actual, _) = mangle_with(source, source_type, options);
assert_eq!(actual, codegen(expected, source_type), "source: {source}");
}
#[test]
fn rewrites_runtime_property_positions() {
test(
"let _foo; ({ _foo }); const { _foo: local } = obj; ({ _foo } = obj); class C { _foo() {} } obj._foo;",
"let _foo; ({ e: _foo }); const { e: local } = obj; ({ e: _foo } = obj); class C { e() {} } obj.e;",
options("^_"),
);
}
#[test]
fn quoted_behavior_is_per_occurrence() {
test(
"obj._foo; obj['_foo']; ({ _foo: 1, '_foo': 2 });",
"obj.e; obj['_foo']; ({ e: 1, '_foo': 2 });",
options("^_"),
);
let mut quoted = options("^_");
quoted.mangle_quoted = true;
test(
"obj._foo; obj['_foo']; ({ _foo: 1, '_foo': 2 }); '_foo' in obj;",
"obj.e; obj.e; ({ e: 1, e: 2 }); 'e' in obj;",
quoted,
);
test(
"obj._field; obj[x ? '_field' : y]; obj?.[x, '_field']; (x ? '_field' : y) in obj;",
"obj.e; obj[x ? '_field' : y]; obj?.[x, '_field']; (x ? '_field' : y) in obj;",
options("^_"),
);
let mut wrapped_quoted = options("^_");
wrapped_quoted.mangle_quoted = true;
test(
"obj[x ? '_field' : y]; obj?.[x, '_field']; ({ [(x, '_field')]: 1 }); (x ? '_field' : y) in obj;",
"obj[x ? 'e' : y]; obj?.[x, 'e']; ({ [(x, 'e')]: 1 }); (x ? 'e' : y) in obj;",
wrapped_quoted,
);
}
#[test]
fn mangle_quoted_rewrites_no_substitution_template_keys() {
let mut quoted = options("^_");
quoted.mangle_quoted = true;
test(
"obj[`_foo`] = 1; obj._foo = 2; `_foo` in obj; ({ [`_foo`]: 3 }); obj[`${suffix}_foo`]; use(`_foo`);",
"obj[`e`] = 1; obj.e = 2; `e` in obj; ({ [`e`]: 3 }); obj[`${suffix}_foo`]; use(`_foo`);",
quoted,
);
}
#[test]
fn optional_chain_properties_are_rewritten_consistently() {
test(
"x._foo; x._foo?.(); x?._foo; x?._foo(); x?._foo._bar; x?._foo._bar();",
"x.e; x.e?.(); x?.e; x?.e(); x?.e.t; x?.e.t();",
options("^_"),
);
}
#[test]
fn hard_reserved_constructor_keeps_super_calls_valid() {
test(
"class Base {} class Derived extends Base { constructor() { super(); } _method() { return super._method(); } }",
"class Base {} class Derived extends Base { constructor() { super(); } e() { return super.e(); } }",
options("."),
);
}
#[test]
fn module_and_commonjs_member_positions_are_rewritten() {
test(
"export const value = namespace._field; export { value as _binding };",
"export const value = namespace.e; export { value as _binding };",
options("^_"),
);
let source_type = SourceType::cjs();
let (actual, _) = mangle_with(
"exports._field = require('pkg')._other; let _local = 1;",
source_type,
options("^_"),
);
assert_eq!(actual, codegen("exports.e = require('pkg').t; let _local = 1;", source_type));
}
#[test]
fn automatic_names_avoid_source_property_spellings() {
test("obj.e; obj.t; obj._foo; obj._bar;", "obj.e; obj.t; obj.r; obj.n;", options("^_"));
}
#[test]
fn annotations_override_quoted_behavior() {
test(
"obj._foo; helper(/* @__KEY__ */ '_foo'); helper(/* #__KEY__ */ `_foo`); helper('_foo');",
"obj.e; helper(/* @__KEY__ */ 'e'); helper(/* #__KEY__ */ `e`); helper('_foo');",
options("^_"),
);
}
#[test]
fn key_annotations_do_not_rewrite_directives() {
let source_type = SourceType::mjs();
let (actual, cache) =
mangle_with("/* @__KEY__ */ '_directive'; obj._field;", source_type, options("^_"));
assert_eq!(actual, codegen("/* @__KEY__ */ '_directive'; obj.e;", source_type));
assert!(cache.get("_directive").is_none());
assert_eq!(cache.get("_field").and_then(Option::as_deref), Some("e"));
}
#[test]
fn trailing_key_annotation_does_not_annotate_offset_zero() {
let source = "'_x' in obj ? yes() : no();\nwork(); /* @__KEY__ */\ndone();";
test(source, source, options("^_"));
}
#[test]
fn exact_reservations_do_not_grow_into_a_denylist() {
test(
"obj.__proto__; obj.constructor; obj.prototype; obj.then; obj.toJSON;",
"obj.__proto__; obj.constructor; obj.prototype; obj.e; obj.t;",
options("."),
);
}
#[test]
fn exclude_keeps_selected_names_unchanged() {
let mut excluded = options("^_");
excluded.exclude = Some(lazy_regex::Regex::new("^_keep(?:One|Two)$").unwrap());
test("obj._keepOne; obj._mangle;", "obj._keepOne; obj.e;", excluded);
}
#[test]
fn frequency_controls_assignment() {
test("a._often; b._rare; c._often; d._often;", "a.e; b.t; c.e; d.e;", options("^_"));
}
#[test]
fn cache_is_authoritative_and_duplicate_targets_are_allowed() {
let mut options = options("^_");
options.cache.insert("_first".into(), Some("A".into())).unwrap();
options.cache.insert("_second".into(), Some("A".into())).unwrap();
options.cache.insert("_keep".into(), None).unwrap();
let (actual, cache) = mangle_with(
"obj._first; obj._second; obj._keep; obj._automatic;",
SourceType::mjs(),
options,
);
assert_eq!(actual, codegen("obj.A; obj.A; obj._keep; obj.e;", SourceType::mjs()));
assert_eq!(cache.get("_first").and_then(Option::as_deref), Some("A"));
assert_eq!(cache.get("_second").and_then(Option::as_deref), Some("A"));
assert_eq!(cache.get("_keep"), Some(&None));
assert_eq!(cache.get("_automatic").and_then(Option::as_deref), Some("e"));
}
#[test]
fn cache_targets_are_not_remapped_during_rewrite() {
let mut options = options("^_");
options.mangle_quoted = true;
options.cache.insert("_first".into(), Some("_second".into())).unwrap();
options.cache.insert("_second".into(), Some("final".into())).unwrap();
test(
"obj._first; obj['_first']; ({ '_first': 1 }); helper(/* @__KEY__ */ '_first'); obj._second;",
"obj._second; obj._second; ({ _second: 1 }); helper(/* @__KEY__ */ '_second'); obj.final;",
options,
);
}
#[test]
fn cache_cycles_are_applied_once() {
let mut options = options(".");
options.cache.insert("a".into(), Some("b".into())).unwrap();
options.cache.insert("b".into(), Some("a".into())).unwrap();
test("obj.a; obj.b;", "obj.b; obj.a;", options);
}
#[test]
fn non_matching_cache_entries_are_inert_and_preserved() {
let mut options = options("^_");
options.cache.insert("public".into(), Some("A".into())).unwrap();
let (actual, cache) = mangle_with("obj.public; obj._private;", SourceType::mjs(), options);
assert_eq!(actual, codegen("obj.public; obj.e;", SourceType::mjs()));
assert_eq!(cache.get("public").and_then(Option::as_deref), Some("A"));
}
#[test]
fn automatic_names_avoid_unchanged_and_cached_spellings() {
let mut options = options("^_");
options.cache.insert("unused".into(), Some("e".into())).unwrap();
test("obj._foo; obj['t'];", "obj.n; obj['t'];", options);
}
#[test]
fn cache_keys_occupy_the_automatic_output_namespace_across_calls() {
let mut first_options = options(".");
first_options.cache.insert("e".into(), None).unwrap();
let (first_output, cache) = mangle_with("obj.foo;", SourceType::mjs(), first_options);
assert_eq!(first_output, codegen("obj.t;", SourceType::mjs()));
assert_eq!(cache.get("e"), Some(&None));
assert_eq!(cache.get("foo").and_then(Option::as_deref), Some("t"));
let mut second_options = options(".");
second_options.cache = cache;
test("obj.e; obj.foo;", "obj.e; obj.t;", second_options);
let mut inert_cache_key = options("^_");
inert_cache_key.cache.insert("e".into(), Some("A".into())).unwrap();
test("obj._foo;", "obj.t;", inert_cache_key);
}
#[test]
fn jsx_properties_are_rewritten_but_namespaces_are_not() {
let source_type = SourceType::jsx();
let (actual, _) = mangle_with(
"const x = <Components._Widget _prop={1} ns:_keep={2} />;",
source_type,
options("^_"),
);
assert_eq!(actual, codegen("const x = <Components.e t={1} ns:_keep={2} />;", source_type));
}
#[test]
fn typescript_type_space_properties_are_not_rewritten() {
let source_type = SourceType::ts();
let (actual, _) = mangle_with(
"interface Shape { _field: string; _method(): void } const object = { _field: 1, _method() {} }; object._field; object._method();",
source_type,
options("^_"),
);
assert_eq!(
actual,
codegen(
"interface Shape { _field: string; _method(): void } const object = { e: 1, t() {} }; object.e; object.t();",
source_type,
)
);
}
#[test]
fn rewrites_defaults_class_fields_and_accessors() {
test(
"let { _field: local = init } = obj; ({ _field: target = init } = obj); class C { _field = 1; accessor _field = 2; get _field() { return this._field; } }",
"let { e: local = init } = obj; ({ e: target = init } = obj); class C { e = 1; accessor e = 2; get e() { return this.e; } }",
options("^_"),
);
}
#[test]
fn debug_names_are_readable() {
let mut readable = options("^_");
readable.debug = true;
test(
"obj._alpha; obj._beta; obj._gamma;",
"obj._$_alpha$_; obj._$_beta$_; obj._$_gamma$_;",
readable,
);
let mut collision = options("^_");
collision.debug = true;
test("obj['_$_field$_']; obj._field;", "obj['_$_field$_']; obj._$_field$1$_;", collision);
let mut non_identifier = options(".");
non_identifier.debug = true;
non_identifier.mangle_quoted = true;
test("obj['not-valid'];", "obj._$property0$_;", non_identifier);
}
#[test]
fn assignment_is_deterministic_for_equal_frequencies() {
let (_, forward) =
mangle_with("obj._alpha; obj._beta; obj._gamma;", SourceType::mjs(), options("^_"));
let (_, reverse) =
mangle_with("obj._gamma; obj._beta; obj._alpha;", SourceType::mjs(), options("^_"));
assert_eq!(forward, reverse);
}
#[test]
fn property_mangling_is_idempotent_when_outputs_do_not_match_include() {
let (once, _) = mangle_with("obj._field;", SourceType::mjs(), options("^_"));
let (twice, second_cache) = mangle_with(&once, SourceType::mjs(), options("^_"));
assert_eq!(twice, once);
assert!(second_cache.is_empty());
}
#[test]
fn reserved_names_are_not_automatic_outputs() {
let mut options = options("^_");
options.reserved.insert("e".into());
test("obj._field;", "obj.t;", options);
}
#[test]
fn numeric_spellings_are_never_mangled_and_template_keys_follow_quoted() {
let mut options = options(".");
options.mangle_quoted = true;
test(
"obj['0']; obj[0]; obj[`template`]; obj.regular;",
"obj['0']; obj[0]; obj[`t`]; obj.e;",
options,
);
}
#[test]
fn numeric_keys_use_javascript_string_spelling() {
let mut quoted = options(".");
quoted.mangle_quoted = true;
let source = "const obj = { 1e21: 1 }; obj['1e+21'];";
test(source, source, quoted);
}
#[test]
fn eval_function_and_with_do_not_disable_property_mangling() {
let source_type = SourceType::script();
let source = "eval('dynamic'); Function('return 0'); with (obj) obj._field;";
let (actual, _) = mangle_with(source, source_type, options("^_"));
assert_eq!(
actual,
codegen("eval('dynamic'); Function('return 0'); with (obj) obj.e;", source_type)
);
}
#[test]
fn rewrite_happens_before_compression_erases_quotes() {
let allocator = Allocator::default();
let parsed =
Parser::new(&allocator, "globalThis._foo; globalThis['_foo'];", SourceType::mjs()).parse();
let mut program = parsed.program;
let result = Minifier::new(MinifierOptions {
mangle: None,
mangle_properties: Some(options("^_")),
compress: Some(CompressOptions::default()),
})
.minify(&allocator, &mut program);
let actual = Codegen::new().with_scoping(result.scoping).build(&program).code;
assert!(actual.contains("globalThis.e"), "{actual}");
assert!(actual.contains("globalThis._foo"), "{actual}");
}
#[test]
fn property_identifier_and_compression_pipeline_runs_together() {
let allocator = Allocator::default();
let parsed = Parser::new(
&allocator,
"export function read(_object) { const _local = _object; return _local._field + _local._field; }",
SourceType::mjs(),
)
.parse();
let mut program = parsed.program;
let result = Minifier::new(MinifierOptions {
mangle: Some(MangleOptions::default()),
mangle_properties: Some(options("^_")),
compress: Some(CompressOptions::default()),
})
.minify(&allocator, &mut program);
let actual = Codegen::new().with_scoping(result.scoping).build(&program).code;
assert!(!actual.contains("_field"), "{actual}");
assert!(!actual.contains("_local"), "{actual}");
assert!(actual.matches(".e").count() >= 2, "{actual}");
}
#[test]
fn compression_does_not_revisit_folded_computed_keys() {
let allocator = Allocator::default();
let parsed = Parser::new(
&allocator,
"export const o = { ['f' + 'oo_']: 1 }; export const v = o.foo_;",
SourceType::mjs(),
)
.parse();
let mut program = parsed.program;
let result = Minifier::new(MinifierOptions {
mangle: None,
mangle_properties: Some(options("_$")),
compress: Some(CompressOptions::default()),
})
.minify(&allocator, &mut program);
let actual = Codegen::new().with_scoping(result.scoping).build(&program).code;
assert_eq!(
actual,
codegen("export const o = { foo_: 1 }; export const v = o.e;", SourceType::mjs())
);
}
#[test]
fn automatic_names_avoid_keys_materialized_by_compression() {
let allocator = Allocator::default();
let parsed = Parser::new(
&allocator,
"export function f(obj) { const key = 'e'; return obj[key] + obj._foo + obj._foo; }",
SourceType::mjs(),
)
.parse();
let mut program = parsed.program;
let result = Minifier::new(MinifierOptions {
mangle: None,
mangle_properties: Some(options("^_")),
compress: Some(CompressOptions::default()),
})
.minify(&allocator, &mut program);
let actual = Codegen::new().with_scoping(result.scoping).build(&program).code;
assert_eq!(
actual,
codegen("export function f(obj) { return obj.e + obj.t + obj.t; }", SourceType::mjs())
);
}
#[derive(Default)]
struct ZeroSpans;
impl VisitMut<'_> for ZeroSpans {
fn visit_span(&mut self, span: &mut Span) {
*span = Span::default();
}
}
#[test]
fn duplicated_spans_do_not_skip_property_rewrites() {
let allocator = Allocator::default();
let parsed =
Parser::new(&allocator, "x = { _a: 1, _b: 2 }; use_(x._a, x._b);", SourceType::mjs())
.parse();
let mut program = parsed.program;
ZeroSpans.visit_program(&mut program);
let mut mangler = PropertyMangler::new(options("^_"));
mangler.collect(&program);
mangler.assign();
mangler.rewrite(&mut program, &allocator);
assert_eq!(
Codegen::new().build(&program).code,
codegen("x = { e: 1, t: 2 }; use_(x.e, x.t);", SourceType::mjs())
);
}
#[test]
fn one_assignment_can_be_shared_across_programs() {
let allocator_a = Allocator::default();
let allocator_b = Allocator::default();
let mut program_a =
Parser::new(&allocator_a, "a._shared; a._local;", SourceType::mjs()).parse().program;
let mut program_b =
Parser::new(&allocator_b, "b._shared; b._shared; b['_quoted'];", SourceType::mjs())
.parse()
.program;
let mut mangler = PropertyMangler::new(options("^_"));
mangler.collect(&program_a);
mangler.collect(&program_b);
mangler.assign();
mangler.rewrite(&mut program_a, &allocator_a);
mangler.rewrite(&mut program_b, &allocator_b);
assert_eq!(Codegen::new().build(&program_a).code, codegen("a.e; a.t;", SourceType::mjs()));
assert_eq!(
Codegen::new().build(&program_b).code,
codegen("b.e; b.e; b['_quoted'];", SourceType::mjs())
);
}
#[test]
fn independently_collected_programs_can_be_merged_before_assignment() {
let allocator_a = Allocator::default();
let allocator_b = Allocator::default();
let mut program_a =
Parser::new(&allocator_a, "a._shared; a._local;", SourceType::mjs()).parse().program;
let mut program_b =
Parser::new(&allocator_b, "b._shared; b._shared; b['_quoted'];", SourceType::mjs())
.parse()
.program;
let options = options("^_");
let collected_a = PropertyMangleCollection::from_program(&options, &program_a);
let collected_b = PropertyMangleCollection::from_program(&options, &program_b);
let mut mangler = PropertyMangler::new(options);
mangler.merge_collected(collected_a);
mangler.merge_collected(collected_b);
mangler.assign();
mangler.rewrite(&mut program_a, &allocator_a);
mangler.rewrite(&mut program_b, &allocator_b);
assert_eq!(Codegen::new().build(&program_a).code, codegen("a.e; a.t;", SourceType::mjs()));
assert_eq!(
Codegen::new().build(&program_b).code,
codegen("b.e; b.e; b['_quoted'];", SourceType::mjs())
);
}
+1 -1
View File
@@ -383,6 +383,6 @@ impl TryFrom<&MinifyOptions> for oxc_minifier::MinifierOptions {
None | Some(Either::A(true)) => Some(oxc_minifier::MangleOptions::default()),
Some(Either::B(o)) => Some(oxc_minifier::MangleOptions::from(o)),
};
Ok(oxc_minifier::MinifierOptions { compress, mangle })
Ok(oxc_minifier::MinifierOptions { compress, mangle, mangle_properties: None })
}
}
+4 -1
View File
@@ -348,7 +348,10 @@ impl Oxc {
} else {
None
};
Some(Minifier::new(MinifierOptions { mangle, compress }).minify(allocator, program))
Some(
Minifier::new(MinifierOptions { mangle, mangle_properties: None, compress })
.minify(allocator, program),
)
}
fn finalize_output<'a>(
+1
View File
@@ -332,6 +332,7 @@ pub fn print_minified<'a>(program: &mut Program<'a>, allocator: &'a Allocator) -
// Minify
let minify_options = MinifierOptions {
mangle: None,
mangle_properties: None,
compress: Some(CompressOptions {
keep_names: CompressOptionsKeepNames::all_true(),
sequences: false,
+30 -2
View File
@@ -4,7 +4,7 @@ use oxc_allocator::Allocator;
use oxc_ast::ast::Program;
use oxc_benchmark::{BenchmarkId, Criterion, criterion_group, criterion_main};
use oxc_mangler::{MangleOptions, MangleOptionsKeepNames, Mangler};
use oxc_minifier::{CompressOptions, Compressor};
use oxc_minifier::{CompressOptions, Compressor, ManglePropertiesOptions, PropertyMangler};
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
@@ -131,5 +131,33 @@ fn bench_mangler(criterion: &mut Criterion) {
group.finish();
}
criterion_group!(minifier, bench_minifier, bench_mangler);
fn bench_property_mangler(criterion: &mut Criterion) {
let mut group = criterion.benchmark_group("property_mangler");
let options = ManglePropertiesOptions::from_pattern("(^_|_$)").expect("valid benchmark regex");
for file in TestFiles::minimal().files() {
let id = BenchmarkId::from_parameter(&file.file_name);
let source_type = SourceType::from_path(&file.file_name).unwrap();
let source_text = file.source_text.as_str();
let path = Path::new(&file.file_name);
let mut allocator = Allocator::default();
group.bench_function(id, |b| {
b.iter_with_setup_wrapper(|runner| {
allocator.reset();
let mut program = transform_to_js(&allocator, source_text, source_type, path);
runner.run(|| {
let mut mangler = PropertyMangler::new(options.clone());
mangler.collect(&program);
mangler.assign();
mangler.rewrite(&mut program, &allocator);
std::hint::black_box(mangler.into_cache());
});
});
});
}
group.finish();
}
criterion_group!(minifier, bench_minifier, bench_mangler, bench_property_mangler);
criterion_main!(minifier);
+36 -5
View File
@@ -15,13 +15,14 @@ mod test262_status;
use std::{
io::{self, BufRead, BufReader},
process::{Child, Command, Stdio},
sync::LazyLock,
thread,
};
use oxc::{
allocator::Allocator,
codegen::{Codegen, CodegenOptions},
minifier::{Minifier, MinifierOptions},
minifier::{ManglePropertiesOptions, Minifier, MinifierOptions},
parser::Parser,
semantic::SemanticBuilder,
span::SourceType,
@@ -105,6 +106,22 @@ static SKIP_MINIFIER_TEST_CASES: &[&str] = &[
"language/statements/class/subclass/superclass-arrow-function.js",
];
// Property mangling cannot see keys inside dynamic code or link globals to properties.
static SKIP_PROPERTY_MANGLE_TEST_CASES: &[&str] = &[
"language/expressions/class/private-static-setter-multiple-evaluations-of-class-function-ctor.js",
"language/statements/variable/S12.2_A11.js",
];
static PROPERTY_MANGLE_OPTIONS: LazyLock<ManglePropertiesOptions> = LazyLock::new(|| {
let mut options = ManglePropertiesOptions::from_pattern("^_").expect("valid property regex");
options.mangle_quoted = true;
// These methods come from the host, so their names cannot change.
for name in ["__defineGetter__", "__defineSetter__", "__lookupGetter__", "__lookupSetter__"] {
options.reserved.insert(name.into());
}
options
});
static SKIP_ESID: &[&str] = &["sec-privatefieldget", "sec-privatefieldset"];
/// The JSON payload sent to the Node.js runtime server for each execution.
@@ -329,7 +346,7 @@ fn run_test_code(
/// Produce the executable code for a single variant.
///
/// * `transform` runs the full transformer pipeline (down-levelling syntax).
/// * `minify` runs the compressor (without mangling) and prints minified output.
/// * `minify` runs the compressor and property mangler, then prints minified output.
fn get_code(file: &Test262File, transform: bool, minify: bool) -> String {
let source_text = file.code.as_str();
let flags = &file.meta.flags;
@@ -350,9 +367,13 @@ fn get_code(file: &Test262File, transform: bool, minify: bool) -> String {
}
let scoping = if minify {
Minifier::new(MinifierOptions { mangle: None, ..MinifierOptions::default() })
.minify(&allocator, &mut program)
.scoping
Minifier::new(MinifierOptions {
mangle: None,
mangle_properties: property_mangle_options(file),
..MinifierOptions::default()
})
.minify(&allocator, &mut program)
.scoping
} else {
None
};
@@ -371,6 +392,16 @@ fn get_code(file: &Test262File, transform: bool, minify: bool) -> String {
text
}
fn property_mangle_options(file: &Test262File) -> Option<ManglePropertiesOptions> {
let path = file.path.to_string_lossy();
let path = path.trim_start_matches("test262/test/");
if SKIP_PROPERTY_MANGLE_TEST_CASES.contains(&path) {
return None;
}
Some(PROPERTY_MANGLE_OPTIONS.clone())
}
/// Decide whether a case is ineligible for runtime execution.
///
/// These lists encode years of test262 triage and are ported verbatim from the
+14 -14
View File
@@ -1,27 +1,27 @@
| Oxc | ESBuild | Oxc | ESBuild |
Original | minified | minified | gzip | gzip | Iterations | File
| Oxc props | ESBuild | Oxc props | ESBuild |
Original | minified | minified | gzip | gzip | Iterations | File
-------------------------------------------------------------------------------------
72.14 kB | 23.11 kB | 23.70 kB | 8.34 kB | 8.54 kB | 2 | react.development.js
72.14 kB | 22.48 kB | 23.70 kB | 8.20 kB | 8.54 kB | 2 | react.development.js
173.90 kB | 59.34 kB | 59.82 kB | 19.15 kB | 19.33 kB | 2 | moment.js
173.90 kB | 55.53 kB | 59.82 kB | 18.78 kB | 19.33 kB | 2 | moment.js
287.63 kB | 89.20 kB | 90.07 kB | 30.90 kB | 31.95 kB | 2 | jquery.js
287.63 kB | 89.02 kB | 90.07 kB | 30.87 kB | 31.95 kB | 2 | jquery.js
342.15 kB | 116.94 kB | 118.14 kB | 43.18 kB | 44.37 kB | 2 | vue.js
342.15 kB | 114.85 kB | 118.14 kB | 42.73 kB | 44.37 kB | 2 | vue.js
544.10 kB | 70.89 kB | 72.48 kB | 25.71 kB | 26.20 kB | 2 | lodash.js
544.10 kB | 69.88 kB | 72.48 kB | 25.59 kB | 26.20 kB | 2 | lodash.js
555.77 kB | 267.20 kB | 270.13 kB | 87.99 kB | 90.80 kB | 2 | d3.js
555.77 kB | 262.77 kB | 270.13 kB | 87.67 kB | 90.80 kB | 2 | d3.js
1.01 MB | 439.21 kB | 458.89 kB | 122.01 kB | 126.71 kB | 2 | bundle.min.js
1.01 MB | 436.09 kB | 458.89 kB | 121.80 kB | 126.71 kB | 2 | bundle.min.js
1.25 MB | 642.43 kB | 646.76 kB | 159.35 kB | 163.73 kB | 2 | three.js
1.25 MB | 634.41 kB | 646.76 kB | 158.45 kB | 163.73 kB | 2 | three.js
2.14 MB | 710.75 kB | 724.14 kB | 160.33 kB | 181.07 kB | 2 | victory.js
2.14 MB | 707.71 kB | 724.14 kB | 160.11 kB | 181.07 kB | 2 | victory.js
3.20 MB | 1.00 MB | 1.01 MB | 322.54 kB | 331.56 kB | 2 | echarts.js
3.20 MB | 959.19 kB | 1.01 MB | 317.26 kB | 331.56 kB | 2 | echarts.js
6.69 MB | 2.12 MB | 2.31 MB | 453.79 kB | 488.28 kB | 5 | antd.js
6.69 MB | 2.12 MB | 2.31 MB | 453.48 kB | 488.28 kB | 5 | antd.js
10.95 MB | 3.33 MB | 3.49 MB | 852.09 kB | 915.50 kB | 4 | typescript.js
10.95 MB | 3.31 MB | 3.49 MB | 850.31 kB | 915.50 kB | 4 | typescript.js
+24 -9
View File
@@ -15,7 +15,9 @@ use rustc_hash::FxHashMap;
use oxc_allocator::Allocator;
use oxc_codegen::{Codegen, CodegenOptions};
use oxc_minifier::{CompressOptions, MangleOptions, Minifier, MinifierOptions};
use oxc_minifier::{
CompressOptions, MangleOptions, ManglePropertiesOptions, Minifier, MinifierOptions,
};
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
@@ -84,23 +86,22 @@ pub fn run() -> Result<(), io::Error> {
out,
"{:width$} | {:width$} | {:width$} | {:width$} | {:width$} |",
"",
"Oxc",
"Oxc props",
"ESBuild",
"Oxc",
"Oxc props",
"ESBuild",
width = width,
)
.unwrap();
writeln!(
out,
"{:width$} | {:width$} | {:width$} | {:width$} | {:width$} | {:width$} | {:width$}",
"{:width$} | {:width$} | {:width$} | {:width$} | {:width$} | {:width$} | File",
"Original",
"minified",
"minified",
"gzip",
"gzip",
"Iterations",
"File",
width = width,
)
.unwrap();
@@ -124,7 +125,7 @@ pub fn run() -> Result<(), io::Error> {
fs::write(save_path.join(&file.file_name), &minified).unwrap();
let s = format!(
"{:width$} | {:width$} | {:width$} | {:width$} | {:width$} | {:width$} | {:width$} \n\n",
"{:width$} | {:width$} | {:width$} | {:width$} | {:width$} | {:width$} | {}\n\n",
format_size(file.source_text.len(), DECIMAL),
format_size(minified.len(), DECIMAL),
targets[file.file_name.as_str()],
@@ -149,13 +150,19 @@ pub fn run() -> Result<(), io::Error> {
fn minify_twice(file: &TestFile, options: Options) -> (String, u8) {
let source_type = SourceType::cjs().with_script(true);
let (code1, iterations) = minify(&file.source_text, source_type, options);
let (code2, _) = minify(&code1, source_type, options);
let (code1, iterations) = minify(&file.source_text, source_type, options, true);
// Property mangling runs once. Compression may expose new matching keys on the second pass.
let (code2, _) = minify(&code1, source_type, options, false);
assert_eq_minified_code(&code1, &code2, &file.file_name);
(code2, iterations)
}
fn minify(source_text: &str, source_type: SourceType, options: Options) -> (String, u8) {
fn minify(
source_text: &str,
source_type: SourceType,
options: Options,
mangle_properties: bool,
) -> (String, u8) {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source_text, source_type).parse();
assert!(ret.diagnostics.is_empty());
@@ -168,6 +175,8 @@ fn minify(source_text: &str, source_type: SourceType, options: Options) -> (Stri
.build(scoping, &mut program);
let ret = Minifier::new(MinifierOptions {
mangle: (!options.compress_only).then(MangleOptions::default),
mangle_properties: (mangle_properties && !options.compress_only)
.then(property_mangle_options),
compress: Some(CompressOptions::default()),
})
.minify(&allocator, &mut program);
@@ -179,6 +188,12 @@ fn minify(source_text: &str, source_type: SourceType, options: Options) -> (Stri
(code, ret.iterations)
}
fn property_mangle_options() -> ManglePropertiesOptions {
let mut options = ManglePropertiesOptions::from_pattern("^_").expect("valid property regex");
options.mangle_quoted = true;
options
}
fn gzip_size(s: &str) -> usize {
let mut e = GzEncoder::new(Vec::new(), Compression::best());
e.write_all(s.as_bytes()).unwrap();
@@ -311,6 +311,7 @@ pub fn run() -> Result<(), io::Error> {
let parse_options = ParseOptions { parse_regular_expression: true, ..ParseOptions::default() };
let minifier_options = MinifierOptions {
mangle: Some(MangleOptions::default()),
mangle_properties: None,
compress: Some(CompressOptions::smallest()),
};