mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 18:43:44 +00:00
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:
1 parent
9d67cb9463
commit
cf8296fb8e
32 files changed
+1268
-338
No files matched your search
Generated
+1
@@ -2014,6 +2014,7 @@ dependencies = [
|
||||
"cow-utils",
|
||||
"dragonbox_ecma",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"oxc_allocator",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)?));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user