Skip to content

Commit

Permalink
feat(minifier): minify String::concat into template literal (#8443)
Browse files Browse the repository at this point in the history
Compress `"".concat(a, "b", c)` into `` `${a}b${c}` ``.

I'm not sure if this should be merged. It works for antd but it doesn't for typescript.

**References**
- [Spec of `String::concat`](https://tc39.es/ecma262/multipage/text-processing.html#sec-string.prototype.concat)
- [Spec of template literal](https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-template-literals-runtime-semantics-evaluation)
  • Loading branch information
sapphi-red committed Jan 17, 2025
1 parent 991a22f commit 917a5e7
Show file tree
Hide file tree
Showing 3 changed files with 168 additions and 55 deletions.
2 changes: 1 addition & 1 deletion crates/oxc_minifier/src/ast_passes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ impl PeepholeOptimizations {
x5_peephole_minimize_conditions: PeepholeMinimizeConditions::new(target),
x6_peephole_remove_dead_code: PeepholeRemoveDeadCode::new(in_fixed_loop),
x7_convert_to_dotted_properties: ConvertToDottedProperties::new(in_fixed_loop),
x8_peephole_replace_known_methods: PeepholeReplaceKnownMethods::new(),
x8_peephole_replace_known_methods: PeepholeReplaceKnownMethods::new(target),
x9_peephole_substitute_alternate_syntax: PeepholeSubstituteAlternateSyntax::new(
options.target,
in_fixed_loop,
Expand Down
213 changes: 163 additions & 50 deletions crates/oxc_minifier/src/ast_passes/peephole_replace_known_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@ use std::borrow::Cow;

use cow_utils::CowUtils;

use oxc_allocator::IntoIn;
use oxc_ast::ast::*;
use oxc_ecmascript::{
constant_evaluation::ConstantEvaluation, StringCharAt, StringCharCodeAt, StringIndexOf,
StringLastIndexOf, StringSubstring, ToInt32,
};
use oxc_span::SPAN;
use oxc_syntax::es_target::ESTarget;
use oxc_traverse::{traverse_mut_with_ctx, Ancestor, ReusableTraverseCtx, Traverse, TraverseCtx};

use crate::{ctx::Ctx, CompressorPass};

/// Minimize With Known Methods
/// <https://github.com/google/closure-compiler/blob/v20240609/src/com/google/javascript/jscomp/PeepholeReplaceKnownMethods.java>
pub struct PeepholeReplaceKnownMethods {
target: ESTarget,

pub(crate) changed: bool,
}

Expand All @@ -32,8 +37,8 @@ impl<'a> Traverse<'a> for PeepholeReplaceKnownMethods {
}

impl<'a> PeepholeReplaceKnownMethods {
pub fn new() -> Self {
Self { changed: false }
pub fn new(target: ESTarget) -> Self {
Self { target, changed: false }
}

fn try_fold_known_string_methods(
Expand Down Expand Up @@ -62,7 +67,7 @@ impl<'a> PeepholeReplaceKnownMethods {
"indexOf" | "lastIndexOf" => Self::try_fold_string_index_of(ce, name, object, ctx),
"charAt" => Self::try_fold_string_char_at(ce, object, ctx),
"charCodeAt" => Self::try_fold_string_char_code_at(ce, object, ctx),
"concat" => Self::try_fold_concat(ce, ctx),
"concat" => self.try_fold_concat(ce, ctx),
"replace" | "replaceAll" => Self::try_fold_string_replace(ce, name, object, ctx),
"fromCharCode" => Self::try_fold_string_from_char_code(ce, object, ctx),
"toString" => Self::try_fold_to_string(ce, object, ctx),
Expand Down Expand Up @@ -423,7 +428,9 @@ impl<'a> PeepholeReplaceKnownMethods {
}

/// `[].concat(1, 2)` -> `[1, 2]`
/// `"".concat(a, "b")` -> "`${a}b`"
fn try_fold_concat(
&self,
ce: &mut CallExpression<'a>,
ctx: &mut TraverseCtx<'a>,
) -> Option<Expression<'a>> {
Expand All @@ -435,52 +442,134 @@ impl<'a> PeepholeReplaceKnownMethods {
}

let Expression::StaticMemberExpression(member) = &mut ce.callee else { unreachable!() };
let Expression::ArrayExpression(array_expr) = &mut member.object else { return None };

let can_merge_until = ce
.arguments
.iter()
.enumerate()
.take_while(|(_, argument)| match argument {
Argument::SpreadElement(_) => false,
match_expression!(Argument) => {
let argument = argument.to_expression();
if argument.is_literal() {
true
} else {
matches!(argument, Expression::ArrayExpression(_))
match &mut member.object {
Expression::ArrayExpression(array_expr) => {
let can_merge_until = ce
.arguments
.iter()
.enumerate()
.take_while(|(_, argument)| match argument {
Argument::SpreadElement(_) => false,
match_expression!(Argument) => {
let argument = argument.to_expression();
if argument.is_literal() {
true
} else {
matches!(argument, Expression::ArrayExpression(_))
}
}
})
.map(|(i, _)| i)
.last();

if let Some(can_merge_until) = can_merge_until {
for argument in ce.arguments.drain(..=can_merge_until) {
let argument = argument.into_expression();
if argument.is_literal() {
array_expr.elements.push(ArrayExpressionElement::from(argument));
} else {
let Expression::ArrayExpression(mut argument_array) = argument else {
unreachable!()
};
array_expr.elements.append(&mut argument_array.elements);
}
}
}
})
.map(|(i, _)| i)
.last();

if let Some(can_merge_until) = can_merge_until {
for argument in ce.arguments.drain(..=can_merge_until) {
let argument = argument.into_expression();
if argument.is_literal() {
array_expr.elements.push(ArrayExpressionElement::from(argument));

if ce.arguments.is_empty() {
Some(ctx.ast.move_expression(&mut member.object))
} else if can_merge_until.is_some() {
Some(ctx.ast.expression_call(
ce.span,
ctx.ast.move_expression(&mut ce.callee),
Option::<TSTypeParameterInstantiation>::None,
ctx.ast.move_vec(&mut ce.arguments),
false,
))
} else {
let Expression::ArrayExpression(mut argument_array) = argument else {
unreachable!()
};
array_expr.elements.append(&mut argument_array.elements);
None
}
}
}
Expression::StringLiteral(base_str) => {
if self.target < ESTarget::ES2015
|| ce.arguments.is_empty()
|| !ce.arguments.iter().all(Argument::is_expression)
{
return None;
}

if ce.arguments.is_empty() {
Some(ctx.ast.move_expression(&mut member.object))
} else if can_merge_until.is_some() {
Some(ctx.ast.expression_call(
ce.span,
ctx.ast.move_expression(&mut ce.callee),
Option::<TSTypeParameterInstantiation>::None,
ctx.ast.move_vec(&mut ce.arguments),
false,
))
} else {
None
let expression_count = ce
.arguments
.iter()
.filter(|arg| !matches!(arg, Argument::StringLiteral(_)))
.count();

// whether it is shorter to use `String::concat`
if ".concat()".len() + ce.arguments.len() < "${}".len() * expression_count {
return None;
}

let mut quasi_strs: Vec<Cow<'a, str>> =
vec![Cow::Borrowed(base_str.value.as_str())];
let mut expressions = ctx.ast.vec();
let mut pushed_quasi = true;
for argument in ce.arguments.drain(..) {
if let Argument::StringLiteral(str_lit) = argument {
if pushed_quasi {
let last_quasi = quasi_strs
.last_mut()
.expect("last element should exist because pushed_quasi is true");
last_quasi.to_mut().push_str(&str_lit.value);
} else {
quasi_strs.push(Cow::Borrowed(str_lit.value.as_str()));
}
pushed_quasi = true;
} else {
if !pushed_quasi {
// need a pair
quasi_strs.push(Cow::Borrowed(""));
}
// checked that all the arguments are expression above
expressions.push(argument.into_expression());
pushed_quasi = false;
}
}
if !pushed_quasi {
quasi_strs.push(Cow::Borrowed(""));
}

if expressions.is_empty() {
debug_assert_eq!(quasi_strs.len(), 1);
return Some(ctx.ast.expression_string_literal(
ce.span,
quasi_strs.pop().unwrap(),
None,
));
}

let mut quasis = ctx.ast.vec_from_iter(quasi_strs.into_iter().map(|s| {
let cooked = s.clone().into_in(ctx.ast.allocator);
ctx.ast.template_element(
SPAN,
false,
TemplateElementValue {
raw: s
.cow_replace("`", "\\`")
.cow_replace("${", "\\${")
.cow_replace("\r\n", "\\r\n")
.into_in(ctx.ast.allocator),
cooked: Some(cooked),
},
)
}));
if let Some(last_quasi) = quasis.last_mut() {
last_quasi.tail = true;
}

debug_assert_eq!(quasis.len(), expressions.len() + 1);
Some(ctx.ast.expression_template_literal(ce.span, quasis, expressions))
}
_ => None,
}
}
}
Expand All @@ -489,12 +578,14 @@ impl<'a> PeepholeReplaceKnownMethods {
#[cfg(test)]
mod test {
use oxc_allocator::Allocator;
use oxc_syntax::es_target::ESTarget;

use crate::tester;

fn test(source_text: &str, positive: &str) {
let allocator = Allocator::default();
let mut pass = super::PeepholeReplaceKnownMethods::new();
let target = ESTarget::ESNext;
let mut pass = super::PeepholeReplaceKnownMethods::new(target);
tester::test(&allocator, source_text, positive, &mut pass);
}

Expand Down Expand Up @@ -1238,13 +1329,13 @@ mod test {
fold("var x; [1].concat(x.a).concat(x)", "var x; [1].concat(x.a, x)"); // x.a might have a getter that updates x, but that side effect is preserved correctly

// string
fold("'1'.concat(1).concat(2,['abc']).concat('abc')", "'1'.concat(1,2,['abc'],'abc')");
fold("''.concat(['abc']).concat(1).concat([2,3])", "''.concat(['abc'],1,[2,3])");
fold_same("''.concat(1)");
fold("'1'.concat(1).concat(2,['abc']).concat('abc')", "`1${1}${2}${['abc']}abc`");
fold("''.concat(['abc']).concat(1).concat([2,3])", "`${['abc']}${1}${[2, 3]}`");
fold("''.concat(1)", "`${1}`");

fold("var x, y; ''.concat(x).concat(y)", "var x, y; ''.concat(x, y)");
fold("var y; ''.concat(x).concat(y)", "var y; ''.concat(x, y)"); // x might have a getter that updates y, but that side effect is preserved correctly
fold("var x; ''.concat(x.a).concat(x)", "var x; ''.concat(x.a, x)"); // x.a might have a getter that updates x, but that side effect is preserved correctly
fold("var x, y; ''.concat(x).concat(y)", "var x, y; `${x}${y}`");
fold("var y; ''.concat(x).concat(y)", "var y; `${x}${y}`"); // x might have a getter that updates y, but that side effect is preserved correctly
fold("var x; ''.concat(x.a).concat(x)", "var x; `${x.a}${x}`"); // x.a might have a getter that updates x, but that side effect is preserved correctly

// other
fold_same("obj.concat([1,2]).concat(1)");
Expand Down Expand Up @@ -1377,4 +1468,26 @@ mod test {
test("1e99.toString(b)", "1e99.toString(b)");
test("/./.toString(b)", "/./.toString(b)");
}

#[test]
fn test_fold_string_concat() {
test_same("x = ''.concat()");
test("x = ''.concat(a, b)", "x = `${a}${b}`");
test("x = ''.concat(a, b, c)", "x = `${a}${b}${c}`");
test("x = ''.concat(a, b, c, d)", "x = `${a}${b}${c}${d}`");
test_same("x = ''.concat(a, b, c, d, e)");
test("x = ''.concat('a')", "x = 'a'");
test("x = ''.concat('a', 'b')", "x = 'ab'");
test("x = ''.concat('a', 'b', 'c')", "x = 'abc'");
test("x = ''.concat('a', 'b', 'c', 'd')", "x = 'abcd'");
test("x = ''.concat('a', 'b', 'c', 'd', 'e')", "x = 'abcde'");
test("x = ''.concat(a, 'b')", "x = `${a}b`");
test("x = ''.concat('a', b)", "x = `a${b}`");
test("x = ''.concat(a, 'b', c)", "x = `${a}b${c}`");
test("x = ''.concat('a', b, 'c')", "x = `a${b}c`");
test("x = ''.concat(a, 1)", "x = `${a}${1}`"); // inlining 1 is not implemented yet

test("x = '`'.concat(a)", "x = `\\`${a}`");
test("x = '${'.concat(a)", "x = `\\${${a}`");
}
}
8 changes: 4 additions & 4 deletions tasks/minsize/minsize.snap
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Original | minified | minified | gzip | gzip | Fixture

173.90 kB | 59.79 kB | 59.82 kB | 19.41 kB | 19.33 kB | moment.js

287.63 kB | 90.08 kB | 90.07 kB | 32.03 kB | 31.95 kB | jquery.js
287.63 kB | 90.07 kB | 90.07 kB | 32.02 kB | 31.95 kB | jquery.js

342.15 kB | 118.14 kB | 118.14 kB | 44.45 kB | 44.37 kB | vue.js

Expand All @@ -17,11 +17,11 @@ Original | minified | minified | gzip | gzip | Fixture

1.25 MB | 652.88 kB | 646.76 kB | 163.54 kB | 163.73 kB | three.js

2.14 MB | 724.06 kB | 724.14 kB | 179.94 kB | 181.07 kB | victory.js
2.14 MB | 722.58 kB | 724.14 kB | 179.92 kB | 181.07 kB | victory.js

3.20 MB | 1.01 MB | 1.01 MB | 332.01 kB | 331.56 kB | echarts.js

6.69 MB | 2.32 MB | 2.31 MB | 492.44 kB | 488.28 kB | antd.js
6.69 MB | 2.30 MB | 2.31 MB | 492.20 kB | 488.28 kB | antd.js

10.95 MB | 3.49 MB | 3.49 MB | 907.09 kB | 915.50 kB | typescript.js
10.95 MB | 3.49 MB | 3.49 MB | 907.28 kB | 915.50 kB | typescript.js

0 comments on commit 917a5e7

Please sign in to comment.