Skip to content

Commit 72dc0d1

Browse files
committed
feat(minifier): minify String::concat into template literal
1 parent 0ab0835 commit 72dc0d1

File tree

3 files changed

+168
-55
lines changed

3 files changed

+168
-55
lines changed

crates/oxc_minifier/src/ast_passes/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ impl PeepholeOptimizations {
6363
x4_peephole_fold_constants: PeepholeFoldConstants::new(),
6464
x5_peephole_minimize_conditions: PeepholeMinimizeConditions::new(target),
6565
x6_peephole_remove_dead_code: PeepholeRemoveDeadCode::new(),
66-
x7_peephole_replace_known_methods: PeepholeReplaceKnownMethods::new(),
66+
x7_peephole_replace_known_methods: PeepholeReplaceKnownMethods::new(target),
6767
x8_convert_to_dotted_properties: ConvertToDottedProperties::new(in_fixed_loop),
6868
x9_peephole_substitute_alternate_syntax: PeepholeSubstituteAlternateSyntax::new(
6969
options.target,

crates/oxc_minifier/src/ast_passes/peephole_replace_known_methods.rs

+163-50
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,23 @@ use std::borrow::Cow;
22

33
use cow_utils::CowUtils;
44

5+
use oxc_allocator::IntoIn;
56
use oxc_ast::ast::*;
67
use oxc_ecmascript::{
78
constant_evaluation::ConstantEvaluation, StringCharAt, StringCharCodeAt, StringIndexOf,
89
StringLastIndexOf, StringSubstring, ToInt32,
910
};
11+
use oxc_span::SPAN;
12+
use oxc_syntax::es_target::ESTarget;
1013
use oxc_traverse::{traverse_mut_with_ctx, Ancestor, ReusableTraverseCtx, Traverse, TraverseCtx};
1114

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

1417
/// Minimize With Known Methods
1518
/// <https://github.com/google/closure-compiler/blob/v20240609/src/com/google/javascript/jscomp/PeepholeReplaceKnownMethods.java>
1619
pub struct PeepholeReplaceKnownMethods {
20+
target: ESTarget,
21+
1722
pub(crate) changed: bool,
1823
}
1924

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

3439
impl<'a> PeepholeReplaceKnownMethods {
35-
pub fn new() -> Self {
36-
Self { changed: false }
40+
pub fn new(target: ESTarget) -> Self {
41+
Self { target, changed: false }
3742
}
3843

3944
fn try_fold_known_string_methods(
@@ -52,7 +57,7 @@ impl<'a> PeepholeReplaceKnownMethods {
5257
"indexOf" | "lastIndexOf" => Self::try_fold_string_index_of(ce, member, ctx),
5358
"charAt" => Self::try_fold_string_char_at(ce, member, ctx),
5459
"charCodeAt" => Self::try_fold_string_char_code_at(ce, member, ctx),
55-
"concat" => Self::try_fold_concat(ce, ctx),
60+
"concat" => self.try_fold_concat(ce, ctx),
5661
"replace" | "replaceAll" => Self::try_fold_string_replace(ce, member, ctx),
5762
"fromCharCode" => Self::try_fold_string_from_char_code(ce, member, ctx),
5863
"toString" => Self::try_fold_to_string(ce, member, ctx),
@@ -407,7 +412,9 @@ impl<'a> PeepholeReplaceKnownMethods {
407412
}
408413

409414
/// `[].concat(1, 2)` -> `[1, 2]`
415+
/// `"".concat(a, "b")` -> "`${a}b`"
410416
fn try_fold_concat(
417+
&self,
411418
ce: &mut CallExpression<'a>,
412419
ctx: &mut TraverseCtx<'a>,
413420
) -> Option<Expression<'a>> {
@@ -419,52 +426,134 @@ impl<'a> PeepholeReplaceKnownMethods {
419426
}
420427

421428
let Expression::StaticMemberExpression(member) = &mut ce.callee else { unreachable!() };
422-
let Expression::ArrayExpression(array_expr) = &mut member.object else { return None };
423-
424-
let can_merge_until = ce
425-
.arguments
426-
.iter()
427-
.enumerate()
428-
.take_while(|(_, argument)| match argument {
429-
Argument::SpreadElement(_) => false,
430-
match_expression!(Argument) => {
431-
let argument = argument.to_expression();
432-
if argument.is_literal() {
433-
true
434-
} else {
435-
matches!(argument, Expression::ArrayExpression(_))
429+
match &mut member.object {
430+
Expression::ArrayExpression(array_expr) => {
431+
let can_merge_until = ce
432+
.arguments
433+
.iter()
434+
.enumerate()
435+
.take_while(|(_, argument)| match argument {
436+
Argument::SpreadElement(_) => false,
437+
match_expression!(Argument) => {
438+
let argument = argument.to_expression();
439+
if argument.is_literal() {
440+
true
441+
} else {
442+
matches!(argument, Expression::ArrayExpression(_))
443+
}
444+
}
445+
})
446+
.map(|(i, _)| i)
447+
.last();
448+
449+
if let Some(can_merge_until) = can_merge_until {
450+
for argument in ce.arguments.drain(..=can_merge_until) {
451+
let argument = argument.into_expression();
452+
if argument.is_literal() {
453+
array_expr.elements.push(ArrayExpressionElement::from(argument));
454+
} else {
455+
let Expression::ArrayExpression(mut argument_array) = argument else {
456+
unreachable!()
457+
};
458+
array_expr.elements.append(&mut argument_array.elements);
459+
}
436460
}
437461
}
438-
})
439-
.map(|(i, _)| i)
440-
.last();
441-
442-
if let Some(can_merge_until) = can_merge_until {
443-
for argument in ce.arguments.drain(..=can_merge_until) {
444-
let argument = argument.into_expression();
445-
if argument.is_literal() {
446-
array_expr.elements.push(ArrayExpressionElement::from(argument));
462+
463+
if ce.arguments.is_empty() {
464+
Some(ctx.ast.move_expression(&mut member.object))
465+
} else if can_merge_until.is_some() {
466+
Some(ctx.ast.expression_call(
467+
ce.span,
468+
ctx.ast.move_expression(&mut ce.callee),
469+
Option::<TSTypeParameterInstantiation>::None,
470+
ctx.ast.move_vec(&mut ce.arguments),
471+
false,
472+
))
447473
} else {
448-
let Expression::ArrayExpression(mut argument_array) = argument else {
449-
unreachable!()
450-
};
451-
array_expr.elements.append(&mut argument_array.elements);
474+
None
452475
}
453476
}
454-
}
477+
Expression::StringLiteral(base_str) => {
478+
if self.target < ESTarget::ES2015
479+
|| ce.arguments.is_empty()
480+
|| !ce.arguments.iter().all(Argument::is_expression)
481+
{
482+
return None;
483+
}
455484

456-
if ce.arguments.is_empty() {
457-
Some(ctx.ast.move_expression(&mut member.object))
458-
} else if can_merge_until.is_some() {
459-
Some(ctx.ast.expression_call(
460-
ce.span,
461-
ctx.ast.move_expression(&mut ce.callee),
462-
Option::<TSTypeParameterInstantiation>::None,
463-
ctx.ast.move_vec(&mut ce.arguments),
464-
false,
465-
))
466-
} else {
467-
None
485+
let expression_count = ce
486+
.arguments
487+
.iter()
488+
.filter(|arg| !matches!(arg, Argument::StringLiteral(_)))
489+
.count();
490+
491+
// whether it is shorter to use `String::concat`
492+
if ".concat()".len() + ce.arguments.len() < "${}".len() * expression_count {
493+
return None;
494+
}
495+
496+
let mut quasi_strs: Vec<Cow<'a, str>> =
497+
vec![Cow::Borrowed(base_str.value.as_str())];
498+
let mut expressions = ctx.ast.vec();
499+
let mut pushed_quasi = true;
500+
for argument in ce.arguments.drain(..) {
501+
if let Argument::StringLiteral(str_lit) = argument {
502+
if pushed_quasi {
503+
let last_quasi = quasi_strs
504+
.last_mut()
505+
.expect("last element should exist because pushed_quasi is true");
506+
last_quasi.to_mut().push_str(&str_lit.value);
507+
} else {
508+
quasi_strs.push(Cow::Borrowed(str_lit.value.as_str()));
509+
}
510+
pushed_quasi = true;
511+
} else {
512+
if !pushed_quasi {
513+
// need a pair
514+
quasi_strs.push(Cow::Borrowed(""));
515+
}
516+
// checked that all the arguments are expression above
517+
expressions.push(argument.into_expression());
518+
pushed_quasi = false;
519+
}
520+
}
521+
if !pushed_quasi {
522+
quasi_strs.push(Cow::Borrowed(""));
523+
}
524+
525+
if expressions.is_empty() {
526+
debug_assert_eq!(quasi_strs.len(), 1);
527+
return Some(ctx.ast.expression_string_literal(
528+
ce.span,
529+
quasi_strs.pop().unwrap(),
530+
None,
531+
));
532+
}
533+
534+
let mut quasis = ctx.ast.vec_from_iter(quasi_strs.into_iter().map(|s| {
535+
let cooked = s.clone().into_in(ctx.ast.allocator);
536+
ctx.ast.template_element(
537+
SPAN,
538+
false,
539+
TemplateElementValue {
540+
raw: s
541+
.cow_replace("`", "\\`")
542+
.cow_replace("${", "\\${")
543+
.cow_replace("\r\n", "\\r\n")
544+
.into_in(ctx.ast.allocator),
545+
cooked: Some(cooked),
546+
},
547+
)
548+
}));
549+
if let Some(last_quasi) = quasis.last_mut() {
550+
last_quasi.tail = true;
551+
}
552+
553+
debug_assert_eq!(quasis.len(), expressions.len() + 1);
554+
Some(ctx.ast.expression_template_literal(ce.span, quasis, expressions))
555+
}
556+
_ => None,
468557
}
469558
}
470559
}
@@ -473,12 +562,14 @@ impl<'a> PeepholeReplaceKnownMethods {
473562
#[cfg(test)]
474563
mod test {
475564
use oxc_allocator::Allocator;
565+
use oxc_syntax::es_target::ESTarget;
476566

477567
use crate::tester;
478568

479569
fn test(source_text: &str, positive: &str) {
480570
let allocator = Allocator::default();
481-
let mut pass = super::PeepholeReplaceKnownMethods::new();
571+
let target = ESTarget::ESNext;
572+
let mut pass = super::PeepholeReplaceKnownMethods::new(target);
482573
tester::test(&allocator, source_text, positive, &mut pass);
483574
}
484575

@@ -1225,13 +1316,13 @@ mod test {
12251316
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
12261317

12271318
// string
1228-
fold("'1'.concat(1).concat(2,['abc']).concat('abc')", "'1'.concat(1,2,['abc'],'abc')");
1229-
fold("''.concat(['abc']).concat(1).concat([2,3])", "''.concat(['abc'],1,[2,3])");
1230-
fold_same("''.concat(1)");
1319+
fold("'1'.concat(1).concat(2,['abc']).concat('abc')", "`1${1}${2}${['abc']}abc`");
1320+
fold("''.concat(['abc']).concat(1).concat([2,3])", "`${['abc']}${1}${[2, 3]}`");
1321+
fold("''.concat(1)", "`${1}`");
12311322

1232-
fold("var x, y; ''.concat(x).concat(y)", "var x, y; ''.concat(x, y)");
1233-
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
1234-
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
1323+
fold("var x, y; ''.concat(x).concat(y)", "var x, y; `${x}${y}`");
1324+
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
1325+
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
12351326

12361327
// other
12371328
fold_same("obj.concat([1,2]).concat(1)");
@@ -1367,4 +1458,26 @@ mod test {
13671458
test_same("(-1000000).toString(36)");
13681459
test_same("(-0).toString(36)");
13691460
}
1461+
1462+
#[test]
1463+
fn test_fold_string_concat() {
1464+
test_same("x = ''.concat()");
1465+
test("x = ''.concat(a, b)", "x = `${a}${b}`");
1466+
test("x = ''.concat(a, b, c)", "x = `${a}${b}${c}`");
1467+
test("x = ''.concat(a, b, c, d)", "x = `${a}${b}${c}${d}`");
1468+
test_same("x = ''.concat(a, b, c, d, e)");
1469+
test("x = ''.concat('a')", "x = 'a'");
1470+
test("x = ''.concat('a', 'b')", "x = 'ab'");
1471+
test("x = ''.concat('a', 'b', 'c')", "x = 'abc'");
1472+
test("x = ''.concat('a', 'b', 'c', 'd')", "x = 'abcd'");
1473+
test("x = ''.concat('a', 'b', 'c', 'd', 'e')", "x = 'abcde'");
1474+
test("x = ''.concat(a, 'b')", "x = `${a}b`");
1475+
test("x = ''.concat('a', b)", "x = `a${b}`");
1476+
test("x = ''.concat(a, 'b', c)", "x = `${a}b${c}`");
1477+
test("x = ''.concat('a', b, 'c')", "x = `a${b}c`");
1478+
test("x = ''.concat(a, 1)", "x = `${a}${1}`"); // inlining 1 is not implemented yet
1479+
1480+
test("x = '`'.concat(a)", "x = `\\`${a}`");
1481+
test("x = '${'.concat(a)", "x = `\\${${a}`");
1482+
}
13701483
}

tasks/minsize/minsize.snap

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Original | minified | minified | gzip | gzip | Fixture
55

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

8-
287.63 kB | 90.08 kB | 90.07 kB | 32.03 kB | 31.95 kB | jquery.js
8+
287.63 kB | 90.07 kB | 90.07 kB | 32.02 kB | 31.95 kB | jquery.js
99

1010
342.15 kB | 118.11 kB | 118.14 kB | 44.44 kB | 44.37 kB | vue.js
1111

@@ -17,11 +17,11 @@ Original | minified | minified | gzip | gzip | Fixture
1717

1818
1.25 MB | 652.84 kB | 646.76 kB | 163.54 kB | 163.73 kB | three.js
1919

20-
2.14 MB | 724.18 kB | 724.14 kB | 179.95 kB | 181.07 kB | victory.js
20+
2.14 MB | 722.70 kB | 724.14 kB | 179.93 kB | 181.07 kB | victory.js
2121

2222
3.20 MB | 1.01 MB | 1.01 MB | 331.79 kB | 331.56 kB | echarts.js
2323

24-
6.69 MB | 2.32 MB | 2.31 MB | 492.45 kB | 488.28 kB | antd.js
24+
6.69 MB | 2.30 MB | 2.31 MB | 492.17 kB | 488.28 kB | antd.js
2525

26-
10.95 MB | 3.49 MB | 3.49 MB | 907.19 kB | 915.50 kB | typescript.js
26+
10.95 MB | 3.49 MB | 3.49 MB | 907.38 kB | 915.50 kB | typescript.js
2727

0 commit comments

Comments
 (0)