feat(ecmascript)!: fold surrogate strings with JSStr constants (#27098)

String constant evaluation used `Cow<str>`, so folds stopped at any string with a lone surrogate. `ConstantValue::String` now holds a `JSStr`, so these strings fold too, and results are built with `JSStrBuilder`, which joins surrogate halves the way the runtime does.

`indexOf`, `lastIndexOf`, `substring` and `slice` now share one UTF-16 code-unit implementation for every string. This also fixes miscompiles on main: `'éx'.indexOf('x')` folded to 2, `'abc'.indexOf('a', 4294967296)` to 0, `'abab'.lastIndexOf('b', NaN)` to -1, and `'abc'.substring(1, void 0)` to `''`. It includes #27097, which is closed.

String `concat` calls and chains fold into a template only when no argument with side effects follows one that may be an object, so `''.concat(a, (a.push(1), 'x'))` no longer becomes `` `${a}${(a.push(1), 'x')}` ``.

## Breaking changes

- `ConstantValue::String` holds `JSStr<'a>`, and `ConstantValue::into_string`, `evaluate_value_to_string` and `get_side_free_string_value` return `JSStr<'a>`.
- `StringIndexOf` and `StringLastIndexOf` take the search value as `JSStr` and use UTF-16 code unit positions.
- `StringSubstring` is removed.

Closes #15524. Closes #25158. Closes #26053. Based on #27096. Part of #26242. Continues #26759.

Implemented with AI assistance.
This commit is contained in:
Dunqing committed 2026-10-20 01:09:59 +00:00
1 parent 9d67cb9463
commit cf8296fb8e
32 files changed
+1268 -338

No files matched your search

Generated
+1
View File
@@ -2014,6 +2014,7 @@ dependencies = [
"cow-utils",
"dragonbox_ecma",
"itoa",
"memchr",
"num-bigint",
"num-traits",
"oxc_allocator",
+4
View File
@@ -37,6 +37,10 @@ oxc_syntax = { workspace = true, features = ["to_js_string"] }
cow-utils = { workspace = true, optional = true }
dragonbox_ecma = { workspace = true }
itoa = { workspace = true }
memchr = { workspace = true }
num-bigint = { workspace = true }
num-traits = { workspace = true }
smallvec = { workspace = true, optional = true }
[dev-dependencies]
oxc_allocator = { workspace = true }
@@ -13,11 +13,12 @@ use smallvec::SmallVec;
use oxc_allocator::ArenaVec;
use oxc_ast::ast::*;
use oxc_str::{JSStr, JSStrBuilder};
use oxc_syntax::number::ToJsString;
use crate::{
StringCharAt, StringCharAtResult, StringCharCodeAt, StringIndexOf, StringLastIndexOf,
StringSubstring, ToInt32, ToJsString as ToJsStringTrait, ToUint32,
StringCharCodeAt, StringIndexOf, StringLastIndexOf, ToInt32, ToJsString as ToJsStringTrait,
ToUint32,
constant_evaluation::url_encoding::{
decode_uri_chars, encode_uri_chars, is_uri_always_unescaped,
},
@@ -110,7 +111,7 @@ fn try_fold_string_casing<'a>(
}
let value = match object {
Expression::StringLiteral(s) => Cow::Borrowed(s.value.as_str()?),
Expression::StringLiteral(s) => s.value,
Expression::Identifier(ident) => ident
.reference_id
.get()
@@ -118,6 +119,9 @@ fn try_fold_string_casing<'a>(
.and_then(ConstantValue::into_string)?,
_ => return None,
};
// Casing and trimming operate on UTF-8.
// A value containing a lone surrogate stays unfolded.
let value = value.as_str()?;
let result = match name {
"toLowerCase" => Str::from_str_in(&value.cow_to_lowercase(), ctx),
@@ -127,7 +131,7 @@ fn try_fold_string_casing<'a>(
"trimEnd" => Str::from_str_in(value.trim_end(), ctx),
_ => return None,
};
Some(ConstantValue::String(Cow::Borrowed(result.as_str())))
Some(ConstantValue::String(result.into()))
}
fn try_fold_string_index_of<'a>(
@@ -156,10 +160,8 @@ fn try_fold_string_index_of<'a>(
};
let result = match name {
"indexOf" => s.value.as_str()?.index_of(search_value.as_deref(), search_start_index),
"lastIndexOf" => {
s.value.as_str()?.last_index_of(search_value.as_deref(), search_start_index)
}
"indexOf" => s.value.index_of(search_value, search_start_index),
"lastIndexOf" => s.value.last_index_of(search_value, search_start_index),
_ => unreachable!(),
};
Some(ConstantValue::Number(result as f64))
@@ -198,8 +200,124 @@ fn try_fold_string_substring_or_slice<'a>(
{
return None;
}
// A NaN end cannot be folded: the number alone no longer says whether the argument was
// `undefined` (end of string) or NaN (index zero), and for `slice` a zero end must not
// swap with the start.
if end_idx.is_some_and(f64::is_nan) {
return None;
}
let value = s.value;
// The guards above leave ordered positions and a non-NaN end, and a NaN start converts to
// zero. They bound positions by the byte length, which is at least the number of code
// units, so `skip` and `take` clamp a position past the end.
let to_unit = |position: Option<f64>, default: usize| {
position.map_or(default, |p| if p.is_nan() { 0 } else { p.trunc().max(0.0) as usize })
};
let start = to_unit(start_idx, 0);
let end = to_unit(end_idx, usize::MAX).max(start);
// Skip the prefix once, then size the actual UTF-16 window. An omitted end must not
// reserve the whole receiver for a tiny suffix. Counting only the window also avoids
// scanning the rest of the receiver when an explicit end selects a short prefix.
let mut units = value.encode_utf16();
if start > 0 {
units.nth(start - 1);
}
let units = units.take(end - start);
// Each unit takes at most three bytes, including a half split from a surrogate pair.
let capacity = units.clone().count().saturating_mul(3).min(value.len());
let mut result = JSStrBuilder::with_capacity_in(capacity, ctx);
result.push_code_units(units);
Some(ConstantValue::String(result.into_js_str()))
}
Some(ConstantValue::String(Cow::Owned(s.value.as_str()?.substring(start_idx, end_idx))))
#[cfg(test)]
mod substring_tests {
use oxc_allocator::{Allocator, GetAllocator};
use oxc_ast::builder::{AstBuilder, GetAstBuilder};
use oxc_span::SPAN;
use super::*;
use crate::{
GlobalContext,
side_effects::{MayHaveSideEffectsContext, PropertyReadSideEffects},
};
struct Context<'a>(AstBuilder<'a>);
impl<'a> GetAllocator<'a> for Context<'a> {
fn allocator(&self) -> &'a Allocator {
self.0.allocator()
}
}
impl<'a> GetAstBuilder<'a> for Context<'a> {
type Builder = AstBuilder<'a>;
fn builder(&self) -> &AstBuilder<'a> {
&self.0
}
}
impl<'a> GlobalContext<'a> for Context<'a> {
fn is_global_reference(&self, _: &IdentifierReference<'a>) -> bool {
false
}
}
impl<'a> MayHaveSideEffectsContext<'a> for Context<'a> {
fn annotations(&self) -> bool {
false
}
fn manual_pure_functions(&self, _: &Expression) -> bool {
false
}
fn property_read_side_effects(&self) -> PropertyReadSideEffects {
PropertyReadSideEffects::All
}
fn unknown_global_side_effects(&self) -> bool {
true
}
}
impl<'a> ConstantEvaluationCtx<'a> for Context<'a> {}
#[test]
fn substring_allocates_for_the_clamped_window() {
let large = "x".repeat(1_000_000);
for (text, start, end, expected) in [
(large.as_str(), 999_999.0, None, "x"),
(large.as_str(), 999_999.0, Some(1_000_000.0), "x"),
(large.as_str(), 1_000_000.0, None, ""),
// The byte length exceeds the UTF-16 length.
("é😀", 4.0, None, ""),
] {
let allocator = Allocator::new();
let ctx = Context(AstBuilder::new(&allocator));
let object = Expression::new_string_literal(SPAN, JSStr::from(text), None, &ctx);
let args = ArenaVec::from_iter_in(
std::iter::once(start).chain(end).map(|value| {
Argument::from(Expression::new_numeric_literal(
SPAN,
value,
None,
NumberBase::Decimal,
&ctx,
))
}),
&ctx,
);
let before = allocator.used_bytes();
let result = try_fold_string_substring_or_slice(&args, &object, &ctx)
.unwrap()
.into_string()
.unwrap();
assert_eq!(result.as_str(), Some(expected));
assert!(allocator.used_bytes() - before <= expected.len() * 3);
}
}
}
fn try_fold_string_char_at<'a>(
@@ -219,12 +337,18 @@ fn try_fold_string_char_at<'a>(
None => None,
};
let result = match s.value.as_str()?.char_at(char_at_index) {
StringCharAtResult::Value(c) => c.to_string(),
StringCharAtResult::InvalidChar(_) => return None,
StringCharAtResult::OutOfRange => String::new(),
// The result is the single code unit at the position, which may be one half
// of a surrogate pair.
// A position out of range gives the empty string.
let result = match s.value.char_code_at(char_at_index) {
Some(unit) => {
let mut result = JSStrBuilder::with_capacity_in(3, ctx);
result.push_code_unit(unit as u16);
result.into_js_str()
}
None => JSStr::empty(),
};
Some(ConstantValue::String(Cow::Owned(result)))
Some(ConstantValue::String(result))
}
fn try_fold_string_char_code_at<'a>(
@@ -248,7 +372,7 @@ fn try_fold_string_char_code_at<'a>(
None => None,
};
let value = s.value.as_str()?.char_code_at(char_at_index).map_or(f64::NAN, |n| n as f64);
let value = s.value.char_code_at(char_at_index).map_or(f64::NAN, |n| n as f64);
Some(ConstantValue::Number(value))
}
@@ -262,7 +386,11 @@ fn try_fold_starts_with<'a>(
}
let Argument::StringLiteral(arg) = args.first().unwrap() else { return None };
let Expression::StringLiteral(s) = object else { return None };
Some(ConstantValue::Boolean(s.value.as_str()?.starts_with(arg.value.as_str()?)))
// A UTF-8 search value prefix-matches over WTF-8 bytes even when the receiver
// holds lone surrogates.
// A search value with a lone surrogate could still match the same half of a formed pair,
// which the byte comparison misses, so it stays unfolded.
Some(ConstantValue::Boolean(s.value.starts_with(arg.value.as_str()?)))
}
fn try_fold_string_replace<'a>(
@@ -275,6 +403,9 @@ fn try_fold_string_replace<'a>(
return None;
}
let Expression::StringLiteral(s) = object else { return None };
// The replace machinery is UTF-8.
// A receiver, search value, or replacement containing a lone surrogate stays unfolded.
let value = s.value.as_str()?;
let search_value = args.first().unwrap();
let search_value = match search_value {
Argument::SpreadElement(_) => return None,
@@ -283,25 +414,25 @@ fn try_fold_string_replace<'a>(
if value.may_have_side_effects(ctx) {
return None;
}
value.evaluate_value(ctx)?.into_string()?
value.evaluate_value(ctx)?.into_string()?.as_str()?
}
};
let replace_value = args.get(1).unwrap();
let replace_value = match replace_value {
Argument::SpreadElement(_) => return None,
match_expression!(Argument) => {
replace_value.to_expression().get_side_free_string_value(ctx)?
replace_value.to_expression().get_side_free_string_value(ctx)?.as_str()?
}
};
if replace_value.contains('$') {
return None;
}
let result = match name {
"replace" => s.value.as_str()?.cow_replacen(search_value.as_ref(), &replace_value, 1),
"replaceAll" => s.value.as_str()?.cow_replace(search_value.as_ref(), &replace_value),
"replace" => value.cow_replacen(search_value, replace_value, 1),
"replaceAll" => value.cow_replace(search_value, replace_value),
_ => unreachable!(),
};
Some(ConstantValue::String(result))
Some(ConstantValue::String(JSStr::from_cow_in(&result, ctx)))
}
fn try_fold_string_from_char_code<'a>(
@@ -312,15 +443,21 @@ fn try_fold_string_from_char_code<'a>(
if !ctx.is_global_expr("String", object) {
return None;
}
let mut s = String::with_capacity(args.len());
for arg in args {
let expr = arg.as_expression()?;
let v = expr.get_side_free_number_value(ctx)?;
let v = v.to_int_32() as u16 as u32;
let c = char::try_from(v).ok()?;
s.push(c);
}
Some(ConstantValue::String(Cow::Owned(s)))
// Each argument contributes its ToUint16 code unit.
// Read them all before allocating in the arena, which never frees.
let units = args
.iter()
.map(|arg| {
let v = arg.as_expression()?.get_side_free_number_value(ctx)?;
Some(v.to_int_32() as u16)
})
.collect::<Option<Vec<u16>>>()?;
// Like the runtime string, a lead surrogate followed by a trail surrogate forms the
// supplementary character, and any other surrogate stays a lone unit.
// The capacity is in bytes, and a code unit encodes to at most three.
let mut result = JSStrBuilder::with_capacity_in(units.len() * 3, ctx);
result.push_utf16(&units);
Some(ConstantValue::String(result.into_js_str()))
}
fn try_fold_to_string<'a>(
@@ -348,16 +485,16 @@ fn try_fold_to_string<'a>(
}
if radix == 10 {
let s = lit.value.to_js_string();
return Some(ConstantValue::String(Cow::Owned(s)));
return Some(ConstantValue::String(JSStr::from_str_in(&s, ctx)));
}
// Only convert integers for other radix values.
let value = lit.value;
if value.is_infinite() {
let s = if value.is_sign_negative() { "-Infinity" } else { "Infinity" };
return Some(ConstantValue::String(Cow::Borrowed(s)));
return Some(ConstantValue::String(JSStr::from(s)));
}
if value.is_nan() {
return Some(ConstantValue::String(Cow::Borrowed("NaN")));
return Some(ConstantValue::String(JSStr::from("NaN")));
}
if value >= 0.0 && value.fract() != 0.0 {
return None;
@@ -367,16 +504,17 @@ fn try_fold_to_string<'a>(
return None;
}
let result = format_radix(i, radix);
Some(ConstantValue::String(Cow::Owned(result)))
Some(ConstantValue::String(JSStr::from_str_in(&result, ctx)))
}
Expression::RegExpLiteral(lit) if args.is_empty() => {
lit.to_js_string(ctx).map(ConstantValue::String)
lit.to_js_string(ctx).map(|s| ConstantValue::String(JSStr::from_cow_in(&s, ctx)))
}
e if args.is_empty() && !e.may_have_side_effects(ctx) => e
.evaluate_value(ctx)
// `null` and `undefined` returns type errors
.filter(|v| !v.is_undefined() && !v.is_null())
.and_then(|v| v.to_js_string(ctx).map(ConstantValue::String)),
.and_then(|v| v.into_js_str(ctx))
.map(ConstantValue::String),
_ => None,
}
}
@@ -566,7 +704,7 @@ fn try_fold_encode_uri<'a>(
ctx: &impl ConstantEvaluationCtx<'a>,
) -> Option<ConstantValue<'a>> {
if args.is_empty() {
return Some(ConstantValue::String(Cow::Borrowed("undefined")));
return Some(ConstantValue::String(JSStr::from("undefined")));
}
if args.len() != 1 {
return None;
@@ -574,6 +712,9 @@ fn try_fold_encode_uri<'a>(
let arg = args.first()?;
let expr = arg.as_expression()?;
let string_value = expr.get_side_free_string_value(ctx)?;
// `encodeURI` throws a URIError on a lone surrogate at runtime,
// so such an input must stay unfolded.
let string_value = Cow::Borrowed(string_value.as_str()?);
// SAFETY: should_encode only returns false for ascii chars
let encoded = unsafe {
@@ -587,7 +728,7 @@ fn try_fold_encode_uri<'a>(
},
)
};
Some(ConstantValue::String(encoded))
Some(ConstantValue::String(JSStr::from_cow_in(&encoded, ctx)))
}
fn try_fold_encode_uri_component<'a>(
@@ -595,7 +736,7 @@ fn try_fold_encode_uri_component<'a>(
ctx: &impl ConstantEvaluationCtx<'a>,
) -> Option<ConstantValue<'a>> {
if args.is_empty() {
return Some(ConstantValue::String(Cow::Borrowed("undefined")));
return Some(ConstantValue::String(JSStr::from("undefined")));
}
if args.len() != 1 {
return None;
@@ -603,6 +744,9 @@ fn try_fold_encode_uri_component<'a>(
let arg = args.first()?;
let expr = arg.as_expression()?;
let string_value = expr.get_side_free_string_value(ctx)?;
// `encodeURIComponent` throws a URIError on a lone surrogate at runtime,
// so such an input must stay unfolded.
let string_value = Cow::Borrowed(string_value.as_str()?);
// SAFETY: should_encode only returns false for ascii chars
let encoded = unsafe {
@@ -612,7 +756,7 @@ fn try_fold_encode_uri_component<'a>(
|c| !is_uri_always_unescaped(c),
)
};
Some(ConstantValue::String(encoded))
Some(ConstantValue::String(JSStr::from_cow_in(&encoded, ctx)))
}
fn try_fold_decode_uri<'a>(
@@ -620,7 +764,7 @@ fn try_fold_decode_uri<'a>(
ctx: &impl ConstantEvaluationCtx<'a>,
) -> Option<ConstantValue<'a>> {
if args.is_empty() {
return Some(ConstantValue::String(Cow::Borrowed("undefined")));
return Some(ConstantValue::String(JSStr::from("undefined")));
}
if args.len() != 1 {
return None;
@@ -628,13 +772,16 @@ fn try_fold_decode_uri<'a>(
let arg = args.first()?;
let expr = arg.as_expression()?;
let string_value = expr.get_side_free_string_value(ctx)?;
// The decode machinery is UTF-8.
// An input containing a lone surrogate stays unfolded.
let string_value = Cow::Borrowed(string_value.as_str()?);
let decoded = decode_uri_chars(
string_value,
#[inline(always)]
|c| matches!(c, b';' | b',' | b'/' | b'?' | b':' | b'@' | b'&' | b'=' | b'+' | b'$' | b'#'),
)?;
Some(ConstantValue::String(decoded))
Some(ConstantValue::String(JSStr::from_cow_in(&decoded, ctx)))
}
fn try_fold_decode_uri_component<'a>(
@@ -642,7 +789,7 @@ fn try_fold_decode_uri_component<'a>(
ctx: &impl ConstantEvaluationCtx<'a>,
) -> Option<ConstantValue<'a>> {
if args.is_empty() {
return Some(ConstantValue::String(Cow::Borrowed("undefined")));
return Some(ConstantValue::String(JSStr::from("undefined")));
}
if args.len() != 1 {
return None;
@@ -650,6 +797,9 @@ fn try_fold_decode_uri_component<'a>(
let arg = args.first()?;
let expr = arg.as_expression()?;
let string_value = expr.get_side_free_string_value(ctx)?;
// The decode machinery is UTF-8.
// An input containing a lone surrogate stays unfolded.
let string_value = Cow::Borrowed(string_value.as_str()?);
// decodeURIComponent decodes all percent-encoded sequences
let decoded = decode_uri_chars(
@@ -657,7 +807,7 @@ fn try_fold_decode_uri_component<'a>(
#[inline(always)]
|_| false,
)?;
Some(ConstantValue::String(decoded))
Some(ConstantValue::String(JSStr::from_cow_in(&decoded, ctx)))
}
fn try_fold_global_is_nan<'a>(
@@ -705,6 +855,9 @@ fn try_fold_global_parse_float<'a>(
let arg = args.first().unwrap();
let expr = arg.as_expression()?;
let input_string = expr.get_side_free_string_value(ctx)?;
// The prefix scan is UTF-8.
// An input containing a lone surrogate stays unfolded.
let input_string = input_string.as_str()?;
let trimmed = input_string.trim_start();
let Some(trimmed_prefix) = find_str_decimal_literal_prefix(trimmed) else {
return Some(ConstantValue::Number(f64::NAN));
@@ -812,6 +965,9 @@ fn try_fold_global_parse_int<'a>(
let string_arg = args.first().unwrap();
let string_expr = string_arg.as_expression()?;
let string_value = string_expr.evaluate_value_to_string(ctx)?;
// The digit scan is UTF-8.
// An input containing a lone surrogate stays unfolded.
let string_value = string_value.as_str()?;
let mut string_value = string_value.trim_start();
let mut sign = 1;
@@ -8,12 +8,11 @@ pub use crate::{ConstantValue, DetermineValueType, ValueType};
pub use is_int32_or_uint32::IsInt32OrUint32;
pub use is_literal_value::IsLiteralValue;
use std::borrow::Cow;
use num_bigint::BigInt;
use num_traits::{ToPrimitive, Zero};
use oxc_allocator::GetAllocator;
use oxc_ast::{ast::*, builder::GetAstBuilder};
use oxc_str::JSStr;
use equality_comparison::{abstract_equality_comparison, strict_equality_comparison};
@@ -66,11 +65,8 @@ pub trait ConstantEvaluation<'a>: MayHaveSideEffects<'a> {
}
/// Evaluate the expression to a constant value and convert it to a string.
fn evaluate_value_to_string(
&self,
ctx: &impl ConstantEvaluationCtx<'a>,
) -> Option<Cow<'a, str>> {
self.evaluate_value_to(ctx, Some(ValueType::String))?.to_js_string(ctx)
fn evaluate_value_to_string(&self, ctx: &impl ConstantEvaluationCtx<'a>) -> Option<JSStr<'a>> {
self.evaluate_value_to(ctx, Some(ValueType::String))?.into_js_str(ctx)
}
fn get_side_free_number_value(&self, ctx: &impl ConstantEvaluationCtx<'a>) -> Option<f64> {
@@ -95,12 +91,40 @@ pub trait ConstantEvaluation<'a>: MayHaveSideEffects<'a> {
fn get_side_free_string_value(
&self,
ctx: &impl ConstantEvaluationCtx<'a>,
) -> Option<Cow<'a, str>> {
) -> Option<JSStr<'a>> {
let value = self.evaluate_value_to_string(ctx)?;
(!self.may_have_side_effects(ctx)).then_some(value)
}
}
impl<'a> ConstantValue<'a> {
/// `ToString` as a [`JSStr`], keeping the lone surrogates of a string value.
///
/// [`ToJsString`](crate::ToJsString) produces UTF-8, so it declines a string value with a lone
/// surrogate.
pub fn into_js_str(self, ctx: &impl ConstantEvaluationCtx<'a>) -> Option<JSStr<'a>> {
match self {
Self::String(value) => Some(value),
value => value.to_js_string(ctx).map(|value| JSStr::from_cow_in(&value, ctx)),
}
}
}
/// `ToString` of an expression as a [`JSStr`], so string literals that contain lone surrogates
/// evaluate instead of declining.
pub fn evaluate_to_js_string<'a>(
expr: &Expression<'a>,
ctx: &impl ConstantEvaluationCtx<'a>,
) -> Option<JSStr<'a>> {
match expr {
Expression::StringLiteral(lit) => Some(lit.value),
// A template with no substitutions has a single quasi,
// whose cooked value is already the WTF-8 string.
Expression::TemplateLiteral(lit) if lit.expressions.is_empty() => lit.single_quasi(),
_ => expr.to_js_string(ctx).map(|value| JSStr::from_cow_in(&value, ctx)),
}
}
impl<'a, T: ConstantEvaluation<'a>> ConstantEvaluation<'a> for Option<T> {
fn evaluate_value(&self, ctx: &impl ConstantEvaluationCtx<'a>) -> Option<ConstantValue<'a>> {
self.as_ref().and_then(|t| t.evaluate_value(ctx))
@@ -145,7 +169,7 @@ impl<'a> ConstantEvaluation<'a> for Expression<'a> {
Some(ValueType::Boolean) => self.to_boolean(ctx).map(ConstantValue::Boolean),
Some(ValueType::Number) => self.to_number(ctx).map(ConstantValue::Number),
Some(ValueType::BigInt) => self.to_big_int(ctx).map(ConstantValue::BigInt),
Some(ValueType::String) => self.to_js_string(ctx).map(ConstantValue::String),
Some(ValueType::String) => evaluate_to_js_string(self, ctx).map(ConstantValue::String),
_ => None,
};
if result.is_some() {
@@ -161,10 +185,7 @@ impl<'a> ConstantEvaluation<'a> for Expression<'a> {
Expression::NullLiteral(_) => Some(ConstantValue::Null),
Expression::BooleanLiteral(lit) => Some(ConstantValue::Boolean(lit.value)),
Expression::BigIntLiteral(lit) => lit.to_big_int(ctx).map(ConstantValue::BigInt),
Expression::StringLiteral(lit) => {
// `ConstantValue::String` cannot represent lone surrogates.
Some(ConstantValue::String(Cow::Borrowed(lit.value.as_str()?)))
}
Expression::StringLiteral(lit) => Some(ConstantValue::String(lit.value)),
Expression::StaticMemberExpression(e) => e.evaluate_value_to(ctx, target_ty),
Expression::ComputedMemberExpression(e) => e.evaluate_value_to(ctx, target_ty),
Expression::CallExpression(e) => e.evaluate_value_to(ctx, target_ty),
@@ -218,7 +239,11 @@ fn binary_operation_evaluate_value_to<'a>(
{
let lval = left.evaluate_value_to_string(ctx)?;
let rval = right.evaluate_value_to_string(ctx)?;
return Some(ConstantValue::String(lval + rval));
// Concatenation repairs a surrogate pair split across the operand boundary.
return Some(ConstantValue::String(JSStr::from_js_strs_array_in(
[lval, rval],
ctx,
)));
}
let left_to_numeric_type = left_to_primitive.to_numeric(ctx);
let right_to_numeric_type = right_to_primitive.to_numeric(ctx);
@@ -474,7 +499,7 @@ impl<'a> ConstantEvaluation<'a> for UnaryExpression<'a> {
_ => return None,
},
};
Some(ConstantValue::String(Cow::Borrowed(s)))
Some(ConstantValue::String(JSStr::from(s)))
}
UnaryOperator::Void => Some(ConstantValue::Undefined),
UnaryOperator::LogicalNot => {
@@ -549,7 +574,7 @@ fn evaluate_value_length<'a>(
ctx: &impl ConstantEvaluationCtx<'a>,
) -> Option<ConstantValue<'a>> {
if let Some(ConstantValue::String(s)) = object.evaluate_value(ctx) {
Some(ConstantValue::Number(s.encode_utf16().count().to_f64().unwrap()))
Some(ConstantValue::Number(s.len_utf16().to_f64().unwrap()))
} else if let Expression::ArrayExpression(arr) = object {
if arr.elements.iter().any(|e| matches!(e, ArrayExpressionElement::SpreadElement(_))) {
None
+10 -1
View File
@@ -13,6 +13,15 @@ pub trait GlobalContext<'a>: Sized {
.is_some_and(|ident| self.is_global_reference(ident))
}
/// The constant value that a reference resolves to, if known.
///
/// A returned [`ConstantValue::String`] borrows its text for `'a`, and this trait provides
/// no allocator. A context that keeps its constants elsewhere can copy them into the arena
/// with [`JSStr::from_str_in`] when it also has an allocator. Or it can implement these
/// traits for the shorter lifetime of its own storage and lend the text: the AST is
/// covariant in `'a`, so a longer-lived AST can be evaluated with such a context.
///
/// [`JSStr::from_str_in`]: oxc_str::JSStr::from_str_in
fn get_constant_value_for_reference_id(
&self,
_reference_id: ReferenceId,
@@ -24,7 +33,7 @@ pub trait GlobalContext<'a>: Sized {
///
/// Cheaper than [`Self::get_constant_value_for_reference_id`] when only the
/// type discriminant is needed, since it avoids cloning the value
/// (relevant for `BigInt` / owned `String`).
/// (relevant for `BigInt`).
fn value_type_for_reference_id(&self, _reference_id: ReferenceId) -> Option<ValueType> {
None
}
+8 -5
View File
@@ -5,9 +5,10 @@ use num_traits::FromPrimitive;
use oxc_ast::ast::Expression;
use crate::{
ToBigInt, ToJsString,
ToBigInt,
constant_evaluation::{
ConstantEvaluation, ConstantEvaluationCtx, ConstantValue, DetermineValueType,
evaluate_to_js_string,
},
};
@@ -28,8 +29,10 @@ pub fn is_less_than<'a>(
// 3. If px is a String and py is a String, then
if px.is_string() && py.is_string() {
let left_string = x.to_js_string(ctx)?;
let right_string = y.to_js_string(ctx)?;
// The comparison is over UTF-16 code units, so values containing lone surrogates order like
// any other unit sequence.
let left_string = evaluate_to_js_string(x, ctx)?;
let right_string = evaluate_to_js_string(y, ctx)?;
return Some(ConstantValue::Boolean(
left_string.encode_utf16().cmp(right_string.encode_utf16()) == Ordering::Less,
));
@@ -38,14 +41,14 @@ pub fn is_less_than<'a>(
// a. If px is a BigInt and py is a String, then
if px.is_bigint() && py.is_string() {
use crate::StringToBigInt;
let ny = y.to_js_string(ctx)?.as_ref().string_to_big_int();
let ny = evaluate_to_js_string(y, ctx)?.string_to_big_int();
let Some(ny) = ny else { return Some(ConstantValue::Undefined) };
return Some(ConstantValue::Boolean(x.to_big_int(ctx)? < ny));
}
// b. If px is a String and py is a BigInt, then
if px.is_string() && py.is_bigint() {
use crate::StringToBigInt;
let nx = x.to_js_string(ctx)?.as_ref().string_to_big_int();
let nx = evaluate_to_js_string(x, ctx)?.string_to_big_int();
let Some(nx) = nx else { return Some(ConstantValue::Undefined) };
return Some(ConstantValue::Boolean(nx < y.to_big_int(ctx)?));
}
+1 -2
View File
@@ -19,7 +19,7 @@ mod string_char_at;
mod string_char_code_at;
mod string_index_of;
mod string_last_index_of;
mod string_substring;
mod string_search;
mod string_to_big_int;
mod string_to_number;
mod to_big_int;
@@ -55,7 +55,6 @@ pub use self::{
string_char_code_at::StringCharCodeAt,
string_index_of::StringIndexOf,
string_last_index_of::StringLastIndexOf,
string_substring::StringSubstring,
string_to_big_int::StringToBigInt,
string_to_number::StringToNumber,
to_big_int::ToBigInt,
@@ -1,8 +1,16 @@
use crate::{StringCharAt, string_char_at::StringCharAtResult};
use oxc_str::JSStr;
use crate::{
StringCharAt, string_char_at::StringCharAtResult,
to_integer_or_infinity::ToIntegerOrInfinityResult,
};
pub trait StringCharCodeAt {
/// `String.prototype.charCodeAt ( pos )`
/// <https://tc39.es/ecma262/#sec-string.prototype.charcodeat>
///
/// The position is a UTF-16 code unit index.
/// The result is the code unit at that position, which may be a lone or paired surrogate.
fn char_code_at(&self, index: Option<f64>) -> Option<u32>;
}
@@ -16,6 +24,23 @@ impl StringCharCodeAt for &str {
}
}
impl StringCharCodeAt for JSStr<'_> {
fn char_code_at(&self, index: Option<f64>) -> Option<u32> {
use crate::to_integer_or_infinity::ToIntegerOrInfinity;
// A value without lone surrogates delegates to the UTF-8 lookup.
if let Some(value) = self.as_str() {
return value.char_code_at(index);
}
let position = match index.unwrap_or(0.0).to_integer_or_infinity_as_i64() {
ToIntegerOrInfinityResult::Value(value) if value >= 0 => usize::try_from(value).ok()?,
_ => return None,
};
// A lone surrogate is itself a code unit, so every in-range position has a value.
self.encode_utf16().nth(position).map(u32::from)
}
}
#[cfg(test)]
mod test {
use super::StringCharCodeAt;
@@ -34,5 +59,31 @@ mod test {
assert_eq!(s.char_code_at(Some(0.0)), Some(97));
assert_eq!(s.char_code_at(Some(f64::NAN)), Some(97));
assert_eq!(s.char_code_at(Some(f64::INFINITY)), None);
// An astral character is two code units, each a surrogate half.
let astral = "\u{1F600}";
assert_eq!(astral.char_code_at(Some(0.0)), Some(0xD83D));
assert_eq!(astral.char_code_at(Some(1.0)), Some(0xDE00));
assert_eq!(astral.char_code_at(Some(2.0)), None);
}
#[test]
fn test_evaluate_char_code_at_with_lone_surrogates() {
use oxc_allocator::Allocator;
use oxc_str::JSStrBuilder;
// "a\u{D800}b": the lone surrogate is the code unit at position 1.
let allocator = Allocator::new();
let mut builder = JSStrBuilder::new_in(&&allocator);
builder.push_utf16(&[0x61, 0xD800, 0x62]);
let value = builder.into_js_str();
assert_eq!(value.char_code_at(Some(0.0)), Some(97));
assert_eq!(value.char_code_at(Some(1.0)), Some(0xD800));
assert_eq!(value.char_code_at(Some(2.0)), Some(98));
assert_eq!(value.char_code_at(Some(3.0)), None);
assert_eq!(value.char_code_at(Some(-1.0)), None);
assert_eq!(value.char_code_at(None), Some(97));
assert_eq!(value.char_code_at(Some(f64::NAN)), Some(97));
assert_eq!(value.char_code_at(Some(f64::INFINITY)), None);
}
}
+135 -29
View File
@@ -1,49 +1,155 @@
use crate::ToInt32;
use oxc_str::JSStr;
pub trait StringIndexOf {
/// `String.prototype.indexOf ( searchString [ , position ] )`
/// <https://tc39.es/ecma262/#sec-string.prototype.indexof>
fn index_of(&self, search_value: Option<&str>, from_index: Option<f64>) -> isize;
fn index_of(&self, search_value: Option<JSStr<'_>>, from_index: Option<f64>) -> isize;
}
impl StringIndexOf for &str {
#[expect(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
fn index_of(&self, search_value: Option<&str>, from_index: Option<f64>) -> isize {
let from_index = from_index.map_or(0, |x| x.to_int_32().max(0)) as usize;
let search_value = search_value.unwrap_or("undefined");
let result = self.chars().skip(from_index).collect::<String>().find(search_value);
result.map(|index| index + from_index).map_or(-1, |index| index as isize)
fn index_of(&self, search_value: Option<JSStr<'_>>, from_index: Option<f64>) -> isize {
JSStr::from(*self).index_of(search_value, from_index)
}
}
impl StringIndexOf for JSStr<'_> {
#[expect(clippy::cast_possible_wrap, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn index_of(&self, search_value: Option<JSStr<'_>>, from_index: Option<f64>) -> isize {
let search = search_value.unwrap_or(JSStr::from("undefined"));
// Step 8: `ToIntegerOrInfinity(position)`.
// An absent position and NaN both give 0.
// The saturating float cast is exact for any position that can land inside a string.
let start = from_index
.map_or(0.0, |position| if position.is_nan() { 0.0 } else { position.trunc() })
.max(0.0) as usize;
// Step 9 clamps into the string.
// An empty search matches at the clamped position itself,
// which may sit inside a surrogate pair.
if search.is_empty() {
return self.len_utf16().min(start) as isize;
}
// Step 10: first occurrence at or after `start`.
if let Some(search) = search.as_str() {
return crate::string_search::index_of_utf8(*self, search, start)
.map_or(-1, |index| index as isize);
}
// A surrogate needle can match the same half of a formed pair.
crate::string_search::index_of_code_units(*self, search, start)
.map_or(-1, |index| index as isize)
}
}
#[cfg(test)]
mod test {
use oxc_str::JSStr;
#[test]
fn test_string_index_of() {
use super::StringIndexOf;
assert_eq!("test test test".index_of(Some("t"), Some(0.0)), 0);
assert_eq!("test test test".index_of(Some("t"), Some(1.0)), 3);
assert_eq!("test test test".index_of(Some("t"), Some(4.0)), 5);
assert_eq!("test test test".index_of(Some("t"), Some(4.1)), 5);
assert_eq!("test test test".index_of(Some("t"), Some(0.0)), 0);
assert_eq!("test test test".index_of(Some("t"), Some(-1.0)), 0);
assert_eq!("test test test".index_of(Some("t"), Some(-1.0)), 0);
assert_eq!("test test test".index_of(Some("t"), Some(-1.1)), 0);
assert_eq!("test test test".index_of(Some("t"), Some(-1_073_741_825.0)), 0);
assert_eq!("test test test".index_of(Some("e"), Some(0.0)), 1);
assert_eq!("test test test".index_of(Some("s"), Some(0.0)), 2);
assert_eq!("test test test".index_of(Some("test"), Some(4.0)), 5);
assert_eq!("test test test".index_of(Some("test"), Some(5.0)), 5);
assert_eq!("test test test".index_of(Some("test"), Some(6.0)), 10);
assert_eq!("test test test".index_of(Some("test"), Some(0.0)), 0);
assert_eq!("test test test".index_of(Some("test"), Some(-1.0)), 0);
assert_eq!("test test test".index_of(Some("not found"), Some(-1.0)), -1);
assert_eq!("test test test".index_of(Some("test"), Some(-1.0)), 0);
assert_eq!("test test test".index_of(Some("test"), Some(-1_073_741_825.0)), 0);
assert_eq!("test test test".index_of(Some("test"), Some(0.0)), 0);
assert_eq!("test test test".index_of(Some("notpresent"), Some(0.0)), -1);
assert_eq!("test test test".index_of(Some(JSStr::from("t")), Some(0.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("t")), Some(1.0)), 3);
assert_eq!("test test test".index_of(Some(JSStr::from("t")), Some(4.0)), 5);
assert_eq!("test test test".index_of(Some(JSStr::from("t")), Some(4.1)), 5);
assert_eq!("test test test".index_of(Some(JSStr::from("t")), Some(0.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("t")), Some(-1.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("t")), Some(-1.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("t")), Some(-1.1)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("t")), Some(-1_073_741_825.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("e")), Some(0.0)), 1);
assert_eq!("test test test".index_of(Some(JSStr::from("s")), Some(0.0)), 2);
assert_eq!("test test test".index_of(Some(JSStr::from("test")), Some(4.0)), 5);
assert_eq!("test test test".index_of(Some(JSStr::from("test")), Some(5.0)), 5);
assert_eq!("test test test".index_of(Some(JSStr::from("test")), Some(6.0)), 10);
assert_eq!("test test test".index_of(Some(JSStr::from("test")), Some(0.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("test")), Some(-1.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("not found")), Some(-1.0)), -1);
assert_eq!("test test test".index_of(Some(JSStr::from("test")), Some(-1.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("test")), Some(-1_073_741_825.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("test")), Some(0.0)), 0);
assert_eq!("test test test".index_of(Some(JSStr::from("notpresent")), Some(0.0)), -1);
assert_eq!("undefined".index_of(None, Some(0.0)), 0);
assert_eq!("test test test".index_of(None, Some(0.0)), -1);
// Positions and results count UTF-16 code units, not UTF-8 bytes or code points.
assert_eq!("éx".index_of(Some(JSStr::from("x")), None), 1);
assert_eq!("😀x".index_of(Some(JSStr::from("x")), Some(1.0)), 2);
assert_eq!("你好b".index_of(Some(JSStr::from("b")), None), 2);
// A position beyond `i32` does not wrap, and an empty search clamps into the string.
assert_eq!("abc".index_of(Some(JSStr::from("a")), Some(4_294_967_296.0)), -1);
assert_eq!("abc".index_of(Some(JSStr::from("")), Some(5.0)), 3);
}
#[test]
fn test_string_index_of_with_lone_surrogates() {
use oxc_allocator::Allocator;
use oxc_str::JSStrBuilder;
use super::StringIndexOf;
let allocator = Allocator::new();
let js_str = |units: &[u16]| {
let mut builder = JSStrBuilder::new_in(&&allocator);
builder.push_utf16(units);
builder.into_js_str()
};
// "a\u{D800}b": a lone surrogate is one code unit.
let value = js_str(&[0x61, 0xD800, 0x62]);
assert_eq!(value.index_of(Some(JSStr::from("a")), None), 0);
assert_eq!(value.index_of(Some(JSStr::from("b")), None), 2);
assert_eq!(value.index_of(Some(JSStr::from("b")), Some(2.0)), 2);
assert_eq!(value.index_of(Some(JSStr::from("b")), Some(3.0)), -1);
assert_eq!(value.index_of(Some(JSStr::from("c")), None), -1);
// An empty search matches at the clamped position.
assert_eq!(value.index_of(Some(JSStr::from("")), None), 0);
assert_eq!(value.index_of(Some(JSStr::from("")), Some(2.0)), 2);
assert_eq!(value.index_of(Some(JSStr::from("")), Some(5.0)), 3);
// An absent search value means the string "undefined".
assert_eq!(value.index_of(None, None), -1);
let value = js_str(&[0x75, 0x6E, 0x64, 0x65, 0x66, 0x69, 0x6E, 0x65, 0x64, 0xD800]);
assert_eq!(value.index_of(None, None), 0);
// "x\u{D83D}y\u{DE00}z": separated lone halves never match the surrogate pair of an
// astral search value.
let value = js_str(&[0x78, 0xD83D, 0x79, 0xDE00, 0x7A]);
assert_eq!(value.index_of(Some(JSStr::from("\u{1F600}")), None), -1);
// "a\u{1F600}\u{D800}b": an astral character is two code units.
let value = js_str(&[0x61, 0xD83D, 0xDE00, 0xD800, 0x62]);
assert_eq!(value.index_of(Some(JSStr::from("\u{1F600}")), None), 1);
assert_eq!(value.index_of(Some(JSStr::from("b")), None), 4);
// A position inside the pair rounds up past it.
assert_eq!(value.index_of(Some(JSStr::from("b")), Some(2.0)), 4);
assert_eq!(value.index_of(Some(JSStr::from("\u{1F600}")), Some(2.0)), -1);
// "a\u{D800}a": a start beyond `i32` does not wrap, and NaN means 0.
let value = js_str(&[0x61, 0xD800, 0x61]);
assert_eq!(value.index_of(Some(JSStr::from("a")), Some(3e9)), -1);
assert_eq!(value.index_of(Some(JSStr::from("a")), Some(f64::NAN)), 0);
// Adjacent halves form a supplementary character and take the UTF-8 path.
let value = js_str(&[0xD83D, 0xDE00]);
assert_eq!(value.as_str(), Some("\u{1F600}"));
assert_eq!(value.index_of(Some(JSStr::from("\u{1F600}")), None), 0);
// Byte offsets differ from code unit indices, and matches may overlap.
let value =
js_str(&[0xE9, 0xD800, 0x61, 0x62, 0x61, 0x62, 0x61, 0xD83D, 0xDE00, 0x61, 0x62]);
assert_eq!(value.index_of(Some(JSStr::from("aba")), Some(3.0)), 4);
assert_eq!(value.index_of(Some(JSStr::from("b")), Some(8.0)), 10);
assert_eq!(value.index_of(Some(JSStr::from("\u{1F600}")), Some(8.0)), -1);
// A lone half in the search value matches the same half of a formed pair,
// whether the receiver is UTF-8 or holds lone surrogates.
let lead = js_str(&[0xD83D]);
let trail = js_str(&[0xDE00]);
assert_eq!("a\u{1F600}b".index_of(Some(lead), None), 1);
assert_eq!("a\u{1F600}b".index_of(Some(trail), None), 2);
assert_eq!("a\u{1F600}b".index_of(Some(lead), Some(2.0)), -1);
let value = js_str(&[0x61, 0xD800, 0xD83D, 0xDE00]);
assert_eq!(value.index_of(Some(lead), None), 2);
assert_eq!(value.index_of(Some(js_str(&[0xD800])), None), 1);
// A search value of two lone halves matches the formed pair.
assert_eq!(value.index_of(Some(js_str(&[0xD83D, 0xDE00])), None), 2);
}
}
+114 -21
View File
@@ -1,41 +1,134 @@
use crate::ToInt32;
use oxc_str::JSStr;
pub trait StringLastIndexOf {
/// `String.prototype.lastIndexOf ( searchString [ , position ] )`
/// <https://tc39.es/ecma262/#sec-string.prototype.lastindexof>
fn last_index_of(&self, search_value: Option<&str>, from_index: Option<f64>) -> isize;
fn last_index_of(&self, search_value: Option<JSStr<'_>>, from_index: Option<f64>) -> isize;
}
impl StringLastIndexOf for &str {
#[expect(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
fn last_index_of(&self, search_value: Option<&str>, from_index: Option<f64>) -> isize {
let search_value = search_value.unwrap_or("undefined");
let from_index =
from_index.map_or(usize::MAX, |x| x.to_int_32().max(0) as usize + search_value.len());
self.chars()
.take(from_index)
.collect::<String>()
.rfind(search_value)
fn last_index_of(&self, search_value: Option<JSStr<'_>>, from_index: Option<f64>) -> isize {
JSStr::from(*self).last_index_of(search_value, from_index)
}
}
impl StringLastIndexOf for JSStr<'_> {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)]
fn last_index_of(&self, search_value: Option<JSStr<'_>>, from_index: Option<f64>) -> isize {
let search = search_value.unwrap_or(JSStr::from("undefined"));
// Steps 6-7: `ToIntegerOrInfinity(position)`, except that an absent position and NaN mean
// positive infinity for this method.
let position = from_index
.map_or(f64::INFINITY, |p| if p.is_nan() { f64::INFINITY } else { p.trunc() })
.max(0.0);
// An empty search matches at the clamped position itself,
// which may sit inside a surrogate pair.
if search.is_empty() {
return position.min(self.len_utf16() as f64) as isize;
}
// Steps 9-10: last occurrence starting at or before the position.
if let Some(search) = search.as_str() {
return crate::string_search::last_index_of_utf8(*self, search, position as usize)
.map_or(-1, |index| index as isize);
}
// A surrogate needle can match the same half of a formed pair.
crate::string_search::last_index_of_code_units(*self, search, position as usize)
.map_or(-1, |index| index as isize)
}
}
#[cfg(test)]
mod test {
use oxc_str::JSStr;
#[test]
fn test_prototype_last_index_of() {
use super::StringLastIndexOf;
assert_eq!("test test test".last_index_of(Some("test"), Some(15.0)), 10);
assert_eq!("test test test".last_index_of(Some("test"), Some(14.0)), 10);
assert_eq!("test test test".last_index_of(Some("test"), Some(10.0)), 10);
assert_eq!("test test test".last_index_of(Some("test"), Some(9.0)), 5);
assert_eq!("test test test".last_index_of(Some("test"), Some(6.0)), 5);
assert_eq!("test test test".last_index_of(Some("test"), Some(5.0)), 5);
assert_eq!("test test test".last_index_of(Some("test"), Some(4.0)), 0);
assert_eq!("test test test".last_index_of(Some("test"), Some(0.0)), 0);
assert_eq!("test test test".last_index_of(Some("notpresent"), Some(0.0)), -1);
assert_eq!("test test test".last_index_of(Some(JSStr::from("test")), Some(15.0)), 10);
assert_eq!("test test test".last_index_of(Some(JSStr::from("test")), Some(14.0)), 10);
assert_eq!("test test test".last_index_of(Some(JSStr::from("test")), Some(10.0)), 10);
assert_eq!("test test test".last_index_of(Some(JSStr::from("test")), Some(9.0)), 5);
assert_eq!("test test test".last_index_of(Some(JSStr::from("test")), Some(6.0)), 5);
assert_eq!("test test test".last_index_of(Some(JSStr::from("test")), Some(5.0)), 5);
assert_eq!("test test test".last_index_of(Some(JSStr::from("test")), Some(4.0)), 0);
assert_eq!("test test test".last_index_of(Some(JSStr::from("test")), Some(0.0)), 0);
assert_eq!("test test test".last_index_of(Some(JSStr::from("notpresent")), Some(0.0)), -1);
assert_eq!("undefined".last_index_of(None, None), 0);
assert_eq!("test test test".last_index_of(None, None), -1);
assert_eq!("abcdef".last_index_of(Some("b"), None), 1);
assert_eq!("abcdef".last_index_of(Some(JSStr::from("b")), None), 1);
// Positions and results count UTF-16 code units, not UTF-8 bytes or code points.
assert_eq!("éb".last_index_of(Some(JSStr::from("b")), None), 1);
assert_eq!("aé".last_index_of(Some(JSStr::from("é")), Some(0.0)), -1);
assert_eq!("ééé".last_index_of(Some(JSStr::from("é")), Some(0.0)), 0);
// A NaN position means positive infinity, and a large one does not wrap.
assert_eq!("abab".last_index_of(Some(JSStr::from("b")), Some(f64::NAN)), 3);
assert_eq!("abc".last_index_of(Some(JSStr::from("c")), Some(4_294_967_296.0)), 2);
}
#[test]
fn test_string_last_index_of_with_lone_surrogates() {
use oxc_allocator::Allocator;
use oxc_str::JSStrBuilder;
use super::StringLastIndexOf;
let allocator = Allocator::new();
let js_str = |units: &[u16]| {
let mut builder = JSStrBuilder::new_in(&&allocator);
builder.push_utf16(units);
builder.into_js_str()
};
// "a\u{D800}b\u{D800}b": a lone surrogate is one code unit,
// and the position bounds the start of the match.
let value = js_str(&[0x61, 0xD800, 0x62, 0xD800, 0x62]);
assert_eq!(value.last_index_of(Some(JSStr::from("b")), None), 4);
assert_eq!(value.last_index_of(Some(JSStr::from("b")), Some(3.0)), 2);
assert_eq!(value.last_index_of(Some(JSStr::from("b")), Some(1.0)), -1);
// NaN means searching from the end.
assert_eq!(value.last_index_of(Some(JSStr::from("b")), Some(f64::NAN)), 4);
assert_eq!(value.last_index_of(Some(JSStr::from("c")), None), -1);
// "a\u{D800}b": an empty search matches at the clamped position.
let value = js_str(&[0x61, 0xD800, 0x62]);
assert_eq!(value.last_index_of(Some(JSStr::from("")), None), 3);
assert_eq!(value.last_index_of(Some(JSStr::from("")), Some(1.0)), 1);
// An absent search value means the string "undefined".
assert_eq!(value.last_index_of(None, None), -1);
let value = js_str(&[0x75, 0x6E, 0x64, 0x65, 0x66, 0x69, 0x6E, 0x65, 0x64, 0xD800]);
assert_eq!(value.last_index_of(None, None), 0);
// "a\u{1F600}\u{D800}b": an astral character is two code units.
let value = js_str(&[0x61, 0xD83D, 0xDE00, 0xD800, 0x62]);
assert_eq!(value.last_index_of(Some(JSStr::from("\u{1F600}")), None), 1);
assert_eq!(value.last_index_of(Some(JSStr::from("b")), None), 4);
// A position inside the pair still finds a match starting at the pair.
assert_eq!(value.last_index_of(Some(JSStr::from("\u{1F600}")), Some(2.0)), 1);
assert_eq!(value.last_index_of(Some(JSStr::from("b")), Some(2.0)), -1);
assert_eq!(value.last_index_of(Some(JSStr::from("\u{1F600}")), Some(0.0)), -1);
// The bound applies to the start even when a match extends beyond it.
let value =
js_str(&[0xE9, 0xD800, 0x61, 0x62, 0x61, 0x62, 0x61, 0xD83D, 0xDE00, 0x61, 0x62]);
assert_eq!(value.last_index_of(Some(JSStr::from("aba")), Some(3.0)), 2);
assert_eq!(value.last_index_of(Some(JSStr::from("aba")), Some(5.0)), 4);
assert_eq!(value.last_index_of(Some(JSStr::from("\u{1F600}")), Some(8.0)), 7);
assert_eq!(value.last_index_of(Some(JSStr::from("ab")), Some(8.0)), 4);
// A lone half in the search value matches the same half of a formed pair,
// whether the receiver is UTF-8 or holds lone surrogates.
let lead = js_str(&[0xD83D]);
let trail = js_str(&[0xDE00]);
assert_eq!("a\u{1F600}b".last_index_of(Some(lead), None), 1);
assert_eq!("a\u{1F600}b".last_index_of(Some(trail), None), 2);
assert_eq!("a\u{1F600}b".last_index_of(Some(lead), Some(0.0)), -1);
let value = js_str(&[0x61, 0xD800, 0xD83D, 0xDE00]);
assert_eq!(value.last_index_of(Some(lead), None), 2);
assert_eq!(value.last_index_of(Some(js_str(&[0xD800])), None), 1);
}
}
+135
View File
@@ -0,0 +1,135 @@
use memchr::memmem;
use oxc_str::JSStr;
// A UTF-8 needle cannot match part of a WTF-8 code point or a lone surrogate.
// Search bytes once, then translate the result to a UTF-16 code unit index.
pub fn index_of_utf8(value: JSStr<'_>, search: &str, start: usize) -> Option<usize> {
debug_assert_ne!(search, "");
// If `start` lies inside a surrogate pair, the next code point is the first
// possible match for a UTF-8 needle.
let (byte_start, _) = code_point_indices(value).find(|&(_, index)| index >= start)?;
let found = byte_start + memmem::find(&value.as_bytes()[byte_start..], search.as_bytes())?;
utf16_index(value, found)
}
pub fn last_index_of_utf8(value: JSStr<'_>, search: &str, bound: usize) -> Option<usize> {
debug_assert_ne!(search, "");
let (byte_bound, _) =
code_point_indices(value).take_while(|&(_, index)| index <= bound).last()?;
// The bound limits the match's start; the needle may extend beyond it.
let end = byte_bound.saturating_add(search.len()).min(value.len());
let found = memmem::rfind(&value.as_bytes()[..end], search.as_bytes())?;
utf16_index(value, found)
}
pub fn index_of_code_units(value: JSStr<'_>, search: JSStr<'_>, start: usize) -> Option<usize> {
code_unit_matches(value.encode_utf16().skip(start), search).next().map(|index| start + index)
}
pub fn last_index_of_code_units(
value: JSStr<'_>,
search: JSStr<'_>,
bound: usize,
) -> Option<usize> {
// The bound limits the match's start; the needle may extend beyond it.
let end = bound.saturating_add(search.len_utf16());
code_unit_matches(value.encode_utf16().take(end), search).last()
}
// KMP searches UTF-16 in linear time, including matches that split surrogate pairs.
// Only the needle is buffered; the receiver is decoded once as matches are requested.
fn code_unit_matches(
value: impl Iterator<Item = u16>,
search: JSStr<'_>,
) -> impl Iterator<Item = usize> {
let needle: Vec<_> = search.encode_utf16().collect();
debug_assert_ne!(needle.len(), 0);
let mut prefixes = vec![0; needle.len()];
let mut matched = 0;
for index in 1..needle.len() {
while matched > 0 && needle[index] != needle[matched] {
matched = prefixes[matched - 1];
}
if needle[index] == needle[matched] {
matched += 1;
}
prefixes[index] = matched;
}
let mut matched = 0;
value.enumerate().filter_map(move |(index, unit)| {
while matched > 0 && unit != needle[matched] {
matched = prefixes[matched - 1];
}
if unit == needle[matched] {
matched += 1;
}
if matched == needle.len() {
matched = prefixes[matched - 1];
Some(index + 1 - needle.len())
} else {
None
}
})
}
fn utf16_index(value: JSStr<'_>, byte_index: usize) -> Option<usize> {
code_point_indices(value).find_map(|(byte, index)| (byte == byte_index).then_some(index))
}
fn code_point_indices(value: JSStr<'_>) -> impl Iterator<Item = (usize, usize)> + '_ {
value.chars().scan((0, 0), |offsets, c| {
let result = *offsets;
let width = c.len_bytes();
offsets.0 += width;
offsets.1 += 1 + usize::from(width == 4);
Some(result)
})
}
#[cfg(test)]
mod tests {
use oxc_allocator::Allocator;
use oxc_str::JSStrBuilder;
use crate::{StringIndexOf, StringLastIndexOf};
#[test]
fn surrogate_needles_preserve_overlapping_matches_and_bounds() {
let allocator = Allocator::new();
let js_str = |units: &[u16]| {
let mut builder = JSStrBuilder::new_in(&&allocator);
builder.push_utf16(units);
builder.into_js_str()
};
let value = js_str(&[0xD800, 0x61, 0xD800, 0x61, 0xD800]);
let search = js_str(&[0xD800, 0x61, 0xD800]);
assert_eq!(value.index_of(Some(search), None), 0);
assert_eq!(value.index_of(Some(search), Some(1.0)), 2);
assert_eq!(value.index_of(Some(search), Some(3.0)), -1);
assert_eq!(value.last_index_of(Some(search), None), 2);
assert_eq!(value.last_index_of(Some(search), Some(1.0)), 0);
assert_eq!(value.last_index_of(Some(search), Some(0.0)), 0);
// Both ends of a match may split formed surrogate pairs.
let value = js_str(&[0xD83D, 0xDE00, 0x61, 0xD83D, 0xDE00]);
let search = js_str(&[0xDE00, 0x61, 0xD83D]);
assert_eq!(value.index_of(Some(search), Some(1.0)), 1);
assert_eq!(value.index_of(Some(search), Some(2.0)), -1);
assert_eq!(value.last_index_of(Some(search), Some(1.0)), 1);
assert_eq!(value.last_index_of(Some(search), Some(0.0)), -1);
}
#[test]
fn surrogate_needles_with_long_repeated_prefixes() {
let allocator = Allocator::new();
let prefix = "a".repeat(16_000);
let value = "a".repeat(32_000);
let mut builder = JSStrBuilder::new_in(&&allocator);
builder.push_str(&prefix);
builder.push_code_unit(0xD800);
let search = builder.into_js_str();
assert_eq!(value.as_str().index_of(Some(search), None), -1);
assert_eq!(value.as_str().last_index_of(Some(search), None), -1);
}
}
@@ -1,37 +0,0 @@
use crate::ToInt32;
pub trait StringSubstring {
/// `String.prototype.substring ( start , end ] )`
/// <https://tc39.es/ecma262/#sec-string.prototype.substring>
fn substring(&self, start: Option<f64>, end: Option<f64>) -> String;
}
impl StringSubstring for &str {
#[expect(clippy::cast_sign_loss)]
fn substring(&self, start: Option<f64>, end: Option<f64>) -> String {
let start = start.map_or(0, |x| x.to_int_32().max(0) as usize);
let end = end.map_or(usize::MAX, |x| x.to_int_32().max(0) as usize);
let start = start.min(self.len());
let end = end.min(self.len());
if start > end {
return String::new();
}
self.chars().skip(start).take(end - start).collect()
}
}
#[cfg(test)]
mod test {
#[test]
fn test_prototype_last_index_of() {
use super::StringSubstring;
assert_eq!("foo".substring(Some(1.0), None), "oo");
assert_eq!("foo".substring(Some(1.0), Some(2.0)), "o");
assert_eq!("foo".substring(Some(1.0), Some(1.0)), "");
assert_eq!("foo".substring(Some(1.0), Some(0.0)), "");
assert_eq!("foo".substring(Some(0.0), Some(0.0)), "");
assert_eq!("foo".substring(Some(0.0), Some(1.0)), "f");
assert_eq!("abc".substring(Some(0.0), Some(2.0)), "ab");
assert_eq!("abcde".substring(Some(0.0), Some(2.0)), "ab");
}
}
@@ -1,5 +1,7 @@
use num_bigint::BigInt;
use num_traits::{Num, Zero};
use oxc_str::JSStr;
use oxc_syntax::{identifier::is_white_space, line_terminator::is_line_terminator};
/// `StringToBigInt`
@@ -9,6 +11,14 @@ pub trait StringToBigInt<'a> {
fn string_to_big_int(&self) -> Option<BigInt>;
}
impl StringToBigInt<'_> for JSStr<'_> {
fn string_to_big_int(&self) -> Option<BigInt> {
// A string containing a lone surrogate is not a StringIntegerLiteral,
// so it converts to undefined like any other non-numeric text.
self.as_str().and_then(|value| value.string_to_big_int())
}
}
impl StringToBigInt<'_> for &str {
fn string_to_big_int(&self) -> Option<BigInt> {
// `StringIntegerLiteral` allows `StrWhiteSpace` (`WhiteSpace` and `LineTerminator`)
@@ -53,6 +63,8 @@ impl StringToBigInt<'_> for &str {
#[cfg(test)]
mod test {
use num_bigint::BigInt;
use oxc_allocator::Allocator;
use oxc_str::{JSStr, JSStrBuilder};
use super::StringToBigInt;
@@ -80,4 +92,14 @@ mod test {
assert_eq!("1.0".string_to_big_int(), None);
assert_eq!("1 2".string_to_big_int(), None);
}
#[test]
fn js_str_converts_like_str_unless_it_has_a_lone_surrogate() {
let allocator = Allocator::new();
assert_eq!(JSStr::from(" 12 ").string_to_big_int(), Some(BigInt::from(12)));
let mut builder = JSStrBuilder::new_in(&&allocator);
builder.push_str("1");
builder.push_code_unit(0xD800);
assert_eq!(builder.into_js_str().string_to_big_int(), None);
}
}
@@ -1,9 +1,18 @@
use oxc_str::JSStr;
use oxc_syntax::{identifier::is_white_space, line_terminator::is_line_terminator};
pub trait StringToNumber {
fn string_to_number(&self) -> f64;
}
impl StringToNumber for JSStr<'_> {
fn string_to_number(&self) -> f64 {
// A string containing a lone surrogate can never be a StringNumericLiteral,
// so it converts to NaN.
self.as_str().map_or(f64::NAN, |value| value.string_to_number())
}
}
/// `StringToNumber`
///
/// <https://tc39.es/ecma262/#sec-stringtonumber>
@@ -96,4 +105,14 @@ mod tests {
fn does_not_trim_non_ecmascript_whitespace() {
assert!("1\u{0085}".string_to_number().is_nan());
}
#[test]
fn js_str_converts_like_str_unless_it_has_a_lone_surrogate() {
let allocator = oxc_allocator::Allocator::new();
assert_eq!(oxc_str::JSStr::from(" 12 ").string_to_number(), 12.0);
let mut builder = oxc_str::JSStrBuilder::new_in(&&allocator);
builder.push_str("1");
builder.push_code_unit(0xD800);
assert!(builder.into_js_str().string_to_number().is_nan());
}
}
+3 -1
View File
@@ -47,7 +47,9 @@ impl<'a> ToBigInt<'a> for Expression<'a> {
_ => None,
},
Expression::StringLiteral(string_literal) => {
string_literal.value.as_str()?.string_to_big_int()
// A lone surrogate converts to undefined, and `ToBigInt` throws a SyntaxError for
// it, so the value stays unevaluated.
string_literal.value.string_to_big_int()
}
Expression::TemplateLiteral(_) => {
self.to_js_string(ctx).and_then(|value| value.as_ref().string_to_big_int())
+1 -3
View File
@@ -29,9 +29,7 @@ impl<'a> ToNumber<'a> for Expression<'a> {
"NaN" | "undefined" if ctx.is_global_reference(ident) => Some(f64::NAN),
_ => None,
},
Expression::StringLiteral(lit) => {
Some(lit.value.as_str().map_or(f64::NAN, |value| value.string_to_number()))
}
Expression::StringLiteral(lit) => Some(lit.value.string_to_number()),
Expression::UnaryExpression(unary) if unary.operator.is_not() => {
let boolean = unary.argument.to_boolean(ctx)?;
Some(if boolean { 0.0 } else { 1.0 })
+10 -5
View File
@@ -3,13 +3,15 @@ use std::borrow::Cow;
use num_bigint::BigInt;
use num_traits::Zero;
use oxc_str::JSStr;
use crate::{GlobalContext, ToBoolean, ToJsString, ToNumber, ValueType};
#[derive(Debug, PartialEq, Clone)]
pub enum ConstantValue<'a> {
Number(f64),
BigInt(BigInt),
String(Cow<'a, str>),
String(JSStr<'a>),
Boolean(bool),
Undefined,
Null,
@@ -51,7 +53,7 @@ impl<'a> ConstantValue<'a> {
}
}
pub fn into_string(self) -> Option<Cow<'a, str>> {
pub fn into_string(self) -> Option<JSStr<'a>> {
match self {
Self::String(s) => Some(s),
_ => None,
@@ -89,7 +91,10 @@ impl<'a> ToJsString<'a> for ConstantValue<'a> {
}
// https://tc39.es/ecma262/#sec-numeric-types-bigint-tostring
Self::BigInt(n) => Some(Cow::Owned(n.to_string())),
Self::String(s) => Some(s.clone()),
// This analysis returns UTF-8.
// A value containing a lone surrogate is handled by the fold paths that consume the
// `JSStr` payload directly.
Self::String(s) => s.as_str().map(Cow::Borrowed),
Self::Boolean(b) => Some(Cow::Borrowed(if *b { "true" } else { "false" })),
Self::Undefined => Some(Cow::Borrowed("undefined")),
Self::Null => Some(Cow::Borrowed("null")),
@@ -103,7 +108,7 @@ impl<'a> ToNumber<'a> for ConstantValue<'a> {
match self {
Self::Number(n) => Some(*n),
Self::BigInt(_) => None,
Self::String(s) => Some(s.as_ref().string_to_number()),
Self::String(s) => Some(s.string_to_number()),
Self::Boolean(true) => Some(1.0),
Self::Boolean(false) | Self::Null => Some(0.0),
Self::Undefined => Some(f64::NAN),
@@ -116,7 +121,7 @@ impl<'a> ToBoolean<'a> for ConstantValue<'a> {
match self {
Self::Number(n) => Some(!n.is_nan() && *n != 0.0),
Self::BigInt(n) => Some(*n != BigInt::zero()),
Self::String(s) => Some(!s.as_ref().is_empty()),
Self::String(s) => Some(!s.is_empty()),
Self::Boolean(b) => Some(*b),
Self::Null | Self::Undefined => Some(false),
}
@@ -2,7 +2,9 @@ use oxc_allocator::{ArenaVec, TakeIn};
use oxc_ast::ast::*;
use oxc_ecmascript::{
GlobalContext, ToJsString,
constant_evaluation::{ConstantEvaluation, ConstantValue, DetermineValueType, ValueType},
constant_evaluation::{
ConstantEvaluation, ConstantValue, DetermineValueType, ValueType, evaluate_to_js_string,
},
side_effects::MayHaveSideEffects,
with_number_literal,
};
@@ -594,7 +596,7 @@ impl<'a> PeepholeOptimizations {
.span()
.merge_within(e.right.span(), e.span)
.unwrap_or(SPAN);
let value = Str::from_strs_array_in([&left_str, &right_str], ctx);
let value = JSStr::from_js_strs_array_in([left_str, right_str], ctx);
let right = Expression::new_string_literal(span, value, None, ctx);
let left = left_binary_expr.left.take_in(ctx);
return Some(Expression::new_binary_expression(
@@ -631,7 +633,7 @@ impl<'a> PeepholeOptimizations {
.quasis
.first_mut()
.expect("template literal must have at least one quasi");
left_last_quasi.value.raw = Str::from_strs_array_in(
left_last_quasi.value.raw = Self::concat_template_literal_raw(
[left_last_quasi.value.raw.as_str(), right_first_quasi.value.raw.as_str()],
ctx,
);
@@ -656,16 +658,17 @@ impl<'a> PeepholeOptimizations {
left.span = left.span.merge_within(right_expr.span(), parent_span).unwrap_or(SPAN);
let last_quasi =
left.quasis.last_mut().expect("template literal must have at least one quasi");
last_quasi.value.raw = Str::from_strs_array_in(
last_quasi.value.raw = Self::concat_template_literal_raw(
[
last_quasi.value.raw.as_str(),
Self::escape_string_for_template_literal(&right_str).as_ref(),
Self::escape_string_for_template_literal(right_str).as_ref(),
],
ctx,
);
let new_cooked = last_quasi.value.cooked.map(|cooked| {
JSStr::from_js_strs_array_in([cooked, JSStr::from(right_str.as_ref())], ctx)
});
let new_cooked = last_quasi
.value
.cooked
.map(|cooked| JSStr::from_js_strs_array_in([cooked, right_str], ctx));
last_quasi.value.cooked = new_cooked;
return Some(left_expr.take_in(ctx));
}
@@ -677,16 +680,17 @@ impl<'a> PeepholeOptimizations {
.quasis
.first_mut()
.expect("template literal must have at least one quasi");
first_quasi.value.raw = Str::from_strs_array_in(
first_quasi.value.raw = Self::concat_template_literal_raw(
[
Self::escape_string_for_template_literal(&left_str).as_ref(),
Self::escape_string_for_template_literal(left_str).as_ref(),
first_quasi.value.raw.as_str(),
],
ctx,
);
let new_cooked = first_quasi.value.cooked.map(|cooked| {
JSStr::from_js_strs_array_in([JSStr::from(left_str.as_ref()), cooked], ctx)
});
let new_cooked = first_quasi
.value
.cooked
.map(|cooked| JSStr::from_js_strs_array_in([left_str, cooked], ctx));
first_quasi.value.cooked = new_cooked;
return Some(right_expr.take_in(ctx));
}
@@ -989,17 +993,19 @@ impl<'a> PeepholeOptimizations {
///
/// - `foo${1}bar${i}` => `foo1bar${i}`
pub fn inline_template_literal(t: &mut TemplateLiteral<'a>, ctx: &mut TraverseCtx<'a>) {
// Single pass: compute the side-effect / `to_js_string` checks once (previously they ran in an
// `.any()` pre-scan and then again in the drain loop) and collect the inline-able expressions
// as `(index, value)`. Bail out cheaply when there is nothing to inline, leaving
// Single pass: compute the side-effect / `evaluate_to_js_string` checks once (previously they
// ran in an `.any()` pre-scan and then again in the drain loop) and collect the inline-able
// expressions as `(index, value)`. Bail out cheaply when there is nothing to inline, leaving
// `t.expressions` untouched (no drain, no allocation — `inline_exprs` only allocates on push).
let mut inline_exprs = Vec::new();
for (idx, expr) in t.expressions.iter().enumerate() {
if !expr.may_have_side_effects(ctx)
&& let Some(str) = expr.to_js_string(ctx)
{
inline_exprs.push((idx, str));
if expr.may_have_side_effects(ctx) {
continue;
}
let Some(value) = evaluate_to_js_string(expr, ctx) else {
continue;
};
inline_exprs.push((idx, value));
}
if inline_exprs.is_empty() {
return;
@@ -1021,33 +1027,19 @@ impl<'a> PeepholeOptimizations {
// inline the extracted inline-able expressions into quasis
// "current_quasis + extracted_value + next_quasis"
for (i, (idx, str)) in inline_exprs.into_iter().enumerate() {
for (i, (idx, value)) in inline_exprs.into_iter().enumerate() {
let idx = idx - i;
let next_quasi = (idx + 1 < t.quasis.len()).then(|| t.quasis.remove(idx + 1));
let quasi = &mut t.quasis[idx];
let escaped = Self::escape_string_for_template_literal(&str);
let escaped = Self::escape_string_for_template_literal(value);
let next_raw = next_quasi.as_ref().map(|q| q.value.raw.as_str()).unwrap_or_default();
let raw = quasi.value.raw.as_str();
let starts_with_digit = escaped
.as_bytes()
.first()
.or_else(|| next_raw.as_bytes().first())
.is_some_and(u8::is_ascii_digit);
let cooked_ends_with_null =
quasi.value.cooked.is_some_and(|cooked| cooked.as_bytes().last() == Some(&0));
quasi.value.raw = if starts_with_digit
&& cooked_ends_with_null
&& let Some(prefix) = raw.strip_suffix("\\0")
{
Str::from_strs_array_in([prefix, "\\x00", &escaped, next_raw], ctx)
} else {
Str::from_strs_array_in([raw, &escaped, next_raw], ctx)
};
quasi.value.raw = Self::concat_template_literal_raw([raw, &escaped, next_raw], ctx);
let new_cooked = if let (Some(cooked1), Some(cooked2)) =
(quasi.value.cooked, next_quasi.as_ref().map(|q| q.value.cooked))
{
Some(JSStr::from_js_strs_array_in(
[cooked1, JSStr::from(str.as_ref()), cooked2.unwrap_or(JSStr::empty())],
[cooked1, value, cooked2.unwrap_or(JSStr::empty())],
ctx,
))
} else {
+1 -1
View File
@@ -125,7 +125,7 @@ impl<'a> PeepholeOptimizations {
match cv {
ConstantValue::Boolean(b) => !b,
ConstantValue::Number(n) => n.is_nan() || *n == 0.0,
ConstantValue::String(s) => s.as_ref().is_empty(),
ConstantValue::String(s) => s.is_empty(),
ConstantValue::Null | ConstantValue::Undefined => true,
ConstantValue::BigInt(_) => false,
}
@@ -1,17 +1,17 @@
use std::borrow::Cow;
use std::{borrow::Cow, fmt::Write, mem};
use cow_utils::CowUtils;
use oxc_allocator::{ArenaBox, ArenaVec, GetAllocator, TakeIn};
use oxc_ast::ast::*;
use oxc_allocator::{ArenaBox, ArenaVec, GetAllocator, StringBuilder, TakeIn};
use oxc_ast::{ast::*, builder::AstBuilder};
use oxc_compat::ESFeature;
use oxc_ecmascript::{
ArrayJoin, ToBigInt, ToIntegerIndex, ToJsString,
constant_evaluation::{ConstantEvaluation, DetermineValueType},
constant_evaluation::{ConstantEvaluation, DetermineValueType, ValueType},
side_effects::{MayHaveSideEffects, is_regexp_syntax_supported},
};
use oxc_span::SPAN;
use oxc_str::JSStrBuilder;
use oxc_str::{JSStr, JSStrBuilder};
use crate::{TraverseCtx, generated::ancestor::Ancestor};
@@ -146,9 +146,7 @@ impl<'a> PeepholeOptimizations {
return;
}
// We don't need to check if the arguments has a side effect here.
//
// The only side effect Array::concat / String::concat can cause is throwing an error when the created array is too long.
// For arrays, creating the intermediate result can throw when it is too long.
// With the compressor assumption, that error can be moved.
//
// For example, if we have `[].concat(a).concat(b)`, the steps before the compression is:
@@ -188,6 +186,55 @@ impl<'a> PeepholeOptimizations {
return;
}
let Expression::StaticMemberExpression(member) = &*new_root_callee else { unreachable!() };
// Convert the chain directly when each call can interleave its own argument
// evaluations and coercions. Keeping the calls' coercion boundaries permits
// later calls to mutate earlier arguments, even when flattening is unsafe.
if let Expression::StringLiteral(base) = &member.object
&& ctx.supports_feature(ESFeature::ES2015TemplateLiterals)
&& collected_arguments
.iter()
.all(|args| Self::string_concat_can_interleave(args.iter(), ctx))
{
let argument_count = collected_arguments.iter().map(|args| args.len()).sum::<usize>();
let expression_count = collected_arguments
.iter()
.flat_map(|args| args.iter())
.filter(|arg| !matches!(arg, Argument::StringLiteral(_)))
.count();
let string_count = argument_count - expression_count;
// If flattening is unsafe, the alternative is the whole original chain.
let call_count = if Self::string_concat_can_interleave(
collected_arguments.iter().rev().flat_map(|args| args.iter()),
ctx,
) {
1
} else {
collected_arguments.len()
};
if ".concat()".len() * call_count + argument_count + "''".len() * string_count
>= "${}".len() * expression_count
{
let replacement = Self::fold_string_concat(
original_span,
base.value,
collected_arguments.into_iter().rev().flat_map(|args| args.take_in(ctx)),
expression_count,
ctx,
);
ctx.replace_expression(node, replacement);
return;
}
}
if matches!(member.object, Expression::StringLiteral(_))
&& !Self::string_concat_can_interleave(
collected_arguments.iter().rev().flat_map(|args| args.iter()),
ctx,
)
{
return;
}
let new_expr = Expression::new_call_expression(
original_span,
new_root_callee.take_in(ctx),
@@ -208,7 +255,7 @@ impl<'a> PeepholeOptimizations {
span: Span,
args: &mut Arguments<'a>,
callee: &mut Expression<'a>,
ctx: &mut TraverseCtx<'a>,
ctx: &TraverseCtx<'a>,
) -> Option<Expression<'a>> {
// let concat chaining reduction handle it first
if let Ancestor::StaticMemberExpressionObject(parent_member) = ctx.parent()
@@ -273,15 +320,11 @@ impl<'a> PeepholeOptimizations {
Expression::StringLiteral(base_str) => {
if !ctx.supports_feature(ESFeature::ES2015TemplateLiterals)
|| args.is_empty()
|| !args.iter().all(Argument::is_expression)
|| !Self::string_concat_can_interleave(args.iter(), ctx)
{
return None;
}
let base_value = base_str.value.as_str()?;
if args.iter().any(|arg| matches!(arg, Argument::StringLiteral(lit) if lit.value.has_lone_surrogate())) {
return None;
}
let expression_count =
args.iter().filter(|arg| !matches!(arg, Argument::StringLiteral(_))).count();
let string_count = args.len() - expression_count;
@@ -293,77 +336,70 @@ impl<'a> PeepholeOptimizations {
return None;
}
// Borrow the arena builder field directly.
// It's disjoint from `&mut ctx.state` below, so the two can coexist.
let ast = &ctx.ast;
// Reuse a scratch `String` held on `MinifierState` across calls
// to accumulate the cooked text of the in-progress quasi. On
// flush we copy the text into the arena and clear the scratch.
//
// INVARIANT: every flush must end with `scratch.clear()` so
// the next string-arg `push_str` starts a fresh quasi. Two
// expressions in a row rely on this — the second one's flush
// sees an empty `scratch`, producing the required empty
// separator quasi without a state-machine flag.
let scratch = &mut ctx.state.concat_scratch;
scratch.clear();
scratch.push_str(base_value);
let mut expressions = ArenaVec::with_capacity_in(expression_count, ast);
let mut quasis = ArenaVec::with_capacity_in(expression_count + 1, ast);
for argument in args.drain(..) {
if let Argument::StringLiteral(str_lit) = argument {
// Append onto the in-progress quasi.
// Checked before draining the arguments.
scratch.push_str(str_lit.value.as_str().unwrap());
} else {
// Flush the current quasi (possibly empty) before
// pushing the next expression.
let cooked = Str::from_str_in(scratch, ast);
let raw_cow = Self::escape_string_for_template_literal(scratch);
let raw = Str::from_str_in(&raw_cow, ast);
// `raw` is already escaped
quasis.push(TemplateElement::new(
SPAN,
TemplateElementValue { raw, cooked: Some(cooked.into()) },
false,
ast,
));
scratch.clear(); // maintains INVARIANT above
// checked that all the arguments are expression above
expressions.push(argument.into_expression());
}
}
if expressions.is_empty() {
debug_assert_eq!(quasis.len(), 0);
let s = Str::from_str_in(scratch, ast);
return Some(Expression::new_string_literal(span, s, None, ast));
}
// Flush the trailing quasi. If the last arg was an expression
// `scratch` is empty, giving the required trailing empty
// quasi; otherwise it holds the accumulated tail text.
let cooked = Str::from_str_in(scratch, ast);
let raw_cow = Self::escape_string_for_template_literal(scratch);
let raw = Str::from_str_in(&raw_cow, ast);
// `raw` is already escaped
quasis.push(TemplateElement::new(
SPAN,
TemplateElementValue { raw, cooked: Some(cooked.into()) },
true, /* tail */
ast,
));
debug_assert_eq!(quasis.len(), expressions.len() + 1);
Some(Expression::new_template_literal(span, quasis, expressions, ast))
Some(Self::fold_string_concat(
span,
base_str.value,
args.drain(..),
expression_count,
ctx,
))
}
_ => None,
}
}
/// Build a template after checking that each original call can interleave its arguments.
fn fold_string_concat(
span: Span,
base: JSStr<'a>,
arguments: impl Iterator<Item = Argument<'a>>,
expression_count: usize,
ctx: &TraverseCtx<'a>,
) -> Expression<'a> {
let ast = &ctx.ast;
let mut expressions = ArenaVec::with_capacity_in(expression_count, ast);
let mut quasis = ArenaVec::with_capacity_in(expression_count + 1, ast);
let mut cooked = JSStrBuilder::new_in(ctx);
cooked.push_js_str(base);
for argument in arguments {
if let Argument::StringLiteral(lit) = argument {
cooked.push_js_str(lit.value);
} else {
let quasi = mem::replace(&mut cooked, JSStrBuilder::new_in(ctx));
quasis.push(Self::template_element(quasi.into_js_str(), false, ast));
expressions.push(argument.into_expression());
}
}
let tail = cooked.into_js_str();
if expressions.is_empty() {
return Expression::new_string_literal(span, tail, None, ast);
}
quasis.push(Self::template_element(tail, true, ast));
Expression::new_template_literal(span, quasis, expressions, ast)
}
/// `concat` evaluates every argument before coercing any of them. Templates interleave
/// evaluation and coercion, and flattening a chain delays the inner calls' coercions.
/// An earlier object's coercion can observe mutations made by a later argument.
fn string_concat_can_interleave<'b>(
arguments: impl Iterator<Item = &'b Argument<'a>>,
ctx: &TraverseCtx<'a>,
) -> bool
where
'a: 'b,
{
let mut may_coerce_object = false;
for argument in arguments {
let Some(argument) = argument.as_expression() else { return false };
if may_coerce_object && argument.may_have_side_effects(ctx) {
return false;
}
may_coerce_object |=
matches!(argument.value_type(ctx), ValueType::Object | ValueType::Undetermined);
}
true
}
/// `['a','b'].join()` -> `'a,b'`
fn try_fold_array_join(
span: Span,
@@ -404,7 +440,85 @@ impl<'a> PeepholeOptimizations {
Some(Expression::new_string_literal(span, Str::from_str_in(&joined, ctx), None, ctx))
}
pub fn escape_string_for_template_literal(s: &str) -> Cow<'_, str> {
/// A template quasi holding `cooked`.
/// The raw text borrows the cooked value when nothing needs escaping.
fn template_element(
cooked: JSStr<'a>,
tail: bool,
ast: &AstBuilder<'a>,
) -> TemplateElement<'a> {
let raw = Str::from_cow_in(&Self::escape_string_for_template_literal(cooked), ast);
TemplateElement::new(SPAN, TemplateElementValue { raw, cooked: Some(cooked) }, tail, ast)
}
/// Join untagged template raw text without forming an interpolation or a decimal escape
/// across a boundary. Empty parts do not separate the neighboring source text.
pub(super) fn concat_template_literal_raw<const N: usize>(
mut parts: [&str; N],
ctx: &impl GetAllocator<'a>,
) -> Str<'a> {
let mut suffixes = [""; N];
for index in 0..N {
let Some(next) =
parts[index + 1..].iter().find_map(|part| part.as_bytes().first().copied())
else {
break;
};
let raw = parts[index];
if raw.ends_with('$') && next == b'{' {
let backslashes =
raw[..raw.len() - 1].bytes().rev().take_while(|&byte| byte == b'\\').count();
if backslashes % 2 == 0 {
// Escape the opening brace, leaving any preceding backslashes unchanged.
suffixes[index] = "\\";
}
} else if raw.ends_with("\\0") && next.is_ascii_digit() {
let prefix = &raw[..raw.len() - 1];
let backslashes = prefix.bytes().rev().take_while(|&byte| byte == b'\\').count();
if backslashes % 2 == 1 {
parts[index] = prefix;
suffixes[index] = "x00";
}
}
}
if suffixes.iter().all(|suffix| suffix.is_empty()) {
return Str::from_strs_array_in(parts, ctx);
}
let capacity = parts
.iter()
.zip(suffixes)
.try_fold(0usize, |length, (part, suffix)| {
length.checked_add(part.len())?.checked_add(suffix.len())
})
.expect("template literal raw text is too long");
let mut raw = StringBuilder::with_capacity_in(capacity, ctx.allocator());
for (part, suffix) in parts.into_iter().zip(suffixes) {
raw.push_str(part);
raw.push_str(suffix);
}
Str::from(raw.into_str())
}
/// Escape a string value as template literal raw text.
/// Raw text is source, so a lone surrogate is written as a `\u` escape.
pub fn escape_string_for_template_literal(value: JSStr<'_>) -> Cow<'_, str> {
let Some(s) = value.as_str() else {
let mut raw = String::with_capacity(value.len());
for c in value.chars() {
match c.to_char() {
Some(c @ ('\\' | '`' | '$')) => {
raw.push('\\');
raw.push(c);
}
Some('\r') => raw.push_str("\\r"),
Some(c) => raw.push(c),
None => {
let _ = write!(raw, "\\u{:04x}", c.to_u32());
}
}
}
return Cow::Owned(raw);
};
if s.contains(['\\', '`', '$', '\r']) {
Cow::Owned(
s.cow_replace("\\", "\\\\")
@@ -5,8 +5,10 @@ use oxc_allocator::{ArenaVec, CloneIn, GetAllocator, TakeIn};
use oxc_ast::ast::*;
use oxc_compat::ESFeature;
use oxc_ecmascript::{
BoundNames, ToJsString, ToNumber,
constant_evaluation::{ConstantEvaluation, ConstantValue, DetermineValueType},
BoundNames, ToNumber,
constant_evaluation::{
ConstantEvaluation, ConstantValue, DetermineValueType, evaluate_to_js_string,
},
side_effects::{MayHaveSideEffects, MayHaveSideEffectsContext, is_typed_array_constructor},
};
use oxc_semantic::ReferenceFlags;
@@ -1389,12 +1391,15 @@ impl<'a> PeepholeOptimizations {
}
pub fn substitute_template_literal(expr: &mut Expression<'a>, ctx: &mut TraverseCtx<'a>) {
let Expression::TemplateLiteral(t) = expr else { return };
let Some(val) = t.to_js_string(ctx).filter(|_| !t.may_have_side_effects(ctx)) else {
let Expression::TemplateLiteral(t) = &*expr else { return };
if t.may_have_side_effects(ctx) {
return;
};
let new_value =
Expression::new_string_literal(t.span(), Str::from_cow_in(&val, ctx), None, ctx);
}
let span = t.span();
// The cooked value is kept as a `JSStr`, so a template holding a lone surrogate
// becomes a string literal with the same code units.
let Some(value) = evaluate_to_js_string(expr, ctx) else { return };
let new_value = Expression::new_string_literal(span, value, None, ctx);
ctx.replace_expression(expr, new_value);
}
-5
View File
@@ -108,10 +108,6 @@ pub struct MinifierState<'a> {
/// Per-pass change accumulator populated by typed mutation helpers and
/// consumed by `compression_pass` after Normalize and every peephole pass.
pub(crate) pass_changes: PassChanges<'a>,
/// Scratch buffer reused by `try_fold_concat` to build template literal
/// quasis without allocating a fresh `String` per call.
pub concat_scratch: String,
}
impl<'a> MinifierState<'a> {
@@ -133,7 +129,6 @@ impl<'a> MinifierState<'a> {
this_initialized_at: None,
}),
pass_changes: PassChanges::new(scoping.references_len(), allocator),
concat_scratch: String::new(),
}
}
+2 -1
View File
@@ -139,7 +139,8 @@ impl SymbolValue<'_> {
|| match constant {
ConstantValue::Number(n) => n.fract() == 0.0 && *n >= -99.0 && *n <= 999.0,
ConstantValue::BigInt(_) => false,
ConstantValue::String(s) => s.len() <= 3,
// A lone surrogate is 3 bytes but prints as a 6-character escape.
ConstantValue::String(s) => s.as_str().is_some_and(|s| s.len() <= 3),
ConstantValue::Boolean(_) | ConstantValue::Undefined | ConstantValue::Null => true,
}
}
@@ -279,9 +279,7 @@ impl<'a> TraverseCtx<'a, MinifierState<'a>> {
let value = format_str!(self.allocator(), "{bigint}");
Expression::new_big_int_literal(span, value, None, BigintBase::Decimal, self)
}
ConstantValue::String(s) => {
Expression::new_string_literal(span, Str::from_cow_in(&s, self), None, self)
}
ConstantValue::String(s) => Expression::new_string_literal(span, s, None, self),
ConstantValue::Boolean(b) => Expression::new_boolean_literal(span, b, self),
ConstantValue::Undefined => Expression::new_void_0(span, self),
ConstantValue::Null => Expression::new_null_literal(span, self),
@@ -944,9 +944,7 @@ fn test_fold_bit_shifts() {
#[test]
fn test_string_add() {
fold("x = 'a' + 'bc'", "x = 'abc'");
// Lone surrogates are stored escaped in the string value; folding would
// materialize the escape encoding as literal text.
fold_same("x = '\\ud800' + 'y'");
fold("x = '\\ud800' + 'y'", "x = '\\ud800y'");
fold("x = 'a' + 5", "x = 'a5'");
fold("x = 5 + 'a'", "x = '5a'");
fold("x = 'a' + 5n", "x = 'a5'");
@@ -1554,6 +1552,107 @@ fn test_inline_values_in_template_literal() {
fold_same("foo`foo${1}bar`");
}
#[test]
fn template_joins_preserve_raw_boundaries() {
fold(r#"x = `${z}$` + "{a}\uD800""#, r"x = `${z}$\{a}\ud800`");
fold(r#"x = `$${"{a}\uD800"}${z}`"#, r"x = `$\{a}\ud800${z}`");
fold(r#"x = `$${""}{a}${z}`"#, r"x = `$\{a}${z}`");
fold(r"x = `${z}$` + `{a}${w}`", r"x = `${z}$\{a}${w}`");
fold(r#"x = `${z}\0` + "1\uD800""#, r"x = `${z}\x001\ud800`");
fold(r"x = `${z}\0` + `1${w}`", r"x = `${z}\x001${w}`");
fold(r#"x = `${z}\0${""}1${w}`"#, r"x = `${z}\x001${w}`");
// An escaped backslash followed by zero is not a null escape.
fold(r#"x = `${z}\\0` + "1\uD800""#, r"x = `${z}\\01\ud800`");
fold(r#"x = `${z}\\\0` + "1\uD800""#, r"x = `${z}\\\x001\ud800`");
// An already escaped dollar must keep its original value.
fold(r#"x = `${z}\$` + "{a}\uD800""#, r"x = `${z}\${a}\ud800`");
}
#[test]
fn string_method_folds_handle_lone_surrogates() {
// A lone surrogate in the receiver is one UTF-16 code unit.
// The numeric folds count it like any other unit.
fold(r#"x = "a\uD800b".indexOf("b")"#, "x = 2");
fold(r#"x = "a\uD800b".indexOf("b", 2)"#, "x = 2");
fold(r#"x = "a\uD800b".indexOf("b", 3)"#, "x = -1");
fold(r#"x = "a\uD800b".indexOf("c")"#, "x = -1");
// Separated lone halves never match a formed surrogate pair.
fold(r#"x = "x\uD83Dy\uDE00z".indexOf("\u{1F600}")"#, "x = -1");
fold(r#"x = "a\u{1F600}\uD800b".indexOf("\u{1F600}")"#, "x = 1");
fold(r#"x = "a\uD800b\uD800b".lastIndexOf("b")"#, "x = 4");
fold(r#"x = "a\uD800b\uD800b".lastIndexOf("b", 3)"#, "x = 2");
fold(r#"x = "a\uD800b".lastIndexOf("")"#, "x = 3");
fold(r#"x = "a\uD800b".charCodeAt(1)"#, "x = 55296");
fold(r#"x = "a\uD800b".charCodeAt(2)"#, "x = 98");
fold(r#"x = "a\uD800b".charCodeAt(3)"#, "x = NaN");
fold(r#"x = +"\uD800""#, "x = NaN");
// A search value with a lone surrogate matches its code unit,
// including the same half of a formed pair.
fold(r#"x = "a\uD800b".indexOf("\uD800")"#, "x = 1");
fold(r#"x = "a\uD800b".lastIndexOf("\uD800")"#, "x = 1");
fold(r#"x = "a\u{1F600}b".indexOf("\uD83D")"#, "x = 1");
fold(r#"x = "a\u{1F600}b".indexOf("\uDE00")"#, "x = 2");
fold(r#"x = "a\u{1F600}b".lastIndexOf("\uD83D")"#, "x = 1");
}
#[test]
fn string_folds_produce_lone_surrogates() {
// Concatenation repairs a pair split across the operands and keeps unmatched
// halves as lone units.
fold(r#"x = "\uD800" + "\uDC00""#, r#"x = "\u{10000}""#);
fold(r#"x = "a\uD83D" + "\uDE00b""#, r#"x = "a\u{1F600}b""#);
fold(r#"x = "\uD800" + "y""#, r#"x = "\uD800y""#);
fold(r#"x = "x" + "\uDC00""#, r#"x = "x\uDC00""#);
// A lone surrogate is one UTF-16 code unit of the length.
fold(r#"x = "a\uD800b".length"#, "x = 3");
fold(r#"x = "abc\uD800".length"#, "x = 4");
fold(r#"x = "a\u{1F600}b".length"#, "x = 4");
// Substring folds may split a pair or slice around a lone unit.
fold(r#"x = "a\uD800b".substring(1, 2)"#, r#"x = "\uD800""#);
fold(r#"x = "a\uD800b".slice(1)"#, r#"x = "\uD800b""#);
fold(r#"x = "a\u{1F600}b".charAt(1)"#, r#"x = "\uD83D""#);
fold(r#"x = "a\u{1F600}b".charAt(2)"#, r#"x = "\uDE00""#);
fold(r#"x = "a\uD800b".charAt(1)"#, r#"x = "\uD800""#);
fold(r#"x = "a\uD800b".charAt(9)"#, r#"x = """#);
// `fromCharCode` keeps surrogate units and forms adjacent pairs.
fold("x = String.fromCharCode(55296)", r#"x = "\uD800""#);
fold("x = String.fromCharCode(55357, 56832)", r#"x = "\u{1F600}""#);
// Comparisons order and compare by code units.
fold(r#"x = "\uD800" < "\uD801""#, "x = !0");
fold(r#"x = "\uDC00" < "\uD800""#, "x = !1");
fold(r#"x = "a\uD800" === "a\uD800""#, "x = !0");
fold(r#"x = "\uD800" === "\uDC00""#, "x = !1");
}
#[test]
fn string_folds_decline_lone_surrogates() {
// encodeURI and encodeURIComponent throw a URIError on a lone surrogate at runtime,
// so folding them would change behavior.
fold_same(r#"x = encodeURI("a\uD800")"#);
fold_same(r#"x = encodeURIComponent("\uD800")"#);
fold_same(r#"x = decodeURI("a\uD800")"#);
fold_same(r#"x = decodeURIComponent("a\uD800")"#);
// The replace and casing machinery is UTF-8 and stays unfolded.
fold_same(r#"x = "a\uD800b".replace("a", "x")"#);
fold_same(r#"x = "ab".replace("a", "\uD800")"#);
fold_same(r#"x = "A\uD800".toLowerCase()"#);
}
#[test]
fn string_values_with_lone_surrogates_join_templates() {
// A string holding a lone surrogate is inlined into a substitution or merged into a template.
// The raw text writes the surrogate as an escape, and a template left without substitutions
// becomes a string literal.
fold(r#"x = `a${"\uD800"}b`"#, r#"x = "a\uD800b""#);
fold(r#"x = `${"\uD83D"}${"\uDE00"}${a}`"#, r"x = `\ud83d\ude00${a}`");
fold(r#"x = `p${a}` + "\uDC00""#, r"x = `p${a}\udc00`");
fold(r#"x = "\uD83D" + `\uDE00${a}`"#, r"x = `\ud83d\uDE00${a}`");
fold(r#"x = `${"`\uD800$"}${a}`"#, r"x = `\`\ud800\$${a}`");
// `concat` joins a pair split across its arguments, as at runtime.
fold(r#"x = "a".concat("\uD800", "\uDC00")"#, r#"x = "a\u{10000}""#);
fold(r#"x = "\uD800".concat(a, "\uDC00")"#, r"x = `\ud800${a}\udc00`");
}
#[test]
fn template_concatenation_preserves_surrogates() {
fold(r"`${a}\uD800` + `\uDC00${b}`", r"`${a}\uD800\uDC00${b}`");
@@ -697,6 +697,8 @@ fn small_value() {
test_options("const foo = 'aaa'; log(foo), log(foo)", "log('aaa'), log('aaa')", &options);
test_same_options("const foo = 'aaaa'; log(foo), log(foo)", &options);
// A lone surrogate takes 3 bytes but prints as a 6-character escape.
test_same_options(r"const foo = '\uD800'; log(foo), log(foo)", &options);
test_options("const foo = true; log(foo), log(foo)", "log(!0), log(!0)", &options);
test_options("const foo = false; log(foo), log(foo)", "log(!1), log(!1)", &options);
@@ -53,6 +53,13 @@ fn test_string_index_of() {
test("x = `abcdef`.indexOf('b')", "x = 1");
test_same("x = `Hello ${name}`.indexOf('a')");
test_same("x = tag `Hello ${name}`.indexOf('a')");
// Indices count UTF-16 code units, and positions are not wrapped to 32 bits.
test("x = 'éx'.indexOf('x')", "x = 1");
test("x = '😀x'.indexOf('x', 1)", "x = 2");
test("x = 'abc'.indexOf('a', 4294967296)", "x = -1");
test("x = 'éb'.lastIndexOf('b')", "x = 1");
test("x = 'abab'.lastIndexOf('b', NaN)", "x = 3");
}
#[test]
@@ -239,6 +246,14 @@ fn test_fold_string_substring() {
// Template strings
test("x = `abcdef`.substring(0,2)", "x = 'ab'");
test_same("x = `abcdef ${abc}`.substring(0,2)");
// Positions count UTF-16 code units, so a boundary may split a surrogate pair.
test("x = 'a😀b'.substring(1, 2)", "x = '\\ud83d'");
test("x = 'é😀b'.substring(1)", "x = '😀b'");
// An `undefined` end is not folded: its number value NaN would mean index 0.
test_same("x = 'abc'.substring(1, void 0)");
// A NaN start is index 0.
test("x = 'abc'.substring(NaN, 2)", "x = 'ab'");
}
#[test]
@@ -246,6 +261,8 @@ fn test_fold_string_slice() {
test("x = 'abcde'.slice(0,2)", "x = 'ab'");
test("x = 'abcde'.slice(1,2)", "x = 'b'");
test("x = 'abcde'.slice(2)", "x = 'cde'");
// Positions count UTF-16 code units, so a boundary may split a surrogate pair.
test("x = '😀a'.slice(1)", "x = '\\ude00a'");
// we should be leaving negative, out-of-bound, and inverted indices alone for now
test_same("x = 'abcde'.slice(-1)");
@@ -253,6 +270,10 @@ fn test_fold_string_slice() {
test_same("x = 'abcde'.slice(1, 2, 3)");
test_same("x = 'abcde'.slice(2, 0)");
test_same("x = 'a'.slice(0, 2)");
// A NaN or `undefined` end is not folded, and a NaN or `undefined` start is index 0.
test_same("x = 'abc'.slice(1, void 0)");
test_same("x = 'abc'.slice(0, NaN)");
test("x = 'abc'.slice(void 0, 1)", "x = 'a'");
// Template strings
test("x = `abcdef`.slice(0, 2)", "x = 'ab'");
@@ -276,8 +297,8 @@ fn test_fold_string_char_at() {
test_same("x = 'abcde'.charAt(y)");
test("x = 'abcde'.charAt(null)", "x = 'a'");
test("x = 'abcde'.charAt(!0)", "x = 'b'");
test_same("x = '\\ud834\\udd1e'.charAt(0)"); // or x = '\\ud834'
test_same("x = '\\ud834\\udd1e'.charAt(1)"); // or x = '\\udd1e'
test("x = '\\ud834\\udd1e'.charAt(0)", "x = '\\ud834'");
test("x = '\\ud834\\udd1e'.charAt(1)", "x = '\\udd1e'");
// Template strings
test("x = `abcdef`.charAt(0)", "x = 'a'");
@@ -1003,7 +1024,8 @@ fn test_fold_string_from_char_code() {
test("x = String.fromCharCode(0)", "x = '\\0'");
test("x = String.fromCharCode(120)", "x = 'x'");
test("x = String.fromCharCode(120, 121)", "x = 'xy'");
test_same("x = String.fromCharCode(55358, 56768)");
test("x = String.fromCharCode(55358, 56768)", "x = '\u{1F9C0}'");
test("x = String.fromCharCode(55296)", "x = '\\ud800'");
test("x = String.fromCharCode(0x10000)", "x = '\\0'");
test("x = String.fromCharCode(0x10078, 0x10079)", "x = 'xy'");
test("x = String.fromCharCode(0x1_0000_FFFF)", "x = '\u{ffff}'");
@@ -1020,12 +1042,58 @@ fn test_fold_string_from_char_code() {
test_same("x = Unknown.fromCharCode('0.5')");
}
#[test]
fn string_concat_preserves_coercion_order() {
test_same(r#"x = "\uD800".concat(a, b())"#);
test_same(r#"x = "\uD800".concat(a, b()).concat("tail")"#);
// A primitive cannot observe a later argument's mutation through coercion.
test(r#"x = "\uD800".concat(1, b())"#, r"x = `\ud8001${b()}`");
// A local parameter read can cross an earlier coercion.
test(
r#"function f(a) { return "\uD800".concat(b(), a) }"#,
r"function f(a) { return `\ud800${b()}${a}` }",
);
test(
r#"function f(a, b) { return "\uD800".concat(a, b) }"#,
r"function f(a, b) { return `\ud800${a}${b}` }",
);
}
#[test]
fn string_concat_chains_preserve_call_boundaries() {
test(
r#"x = "translate(".concat(origin.x, ", ").concat(origin.y, ")")"#,
"x = `translate(${origin.x}, ${origin.y})`",
);
test(r#"x = "\uD800".concat(a).concat(b())"#, r"x = `\ud800${a}${b()}`");
// A template must preserve the argument/coercion order within every call too.
test_same(r#"x = "\uD800".concat(a, b()).concat(c)"#);
test_same(r#"x = "\uD800".concat(a).concat(b, c())"#);
test_same(r#"x = "\uD800".concat(...a).concat(b())"#);
// When flattening is unsafe, compare against the cost of the whole chain.
test(
"function f(a,b,c,d,e) { return ''.concat(a).concat(b()).concat(c).concat(d).concat(e) }",
"function f(a,b,c,d,e) { return `${a}${b()}${c}${d}${e}` }",
);
// A single flattened call is still shorter when all arguments are local reads.
test(
"function f(a,b,c,d,e) { return ''.concat(a).concat(b).concat(c).concat(d).concat(e) }",
"function f(a,b,c,d,e) { return ''.concat(a,b,c,d,e) }",
);
test_target("x = ''.concat(a).concat(b())", "x = ''.concat(a).concat(b())", "chrome40");
}
#[test]
fn test_fold_string_concat() {
// A lone surrogate is kept as an escape in the template's raw text.
test_same(r#"x = "\uD800".concat(a, b)"#);
test(r#"x = "\uD83D".concat(a, "\uDE00")"#, "x = `\\ud83d${a}\\ude00`");
test(r#"x = "\uD83D".concat("\uDE00", a)"#, "x = `\u{1F600}${a}`");
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}`");
// Unresolved reads can invoke a global getter or throw, so they cannot cross coercion.
test_same("x = ''.concat(a, b)");
test_same("x = ''.concat(a, b, c)");
test_same("x = ''.concat(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'");
@@ -1034,11 +1102,10 @@ fn test_fold_string_concat() {
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_same("x = ''.concat(a, 'b', c)");
test("x = ''.concat('a', b, 'c')", "x = `a${b}c`");
test(
test_same(
"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}`",
);
test("x = ''.concat(a, 1)", "x = `${a}1`");
@@ -331,9 +331,11 @@ fn test_string_array_splitting() {
#[test]
fn test_template_string_to_string() {
test("x = `abcde`", "x = 'abcde'");
// Lone surrogates are stored escaped in the cooked value; converting to a
// string literal would materialize the escape encoding as literal text.
test_same("x = `\\ud800y`");
// A lone surrogate in the cooked value is kept as the same code unit.
test("x = `\\ud800y`", "x = '\\ud800y'");
test("x = `${'\\ud800'}`", "x = '\\ud800'");
test("x = `${'\\ud800'}`.length", "x = 1");
test("x = `\\ud83d${'\\ude00'}`", "x = '\u{1F600}'");
test("x = `ab cd ef`", "x = 'ab cd ef'");
test_same("x = `hello ${name}`");
test_same("tag `hello ${name}`");
+13
View File
@@ -1,4 +1,5 @@
use std::{
borrow::Cow,
fmt::{self, Debug, Display, Write},
hash::{Hash, Hasher},
iter::FusedIterator,
@@ -134,6 +135,18 @@ impl<'a> JSStr<'a> {
JSStr::from(value).clone_in(allocator.allocator())
}
/// Borrow a borrowed `Cow`, or copy an owned one into the arena, like [`Str::from_cow_in`].
///
/// # Panics
/// Panics if the byte length exceeds `u32::MAX`.
#[inline]
pub fn from_cow_in(value: &Cow<'a, str>, allocator: &impl GetAllocator<'a>) -> Self {
match value {
Cow::Borrowed(value) => JSStr::from(*value),
Cow::Owned(value) => JSStr::from_str_in(value, allocator),
}
}
/// Concatenate JavaScript strings into an arena.
///
/// Leading and trailing surrogates pair across string boundaries, including empty strings between them.
+9 -1
View File
@@ -128,7 +128,15 @@ impl<'a> JSStrBuilder<'a> {
/// A pair may span consecutive calls, including empty buffers.
#[inline]
pub fn push_utf16(&mut self, units: &[u16]) {
for &unit in units {
self.push_code_units(units.iter().copied());
}
/// Append potentially ill-formed UTF-16 code units from an iterator, like [`push_utf16`].
///
/// [`push_utf16`]: Self::push_utf16
#[inline]
pub fn push_code_units(&mut self, units: impl IntoIterator<Item = u16>) {
for unit in units {
self.push_code_unit(unit);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
import { minifySync } from "../index";
const cases = [
'globalThis.result = `${z}$` + "{a}\\uD800";',
'globalThis.result = `$${"{a}\\uD800"}${z}`;',
'globalThis.result = `$${""}{a}${z}`;',
"globalThis.result = `${z}$` + `{a}${w}`;",
'globalThis.result = `${z}\\0` + "1\\uD800";',
"globalThis.result = `${z}\\0` + `1${w}`;",
'globalThis.result = `${z}\\0${""}1${w}`;',
'globalThis.result = `${z}\\\\0` + "1\\uD800";',
'globalThis.result = `${z}\\$` + "{a}\\uD800";',
'var a=["before"]; function b(){a[0]="after";return "!"} globalThis.result="\\uD800".concat(a,b());',
'var a=["before"]; function b(){a[0]="after";return "!"} globalThis.result="\\uD800".concat(a,b()).concat("tail");',
'var a=["before"]; function b(){a[0]="after";return "!"} globalThis.result="\\uD800".concat(a).concat(b());',
'var a=["before"]; Object.defineProperty(globalThis,"b",{get(){a[0]="after";return "!"}}); globalThis.result="\\uD800".concat(a,b);',
'var a=["before"]; var p={x:a,get y(){a[0]="after";return "!"}}; globalThis.result="translate(".concat(p.x,", ").concat(p.y,")");',
'var a=["before"]; Object.defineProperty(globalThis,"b",{get(){a[0]="after";return "!"}}); globalThis.result="\\uD800".concat(a).concat(b);',
'var a=["before"]; function b(){a[0]="after";return "!"} globalThis.result="".concat(a).concat(b()).concat(a).concat(a).concat(a);',
'var a=["before"]; function b(){a[0]="after";return "!"} globalThis.result="".concat(a,b()).concat(a);',
];
function evaluate(source: string) {
const context = { z: "Z", w: "W", a: "INJECTED", result: undefined };
runInNewContext(source, context, { timeout: 1000 });
return context.result;
}
it.each(cases)("preserves string-folding runtime behavior: %s", (source) => {
const expected = evaluate(source);
for (const module of [false, true]) {
for (const mangle of [false, true]) {
const options = { module, mangle };
const result = minifySync("test.js", source, options);
expect(result.errors).toEqual([]);
expect(evaluate(result.code)).toBe(expected);
expect(minifySync("test.js", result.code, options).code).toBe(result.code);
}
}
});
@@ -1,22 +1,22 @@
checker.ts:
file size: 2922154 # 2.92 MB
sys allocs: 122
sys allocs: 40
sys reallocs: 5
sys deallocs: 122
sys deallocs: 40
sys alloc bytes: 14996287 # 15.00 MB
sys peak growth: 10169560 # 10.17 MB
arena allocs: 56229
arena allocs: 56228
arena reallocs: 5084
arena size: 16501168 # 16.50 MB
App.tsx:
file size: 415342 # 415.34 kB
sys allocs: 61
sys allocs: 35
sys reallocs: 8
sys deallocs: 61
sys deallocs: 35
sys alloc bytes: 2128539 # 2.13 MB
sys peak growth: 1612427 # 1.61 MB
arena allocs: 7728
arena allocs: 7729
arena reallocs: 920
arena size: 2782336 # 2.78 MB
@@ -33,31 +33,31 @@ RadixUIAdoptionSection.jsx:
pdf.mjs:
file size: 567296 # 567.30 kB
sys allocs: 2343
sys allocs: 1933
sys reallocs: 205
sys deallocs: 2343
sys deallocs: 1933
sys alloc bytes: 5313795 # 5.31 MB
sys peak growth: 3693355 # 3.69 MB
arena allocs: 19585
arena allocs: 19581
arena reallocs: 2574
arena size: 5727264 # 5.73 MB
antd.js:
file size: 6686316 # 6.69 MB
sys allocs: 845
sys reallocs: 56
sys deallocs: 845
sys allocs: 155
sys reallocs: 52
sys deallocs: 155
sys alloc bytes: 29973448 # 29.97 MB
sys peak growth: 19934725 # 19.93 MB
arena allocs: 181536
arena reallocs: 34406
arena size: 44087584 # 44.09 MB
arena allocs: 175847
arena reallocs: 34169
arena size: 44039472 # 44.04 MB
binder.ts:
file size: 193077 # 193.08 kB
sys allocs: 33
sys allocs: 25
sys reallocs: 1
sys deallocs: 33
sys deallocs: 25
sys alloc bytes: 963008 # 963.01 kB
sys peak growth: 657404 # 657.40 kB
arena allocs: 2930
@@ -66,11 +66,11 @@ binder.ts:
kitchen-sink.tsx:
file size: 732898 # 732.90 kB
sys allocs: 1857
sys allocs: 354
sys reallocs: 174
sys deallocs: 1857
sys deallocs: 354
sys alloc bytes: 5304252 # 5.30 MB
sys peak growth: 3707425 # 3.71 MB
arena allocs: 31114
arena allocs: 31100
arena reallocs: 3011
arena size: 8461040 # 8.46 MB
arena size: 8460008 # 8.46 MB