Skip to content

Commit 044b0ff

Browse files
committed
feat(minifier): minify String::concat into template literal (#8443)
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)
1 parent d966e0a commit 044b0ff

File tree

3 files changed

+174
-55
lines changed

3 files changed

+174
-55
lines changed

crates/oxc_minifier/src/ast_passes/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ impl PeepholeOptimizations {
6262
x5_peephole_minimize_conditions: PeepholeMinimizeConditions::new(target),
6363
x6_peephole_remove_dead_code: PeepholeRemoveDeadCode::new(in_fixed_loop),
6464
x7_convert_to_dotted_properties: ConvertToDottedProperties::new(in_fixed_loop),
65-
x8_peephole_replace_known_methods: PeepholeReplaceKnownMethods::new(),
65+
x8_peephole_replace_known_methods: PeepholeReplaceKnownMethods::new(target),
6666
x9_peephole_substitute_alternate_syntax: PeepholeSubstituteAlternateSyntax::new(
6767
options.target,
6868
in_fixed_loop,

crates/oxc_minifier/src/ast_passes/peephole_replace_known_methods.rs

Lines changed: 169 additions & 50 deletions
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(
@@ -62,7 +67,7 @@ impl<'a> PeepholeReplaceKnownMethods {
6267
"indexOf" | "lastIndexOf" => Self::try_fold_string_index_of(ce, name, object, ctx),
6368
"charAt" => Self::try_fold_string_char_at(ce, object, ctx),
6469
"charCodeAt" => Self::try_fold_string_char_code_at(ce, object, ctx),
65-
"concat" => Self::try_fold_concat(ce, ctx),
70+
"concat" => self.try_fold_concat(ce, ctx),
6671
"replace" | "replaceAll" => Self::try_fold_string_replace(ce, name, object, ctx),
6772
"fromCharCode" => Self::try_fold_string_from_char_code(ce, object, ctx),
6873
"toString" => Self::try_fold_to_string(ce, object, ctx),
@@ -423,7 +428,9 @@ impl<'a> PeepholeReplaceKnownMethods {
423428
}
424429

425430
/// `[].concat(1, 2)` -> `[1, 2]`
431+
/// `"".concat(a, "b")` -> "`${a}b`"
426432
fn try_fold_concat(
433+
&self,
427434
ce: &mut CallExpression<'a>,
428435
ctx: &mut TraverseCtx<'a>,
429436
) -> Option<Expression<'a>> {
@@ -435,52 +442,138 @@ impl<'a> PeepholeReplaceKnownMethods {
435442
}
436443

437444
let Expression::StaticMemberExpression(member) = &mut ce.callee else { unreachable!() };
438-
let Expression::ArrayExpression(array_expr) = &mut member.object else { return None };
439-
440-
let can_merge_until = ce
441-
.arguments
442-
.iter()
443-
.enumerate()
444-
.take_while(|(_, argument)| match argument {
445-
Argument::SpreadElement(_) => false,
446-
match_expression!(Argument) => {
447-
let argument = argument.to_expression();
448-
if argument.is_literal() {
449-
true
450-
} else {
451-
matches!(argument, Expression::ArrayExpression(_))
445+
match &mut member.object {
446+
Expression::ArrayExpression(array_expr) => {
447+
let can_merge_until = ce
448+
.arguments
449+
.iter()
450+
.enumerate()
451+
.take_while(|(_, argument)| match argument {
452+
Argument::SpreadElement(_) => false,
453+
match_expression!(Argument) => {
454+
let argument = argument.to_expression();
455+
if argument.is_literal() {
456+
true
457+
} else {
458+
matches!(argument, Expression::ArrayExpression(_))
459+
}
460+
}
461+
})
462+
.map(|(i, _)| i)
463+
.last();
464+
465+
if let Some(can_merge_until) = can_merge_until {
466+
for argument in ce.arguments.drain(..=can_merge_until) {
467+
let argument = argument.into_expression();
468+
if argument.is_literal() {
469+
array_expr.elements.push(ArrayExpressionElement::from(argument));
470+
} else {
471+
let Expression::ArrayExpression(mut argument_array) = argument else {
472+
unreachable!()
473+
};
474+
array_expr.elements.append(&mut argument_array.elements);
475+
}
452476
}
453477
}
454-
})
455-
.map(|(i, _)| i)
456-
.last();
457-
458-
if let Some(can_merge_until) = can_merge_until {
459-
for argument in ce.arguments.drain(..=can_merge_until) {
460-
let argument = argument.into_expression();
461-
if argument.is_literal() {
462-
array_expr.elements.push(ArrayExpressionElement::from(argument));
478+
479+
if ce.arguments.is_empty() {
480+
Some(ctx.ast.move_expression(&mut member.object))
481+
} else if can_merge_until.is_some() {
482+
Some(ctx.ast.expression_call(
483+
ce.span,
484+
ctx.ast.move_expression(&mut ce.callee),
485+
Option::<TSTypeParameterInstantiation>::None,
486+
ctx.ast.move_vec(&mut ce.arguments),
487+
false,
488+
))
463489
} else {
464-
let Expression::ArrayExpression(mut argument_array) = argument else {
465-
unreachable!()
466-
};
467-
array_expr.elements.append(&mut argument_array.elements);
490+
None
468491
}
469492
}
470-
}
493+
Expression::StringLiteral(base_str) => {
494+
if self.target < ESTarget::ES2015
495+
|| ce.arguments.is_empty()
496+
|| !ce.arguments.iter().all(Argument::is_expression)
497+
{
498+
return None;
499+
}
471500

472-
if ce.arguments.is_empty() {
473-
Some(ctx.ast.move_expression(&mut member.object))
474-
} else if can_merge_until.is_some() {
475-
Some(ctx.ast.expression_call(
476-
ce.span,
477-
ctx.ast.move_expression(&mut ce.callee),
478-
Option::<TSTypeParameterInstantiation>::None,
479-
ctx.ast.move_vec(&mut ce.arguments),
480-
false,
481-
))
482-
} else {
483-
None
501+
let expression_count = ce
502+
.arguments
503+
.iter()
504+
.filter(|arg| !matches!(arg, Argument::StringLiteral(_)))
505+
.count();
506+
let string_count = ce.arguments.len() - expression_count;
507+
508+
// whether it is shorter to use `String::concat`
509+
if ".concat()".len() + ce.arguments.len() + "''".len() * string_count
510+
< "${}".len() * expression_count
511+
{
512+
return None;
513+
}
514+
515+
let mut quasi_strs: Vec<Cow<'a, str>> =
516+
vec![Cow::Borrowed(base_str.value.as_str())];
517+
let mut expressions = ctx.ast.vec();
518+
let mut pushed_quasi = true;
519+
for argument in ce.arguments.drain(..) {
520+
if let Argument::StringLiteral(str_lit) = argument {
521+
if pushed_quasi {
522+
let last_quasi = quasi_strs
523+
.last_mut()
524+
.expect("last element should exist because pushed_quasi is true");
525+
last_quasi.to_mut().push_str(&str_lit.value);
526+
} else {
527+
quasi_strs.push(Cow::Borrowed(str_lit.value.as_str()));
528+
}
529+
pushed_quasi = true;
530+
} else {
531+
if !pushed_quasi {
532+
// need a pair
533+
quasi_strs.push(Cow::Borrowed(""));
534+
}
535+
// checked that all the arguments are expression above
536+
expressions.push(argument.into_expression());
537+
pushed_quasi = false;
538+
}
539+
}
540+
if !pushed_quasi {
541+
quasi_strs.push(Cow::Borrowed(""));
542+
}
543+
544+
if expressions.is_empty() {
545+
debug_assert_eq!(quasi_strs.len(), 1);
546+
return Some(ctx.ast.expression_string_literal(
547+
ce.span,
548+
quasi_strs.pop().unwrap(),
549+
None,
550+
));
551+
}
552+
553+
let mut quasis = ctx.ast.vec_from_iter(quasi_strs.into_iter().map(|s| {
554+
let cooked = s.clone().into_in(ctx.ast.allocator);
555+
ctx.ast.template_element(
556+
SPAN,
557+
false,
558+
TemplateElementValue {
559+
raw: s
560+
.cow_replace("\\", "\\\\")
561+
.cow_replace("`", "\\`")
562+
.cow_replace("${", "\\${")
563+
.cow_replace("\r\n", "\\r\n")
564+
.into_in(ctx.ast.allocator),
565+
cooked: Some(cooked),
566+
},
567+
)
568+
}));
569+
if let Some(last_quasi) = quasis.last_mut() {
570+
last_quasi.tail = true;
571+
}
572+
573+
debug_assert_eq!(quasis.len(), expressions.len() + 1);
574+
Some(ctx.ast.expression_template_literal(ce.span, quasis, expressions))
575+
}
576+
_ => None,
484577
}
485578
}
486579
}
@@ -489,12 +582,14 @@ impl<'a> PeepholeReplaceKnownMethods {
489582
#[cfg(test)]
490583
mod test {
491584
use oxc_allocator::Allocator;
585+
use oxc_syntax::es_target::ESTarget;
492586

493587
use crate::tester;
494588

495589
fn test(source_text: &str, positive: &str) {
496590
let allocator = Allocator::default();
497-
let mut pass = super::PeepholeReplaceKnownMethods::new();
591+
let target = ESTarget::ESNext;
592+
let mut pass = super::PeepholeReplaceKnownMethods::new(target);
498593
tester::test(&allocator, source_text, positive, &mut pass);
499594
}
500595

@@ -1238,13 +1333,13 @@ mod test {
12381333
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
12391334

12401335
// string
1241-
fold("'1'.concat(1).concat(2,['abc']).concat('abc')", "'1'.concat(1,2,['abc'],'abc')");
1242-
fold("''.concat(['abc']).concat(1).concat([2,3])", "''.concat(['abc'],1,[2,3])");
1243-
fold_same("''.concat(1)");
1336+
fold("'1'.concat(1).concat(2,['abc']).concat('abc')", "`1${1}${2}${['abc']}abc`");
1337+
fold("''.concat(['abc']).concat(1).concat([2,3])", "`${['abc']}${1}${[2, 3]}`");
1338+
fold("''.concat(1)", "`${1}`");
12441339

1245-
fold("var x, y; ''.concat(x).concat(y)", "var x, y; ''.concat(x, y)");
1246-
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
1247-
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
1340+
fold("var x, y; ''.concat(x).concat(y)", "var x, y; `${x}${y}`");
1341+
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
1342+
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
12481343

12491344
// other
12501345
fold_same("obj.concat([1,2]).concat(1)");
@@ -1377,4 +1472,28 @@ mod test {
13771472
test("1e99.toString(b)", "1e99.toString(b)");
13781473
test("/./.toString(b)", "/./.toString(b)");
13791474
}
1475+
1476+
#[test]
1477+
fn test_fold_string_concat() {
1478+
test_same("x = ''.concat()");
1479+
test("x = ''.concat(a, b)", "x = `${a}${b}`");
1480+
test("x = ''.concat(a, b, c)", "x = `${a}${b}${c}`");
1481+
test("x = ''.concat(a, b, c, d)", "x = `${a}${b}${c}${d}`");
1482+
test_same("x = ''.concat(a, b, c, d, e)");
1483+
test("x = ''.concat('a')", "x = 'a'");
1484+
test("x = ''.concat('a', 'b')", "x = 'ab'");
1485+
test("x = ''.concat('a', 'b', 'c')", "x = 'abc'");
1486+
test("x = ''.concat('a', 'b', 'c', 'd')", "x = 'abcd'");
1487+
test("x = ''.concat('a', 'b', 'c', 'd', 'e')", "x = 'abcde'");
1488+
test("x = ''.concat(a, 'b')", "x = `${a}b`");
1489+
test("x = ''.concat('a', b)", "x = `a${b}`");
1490+
test("x = ''.concat(a, 'b', c)", "x = `${a}b${c}`");
1491+
test("x = ''.concat('a', b, 'c')", "x = `a${b}c`");
1492+
test("x = ''.concat('a', b, 'c', d, 'e', f, 'g', h, 'i', j, 'k', l, 'm', n, 'o', p, 'q', r, 's', t)", "x = `a${b}c${d}e${f}g${h}i${j}k${l}m${n}o${p}q${r}s${t}`");
1493+
test("x = ''.concat(a, 1)", "x = `${a}${1}`"); // inlining 1 is not implemented yet
1494+
1495+
test("x = '\\\\s'.concat(a)", "x = `\\\\s${a}`");
1496+
test("x = '`'.concat(a)", "x = `\\`${a}`");
1497+
test("x = '${'.concat(a)", "x = `\\${${a}`");
1498+
}
13801499
}

tasks/minsize/minsize.snap

Lines changed: 4 additions & 4 deletions
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.14 kB | 118.14 kB | 44.45 kB | 44.37 kB | vue.js
1111

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

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

20-
2.14 MB | 724.05 kB | 724.14 kB | 179.94 kB | 181.07 kB | victory.js
20+
2.14 MB | 722.44 kB | 724.14 kB | 179.88 kB | 181.07 kB | victory.js
2121

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

24-
6.69 MB | 2.31 MB | 2.31 MB | 492.51 kB | 488.28 kB | antd.js
24+
6.69 MB | 2.30 MB | 2.31 MB | 492.21 kB | 488.28 kB | antd.js
2525

26-
10.95 MB | 3.49 MB | 3.49 MB | 907.24 kB | 915.50 kB | typescript.js
26+
10.95 MB | 3.49 MB | 3.49 MB | 907.46 kB | 915.50 kB | typescript.js
2727

0 commit comments

Comments
 (0)