Skip to main content

submilli_engine/runtime/prelude/string/
mod.rs

1//! The submilli string and its operations — the Rust port of the prelude's
2//! `String` methods.
3//!
4//! Everything here works in UTF-16 *code-unit* space (`[u16]`), the
5//! representation `$string` stores and the one JS string semantics are defined
6//! over. Operations never decode to a Rust `String`: that would round-trip
7//! through UTF-8 (lossy on lone surrogates, and an allocation on every call).
8//! The `$string` ↔ [`Str`] marshalling and host-fn registration live in
9//! [`install`]; this file is just the strings.
10
11mod install;
12pub(crate) mod search;
13mod transforms;
14
15pub(crate) use install::declare_types;
16pub use install::{declare, install};
17
18use unicode_normalization::UnicodeNormalization;
19
20use crate::runtime::host::{fatal_host_error, range_error};
21use crate::runtime::limits::{HostBytes, TenantLimits};
22
23/// A submilli string: its UTF-16 code units. Cheap to slice and concatenate, and
24/// faithful to JS — indices, `length`, and surrogate handling are all code-unit
25/// based.
26pub struct Str(Vec<u16>);
27
28impl Str {
29    pub fn from_units(units: Vec<u16>) -> Self {
30        Self(units)
31    }
32
33    pub fn units(&self) -> &[u16] {
34        &self.0
35    }
36
37    pub fn len(&self) -> usize {
38        self.0.len()
39    }
40
41    pub fn is_empty(&self) -> bool {
42        self.0.is_empty()
43    }
44}
45
46/// A native string whose allocation remains charged until its buffer is dropped.
47/// Only borrowed access is exposed so the reservation cannot be separated from the buffer.
48pub struct AdmittedStr {
49    // Fields drop in declaration order: free the buffer before refunding bytes.
50    value: Str,
51    _native: HostBytes,
52}
53
54impl AdmittedStr {
55    pub fn as_str(&self) -> &Str {
56        &self.value
57    }
58
59    fn allocate(limits: &TenantLimits, units: usize) -> wasmtime::Result<Self> {
60        let native = admit_units(limits, units)?;
61        let mut buffer = Vec::new();
62        buffer.try_reserve_exact(units).map_err(fatal_host_error)?;
63        Ok(Self {
64            value: Str::from_units(buffer),
65            _native: native,
66        })
67    }
68}
69
70fn admit_units(limits: &TenantLimits, units: usize) -> wasmtime::Result<HostBytes> {
71    let bytes = units
72        .checked_mul(std::mem::size_of::<u16>())
73        .and_then(|bytes| u64::try_from(bytes).ok())
74        .ok_or_else(|| fatal_host_error("native string byte length overflow"))?;
75    Ok(HostBytes::new(limits, bytes)?)
76}
77
78/// A string operation rejected its input the way JS would (a `RangeError`).
79/// [`install`] raises it as a catchable guest `RangeError`.
80#[derive(Debug)]
81pub struct RangeError(&'static str);
82
83impl RangeError {
84    pub fn message(&self) -> &'static str {
85        self.0
86    }
87}
88
89pub type Result<T> = std::result::Result<T, RangeError>;
90
91/// Caps a built result so a ported op can't allocate an unbounded host buffer.
92/// This language-level ceiling complements tenant admission; it does not prove
93/// that a native allocation fits the tenant's remaining memory budget.
94const MAX_RESULT_UNITS: usize = 32 * 1024 * 1024;
95
96/// `String.prototype.repeat`. `ToInteger(count)` truncates toward zero, leaving
97/// three bands — and the guard order below encodes them, so `NaN` must fall
98/// through the negative check to the zero case:
99///   - `count <= -1` → throws (negative repeat)
100///   - `-1 < count < 1`, or `NaN` → `""` (zero repeats)
101///   - `count >= 1` → repeat, capped so an overflowing or `+Infinity` count
102///     throws instead of allocating (see [`MAX_RESULT_UNITS`]).
103pub fn repeat(s: &Str, count: f64, limits: &TenantLimits) -> wasmtime::Result<AdmittedStr> {
104    let total = repeat_length(s.len(), count).map_err(|error| range_error(error.message()))?;
105    let mut out = AdmittedStr::allocate(limits, total)?;
106    if total == 0 {
107        return Ok(out);
108    }
109    // repeat_length returns nonzero only for a nonempty receiver and an exact
110    // multiple of its length; the reserved capacity covers every extension.
111    for _ in 0..total / s.len() {
112        out.value.0.extend_from_slice(s.units());
113    }
114    Ok(out)
115}
116
117pub(crate) fn repeat_length(len: usize, count: f64) -> Result<usize> {
118    let n = count.trunc();
119    if n <= -1.0 {
120        return Err(RangeError("Invalid count value"));
121    }
122    if n.is_nan() || n < 1.0 || len == 0 {
123        return Ok(0);
124    }
125    // The saturating cast makes positive infinity fail the checked bound.
126    len.checked_mul(n as usize)
127        .filter(|&total| total <= MAX_RESULT_UNITS)
128        .ok_or(RangeError("Invalid count value"))
129}
130
131/// Trunc-sat `f64`→`i32`, matching the prelude's `i32.trunc_sat_f64_s`: `NaN`→0,
132/// out-of-range saturates to `i32::MIN`/`MAX`. Rust's `as` cast is already
133/// saturating, so the index methods agree with the Wasm bodies they replace.
134fn trunc_sat_i32(x: f64) -> i32 {
135    x as i32
136}
137
138/// Rejects a builder result that would exceed [`MAX_RESULT_UNITS`]. JS throws a
139/// `RangeError` ("Invalid string length") here; the Wasm bodies trapped through
140/// the GC limiter, which a Rust `Vec` would bypass — so host builders bound
141/// themselves explicitly.
142fn checked_len(n: usize) -> Result<usize> {
143    if n > MAX_RESULT_UNITS {
144        Err(RangeError("Invalid string length"))
145    } else {
146        Ok(n)
147    }
148}
149
150/// Clamp an index into `[0, len]` (negatives to 0, overshoot to `len`).
151fn clamp_to_len(x: i32, len: usize) -> usize {
152    if x < 0 { 0 } else { (x as usize).min(len) }
153}
154
155/// JS slice-style index normalization: a negative index counts from the end,
156/// then the result is clamped into `[0, len]`.
157fn normalize_slice_index(x: i32, len: usize) -> usize {
158    if x < 0 {
159        let from_end = x as i64 + len as i64;
160        if from_end < 0 {
161            0
162        } else {
163            (from_end as usize).min(len)
164        }
165    } else {
166        (x as usize).min(len)
167    }
168}
169
170/// First index `>= from` at which `needle` occurs in `haystack`. An empty
171/// `needle` matches at `min(from, len)` — JS `indexOf`/`includes` search order.
172fn raw_index_of(haystack: &[u16], needle: &[u16], from: usize) -> Option<usize> {
173    search::Search::new(needle, false).find(haystack, from.min(haystack.len()))
174}
175
176/// `charAt`: the single code unit at `index`, or `""` out of range.
177pub fn char_at(s: &Str, index: f64) -> Str {
178    let units = s.units();
179    match unit_index(units.len(), index) {
180        Some(i) => Str::from_units(vec![units[i]]),
181        None => Str::from_units(Vec::new()),
182    }
183}
184
185/// `at`: like `charAt`, but a negative `index` counts from the end. `None` on
186/// OOB — `Array#at` and `Uint8Array#at` both answer `undefined` there, and `""` is
187/// indistinguishable from a legitimate empty read.
188pub fn at(s: &Str, index: f64) -> Option<Str> {
189    let units = s.units();
190    at_index(units.len(), index).map(|i| Str::from_units(vec![units[i]]))
191}
192
193/// `charCodeAt`: the UTF-16 code unit at `index` as `f64`, or `NaN` out of range.
194pub fn char_code_at(s: &Str, index: f64) -> f64 {
195    let units = s.units();
196    unit_index(units.len(), index).map_or(f64::NAN, |i| units[i] as f64)
197}
198
199/// `slice`: negatives count from the end; empty when `start >= end` after
200/// normalization.
201pub fn slice(s: &Str, start: f64, end: f64) -> Str {
202    let units = s.units();
203    Str::from_units(units[slice_range(units.len(), start, end)].to_vec())
204}
205
206/// `substring`: negatives clamp to 0 (not from the end), and the arguments swap
207/// when `start > end`.
208pub fn substring(s: &Str, start: f64, end: f64) -> Str {
209    let units = s.units();
210    Str::from_units(units[substring_range(units.len(), start, end)].to_vec())
211}
212
213/// `indexOf`: first match at or after `fromIndex`, else `-1`.
214pub fn index_of(s: &Str, search: &Str, from: f64) -> f64 {
215    let haystack = s.units();
216    let from = clamp_to_len(trunc_sat_i32(from), haystack.len());
217    match raw_index_of(haystack, search.units(), from) {
218        Some(i) => i as f64,
219        None => -1.0,
220    }
221}
222
223/// `lastIndexOf`: last match at or before `fromIndex` (default end), else `-1`.
224pub fn last_index_of(s: &Str, search: &Str, from: f64) -> f64 {
225    let haystack = s.units();
226    let needle = search.units();
227    if needle.len() > haystack.len() {
228        return -1.0;
229    }
230    let max_start = haystack.len() - needle.len();
231    let from = clamp_to_len(trunc_sat_i32(from), max_start);
232    if needle.is_empty() {
233        return from as f64;
234    }
235    let end = from + needle.len();
236    search::Search::new(needle, true)
237        .find(&haystack[..end], 0)
238        .map_or(-1.0, |i| (end - i - needle.len()) as f64)
239}
240
241/// `includes`: whether `search` occurs at or after `fromIndex`.
242pub fn includes(s: &Str, search: &Str, from: f64) -> bool {
243    let haystack = s.units();
244    let from = clamp_to_len(trunc_sat_i32(from), haystack.len());
245    raw_index_of(haystack, search.units(), from).is_some()
246}
247
248/// `startsWith`: whether `search` sits at `position`.
249pub fn starts_with(s: &Str, search: &Str, position: f64) -> bool {
250    let haystack = s.units();
251    let needle = search.units();
252    starts_with_window(haystack.len(), needle.len(), position)
253        .is_some_and(|window| &haystack[window] == needle)
254}
255
256/// `endsWith`: whether `search` ends at `endPosition` (default end).
257pub fn ends_with(s: &Str, search: &Str, end_position: f64) -> bool {
258    let haystack = s.units();
259    let needle = search.units();
260    ends_with_window(haystack.len(), needle.len(), end_position)
261        .is_some_and(|window| &haystack[window] == needle)
262}
263
264/// The unit `charAt`/`charCodeAt` read for `index`, if it is in range.
265pub(super) fn unit_index(len: usize, index: f64) -> Option<usize> {
266    let i = trunc_sat_i32(index);
267    (i >= 0 && (i as usize) < len).then_some(i as usize)
268}
269
270/// The unit `at` reads for `index`, a negative one counting from the end.
271pub(super) fn at_index(len: usize, index: f64) -> Option<usize> {
272    let len = len as i64;
273    let mut i = trunc_sat_i32(index) as i64;
274    if i < 0 {
275        i += len;
276    }
277    (i >= 0 && i < len).then_some(i as usize)
278}
279
280/// The code point `codePointAt` answers for the unit at an index and the one
281/// after it, when a surrogate pair starts there.
282pub(super) fn code_point(hi: u16, next: Option<u16>) -> f64 {
283    match next {
284        Some(lo) if (0xD800..=0xDBFF).contains(&hi) && (0xDC00..=0xDFFF).contains(&lo) => {
285            (0x10000 + (((hi as u32) - 0xD800) << 10) + ((lo as u32) - 0xDC00)) as f64
286        }
287        _ => hi as f64,
288    }
289}
290
291/// The units `slice(start, end)` keeps.
292pub(super) fn slice_range(len: usize, start: f64, end: f64) -> std::ops::Range<usize> {
293    let si = normalize_slice_index(trunc_sat_i32(start), len);
294    let ei = normalize_slice_index(trunc_sat_i32(end), len);
295    si..ei.max(si)
296}
297
298/// The units `substring(start, end)` keeps: negatives clamp to 0 and the
299/// bounds swap when `start > end`.
300pub(super) fn substring_range(len: usize, start: f64, end: f64) -> std::ops::Range<usize> {
301    let a = clamp_to_len(trunc_sat_i32(start), len);
302    let b = clamp_to_len(trunc_sat_i32(end), len);
303    a.min(b)..a.max(b)
304}
305
306/// The window of a haystack `startsWith(needle, position)` compares, if the
307/// needle fits there.
308pub(super) fn starts_with_window(
309    len: usize,
310    needle_len: usize,
311    position: f64,
312) -> Option<std::ops::Range<usize>> {
313    let pos = clamp_to_len(trunc_sat_i32(position), len);
314    (pos + needle_len <= len).then_some(pos..pos + needle_len)
315}
316
317/// The window of a haystack `endsWith(needle, endPosition)` compares, if the
318/// needle fits there.
319pub(super) fn ends_with_window(
320    len: usize,
321    needle_len: usize,
322    end_position: f64,
323) -> Option<std::ops::Range<usize>> {
324    let end = clamp_to_len(trunc_sat_i32(end_position), len);
325    end.checked_sub(needle_len).map(|start| start..end)
326}
327
328/// Structural equality over code units — the `===`/`==` operator primitive.
329pub fn eq(a: &Str, b: &Str) -> bool {
330    a.units() == b.units()
331}
332
333/// Lexicographic compare over code units — the relational-operator primitive.
334/// Returns a sign: the first differing units' difference, else the length
335/// difference (consumers test the sign only).
336pub fn cmp(a: &Str, b: &Str) -> i32 {
337    for (&au, &bu) in a.units().iter().zip(b.units()) {
338        let diff = i32::from(au) - i32::from(bu);
339        if diff != 0 {
340            return diff;
341        }
342    }
343    a.len() as i32 - b.len() as i32
344}
345
346/// `concat`: `self` followed by `other`.
347pub fn concat(s: &Str, other: &Str, limits: &TenantLimits) -> wasmtime::Result<AdmittedStr> {
348    let total = concat_length(s.len(), other.len())?;
349    let mut out = AdmittedStr::allocate(limits, total)?;
350    out.value.0.extend_from_slice(s.units());
351    out.value.0.extend_from_slice(other.units());
352    Ok(out)
353}
354
355pub(crate) fn concat_length(left: usize, right: usize) -> wasmtime::Result<usize> {
356    left.checked_add(right)
357        .ok_or_else(|| fatal_host_error("native string length overflow"))
358}
359
360/// `padStart`: prepend repetitions of `pad` until the string reaches
361/// `target_length`. Copies `self` when already long enough or `pad` is empty.
362pub fn pad_start(
363    s: &Str,
364    target_length: f64,
365    pad: &Str,
366    limits: &TenantLimits,
367) -> wasmtime::Result<AdmittedStr> {
368    pad_string(s, target_length, pad, limits, true)
369}
370
371/// `padEnd`: append repetitions of `pad` until the string reaches
372/// `target_length`. Copies `self` when already long enough or `pad` is empty.
373pub fn pad_end(
374    s: &Str,
375    target_length: f64,
376    pad: &Str,
377    limits: &TenantLimits,
378) -> wasmtime::Result<AdmittedStr> {
379    pad_string(s, target_length, pad, limits, false)
380}
381
382fn pad_string(
383    s: &Str,
384    target_length: f64,
385    pad: &Str,
386    limits: &TenantLimits,
387    leading: bool,
388) -> wasmtime::Result<AdmittedStr> {
389    let total = pad_length(s.len(), target_length, pad.len())
390        .map_err(|error| range_error(error.message()))?;
391    let mut out = AdmittedStr::allocate(limits, total)?;
392    if !leading {
393        out.value.0.extend_from_slice(s.units());
394    }
395    let mut remaining = total - s.len();
396    while remaining > 0 {
397        // pad_length increases the receiver length only when the pad is nonempty.
398        // Each prefix is bounded by its slice and the admitted output capacity.
399        let chunk = remaining.min(pad.len());
400        out.value.0.extend_from_slice(&pad.units()[..chunk]);
401        remaining -= chunk;
402    }
403    if leading {
404        out.value.0.extend_from_slice(s.units());
405    }
406    Ok(out)
407}
408
409pub(crate) fn pad_length(len: usize, target_length: f64, pad_len: usize) -> Result<usize> {
410    let target = trunc_sat_i32(target_length);
411    if target <= 0 || target as usize <= len || pad_len == 0 {
412        return Ok(len);
413    }
414    checked_len(target as usize)
415}
416
417/// `isWellFormed`: `true` when the string contains no lone surrogate.
418pub fn is_well_formed(s: &Str) -> bool {
419    let u = s.units();
420    let mut i = 0;
421    while i < u.len() {
422        let c = u[i];
423        if (c & 0xFC00) == 0xD800 {
424            if i + 1 < u.len() && (u[i + 1] & 0xFC00) == 0xDC00 {
425                i += 2;
426            } else {
427                return false;
428            }
429        } else if (c & 0xFC00) == 0xDC00 {
430            return false;
431        } else {
432            i += 1;
433        }
434    }
435    true
436}
437
438/// `toWellFormed`: a copy with every lone surrogate replaced by U+FFFD.
439pub fn to_well_formed(s: &Str) -> Str {
440    let mut out = s.units().to_vec();
441    let len = out.len();
442    let mut i = 0;
443    while i < len {
444        let c = out[i];
445        if (c & 0xFC00) == 0xD800 {
446            if i + 1 < len && (out[i + 1] & 0xFC00) == 0xDC00 {
447                i += 2;
448            } else {
449                out[i] = 0xFFFD;
450                i += 1;
451            }
452        } else if (c & 0xFC00) == 0xDC00 {
453            out[i] = 0xFFFD;
454            i += 1;
455        } else {
456            i += 1;
457        }
458    }
459    Str::from_units(out)
460}
461
462pub use transforms::{to_lower_case, to_upper_case, trim, trim_end, trim_start};
463
464/// `normalize`: Unicode normalization. `form` must be `"NFC"`/`"NFD"`/`"NFKC"`/
465/// `"NFKD"` (default `"NFC"`); any other value throws. A lone surrogate is
466/// kept: it neither decomposes nor composes, so the text on each side of it
467/// normalizes on its own.
468pub fn normalize(s: &Str, form: &Str) -> Result<Str> {
469    let form: fn(&str) -> String = match String::from_utf16_lossy(form.units()).as_str() {
470        "NFC" => |text| text.nfc().collect(),
471        "NFD" => |text| text.nfd().collect(),
472        "NFKC" => |text| text.nfkc().collect(),
473        "NFKD" => |text| text.nfkd().collect(),
474        _ => {
475            return Err(RangeError(
476                "String.normalize: invalid form — must be 'NFC', 'NFD', 'NFKC', or 'NFKD'",
477            ));
478        }
479    };
480    let mut out = Vec::with_capacity(s.units().len());
481    let mut run = String::new();
482    for scalar in char::decode_utf16(s.units().iter().copied()) {
483        match scalar {
484            Ok(c) => run.push(c),
485            Err(lone) => {
486                out.extend(form(&run).encode_utf16());
487                run.clear();
488                out.push(lone.unpaired_surrogate());
489            }
490        }
491    }
492    out.extend(form(&run).encode_utf16());
493    Ok(Str::from_units(out))
494}
495
496/// `String.fromCharCode(...codes)`: one UTF-16 code unit per argument, through
497/// JavaScript's `ToUint16`, so `-1` is U+FFFF.
498pub fn from_char_code(codes: &[f64]) -> Str {
499    Str::from_units(
500        codes
501            .iter()
502            .map(|&x| crate::runtime::number::to_uint16(x))
503            .collect(),
504    )
505}
506
507/// `String.fromCodePoint(...codePoints)`: astral code points encode as surrogate
508/// pairs; a negative, non-integer, or `> U+10FFFF` value throws.
509pub fn from_code_point(codes: &[f64]) -> Result<Str> {
510    let mut out = Vec::with_capacity(codes.len());
511    for &cpf in codes {
512        let cp = trunc_sat_i32(cpf);
513        if !(0..=0x10FFFF).contains(&cp) || cpf != cp as f64 {
514            return Err(RangeError("invalid code point"));
515        }
516        if cp < 0x10000 {
517            out.push(cp as u16);
518        } else {
519            let v = (cp - 0x10000) as u32;
520            out.push((0xD800 + (v >> 10)) as u16);
521            out.push((0xDC00 + (v & 0x3FF)) as u16);
522        }
523    }
524    Ok(Str::from_units(out))
525}
526
527#[cfg(test)]
528mod allocation_tests {
529    use super::*;
530    use crate::runtime::host::ends_the_run;
531    use crate::runtime::limits::MemoryCapExceeded;
532
533    #[test]
534    fn admitted_outputs_hold_and_refund_exact_bytes() {
535        let limits = TenantLimits::new(8);
536        let s = Str::from_units(vec![0xD800, 0x0061]);
537        let out = repeat(&s, 2.0, &limits).expect("exact output budget");
538        assert_eq!(out.as_str().units(), &[0xD800, 0x0061, 0xD800, 0x0061]);
539        assert_eq!(limits.host_attached_bytes(), 8);
540        assert!(repeat(&s, 1.0, &limits).is_err(), "live output is charged");
541        drop(out);
542        assert_eq!(limits.host_attached_bytes(), 0);
543        assert_eq!(limits.peak_bytes(), 8);
544        let smaller = TenantLimits::new(7);
545        let err = repeat(&s, 2.0, &smaller).err().expect("one byte short");
546        assert!(err.is::<MemoryCapExceeded>());
547        assert_eq!(smaller.host_attached_bytes(), 0);
548        assert_eq!(smaller.peak_bytes(), 0);
549    }
550
551    #[test]
552    fn all_builders_admit_before_filling() {
553        let limits = TenantLimits::new(6);
554        let s = Str::from_units(vec![0xD800]);
555        let pad = Str::from_units(vec![0xDC00, 0x0062]);
556        let start = pad_start(&s, 3.0, &pad, &limits).expect("padStart fits");
557        assert_eq!(start.as_str().units(), &[0xDC00, 0x0062, 0xD800]);
558        drop(start);
559        let end = pad_end(&s, 3.0, &pad, &limits).expect("padEnd fits");
560        assert_eq!(end.as_str().units(), &[0xD800, 0xDC00, 0x0062]);
561        drop(end);
562        let joined = concat(&s, &pad, &limits).expect("concat fits");
563        assert_eq!(joined.as_str().units(), &[0xD800, 0xDC00, 0x0062]);
564        drop(joined);
565        assert_eq!(limits.host_attached_bytes(), 0);
566        let smaller = TenantLimits::new(4);
567        assert!(pad_start(&s, 3.0, &pad, &smaller).is_err());
568        assert!(pad_end(&s, 3.0, &pad, &smaller).is_err());
569        assert!(concat(&s, &pad, &smaller).is_err());
570        assert_eq!(smaller.peak_bytes(), 0);
571    }
572
573    #[test]
574    fn allocation_failure_refunds_admission_and_is_fatal() {
575        let limits = TenantLimits::new(u64::MAX);
576        // The byte layout exceeds isize::MAX, so Vec rejects capacity without
577        // attempting an allocation. This exercises cleanup without inducing OOM.
578        let err = AdmittedStr::allocate(&limits, usize::MAX / 2)
579            .err()
580            .expect("invalid allocation layout");
581        assert!(ends_the_run(&err));
582        assert_eq!(limits.host_attached_bytes(), 0);
583        assert!(limits.peak_bytes() > 0, "admission preceded reservation");
584        assert!(AdmittedStr::allocate(&limits, usize::MAX).is_err());
585        assert_eq!(limits.host_attached_bytes(), 0);
586        assert!(concat_length(usize::MAX, 1).is_err());
587    }
588
589    #[test]
590    fn length_validation_preserves_range_boundaries() {
591        assert_eq!(
592            repeat_length(1, MAX_RESULT_UNITS as f64).unwrap(),
593            MAX_RESULT_UNITS
594        );
595        for count in [
596            -1.0,
597            f64::NEG_INFINITY,
598            f64::INFINITY,
599            f64::MAX,
600            (MAX_RESULT_UNITS + 1) as f64,
601        ] {
602            assert!(repeat_length(1, count).is_err());
603        }
604        for count in [-0.5, 0.0, 0.5, f64::NAN] {
605            assert_eq!(repeat_length(1, count).unwrap(), 0);
606        }
607        assert_eq!(repeat_length(2, 2.9).unwrap(), 4);
608        assert_eq!(repeat_length(0, f64::INFINITY).unwrap(), 0);
609        assert!(repeat_length(usize::MAX, 2.0).is_err());
610        assert_eq!(
611            pad_length(1, MAX_RESULT_UNITS as f64, 1).unwrap(),
612            MAX_RESULT_UNITS
613        );
614        assert!(pad_length(1, (MAX_RESULT_UNITS + 1) as f64, 1).is_err());
615        assert_eq!(pad_length(2, f64::INFINITY, 0).unwrap(), 2);
616        assert_eq!(pad_length(2, f64::NAN, 1).unwrap(), 2);
617        assert_eq!(pad_length(2, -1.0, 1).unwrap(), 2);
618        assert_eq!(pad_length(2, 4.9, 1).unwrap(), 4);
619    }
620
621    #[test]
622    fn empty_and_unpadded_results_use_fallible_admission() {
623        let empty = Str::from_units(Vec::new());
624        let s = Str::from_units(vec![0xD800, 0x0061]);
625        let limits = TenantLimits::new(4);
626        let out = repeat(&empty, f64::INFINITY, &limits).unwrap();
627        assert!(out.as_str().is_empty());
628        assert_eq!(limits.host_attached_bytes(), 0);
629        drop(out);
630        for op in [pad_start, pad_end] {
631            let out = op(&s, f64::INFINITY, &empty, &limits).unwrap();
632            assert_eq!(out.as_str().units(), s.units());
633            assert_eq!(limits.host_attached_bytes(), 4);
634            drop(out);
635            assert!(op(&s, 1.0, &empty, &TenantLimits::new(3)).is_err());
636        }
637        let err = repeat(&s, -1.0, &limits).err().unwrap();
638        assert!(!ends_the_run(&err), "range errors remain catchable");
639        assert_eq!(limits.host_attached_bytes(), 0);
640    }
641}