diff --git a/crates/oxc_minifier/src/ast_passes/mod.rs b/crates/oxc_minifier/src/ast_passes/mod.rs index e656fe4514d25..ee2bf02364fdd 100644 --- a/crates/oxc_minifier/src/ast_passes/mod.rs +++ b/crates/oxc_minifier/src/ast_passes/mod.rs @@ -63,7 +63,7 @@ impl PeepholeOptimizations { x4_peephole_fold_constants: PeepholeFoldConstants::new(), x5_peephole_minimize_conditions: PeepholeMinimizeConditions::new(target), x6_peephole_remove_dead_code: PeepholeRemoveDeadCode::new(), - x7_peephole_replace_known_methods: PeepholeReplaceKnownMethods::new(), + x7_peephole_replace_known_methods: PeepholeReplaceKnownMethods::new(target), x8_convert_to_dotted_properties: ConvertToDottedProperties::new(in_fixed_loop), x9_peephole_substitute_alternate_syntax: PeepholeSubstituteAlternateSyntax::new( options.target, diff --git a/crates/oxc_minifier/src/ast_passes/peephole_replace_known_methods.rs b/crates/oxc_minifier/src/ast_passes/peephole_replace_known_methods.rs index 18149f79fd582..d18f58d539471 100644 --- a/crates/oxc_minifier/src/ast_passes/peephole_replace_known_methods.rs +++ b/crates/oxc_minifier/src/ast_passes/peephole_replace_known_methods.rs @@ -2,11 +2,14 @@ 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}; @@ -14,6 +17,8 @@ use crate::{ctx::Ctx, CompressorPass}; /// Minimize With Known Methods /// pub struct PeepholeReplaceKnownMethods { + target: ESTarget, + pub(crate) changed: bool, } @@ -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( @@ -52,7 +57,7 @@ impl<'a> PeepholeReplaceKnownMethods { "indexOf" | "lastIndexOf" => Self::try_fold_string_index_of(ce, member, ctx), "charAt" => Self::try_fold_string_char_at(ce, member, ctx), "charCodeAt" => Self::try_fold_string_char_code_at(ce, member, ctx), - "concat" => Self::try_fold_concat(ce, ctx), + "concat" => self.try_fold_concat(ce, ctx), "replace" | "replaceAll" => Self::try_fold_string_replace(ce, member, ctx), "fromCharCode" => Self::try_fold_string_from_char_code(ce, member, ctx), "toString" => Self::try_fold_to_string(ce, member, ctx), @@ -407,7 +412,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> { @@ -419,52 +426,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::::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::::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> = + 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, } } } @@ -473,12 +562,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); } @@ -1225,13 +1316,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)"); @@ -1367,4 +1458,26 @@ mod test { test_same("(-1000000).toString(36)"); test_same("(-0).toString(36)"); } + + #[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}`"); + } } diff --git a/tasks/minsize/minsize.snap b/tasks/minsize/minsize.snap index 5c343c1890ef6..cc92b8037564b 100644 --- a/tasks/minsize/minsize.snap +++ b/tasks/minsize/minsize.snap @@ -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.11 kB | 118.14 kB | 44.44 kB | 44.37 kB | vue.js @@ -17,11 +17,11 @@ Original | minified | minified | gzip | gzip | Fixture 1.25 MB | 652.84 kB | 646.76 kB | 163.54 kB | 163.73 kB | three.js -2.14 MB | 724.18 kB | 724.14 kB | 179.95 kB | 181.07 kB | victory.js +2.14 MB | 722.70 kB | 724.14 kB | 179.93 kB | 181.07 kB | victory.js 3.20 MB | 1.01 MB | 1.01 MB | 331.79 kB | 331.56 kB | echarts.js -6.69 MB | 2.32 MB | 2.31 MB | 492.45 kB | 488.28 kB | antd.js +6.69 MB | 2.30 MB | 2.31 MB | 492.17 kB | 488.28 kB | antd.js -10.95 MB | 3.49 MB | 3.49 MB | 907.19 kB | 915.50 kB | typescript.js +10.95 MB | 3.49 MB | 3.49 MB | 907.38 kB | 915.50 kB | typescript.js