From ccb5dd1a1bba710dd57b0603982244a0ee3d5fcc Mon Sep 17 00:00:00 2001 From: xcb3d Date: Fri, 4 Sep 2026 22:01:46 +0700 Subject: [PATCH 1/3] perf(string): single-alloc join/repeat/replace_once with safety fixes - Add JsString::repeat/repeat_str with exponential doubling and checked overflow handling; repeat(1) shares via clone - Add JsString::join with single allocation, empty-neutral Latin1 detection, zero-len early return and hoisted separator variant - Add JsString::replace_once/replace_once_at reusing index_of, checked total_len and empty-neutral encoding - Dedupe JsStr::index_of into shared same/mixed helpers with first-byte skip and debug asserts - Fix Array.prototype.join self-check to object identity only (join.call('a') no longer returns empty) - Fix unsafe pointer writes via &raw mut, document nan-boxed as_str lifetime, harden build_as_latin1 truncation docs - Intern trim results back to static strings; hoist pad filler variant; reuse replace position to avoid double search - Add coverage for join/index_of/replace_once/repeat/trim --- core/engine/src/builtins/array/mod.rs | 42 ++- core/engine/src/builtins/string/mod.rs | 208 +++++++++--- core/engine/src/value/inner/legacy.rs | 14 +- core/engine/src/value/inner/nan_boxed.rs | 30 +- core/engine/src/value/mod.rs | 24 +- core/string/src/builder.rs | 30 +- core/string/src/lib.rs | 410 +++++++++++++++++++++++ core/string/src/str.rs | 153 ++++++++- core/string/src/tests.rs | 217 ++++++++++++ 9 files changed, 1045 insertions(+), 83 deletions(-) diff --git a/core/engine/src/builtins/array/mod.rs b/core/engine/src/builtins/array/mod.rs index acd695878ea..1c2d4bf546c 100644 --- a/core/engine/src/builtins/array/mod.rs +++ b/core/engine/src/builtins/array/mod.rs @@ -1002,28 +1002,42 @@ impl Array { }; // 5. Let R be the empty String. - let mut r = Vec::with_capacity(len as usize + len.saturating_sub(1) as usize); + if len == 0 { + return Ok(StaticJsStrings::EMPTY_STRING.into()); + } + + let mut elements = Vec::new(); // 6. Let k be 0. // 7. Repeat, while k < len, for k in 0..len { - // a. If k > 0, set R to the string-concatenation of R and sep. - if k > 0 { - r.push(separator.clone()); - } // b. Let element be ? Get(O, ! ToString(𝔽(k))). let element = o.get(k, context)?; - // c. If element is undefined, null or the array itself, let next be the empty String; otherwise, let next be ? ToString(element). - let next = if element.is_null_or_undefined() || &element == this { - js_string!() - } else { - element.to_string(context)? - }; - // d. Set R to the string-concatenation of R and next. - r.push(next.clone()); + // c. If element is undefined or null, let next be the empty String; + // otherwise, let next be ? ToString(element). + // NOTE: Boa additionally maps a self-referential element (identity, + // object-only) to the empty string to avoid infinite recursion. + // This must be an identity check: value equality would wrongly + // match primitives, e.g. `Array.prototype.join.call("a", "-")`. + let next = + if element.is_null_or_undefined() || (element.is_object() && &element == this) { + StaticJsStrings::EMPTY_STRING + } else { + element.to_string(context)? + }; + // d. Append element to list. + elements.push(next); // e. Set k to k + 1. } + // Fast path: avoid copying a single huge element through `join`. + if elements.len() == 1 { + // SAFETY: just checked len. + return Ok(elements.pop().expect("len == 1").into()); + } + // `elements` outlives `str_refs`, so the borrowed `JsStr`s stay valid + // for the duration of `JsString::join`. + let str_refs: Vec<_> = elements.iter().map(JsString::as_str).collect(); // 8. Return R. - Ok(js_string!(&r[..]).into()) + Ok(JsString::join(separator.as_str(), &str_refs).into()) } /// `Array.prototype.toString( separator )` diff --git a/core/engine/src/builtins/string/mod.rs b/core/engine/src/builtins/string/mod.rs index 9421b4654d7..c17a0eabc42 100644 --- a/core/engine/src/builtins/string/mod.rs +++ b/core/engine/src/builtins/string/mod.rs @@ -653,18 +653,28 @@ impl String { let this = this.require_object_coercible()?; // 2. Let S be ? ToString(O). - let mut string = this.to_string(context)?; + let string = this.to_string(context)?; + + if args.is_empty() { + return Ok(JsValue::new(string)); + } - // 3. Let R be S. - // 4. For each element next of args, do + if args.len() == 1 { + let next_string = args[0].to_string(context)?; + return Ok(JsValue::new(js_string!(&string, &next_string))); + } + + let mut strings = Vec::with_capacity(args.len() + 1); + strings.push(string); for arg in args { - // a. Let nextString be ? ToString(next). - // b. Set R to the string-concatenation of R and nextString. - string = js_string!(&string, &arg.to_string(context)?); + strings.push(arg.to_string(context)?); } + // `strings` outlives `str_refs`, so the borrowed `JsStr`s stay valid + // for the duration of `concat_array`. + let str_refs: Vec<_> = strings.iter().map(JsString::as_str).collect(); // 5. Return R. - Ok(JsValue::new(string)) + Ok(JsValue::new(JsString::concat_array(&str_refs))) } /// `String.prototype.repeat( count )` @@ -734,14 +744,12 @@ impl String { let n = n as usize; // Charge each repetition against the VM loop-iteration limit. - let mut result = Vec::with_capacity(n); for _ in 0..n { crate::vm::opcode::IncrementLoopIteration::operation((), context)?; - result.push(string.as_str()); } // 6. Return the String value that is made from n copies of S appended together. - Ok(JsString::concat_array(&result).into()) + Ok(string.repeat(n).into()) } /// `String.prototype.slice( beginIndex [, endIndex] )` @@ -1019,12 +1027,29 @@ impl String { ReplaceValue(JsString), } - // 1. Let O be ? RequireObjectCoercible(this value). - let o = this.require_object_coercible()?; + // Ultra-fast path: If this, searchValue, and replaceValue are all strings + // without '$', delegate directly to `replace_once` (no substitution possible). + // + // SAFETY of the ordering: this path requires all three `as_str()` to be + // `Some`, which implies `this` is a primitive string (so + // `RequireObjectCoercible` below would succeed without side effects) and + // both args are primitive strings (so neither is an object with + // `@@replace`, nor a callable functional-replace). Observable behavior + // is therefore identical to the slow path. + if let [arg0, arg1, ..] = args + && let (Some(s), Some(search_str), Some(replace_str)) = + (this.as_str(), arg0.as_str(), arg1.as_str()) + && !replace_str.contains(b'$') + { + return Ok(JsString::replace_once(s, search_str, replace_str).into()); + } let search_value = args.get_or_undefined(0); let replace_value = args.get_or_undefined(1); + // 1. Let O be ? RequireObjectCoercible(this value). + let o = this.require_object_coercible()?; + // 2. If searchValue is an Object, then if search_value.is_object() { // a. Let replacer be ? GetMethod(searchValue, @@replace). @@ -1080,6 +1105,19 @@ impl String { } // 12. Else, CallableOrString::ReplaceValue(replace_value) => { + // Fast path: If replace_value does not contain '$', no substitution is possible. + // Reuse the already-computed `position` via `replace_once_at` + // instead of searching a second time. + if !replace_value.contains(b'$') { + return Ok(JsString::replace_once_at( + string.as_str(), + search_length, + replace_value.as_str(), + position, + ) + .into()); + } + // a. Assert: Type(replaceValue) is String. // b. Let captures be a new empty List. let captures = Vec::new(); @@ -1231,16 +1269,22 @@ impl String { // i. Assert: Type(replaceValue) is String. // ii. Let captures be a new empty List. // iii. Let replacement be ! GetSubstitution(searchString, string, p, captures, undefined, replaceValue). - Err(ref replace_str) => get_substitution( - &search_string, - &string, - p, - &[], - &JsValue::undefined(), - replace_str, - context, - ) - .js_expect("GetSubstitution should never fail here.")?, + Err(ref replace_str) => { + if replace_str.contains(b'$') { + get_substitution( + &search_string, + &string, + p, + &[], + &JsValue::undefined(), + replace_str, + context, + ) + .js_expect("GetSubstitution should never fail here.")? + } else { + replace_str.clone() + } + } }; // d. Set result to the string-concatenation of result, preserved, and replacement. @@ -1566,36 +1610,84 @@ impl String { .into()); } - let filler_len = filler.len() as u64; + let total_len = int_max_length as usize; + let fill_len = fill_len as usize; + let filler_len = filler.len(); + debug_assert!(filler_len > 0, "empty filler returns early above"); - // 9. Let truncatedStringFiller be the String value consisting of repeated - // concatenations of filler truncated to length fillLen. - let repetitions = { - let q = fill_len / filler_len; - let r = fill_len % filler_len; - if r == 0 { q } else { q + 1 } - }; + let filler_str = filler.as_str(); + let s_str = string.as_str(); + + let latin1 = s_str.is_latin1() && filler_str.is_latin1(); - let mut truncated_string_filler = Vec::with_capacity(fill_len as usize); - let filler_slice = filler.to_vec(); - for _ in 0..repetitions { - let remaining = fill_len as usize - truncated_string_filler.len(); - if remaining >= filler_slice.len() { - truncated_string_filler.extend_from_slice(&filler_slice); + let res = if latin1 { + let mut builder = boa_string::Latin1JsStringBuilder::with_capacity(total_len); + let s_bytes = s_str.as_latin1().expect("checked latin1"); + let f_bytes = filler_str.as_latin1().expect("checked latin1"); + + let copy_filler = |builder: &mut boa_string::Latin1JsStringBuilder| { + let mut remaining = fill_len; + while remaining > 0 { + let chunk = remaining.min(filler_len); + debug_assert!(chunk > 0 && chunk <= filler_len); + builder.extend_from_slice(&f_bytes[..chunk]); + remaining -= chunk; + } + }; + + if placement == Placement::Start { + copy_filler(&mut builder); + builder.extend_from_slice(s_bytes); } else { - truncated_string_filler.extend_from_slice(&filler_slice[..remaining]); - break; + builder.extend_from_slice(s_bytes); + copy_filler(&mut builder); } - } - let truncated_string_filler = JsString::from(&truncated_string_filler[..]); - - // 10. If placement is start, return the string-concatenation of truncatedStringFiller and S. - if placement == Placement::Start { - Ok(js_string!(&truncated_string_filler, &string).into()) + // SAFETY: Both parts were checked Latin1 above, so one byte == one code unit. + unsafe { builder.build_as_latin1() } } else { - // 11. Else, return the string-concatenation of S and truncatedStringFiller. - Ok(js_string!(&string, &truncated_string_filler).into()) - } + use boa_string::{JsStr, JsStrVariant, Utf16JsStringBuilder}; + let mut builder = Utf16JsStringBuilder::with_capacity(total_len); + + let append_str = + |builder: &mut Utf16JsStringBuilder, js_s: &JsStr<'_>| match js_s.variant() { + JsStrVariant::Latin1(bytes) => { + builder.extend(bytes.iter().copied().map(u16::from)); + } + JsStrVariant::Utf16(u16s) => { + builder.extend_from_slice(u16s); + } + }; + + // Hoist the filler variant out of the loop: it never changes. + let filler_variant = filler_str.variant(); + let append_filler = |builder: &mut Utf16JsStringBuilder| { + let mut remaining = fill_len; + while remaining > 0 { + let chunk = remaining.min(filler_len); + debug_assert!(chunk > 0 && chunk <= filler_len); + match filler_variant { + JsStrVariant::Latin1(bytes) => { + builder.extend(bytes[..chunk].iter().copied().map(u16::from)); + } + JsStrVariant::Utf16(u16s) => { + builder.extend_from_slice(&u16s[..chunk]); + } + } + remaining -= chunk; + } + }; + + if placement == Placement::Start { + append_filler(&mut builder); + append_str(&mut builder, &s_str); + } else { + append_str(&mut builder, &s_str); + append_filler(&mut builder); + } + builder.build() + }; + + Ok(res.into()) } /// `String.prototype.padEnd( targetLength[, padString] )` @@ -1669,7 +1761,12 @@ impl String { // 2. Return ? TrimString(S, start+end). let object = this.require_object_coercible()?; let string = object.to_string(context)?; - Ok(js_string!(string.trim()).into()) + let trimmed = string.trim(); + // Fold common results back to static strings instead of retaining + // a slice of a potentially huge parent. + Ok(StaticJsStrings::get_string(&trimmed.as_str()) + .unwrap_or(trimmed) + .into()) } /// `String.prototype.trimStart()` @@ -1693,7 +1790,10 @@ impl String { // 2. Return ? TrimString(S, start). let object = this.require_object_coercible()?; let string = object.to_string(context)?; - Ok(js_string!(string.trim_start()).into()) + let trimmed = string.trim_start(); + Ok(StaticJsStrings::get_string(&trimmed.as_str()) + .unwrap_or(trimmed) + .into()) } /// `String.prototype.trimEnd()` @@ -1717,7 +1817,10 @@ impl String { // 2. Return ? TrimString(S, end). let object = this.require_object_coercible()?; let string = object.to_string(context)?; - Ok(string.trim_end().into()) + let trimmed = string.trim_end(); + Ok(StaticJsStrings::get_string(&trimmed.as_str()) + .unwrap_or(trimmed) + .into()) } /// [`String.prototype.toUpperCase()`][upper] and [`String.prototype.toLowerCase()`][lower] @@ -2667,6 +2770,11 @@ pub(crate) fn get_substitution( // 8. Let tailPos be position + matchLength. let tail_pos = position + match_length; + // Fast path: if replacement does not contain '$', no substitutions can occur. + if !replacement.contains(b'$') { + return Ok(replacement.clone()); + } + // 10. Let result be the String value derived from replacement by copying code unit elements // from replacement to result while performing replacements as specified in Table 58. // These $ replacements are done left-to-right, and, once such a replacement is performed, diff --git a/core/engine/src/value/inner/legacy.rs b/core/engine/src/value/inner/legacy.rs index 087331990f3..817c99bccab 100644 --- a/core/engine/src/value/inner/legacy.rs +++ b/core/engine/src/value/inner/legacy.rs @@ -7,7 +7,7 @@ use crate::builtins::is_html_dda::IsHTMLDDA; use crate::{JsBigInt, JsObject, JsSymbol, value::Type}; use boa_engine::JsVariant; use boa_gc::{Finalize, Trace, custom_trace}; -use boa_string::JsString; +use boa_string::{JsStr, JsString}; #[derive(Clone, Debug)] pub(crate) enum EnumBasedValue { @@ -267,6 +267,18 @@ impl EnumBasedValue { } } + /// Returns the value as a [`JsStr`] without cloning. + /// + /// The returned slice borrows `self`; keep the [`JsValue`] alive while using it. + #[must_use] + #[inline] + pub(crate) fn as_str(&self) -> Option> { + match self { + Self::String(value) => Some(value.as_str()), + _ => None, + } + } + /// Converts the value to a boolean without cloning pointer types. #[must_use] #[inline] diff --git a/core/engine/src/value/inner/nan_boxed.rs b/core/engine/src/value/inner/nan_boxed.rs index 6a05d23fce4..cd3ccc8f886 100644 --- a/core/engine/src/value/inner/nan_boxed.rs +++ b/core/engine/src/value/inner/nan_boxed.rs @@ -113,7 +113,7 @@ use crate::{ symbol::RawJsSymbol, value::Type, }; use boa_gc::{Finalize, GcBox, Trace, custom_trace}; -use boa_string::JsString; +use boa_string::{JsStr, JsString}; use core::fmt; use static_assertions::const_assert; use std::{ @@ -732,6 +732,34 @@ impl NanBoxedValue { } } + /// Returns the value as a [`JsStr`] without cloning. + /// + /// The returned slice borrows `self`; keep the [`JsValue`] alive while using it. + #[must_use] + #[inline(always)] + pub(crate) fn as_str(&self) -> Option> { + if self.is_string() { + // SAFETY: + // - `is_string()` was checked, so the inner address holds a valid, + // non-null `JsString` allocation. + // - `&self` holds one strong ref for its whole lifetime (clone does + // `mem::forget`, drop needs `&mut self`), so the allocation and its + // immutable character payload outlive the returned `JsStr<'_>`. + // - `JsStr` points into the heap character data, not into the + // temporary `ManuallyDrop` below, which is never dropped + // and therefore never decrements the refcount. + unsafe { + let s = self.as_string_unchecked(); + let js_str = s.as_str(); + // Extend the local borrow to `&self`: sound by the argument above. + // `transmute` here is purely a lifetime extension, not a layout cast. + Some(mem::transmute::, JsStr<'_>>(js_str)) + } + } else { + None + } + } + /// Returns the value as a [`JsString`] without checking the inner tag. /// /// # Safety diff --git a/core/engine/src/value/mod.rs b/core/engine/src/value/mod.rs index e92d4809447..073346736bd 100644 --- a/core/engine/src/value/mod.rs +++ b/core/engine/src/value/mod.rs @@ -26,7 +26,7 @@ pub use self::{ use crate::builtins::RegExp; use crate::object::{JsFunction, JsPromise, JsRegExp}; use crate::{ - Context, JsBigInt, JsResult, JsString, + Context, JsBigInt, JsResult, JsStr, JsString, builtins::{ Number, Promise, number::{f64_to_int32, f64_to_uint32}, @@ -771,6 +771,28 @@ impl JsValue { self.0.as_string() } + /// Returns the value as a [`JsStr`] slice if it is a string. + /// + /// This borrows `self` without cloning the underlying [`JsString`]. + /// Keep the [`JsValue`] alive while using the returned slice. + /// + /// # Examples + /// + /// ``` + /// use boa_engine::JsValue; + /// + /// let string = JsValue::new("hello"); + /// assert!(string.as_str().is_some()); + /// + /// let number = JsValue::new(42); + /// assert!(number.as_str().is_none()); + /// ``` + #[inline] + #[must_use] + pub fn as_str(&self) -> Option> { + self.0.as_str() + } + /// Returns true if the value is a boolean. /// /// # Examples diff --git a/core/string/src/builder.rs b/core/string/src/builder.rs index 96220f51b69..ca84f88c9ea 100644 --- a/core/string/src/builder.rs +++ b/core/string/src/builder.rs @@ -565,12 +565,12 @@ impl Latin1JsStringBuilder { /// Builds `JsString` from `Latin1JsStringBuilder`, assume that the inner data is `Latin1` encoded /// /// # Safety - /// Caller must ensure that the string is encoded in `Latin1`. + /// Caller must ensure that the string is encoded in `Latin1` + /// (every code point is `U+0000..=U+00FF`, one byte per code unit). /// - /// If the string contains characters outside the `Latin1` range, it may lead to encoding errors, - /// resulting in an incorrect or malformed `JsString`. This could cause undefined behavior - /// when the resulting string is used in further operations or when interfacing with other - /// parts of the system that expect valid `Latin1` encoded string. + /// Violating this precondition does **not** cause memory unsafety — any `u8` + /// is valid Latin1 storage — but it produces a logically wrong/malformed + /// `JsString` for downstream operations that expect valid Latin1. #[inline] #[must_use] pub unsafe fn build_as_latin1(self) -> JsString { @@ -848,12 +848,11 @@ impl<'seg, 'ref_str: 'seg> CommonJsStringBuilder<'seg> { /// Builds `Latin1` encoded `JsString` from `CommonJsStringBuilder`, return `None` if segments can't be encoded as `Latin1` /// /// # Safety - /// Caller must ensure that the string segments can be `Latin1` encoded. + /// Caller must ensure that the string segments can be `Latin1` encoded + /// (every code point is `U+0000..=U+00FF`). /// - /// If string segments can't be `Latin1` encoded, it may lead to encoding errors, - /// resulting in an incorrect or malformed `JsString`. This could cause undefined behavior - /// when the resulting string is used in further operations or when interfacing with other - /// parts of the system that expect valid `Latin1` encoded string. + /// Violating this precondition does **not** cause memory unsafety, but it + /// truncates code points (`as u8`) and yields a logically wrong string. #[inline] #[must_use] pub unsafe fn build_as_latin1(self) -> JsString { @@ -874,7 +873,16 @@ impl<'seg, 'ref_str: 'seg> CommonJsStringBuilder<'seg> { builder.extend_from_slice(s); } Segment::Latin1(latin1) => builder.push(latin1), - Segment::CodePoint(code_point) => builder.push(code_point as u8), + Segment::CodePoint(code_point) => { + debug_assert!( + (code_point as u32) <= 0xFF, + "CodePoint segment must be Latin1 for build_as_latin1" + ); + let Ok(byte) = u8::try_from(code_point as u32) else { + unreachable!("checked Latin1 above") + }; + builder.push(byte); + } } } // SAFETY: All string segments can be encoded as `Latin1` string. diff --git a/core/string/src/lib.rs b/core/string/src/lib.rs index 633cbccb6d7..81fae763bcc 100644 --- a/core/string/src/lib.rs +++ b/core/string/src/lib.rs @@ -376,6 +376,10 @@ impl JsString { } }; + if start == 0 && end + 1 == self.len() { + return self.clone(); + } + // SAFETY: `position(...)` and `rposition(...)` cannot exceed the length of the string. unsafe { Self::slice_unchecked(self, start, end + 1) } } @@ -394,6 +398,10 @@ impl JsString { return StaticJsStrings::EMPTY_STRING; }; + if start == 0 { + return self.clone(); + } + // SAFETY: `position(...)` cannot exceed the length of the string. unsafe { Self::slice_unchecked(self, start, self.len()) } } @@ -412,6 +420,10 @@ impl JsString { return StaticJsStrings::EMPTY_STRING; }; + if end + 1 == self.len() { + return self.clone(); + } + // SAFETY: `rposition(...)` cannot exceed the length of the string. `end` is the first // character that is not trimmable, therefore we need to add 1 to it. unsafe { Self::slice_unchecked(self, 0, end + 1) } @@ -705,6 +717,404 @@ impl JsString { StaticJsStrings::get_string(&string.as_str()).unwrap_or(string) } + /// Creates a new [`JsString`] by repeating `self` `count` times. + /// + /// # Examples + /// + /// ``` + /// use boa_string::JsString; + /// assert_eq!(JsString::from("ab").repeat(3), JsString::from("ababab")); + /// ``` + /// + /// # Panics + /// + /// Panics with an allocation overflow if `len * count` overflows `usize`. + #[inline] + #[must_use] + pub fn repeat(&self, count: usize) -> Self { + if count == 1 { + return self.clone(); + } + Self::repeat_str(self.as_str(), count) + } + + /// Creates a new [`JsString`] by repeating `string` `count` times. + /// + /// Returns [`StaticJsStrings::EMPTY_STRING`] if `count` is zero or `string` is empty, + /// and preserves the Latin1/UTF-16 encoding of the input. + /// + /// # Panics + /// + /// Panics with an allocation overflow if `len * count` overflows `usize`. + #[must_use] + pub fn repeat_str(string: JsStr<'_>, count: usize) -> Self { + if count == 0 || string.is_empty() { + return StaticJsStrings::EMPTY_STRING; + } + if count == 1 { + return string.into(); + } + let len = string.len(); + let Some(total_len) = len.checked_mul(count) else { + alloc_overflow() + }; + + let result = match string.variant() { + JsStrVariant::Latin1(src) => { + let p = SequenceString::::allocate(total_len); + // SAFETY: + // - `p` points to a newly allocated `SequenceString` with capacity `total_len`. + // - `dest` has capacity for `total_len = len * count` bytes. + // - Sources and destination buffers do not overlap. + // - After the copies below, `[0, total_len)` is fully initialized + // before `Self { ptr }` escapes. + // - Doubling partitions `[0, copied)` and `[copied, 2 * copied)` are + // disjoint, so `copy_nonoverlapping` is sound; the tail + // `total_len - copied < copied` is also disjoint. + unsafe { + let dest = (&raw mut (*p.as_ptr()).data).cast::(); + debug_assert!(!dest.is_null()); + // Copy initial chunk + ptr::copy_nonoverlapping(src.as_ptr(), dest, len); + // Exponential doubling + let mut copied = len; + while copied <= total_len / 2 { + ptr::copy_nonoverlapping(dest, dest.add(copied), copied); + copied *= 2; + } + if copied < total_len { + ptr::copy_nonoverlapping(dest, dest.add(copied), total_len - copied); + } + } + Self { ptr: p.cast() } + } + JsStrVariant::Utf16(src) => { + let p = SequenceString::::allocate(total_len); + // SAFETY: + // - `p` points to a newly allocated `SequenceString` with capacity `total_len` u16 words. + // - `dest` is properly aligned to `u16` by `SequenceString::allocate`. + // - Sources and destination buffers do not overlap. + // - After the copies below, `[0, total_len)` is fully initialized + // before `Self { ptr }` escapes. + // - Doubling partitions are disjoint, so `copy_nonoverlapping` is sound. + unsafe { + let dest = (&raw mut (*p.as_ptr()).data).cast::(); + debug_assert!(dest.is_aligned()); + // Copy initial chunk + ptr::copy_nonoverlapping(src.as_ptr(), dest, len); + // Exponential doubling + let mut copied = len; + while copied <= total_len / 2 { + ptr::copy_nonoverlapping(dest, dest.add(copied), copied); + copied *= 2; + } + if copied < total_len { + ptr::copy_nonoverlapping(dest, dest.add(copied), total_len - copied); + } + } + Self { ptr: p.cast() } + } + }; + + StaticJsStrings::get_string(&result.as_str()).unwrap_or(result) + } + + /// Creates a new [`JsString`] by joining `elements` separated by `separator`. + /// + /// An empty `separator` or empty elements are treated as encoding-neutral: + /// they never force a UTF-16 promotion by themselves. + /// + /// # Examples + /// + /// ``` + /// use boa_string::{JsStr, JsString}; + /// let sep = JsStr::latin1(b", "); + /// let elems = [JsStr::latin1(b"a"), JsStr::latin1(b"b")]; + /// assert_eq!(JsString::join(sep, &elems), JsString::from("a, b")); + /// ``` + /// + /// # Panics + /// + /// Panics with an allocation overflow if the combined length overflows `usize`. + #[must_use] + pub fn join(separator: JsStr<'_>, elements: &[JsStr<'_>]) -> Self { + if elements.is_empty() { + return StaticJsStrings::EMPTY_STRING; + } + if elements.len() == 1 { + return elements[0].into(); + } + + let sep_len = separator.len(); + // Empty strings are encoding-neutral: they must not force UTF-16 promotion. + let mut latin1_encoding = separator.is_latin1() || separator.is_empty(); + let mut total_len = 0usize; + + for (i, elem) in elements.iter().enumerate() { + if i > 0 { + let Some(sum) = total_len.checked_add(sep_len) else { + alloc_overflow() + }; + total_len = sum; + } + let Some(sum) = total_len.checked_add(elem.len()) else { + alloc_overflow() + }; + total_len = sum; + if !elem.is_empty() && !elem.is_latin1() { + latin1_encoding = false; + } + } + + if total_len == 0 { + return StaticJsStrings::EMPTY_STRING; + } + + // Hoist the separator variant out of the per-element loop. + let sep_variant = separator.variant(); + + let result = if latin1_encoding { + let p = SequenceString::::allocate(total_len); + // SAFETY: + // - `p` points to a newly allocated `SequenceString` with capacity `total_len`. + // - `dest` has size `total_len` which equals the sum of all elements and separators. + // - All sources are Latin1 and nonoverlapping with `dest`. + // - `[0, total_len)` is fully initialized before `Self { ptr }` escapes. + unsafe { + let mut dest = (&raw mut (*p.as_ptr()).data).cast::(); + debug_assert!(!dest.is_null()); + // Empty separators/elements are encoding-neutral and contribute + // zero bytes, so only materialize the slice when `len > 0`. + let sep_slice = + (sep_len > 0).then(|| separator.as_latin1().expect("separator is latin1")); + for (i, elem) in elements.iter().enumerate() { + if i > 0 { + if let Some(sep_slice) = sep_slice { + ptr::copy_nonoverlapping(sep_slice.as_ptr(), dest, sep_len); + } + dest = dest.add(sep_len); + } + let elem_len = elem.len(); + if elem_len > 0 { + let elem_slice = elem.as_latin1().expect("element is latin1"); + ptr::copy_nonoverlapping(elem_slice.as_ptr(), dest, elem_len); + } + dest = dest.add(elem_len); + } + } + Self { ptr: p.cast() } + } else { + let p = SequenceString::::allocate(total_len); + // SAFETY: + // - `p` points to a newly allocated `SequenceString` with capacity `total_len` u16 elements. + // - `dest` is aligned for `u16` and nonoverlapping with sources. + // - `[0, total_len)` is fully initialized before `Self { ptr }` escapes. + unsafe { + let mut dest = (&raw mut (*p.as_ptr()).data).cast::(); + debug_assert!(dest.is_aligned()); + for (i, elem) in elements.iter().enumerate() { + if i > 0 { + if sep_len > 0 { + match sep_variant { + JsStrVariant::Latin1(s) => { + for (j, &byte) in s.iter().enumerate() { + *dest.add(j) = u16::from(byte); + } + } + JsStrVariant::Utf16(s) => { + ptr::copy_nonoverlapping(s.as_ptr(), dest, sep_len); + } + } + } + dest = dest.add(sep_len); + } + let elem_len = elem.len(); + if elem_len > 0 { + match elem.variant() { + JsStrVariant::Latin1(s) => { + for (j, &byte) in s.iter().enumerate() { + *dest.add(j) = u16::from(byte); + } + } + JsStrVariant::Utf16(s) => { + ptr::copy_nonoverlapping(s.as_ptr(), dest, elem_len); + } + } + } + dest = dest.add(elem_len); + } + } + Self { ptr: p.cast() } + }; + + StaticJsStrings::get_string(&result.as_str()).unwrap_or(result) + } + + /// Replaces the first occurrence of `search` with `replacement` in `string`. + /// + /// An empty `search` inserts `replacement` at position `0`. + /// If `search` is not found, this returns a copy of `string`. + /// The result preserves Latin1 encoding when both `string` and `replacement` + /// are Latin1 (empty strings are encoding-neutral); otherwise it promotes to UTF-16. + /// + /// # Examples + /// + /// ``` + /// use boa_string::{JsStr, JsString}; + /// let s = JsString::from("hello"); + /// let out = JsString::replace_once( + /// s.as_str(), + /// JsStr::latin1(b"l"), + /// JsStr::latin1(b"L"), + /// ); + /// assert_eq!(out, JsString::from("heLlo")); + /// ``` + /// + /// # Panics + /// + /// Panics with an allocation overflow if the combined length overflows `usize`. + #[must_use] + pub fn replace_once(string: JsStr<'_>, search: JsStr<'_>, replacement: JsStr<'_>) -> Self { + let Some(pos) = string.index_of(search, 0) else { + return string.into(); + }; + Self::replace_once_at(string, search.len(), replacement, pos) + } + + /// Replaces the substring at the already-known `pos` with `replacement`. + /// + /// `pos` must be a match position previously returned by + /// [`JsStr::index_of`] for `search` with `search_len` code units, i.e. + /// `pos + search_len <= string.len()`. This avoids searching twice in + /// `String.prototype.replace` slow paths. + /// + /// # Panics + /// + /// Panics with an allocation overflow if the combined length overflows `usize`. + /// Panics in debug if `pos + search_len > string.len()`. + #[must_use] + pub fn replace_once_at( + string: JsStr<'_>, + search_len: usize, + replacement: JsStr<'_>, + pos: usize, + ) -> Self { + debug_assert!( + pos.checked_add(search_len) + .is_some_and(|end| end <= string.len()), + "replace_once_at: pos + search_len must be within string" + ); + + let str_len = string.len(); + let replace_len = replacement.len(); + // `pos + search_len <= str_len` by contract, so this cannot underflow. + let tail_len = str_len - pos - search_len; + let total_len = pos + .checked_add(replace_len) + .and_then(|n| n.checked_add(tail_len)) + .unwrap_or_else(|| alloc_overflow()); + + if total_len == 0 { + return StaticJsStrings::EMPTY_STRING; + } + + // Empty strings are encoding-neutral for the Latin1 decision. + let is_latin1 = string.is_latin1() && (replacement.is_empty() || replacement.is_latin1()); + + let res = if is_latin1 { + let p = SequenceString::::allocate(total_len); + // SAFETY: + // - `p` points to a newly allocated `SequenceString` with capacity `total_len`. + // - `dest` has capacity for `total_len = pos + replace_len + tail_len`. + // - Slices are validated Latin1 and bounds checked. + // - `[0, total_len)` is fully initialized before `Self { ptr }` escapes. + // - Sources and destination do not overlap because `p` was just allocated. + unsafe { + let dest = (&raw mut (*p.as_ptr()).data).cast::(); + debug_assert!(!dest.is_null()); + let s_src = string.as_latin1().expect("checked latin1").as_ptr(); + let r_src = replacement.as_latin1().expect("checked latin1").as_ptr(); + + // 1. Copy preserved head (0..pos) + if pos > 0 { + ptr::copy_nonoverlapping(s_src, dest, pos); + } + // 2. Copy replacement + if replace_len > 0 { + ptr::copy_nonoverlapping(r_src, dest.add(pos), replace_len); + } + // 3. Copy tail (pos + search_len..) + if tail_len > 0 { + ptr::copy_nonoverlapping( + s_src.add(pos + search_len), + dest.add(pos + replace_len), + tail_len, + ); + } + } + Self { ptr: p.cast() } + } else { + let p = SequenceString::::allocate(total_len); + // SAFETY: + // - `p` points to a newly allocated `SequenceString` with capacity `total_len` u16 words. + // - Destination and sources are valid and do not overlap. + // - `[0, total_len)` is fully initialized before `Self { ptr }` escapes. + unsafe { + let dest = (&raw mut (*p.as_ptr()).data).cast::(); + debug_assert!(dest.is_aligned()); + + // Copy head + match string.variant() { + JsStrVariant::Latin1(s) => { + for (i, &b) in s[..pos].iter().enumerate() { + *dest.add(i) = u16::from(b); + } + } + JsStrVariant::Utf16(s) => { + if pos > 0 { + ptr::copy_nonoverlapping(s.as_ptr(), dest, pos); + } + } + } + + // Copy replacement + match replacement.variant() { + JsStrVariant::Latin1(r) => { + for (i, &b) in r.iter().enumerate() { + *dest.add(pos + i) = u16::from(b); + } + } + JsStrVariant::Utf16(r) => { + if replace_len > 0 { + ptr::copy_nonoverlapping(r.as_ptr(), dest.add(pos), replace_len); + } + } + } + + // Copy tail + match string.variant() { + JsStrVariant::Latin1(s) => { + for (i, &b) in s[pos + search_len..].iter().enumerate() { + *dest.add(pos + replace_len + i) = u16::from(b); + } + } + JsStrVariant::Utf16(s) => { + if tail_len > 0 { + ptr::copy_nonoverlapping( + s.as_ptr().add(pos + search_len), + dest.add(pos + replace_len), + tail_len, + ); + } + } + } + } + Self { ptr: p.cast() } + }; + + StaticJsStrings::get_string(&res.as_str()).unwrap_or(res) + } + /// Creates a new [`JsString`] from `data`, without checking if the string is in the interner. fn from_slice_skip_interning(string: JsStr<'_>) -> Self { let count = string.len(); diff --git a/core/string/src/str.rs b/core/string/src/str.rs index 59e98d9d57d..c4dd84dffbe 100644 --- a/core/string/src/str.rs +++ b/core/string/src/str.rs @@ -137,6 +137,17 @@ impl<'a> JsStr<'a> { self.len() == 0 } + /// Creates a new [`JsString`] by repeating `self` `count` times. + /// + /// # Panics + /// + /// Panics with an allocation overflow if `len * count` overflows `usize`. + #[inline] + #[must_use] + pub fn repeat(self, count: usize) -> crate::JsString { + crate::JsString::repeat_str(self, count) + } + /// Returns an element or subslice depending on the type of index, otherwise [`None`]. #[inline] #[must_use] @@ -214,7 +225,6 @@ impl<'a> JsStr<'a> { /// - [ECMAScript reference][spec] /// /// [spec]: https://tc39.es/ecma262/#sec-stringindexof - #[inline] #[must_use] pub fn index_of(&self, search_value: JsStr<'_>, from_index: usize) -> Option { // 1. Assert: Type(string) is String. @@ -238,12 +248,145 @@ impl<'a> JsStr<'a> { // a. Let candidate be the substring of string from i to i + searchLen. // b. If candidate is the same sequence of code units as searchValue, return i. // 8. Return -1. - self.windows(search_value.len()) - .skip(from_index) - .position(|s| s == search_value) - .map(|i| i + from_index) + let search_len = search_value.len(); + // NB: check `search_len > len` first to avoid underflowing `len - search_len`. + if search_len > len || from_index > len - search_len { + return None; + } + debug_assert!(!search_value.is_empty()); + debug_assert!(search_len >= 1); + + match (self.variant(), search_value.variant()) { + (JsStrVariant::Latin1(haystack), JsStrVariant::Latin1(needle)) => { + index_of_same(&haystack[from_index..], needle).map(|i| i + from_index) + } + (JsStrVariant::Utf16(haystack), JsStrVariant::Utf16(needle)) => { + index_of_same(&haystack[from_index..], needle).map(|i| i + from_index) + } + (JsStrVariant::Latin1(haystack), JsStrVariant::Utf16(needle)) => { + // A needle containing units > 0xFF can never match a Latin1 haystack. + if needle.iter().any(|&c| c > 0xFF) { + return None; + } + index_of_mixed_latin1_utf16(&haystack[from_index..], needle).map(|i| i + from_index) + } + (JsStrVariant::Utf16(haystack), JsStrVariant::Latin1(needle)) => { + index_of_mixed_utf16_latin1(&haystack[from_index..], needle).map(|i| i + from_index) + } + } } +} +/// Searches `haystack` for `needle` where both share the same code-unit type. +/// +/// `needle` must be non-empty. Returns the offset within `haystack`. +fn index_of_same(haystack: &[T], needle: &[T]) -> Option { + debug_assert!(!needle.is_empty()); + let needle_len = needle.len(); + if needle_len == 1 { + return haystack.iter().position(|b| *b == needle[0]); + } + if needle_len > haystack.len() { + return None; + } + let first = &needle[0]; + let rest = &needle[1..]; + let max_start = haystack.len() - needle_len; + debug_assert!(needle_len >= 2); + let mut offset = 0; + while offset <= max_start { + if let Some(pos) = haystack[offset..=max_start].iter().position(|b| b == first) { + let match_pos = offset + pos; + debug_assert!(match_pos + needle_len <= haystack.len()); + // SAFETY: `match_pos <= max_start = haystack.len() - needle_len`, + // so `match_pos + 1..match_pos + needle_len` is within bounds. + if unsafe { haystack.get_unchecked(match_pos + 1..match_pos + needle_len) } == rest { + return Some(match_pos); + } + offset = match_pos + 1; + } else { + break; + } + } + None +} + +/// Searches a Latin1 `haystack` for a UTF-16 `needle` with all units `<= 0xFF`. +/// +/// `needle` must be non-empty and contain only units `<= 0xFF`. +fn index_of_mixed_latin1_utf16(haystack: &[u8], needle: &[u16]) -> Option { + debug_assert!(!needle.is_empty()); + debug_assert!(needle.iter().all(|&c| c <= 0xFF)); + let needle_len = needle.len(); + if needle_len > haystack.len() { + return None; + } + // SAFETY: caller guarantees every unit is `<= 0xFF`, so truncation is exact. + #[allow(clippy::cast_possible_truncation)] + let first = needle[0] as u8; + let rest = &needle[1..]; + let max_start = haystack.len() - needle_len; + let mut offset = 0; + while offset <= max_start { + if let Some(pos) = haystack[offset..=max_start] + .iter() + .position(|&b| b == first) + { + let match_pos = offset + pos; + debug_assert!(match_pos + needle_len <= haystack.len()); + // SAFETY: `match_pos <= max_start`, so the range below is within bounds. + if unsafe { haystack.get_unchecked(match_pos + 1..match_pos + needle_len) } + .iter() + .zip(rest) + .all(|(&b, &n)| u16::from(b) == n) + { + return Some(match_pos); + } + offset = match_pos + 1; + } else { + break; + } + } + None +} + +/// Searches a UTF-16 `haystack` for a Latin1 `needle`. +/// +/// `needle` must be non-empty. +fn index_of_mixed_utf16_latin1(haystack: &[u16], needle: &[u8]) -> Option { + debug_assert!(!needle.is_empty()); + let needle_len = needle.len(); + if needle_len > haystack.len() { + return None; + } + let first = u16::from(needle[0]); + let rest = &needle[1..]; + let max_start = haystack.len() - needle_len; + let mut offset = 0; + while offset <= max_start { + if let Some(pos) = haystack[offset..=max_start] + .iter() + .position(|&b| b == first) + { + let match_pos = offset + pos; + debug_assert!(match_pos + needle_len <= haystack.len()); + // SAFETY: `match_pos <= max_start`, so the range below is within bounds. + if unsafe { haystack.get_unchecked(match_pos + 1..match_pos + needle_len) } + .iter() + .zip(rest) + .all(|(&h, &n)| h == u16::from(n)) + { + return Some(match_pos); + } + offset = match_pos + 1; + } else { + break; + } + } + None +} + +impl<'a> JsStr<'a> { /// Abstract operation `CodePointAt( string, position )`. /// /// The abstract operation `CodePointAt` takes arguments `string` (a String) and `position` (a diff --git a/core/string/src/tests.rs b/core/string/src/tests.rs index 0a4f80a602b..933dc569f7f 100644 --- a/core/string/src/tests.rs +++ b/core/string/src/tests.rs @@ -565,3 +565,220 @@ fn starts_with_and_ends_with_basic() { assert!(!basic.starts_with(end_needle)); assert!(basic.ends_with(end_needle)); } + +#[test] +fn repeat() { + let empty = JsString::from(""); + assert_eq!(empty.repeat(0), JsString::from("")); + assert_eq!(empty.repeat(10), JsString::from("")); + + let single = JsString::from("a"); + assert_eq!(single.repeat(0), JsString::from("")); + assert_eq!(single.repeat(1), JsString::from("a")); + assert_eq!(single.repeat(5), JsString::from("aaaaa")); + + let latin = JsString::from("abc"); + assert_eq!(latin.repeat(3), JsString::from("abcabcabc")); + assert_eq!( + latin.repeat(10), + JsString::from("abcabcabcabcabcabcabcabcabcabc") + ); + + let utf16 = JsString::from("🔥🦀"); + assert_eq!(utf16.repeat(0), JsString::from("")); + assert_eq!(utf16.repeat(1), JsString::from("🔥🦀")); + assert_eq!(utf16.repeat(4), JsString::from("🔥🦀🔥🦀🔥🦀🔥🦀")); +} + +#[test] +fn join() { + let sep = JsStr::latin1(", ".as_bytes()); + let empty_list: &[JsStr<'_>] = &[]; + assert_eq!(JsString::join(sep, empty_list), JsString::from("")); + + let single_item = [JsStr::latin1("one".as_bytes())]; + assert_eq!(JsString::join(sep, &single_item), JsString::from("one")); + + let multiple_items = [ + JsStr::latin1("one".as_bytes()), + JsStr::latin1("two".as_bytes()), + JsStr::latin1("three".as_bytes()), + ]; + assert_eq!( + JsString::join(sep, &multiple_items), + JsString::from("one, two, three") + ); + + let utf16_item = JsStr::utf16(&[0xD83D, 0xDD25]); // 🔥 + let mixed_items = [ + JsStr::latin1("fire".as_bytes()), + utf16_item, + JsStr::latin1("crab".as_bytes()), + ]; + assert_eq!( + JsString::join(sep, &mixed_items), + JsString::from("fire, 🔥, crab") + ); + + // Empty separator: pure concatenation. + let no_sep = JsStr::latin1(b""); + let ab = [JsStr::latin1(b"a"), JsStr::latin1(b"b")]; + assert_eq!(JsString::join(no_sep, &ab), JsString::from("ab")); + + // UTF-16 separator forces UTF-16 promotion. + let utf16_sep = JsStr::utf16(&[0xD83D, 0xDD25]); + let latin_elems = [JsStr::latin1(b"a"), JsStr::latin1(b"b")]; + let promoted = JsString::join(utf16_sep, &latin_elems); + assert_eq!(promoted, JsString::from("a🔥b")); + assert!(!promoted.as_str().is_latin1()); + + // All-empty with empty sep hits the `total_len == 0` early return. + let empties = [JsStr::latin1(b""), JsStr::latin1(b"")]; + assert_eq!( + JsString::join(no_sep, &empties), + StaticJsStrings::EMPTY_STRING + ); + + // Empty `JsStr::utf16(&[])` parts are encoding-neutral: result stays Latin1. + let empty_utf16 = JsStr::utf16(&[]); + let neutral = [JsStr::latin1(b"a"), empty_utf16, JsStr::latin1(b"b")]; + let neutral_joined = JsString::join(no_sep, &neutral); + assert_eq!(neutral_joined, JsString::from("ab")); + assert!(neutral_joined.as_str().is_latin1()); + + // Empty input returns the EMPTY static. + assert_eq!( + JsString::join(sep, empty_list), + StaticJsStrings::EMPTY_STRING + ); +} + +#[test] +fn index_of_variants() { + let latin = JsString::from("abcabc"); + let l = latin.as_str(); + assert_eq!(l.index_of(JsStr::latin1(b"bc"), 0), Some(1)); + assert_eq!(l.index_of(JsStr::latin1(b"a"), 1), Some(3)); + assert_eq!(l.index_of(JsStr::latin1(b"abc"), 4), None); + assert_eq!(l.index_of(JsStr::latin1(b"abcdefg"), 0), None); + assert_eq!(l.index_of(JsStr::latin1(b""), 2), Some(2)); + assert_eq!(l.index_of(JsStr::latin1(b""), 99), None); + + let utf16 = JsString::from("a🔥b🔥c"); + let u = utf16.as_str(); + // Lone trail-surrogate unit search in UTF-16 haystack. + assert_eq!(u.index_of(JsStr::utf16(&[0xDD25]), 0), Some(2)); + // Latin1 needle in UTF-16 haystack. + assert_eq!(u.index_of(JsStr::latin1(b"b"), 0), Some(3)); + // UTF-16 needle (<= 0xFF) in Latin1 haystack. + assert_eq!(l.index_of(JsStr::utf16(&[u16::from(b'b')]), 0), Some(1)); + // UTF-16 needle with units > 0xFF can never match Latin1 haystack. + assert_eq!(l.index_of(JsStr::utf16(&[0x2603]), 0), None); + // UTF-16 x UTF-16 multi-unit. + assert_eq!( + u.index_of(JsStr::utf16(&[0xD83D, 0xDD25, u16::from(b'b')]), 0), + Some(1) + ); +} + +#[test] +fn replace_once_cases() { + use crate::{JsStr, JsString, StaticJsStrings}; + + let j = JsString::from; + // Latin1 x Latin1. + assert_eq!( + JsString::replace_once( + j("hello").as_str(), + JsStr::latin1(b"l"), + JsStr::latin1(b"L") + ), + j("heLlo") + ); + // Empty search inserts at 0. + assert_eq!( + JsString::replace_once(j("abc").as_str(), JsStr::latin1(b""), JsStr::latin1(b"X")), + j("Xabc") + ); + // Not found returns an equal value. + assert_eq!( + JsString::replace_once(j("abc").as_str(), JsStr::latin1(b"z"), JsStr::latin1(b"X")), + j("abc") + ); + // Empty replacement deletes. + assert_eq!( + JsString::replace_once( + j("abcabc").as_str(), + JsStr::latin1(b"b"), + JsStr::latin1(b"") + ), + j("acabc") + ); + // total_len == 0 returns the EMPTY static. + assert_eq!( + JsString::replace_once(j("a").as_str(), JsStr::latin1(b"a"), JsStr::latin1(b"")), + StaticJsStrings::EMPTY_STRING + ); + // UTF-16 haystack + Latin1 needle/replacement promotes to UTF-16. + let fire = JsString::from("fire🔥fire"); + let r = JsString::replace_once( + fire.as_str(), + JsStr::latin1(b"fire"), + JsStr::latin1(b"water"), + ); + assert_eq!(r, JsString::from("water🔥fire")); + assert!(!r.as_str().is_latin1()); + // Non-Latin1 needle never matches a Latin1 haystack. + let needle = JsStr::utf16(&[0xD83D, 0xDD25]); + assert_eq!( + JsString::replace_once(j("abc").as_str(), needle, JsStr::latin1(b"X")), + j("abc") + ); + // `replace_once_at` reuses a known position (no second search). + let s = j("abcabc"); + let pos = s.as_str().index_of(JsStr::latin1(b"bc"), 0).unwrap(); + assert_eq!( + JsString::replace_once_at(s.as_str(), 2, JsStr::latin1(b"X"), pos), + j("aXabc") + ); +} + +#[test] +fn repeat_encoding_and_sharing() { + // `count == 1` on `&self` shares instead of copying. + let s = JsString::from("abc"); + assert_eq!(s.repeat(1), JsString::from("abc")); + // BMP non-Latin1 stays UTF-16. + let bmp = JsString::from("年"); + let r = bmp.repeat(3); + assert_eq!(r, JsString::from("年年年")); + assert!(!r.as_str().is_latin1()); + // Latin1 stays Latin1. + let latin = JsString::from("ab").repeat(3); + assert_eq!(latin, JsString::from("ababab")); + assert!(latin.as_str().is_latin1()); + // Doubling-remainder boundary. + assert_eq!( + JsString::from("abc").repeat(10), + JsString::from("abcabcabcabcabcabcabcabcabcabc") + ); +} + +#[test] +fn trim_sharing_and_empty() { + use crate::{JsString, StaticJsStrings}; + + let s = JsString::from("hello"); + // Already-trimmed shares the allocation instead of slicing. + assert_eq!(s.trim(), JsString::from("hello")); + assert_eq!(s.trim_start(), JsString::from("hello")); + assert_eq!(s.trim_end(), JsString::from("hello")); + + assert_eq!(JsString::from(" ").trim(), StaticJsStrings::EMPTY_STRING); + assert_eq!( + JsString::from("").trim_start(), + StaticJsStrings::EMPTY_STRING + ); + assert_eq!(JsString::from(" a").trim_start(), JsString::from("a")); + assert_eq!(JsString::from("a ").trim_end(), JsString::from("a")); +} From be98376d7dae8d91577382452bd5300b14cb5b07 Mon Sep 17 00:00:00 2001 From: xcb3d Date: Fri, 4 Sep 2026 22:16:34 +0700 Subject: [PATCH 2/3] fix(docs): resolve broken intra-doc links for -D warnings Use fully-qualified paths (crate::JsString, crate::JsValue) in new as_str/repeat docs so cargo doc --document-private-items passes with denied warnings. --- core/engine/src/value/inner/legacy.rs | 2 +- core/engine/src/value/inner/nan_boxed.rs | 2 +- core/string/src/str.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/engine/src/value/inner/legacy.rs b/core/engine/src/value/inner/legacy.rs index 817c99bccab..252cf4c9048 100644 --- a/core/engine/src/value/inner/legacy.rs +++ b/core/engine/src/value/inner/legacy.rs @@ -269,7 +269,7 @@ impl EnumBasedValue { /// Returns the value as a [`JsStr`] without cloning. /// - /// The returned slice borrows `self`; keep the [`JsValue`] alive while using it. + /// The returned slice borrows `self`; keep the [`crate::JsValue`] alive while using it. #[must_use] #[inline] pub(crate) fn as_str(&self) -> Option> { diff --git a/core/engine/src/value/inner/nan_boxed.rs b/core/engine/src/value/inner/nan_boxed.rs index cd3ccc8f886..901eed5734f 100644 --- a/core/engine/src/value/inner/nan_boxed.rs +++ b/core/engine/src/value/inner/nan_boxed.rs @@ -734,7 +734,7 @@ impl NanBoxedValue { /// Returns the value as a [`JsStr`] without cloning. /// - /// The returned slice borrows `self`; keep the [`JsValue`] alive while using it. + /// The returned slice borrows `self`; keep the [`crate::JsValue`] alive while using it. #[must_use] #[inline(always)] pub(crate) fn as_str(&self) -> Option> { diff --git a/core/string/src/str.rs b/core/string/src/str.rs index c4dd84dffbe..564666715a2 100644 --- a/core/string/src/str.rs +++ b/core/string/src/str.rs @@ -137,7 +137,7 @@ impl<'a> JsStr<'a> { self.len() == 0 } - /// Creates a new [`JsString`] by repeating `self` `count` times. + /// Creates a new [`crate::JsString`] by repeating `self` `count` times. /// /// # Panics /// From a63a92f4cd5e691928c54355e98c55463f89c2cf Mon Sep 17 00:00:00 2001 From: xcb3d Date: Fri, 4 Sep 2026 22:48:30 +0700 Subject: [PATCH 3/3] fix(value): correct JsValue::as_str doctest and intra-doc links --- core/engine/src/value/inner/nan_boxed.rs | 6 +++--- core/engine/src/value/mod.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/core/engine/src/value/inner/nan_boxed.rs b/core/engine/src/value/inner/nan_boxed.rs index 901eed5734f..8200a98823b 100644 --- a/core/engine/src/value/inner/nan_boxed.rs +++ b/core/engine/src/value/inner/nan_boxed.rs @@ -1,6 +1,6 @@ //! A NaN-boxed inner value for JavaScript values. //! -//! This [`JsValue`] is a float using `NaN` values to represent an inner +//! This [`crate::JsValue`] is a float using `NaN` values to represent an inner //! JavaScript value. //! //! # Assumptions @@ -48,7 +48,7 @@ //! //! # Design //! -//! This [`JsValue`] inner type is a NaN-boxed value, which is a 64-bits value +//! This [`crate::JsValue`] inner type is a NaN-boxed value, which is a 64-bits value //! that can represent any JavaScript value. If the integer is a non-NaN value, //! it will be stored as a 64-bits float. If it is a `f64::NAN` value, it will //! be stored as a quiet `NaN` value. Subnormal numbers are regular float. @@ -321,7 +321,7 @@ const_assert!(f64::from_bits(bits::MASK_STRING).is_nan()); const_assert!(f64::from_bits(bits::MASK_SYMBOL).is_nan()); const_assert!(f64::from_bits(bits::MASK_BIGINT).is_nan()); -/// A NaN-boxed [`JsValue`]'s inner. +/// A NaN-boxed [`crate::JsValue`]'s inner. pub(crate) struct NanBoxedValue { #[cfg(target_pointer_width = "32")] half: u32, diff --git a/core/engine/src/value/mod.rs b/core/engine/src/value/mod.rs index 073346736bd..c0ffffcc5c2 100644 --- a/core/engine/src/value/mod.rs +++ b/core/engine/src/value/mod.rs @@ -779,10 +779,10 @@ impl JsValue { /// # Examples /// /// ``` - /// use boa_engine::JsValue; + /// use boa_engine::{JsValue, js_str, js_string}; /// - /// let string = JsValue::new("hello"); - /// assert!(string.as_str().is_some()); + /// let string = JsValue::new(js_string!("hello")); + /// assert_eq!(string.as_str(), Some(js_str!("hello"))); /// /// let number = JsValue::new(42); /// assert!(number.as_str().is_none());