Skip to main content

runmat_runtime/builtins/strings/transform/
replace.rs

1//! MATLAB-compatible `replace` builtin with GPU-aware semantics for RunMat.
2
3use regex::Regex;
4use runmat_builtins::{
5    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
6    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
7};
8use runmat_builtins::{BuiltinIntegerAuditDescriptor, BuiltinIntegerAuditKind};
9use runmat_macros::runtime_builtin;
10use runmat_value::{CellArray, CharArray, StringArray, Value};
11
12use crate::builtins::common::map_control_flow_with_builtin;
13use crate::builtins::common::spec::{
14    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
15    ReductionNaN, ResidencyPolicy, ShapeRequirements,
16};
17use crate::builtins::strings::common::{
18    char_row_to_string_slice, contains_resident_text_input, is_missing_string,
19};
20use crate::builtins::strings::core::compat::pattern_regex;
21use crate::builtins::strings::type_resolvers::text_preserve_type;
22use crate::{build_runtime_error, gather_if_needed_async, make_cell, BuiltinResult, RuntimeError};
23
24#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::transform::replace")]
25pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
26    name: "replace",
27    op_kind: GpuOpKind::Custom("string-transform"),
28    supported_precisions: &[],
29    broadcast: BroadcastSemantics::None,
30    provider_hooks: &[],
31    constant_strategy: ConstantStrategy::InlineLiteral,
32    residency: ResidencyPolicy::GatherImmediately,
33    nan_mode: ReductionNaN::Include,
34    two_pass_threshold: None,
35    workgroup_size: None,
36    accepts_nan_mode: false,
37    notes:
38        "Executes on the CPU; GPU-resident inputs are gathered to host memory prior to replacement.",
39};
40
41#[runmat_macros::register_fusion_spec(
42    builtin_path = "crate::builtins::strings::transform::replace"
43)]
44pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
45    name: "replace",
46    shape: ShapeRequirements::Any,
47    constant_strategy: ConstantStrategy::InlineLiteral,
48    elementwise: None,
49    reduction: None,
50    emits_nan: false,
51    notes:
52        "String manipulation builtin; not eligible for fusion plans and always gathers GPU inputs.",
53};
54
55const BUILTIN_NAME: &str = "replace";
56
57const REPLACE_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
58    name: "newText",
59    ty: BuiltinParamType::Any,
60    arity: BuiltinParamArity::Required,
61    default: None,
62    description: "Text with replacements applied, preserving input container kind.",
63}];
64
65const REPLACE_INPUTS: [BuiltinParamDescriptor; 3] = [
66    BuiltinParamDescriptor {
67        name: "str",
68        ty: BuiltinParamType::Any,
69        arity: BuiltinParamArity::Required,
70        default: None,
71        description: "Input text (string/char/cell).",
72    },
73    BuiltinParamDescriptor {
74        name: "oldText",
75        ty: BuiltinParamType::Any,
76        arity: BuiltinParamArity::Required,
77        default: None,
78        description: "Search text list (scalar or array/cell).",
79    },
80    BuiltinParamDescriptor {
81        name: "newText",
82        ty: BuiltinParamType::Any,
83        arity: BuiltinParamArity::Required,
84        default: None,
85        description: "Replacement text list (scalar or matching-size list).",
86    },
87];
88
89const REPLACE_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
90    label: "newText = replace(str, oldText, newText)",
91    inputs: &REPLACE_INPUTS,
92    outputs: &REPLACE_OUTPUT,
93}];
94
95const REPLACE_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
96    code: "RM.REPLACE.INVALID_INPUT",
97    identifier: Some("RunMat:replace:InvalidInput"),
98    when: "First argument is not a string array, char array, or cell array of text scalars.",
99    message:
100        "replace: first argument must be a string array, character array, or cell array of character vectors",
101};
102
103const REPLACE_ERROR_PATTERN_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
104    code: "RM.REPLACE.PATTERN_TYPE",
105    identifier: Some("RunMat:replace:PatternType"),
106    when: "Second argument is not a text scalar/array/cell of text scalars.",
107    message:
108        "replace: second argument must be a string array, character array, or cell array of character vectors",
109};
110
111const REPLACE_ERROR_REPLACEMENT_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
112    code: "RM.REPLACE.REPLACEMENT_TYPE",
113    identifier: Some("RunMat:replace:ReplacementType"),
114    when: "Third argument is not a text scalar/array/cell of text scalars.",
115    message:
116        "replace: third argument must be a string array, character array, or cell array of character vectors",
117};
118
119const REPLACE_ERROR_EMPTY_PATTERN: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
120    code: "RM.REPLACE.EMPTY_PATTERN",
121    identifier: Some("RunMat:replace:EmptyPattern"),
122    when: "Search text list is empty.",
123    message: "replace: second argument must contain at least one search string",
124};
125
126const REPLACE_ERROR_EMPTY_REPLACEMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
127    code: "RM.REPLACE.EMPTY_REPLACEMENT",
128    identifier: Some("RunMat:replace:EmptyReplacement"),
129    when: "Replacement text list is empty.",
130    message: "replace: third argument must contain at least one replacement string",
131};
132
133const REPLACE_ERROR_SIZE_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
134    code: "RM.REPLACE.SIZE_MISMATCH",
135    identifier: Some("RunMat:replace:SizeMismatch"),
136    when: "Replacement list is neither scalar nor equal in length to search list.",
137    message: "replace: replacement array must be a scalar or match the number of search strings",
138};
139
140const REPLACE_ERROR_CELL_ELEMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
141    code: "RM.REPLACE.CELL_ELEMENT",
142    identifier: Some("RunMat:replace:CellElement"),
143    when: "Cell arrays contain non-text elements or non-row char arrays.",
144    message: "replace: cell array elements must be string scalars or character vectors",
145};
146
147const REPLACE_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
148    code: "RM.REPLACE.INTERNAL",
149    identifier: Some("RunMat:replace:InternalError"),
150    when: "Internal output container construction failed.",
151    message: "replace: internal error",
152};
153
154const REPLACE_ERRORS: [BuiltinErrorDescriptor; 8] = [
155    REPLACE_ERROR_INVALID_INPUT,
156    REPLACE_ERROR_PATTERN_TYPE,
157    REPLACE_ERROR_REPLACEMENT_TYPE,
158    REPLACE_ERROR_EMPTY_PATTERN,
159    REPLACE_ERROR_EMPTY_REPLACEMENT,
160    REPLACE_ERROR_SIZE_MISMATCH,
161    REPLACE_ERROR_CELL_ELEMENT,
162    REPLACE_ERROR_INTERNAL,
163];
164
165pub const REPLACE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
166    signatures: &REPLACE_SIGNATURES,
167    output_mode: BuiltinOutputMode::Fixed,
168    completion_policy: BuiltinCompletionPolicy::Public,
169    errors: &REPLACE_ERRORS,
170};
171
172pub const REPLACE_INTEGER_AUDIT: BuiltinIntegerAuditDescriptor = BuiltinIntegerAuditDescriptor {
173    kind: BuiltinIntegerAuditKind::NotApplicable,
174    canonical_builtin: None,
175    notes: "replace accepts host string, character, cell text, and pattern inputs only and preserves the input text container. Numeric, logical, symbolic, and provider-resident values reject before gather or provider access.",
176};
177
178fn map_flow(err: RuntimeError) -> RuntimeError {
179    map_control_flow_with_builtin(err, BUILTIN_NAME)
180}
181
182fn replace_error_with_message(
183    message: impl Into<String>,
184    error: &'static BuiltinErrorDescriptor,
185) -> RuntimeError {
186    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
187    if let Some(identifier) = error.identifier {
188        builder = builder.with_identifier(identifier);
189    }
190    builder.build()
191}
192
193fn replace_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
194    replace_error_with_message(error.message, error)
195}
196
197#[runtime_builtin(
198    name = "replace",
199    category = "strings/transform",
200    summary = "Replace substring occurrences in strings, character arrays, and cell arrays.",
201    keywords = "replace,strrep,strings,character array,text",
202    accel = "sink",
203    type_resolver(text_preserve_type),
204    descriptor(crate::builtins::strings::transform::replace::REPLACE_DESCRIPTOR),
205    integer_audit(crate::builtins::strings::transform::replace::REPLACE_INTEGER_AUDIT),
206    builtin_path = "crate::builtins::strings::transform::replace"
207)]
208async fn replace_builtin(text: Value, old: Value, new: Value) -> BuiltinResult<Value> {
209    if [&text, &old, &new]
210        .into_iter()
211        .any(contains_resident_text_input)
212    {
213        return Err(replace_error(&REPLACE_ERROR_INVALID_INPUT));
214    }
215    let text = gather_if_needed_async(&text).await.map_err(map_flow)?;
216    let old = gather_if_needed_async(&old).await.map_err(map_flow)?;
217    let new = gather_if_needed_async(&new).await.map_err(map_flow)?;
218
219    let spec = ReplacementSpec::from_values(&old, &new)?;
220
221    match text {
222        Value::String(s) => Ok(Value::String(replace_string_scalar(s, &spec))),
223        Value::StringArray(sa) => replace_string_array(sa, &spec),
224        Value::CharArray(ca) => replace_char_array(ca, &spec),
225        Value::Cell(cell) => replace_cell_array(cell, &spec),
226        _ => Err(replace_error(&REPLACE_ERROR_INVALID_INPUT)),
227    }
228}
229
230fn replace_string_scalar(text: String, spec: &ReplacementSpec) -> String {
231    if is_missing_string(&text) {
232        text
233    } else {
234        spec.apply(&text)
235    }
236}
237
238fn replace_string_array(array: StringArray, spec: &ReplacementSpec) -> BuiltinResult<Value> {
239    let StringArray { data, shape, .. } = array;
240    let mut replaced = Vec::with_capacity(data.len());
241    for entry in data {
242        if is_missing_string(&entry) {
243            replaced.push(entry);
244        } else {
245            replaced.push(spec.apply(&entry));
246        }
247    }
248    let result = StringArray::new(replaced, shape).map_err(|e| {
249        replace_error_with_message(format!("{BUILTIN_NAME}: {e}"), &REPLACE_ERROR_INTERNAL)
250    })?;
251    Ok(Value::StringArray(result))
252}
253
254fn replace_char_array(array: CharArray, spec: &ReplacementSpec) -> BuiltinResult<Value> {
255    let CharArray {
256        data,
257        shape,
258        rows,
259        cols,
260    } = array;
261    if rows == 0 {
262        return Ok(Value::CharArray(CharArray {
263            data,
264            shape,
265            rows,
266            cols,
267        }));
268    }
269
270    let mut replaced_rows = Vec::with_capacity(rows);
271    let mut target_cols = 0usize;
272    for row in 0..rows {
273        let slice = char_row_to_string_slice(&data, cols, row);
274        let replaced = spec.apply(&slice);
275        let len = replaced.chars().count();
276        target_cols = target_cols.max(len);
277        replaced_rows.push(replaced);
278    }
279
280    let mut flattened = Vec::with_capacity(rows * target_cols);
281    for row_text in replaced_rows {
282        let mut chars: Vec<char> = row_text.chars().collect();
283        if chars.len() < target_cols {
284            chars.resize(target_cols, ' ');
285        }
286        flattened.extend(chars);
287    }
288
289    CharArray::new(flattened, rows, target_cols)
290        .map(Value::CharArray)
291        .map_err(|e| {
292            replace_error_with_message(format!("{BUILTIN_NAME}: {e}"), &REPLACE_ERROR_INTERNAL)
293        })
294}
295
296fn replace_cell_array(cell: CellArray, spec: &ReplacementSpec) -> BuiltinResult<Value> {
297    let CellArray {
298        data, rows, cols, ..
299    } = cell;
300    let mut replaced = Vec::with_capacity(rows * cols);
301    for row in 0..rows {
302        for col in 0..cols {
303            let idx = row * cols + col;
304            let value = replace_cell_element(&data[idx], spec)?;
305            replaced.push(value);
306        }
307    }
308    make_cell(replaced, rows, cols).map_err(|e| {
309        replace_error_with_message(format!("{BUILTIN_NAME}: {e}"), &REPLACE_ERROR_INTERNAL)
310    })
311}
312
313fn replace_cell_element(value: &Value, spec: &ReplacementSpec) -> BuiltinResult<Value> {
314    match value {
315        Value::String(text) => Ok(Value::String(replace_string_scalar(text.clone(), spec))),
316        Value::StringArray(sa) if sa.data.len() == 1 => Ok(Value::String(replace_string_scalar(
317            sa.data[0].clone(),
318            spec,
319        ))),
320        Value::CharArray(ca) if ca.rows <= 1 => replace_char_array(ca.clone(), spec),
321        Value::CharArray(_) => Err(replace_error(&REPLACE_ERROR_CELL_ELEMENT)),
322        _ => Err(replace_error(&REPLACE_ERROR_CELL_ELEMENT)),
323    }
324}
325
326fn extract_pattern_list(value: &Value) -> BuiltinResult<Vec<SearchPattern>> {
327    if matches!(value, Value::Object(_)) {
328        let regex = pattern_regex(value, BUILTIN_NAME).map_err(|err| {
329            replace_error_with_message(err.message().to_string(), &REPLACE_ERROR_PATTERN_TYPE)
330        })?;
331        return Ok(vec![SearchPattern::Regex(Regex::new(&regex).map_err(
332            |err| replace_error_with_message(err.to_string(), &REPLACE_ERROR_PATTERN_TYPE),
333        )?)]);
334    }
335    extract_text_list(value, &REPLACE_ERROR_PATTERN_TYPE)
336        .map(|items| items.into_iter().map(SearchPattern::Literal).collect())
337}
338
339fn extract_replacement_list(value: &Value) -> BuiltinResult<Vec<String>> {
340    extract_text_list(value, &REPLACE_ERROR_REPLACEMENT_TYPE)
341}
342
343fn extract_text_list(
344    value: &Value,
345    type_error: &'static BuiltinErrorDescriptor,
346) -> BuiltinResult<Vec<String>> {
347    match value {
348        Value::String(text) => Ok(vec![text.clone()]),
349        Value::StringArray(array) => Ok(array.data.clone()),
350        Value::CharArray(array) => {
351            let CharArray {
352                data, rows, cols, ..
353            } = array.clone();
354            if rows == 0 {
355                Ok(Vec::new())
356            } else {
357                let mut entries = Vec::with_capacity(rows);
358                for row in 0..rows {
359                    entries.push(char_row_to_string_slice(&data, cols, row));
360                }
361                Ok(entries)
362            }
363        }
364        Value::Cell(cell) => {
365            let CellArray { data, .. } = cell.clone();
366            let mut entries = Vec::with_capacity(data.len());
367            for element in data {
368                match &element {
369                    Value::String(text) => entries.push(text.clone()),
370                    Value::StringArray(sa) if sa.data.len() == 1 => {
371                        entries.push(sa.data[0].clone());
372                    }
373                    Value::CharArray(ca) if ca.rows <= 1 => {
374                        if ca.rows == 0 {
375                            entries.push(String::new());
376                        } else {
377                            entries.push(char_row_to_string_slice(&ca.data, ca.cols, 0));
378                        }
379                    }
380                    Value::CharArray(_) => {
381                        return Err(replace_error(&REPLACE_ERROR_CELL_ELEMENT));
382                    }
383                    _ => {
384                        return Err(replace_error(&REPLACE_ERROR_CELL_ELEMENT));
385                    }
386                }
387            }
388            Ok(entries)
389        }
390        _ => Err(replace_error(type_error)),
391    }
392}
393
394enum SearchPattern {
395    Literal(String),
396    Regex(Regex),
397}
398
399struct ReplacementSpec {
400    pairs: Vec<(SearchPattern, String)>,
401}
402
403impl ReplacementSpec {
404    fn from_values(old: &Value, new: &Value) -> BuiltinResult<Self> {
405        let patterns = extract_pattern_list(old)?;
406        if patterns.is_empty() {
407            return Err(replace_error(&REPLACE_ERROR_EMPTY_PATTERN));
408        }
409
410        let replacements = extract_replacement_list(new)?;
411        if replacements.is_empty() {
412            return Err(replace_error(&REPLACE_ERROR_EMPTY_REPLACEMENT));
413        }
414
415        let pairs = if replacements.len() == patterns.len() {
416            patterns.into_iter().zip(replacements).collect::<Vec<_>>()
417        } else if replacements.len() == 1 {
418            let replacement = replacements[0].clone();
419            patterns
420                .into_iter()
421                .map(|pattern| (pattern, replacement.clone()))
422                .collect::<Vec<_>>()
423        } else {
424            return Err(replace_error(&REPLACE_ERROR_SIZE_MISMATCH));
425        };
426
427        Ok(Self { pairs })
428    }
429
430    fn apply(&self, input: &str) -> String {
431        let mut current = input.to_string();
432        for (pattern, replacement) in &self.pairs {
433            match pattern {
434                SearchPattern::Literal(pattern) => {
435                    if pattern.is_empty() && replacement.is_empty() {
436                        continue;
437                    }
438                    if pattern == replacement {
439                        continue;
440                    }
441                    current = current.replace(pattern, replacement);
442                }
443                SearchPattern::Regex(pattern) => {
444                    current = pattern
445                        .replace_all(&current, replacement.as_str())
446                        .to_string();
447                }
448            }
449        }
450        current
451    }
452}
453
454#[cfg(test)]
455pub(crate) mod tests {
456    use super::*;
457    use runmat_builtins::{ResolveContext, Type};
458
459    fn replace_builtin(text: Value, old: Value, new: Value) -> BuiltinResult<Value> {
460        futures::executor::block_on(super::replace_builtin(text, old, new))
461    }
462
463    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
464    #[test]
465    fn replace_string_scalar_single_term() {
466        let result = replace_builtin(
467            Value::String("RunMat runtime".into()),
468            Value::String("runtime".into()),
469            Value::String("engine".into()),
470        )
471        .expect("replace");
472        assert_eq!(result, Value::String("RunMat engine".into()));
473    }
474
475    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
476    #[test]
477    fn replace_string_array_multiple_terms() {
478        let strings = StringArray::new(
479            vec!["gpu".into(), "cpu".into(), "<missing>".into()],
480            vec![3, 1],
481        )
482        .unwrap();
483        let result = replace_builtin(
484            Value::StringArray(strings),
485            Value::StringArray(
486                StringArray::new(vec!["gpu".into(), "cpu".into()], vec![2, 1]).unwrap(),
487            ),
488            Value::String("device".into()),
489        )
490        .expect("replace");
491        match result {
492            Value::StringArray(sa) => {
493                assert_eq!(sa.shape, vec![3, 1]);
494                assert_eq!(
495                    sa.data,
496                    vec![
497                        String::from("device"),
498                        String::from("device"),
499                        String::from("<missing>")
500                    ]
501                );
502            }
503            other => panic!("expected string array, got {other:?}"),
504        }
505    }
506
507    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
508    #[test]
509    fn replace_char_array_adjusts_width() {
510        let chars = CharArray::new("matrix".chars().collect(), 1, 6).unwrap();
511        let result = replace_builtin(
512            Value::CharArray(chars),
513            Value::String("matrix".into()),
514            Value::String("tensor".into()),
515        )
516        .expect("replace");
517        match result {
518            Value::CharArray(out) => {
519                assert_eq!(out.rows, 1);
520                assert_eq!(out.cols, 6);
521                let expected: Vec<char> = "tensor".chars().collect();
522                assert_eq!(out.data, expected);
523            }
524            other => panic!("expected char array, got {other:?}"),
525        }
526    }
527
528    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
529    #[test]
530    fn replace_char_array_handles_padding() {
531        let chars = CharArray::new(vec!['a', 'b', 'c', 'd'], 2, 2).unwrap();
532        let result = replace_builtin(
533            Value::CharArray(chars),
534            Value::String("b".into()),
535            Value::String("beta".into()),
536        )
537        .expect("replace");
538        match result {
539            Value::CharArray(out) => {
540                assert_eq!(out.rows, 2);
541                assert_eq!(out.cols, 5);
542                let expected: Vec<char> = vec!['a', 'b', 'e', 't', 'a', 'c', 'd', ' ', ' ', ' '];
543                assert_eq!(out.data, expected);
544            }
545            other => panic!("expected char array, got {other:?}"),
546        }
547    }
548
549    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
550    #[test]
551    fn replace_cell_array_mixed_content() {
552        let cell = CellArray::new(
553            vec![
554                Value::CharArray(CharArray::new_row("Kernel Planner")),
555                Value::String("GPU Fusion".into()),
556            ],
557            1,
558            2,
559        )
560        .unwrap();
561        let result = replace_builtin(
562            Value::Cell(cell),
563            Value::Cell(
564                CellArray::new(
565                    vec![Value::String("Kernel".into()), Value::String("GPU".into())],
566                    1,
567                    2,
568                )
569                .unwrap(),
570            ),
571            Value::StringArray(
572                StringArray::new(vec!["Shader".into(), "Device".into()], vec![1, 2]).unwrap(),
573            ),
574        )
575        .expect("replace");
576        match result {
577            Value::Cell(out) => {
578                let first = out.get(0, 0).unwrap();
579                let second = out.get(0, 1).unwrap();
580                assert_eq!(
581                    first,
582                    Value::CharArray(CharArray::new_row("Shader Planner"))
583                );
584                assert_eq!(second, Value::String("Device Fusion".into()));
585            }
586            other => panic!("expected cell array, got {other:?}"),
587        }
588    }
589
590    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
591    #[test]
592    fn replace_errors_on_invalid_first_argument() {
593        let err = replace_builtin(
594            Value::Num(1.0),
595            Value::String("a".into()),
596            Value::String("b".into()),
597        )
598        .unwrap_err();
599        assert_eq!(err.to_string(), REPLACE_ERROR_INVALID_INPUT.message);
600    }
601
602    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
603    #[test]
604    fn replace_errors_on_invalid_pattern_type() {
605        let err = replace_builtin(
606            Value::String("abc".into()),
607            Value::Num(1.0),
608            Value::String("x".into()),
609        )
610        .unwrap_err();
611        assert_eq!(err.to_string(), REPLACE_ERROR_PATTERN_TYPE.message);
612    }
613
614    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
615    #[test]
616    fn replace_errors_on_size_mismatch() {
617        let err = replace_builtin(
618            Value::String("abc".into()),
619            Value::StringArray(StringArray::new(vec!["a".into(), "b".into()], vec![2, 1]).unwrap()),
620            Value::StringArray(
621                StringArray::new(vec!["x".into(), "y".into(), "z".into()], vec![3, 1]).unwrap(),
622            ),
623        )
624        .unwrap_err();
625        assert_eq!(err.to_string(), REPLACE_ERROR_SIZE_MISMATCH.message);
626    }
627
628    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
629    #[test]
630    fn replace_preserves_missing_string() {
631        let result = replace_builtin(
632            Value::String("<missing>".into()),
633            Value::String("missing".into()),
634            Value::String("value".into()),
635        )
636        .expect("replace");
637        assert_eq!(result, Value::String("<missing>".into()));
638    }
639
640    #[test]
641    fn replace_accepts_pattern_object() {
642        let pattern = crate::builtins::strings::core::compat::pattern_object(r"\d+");
643        let result = replace_builtin(
644            Value::String("run42mat".into()),
645            pattern,
646            Value::String("-".into()),
647        )
648        .expect("replace");
649        assert_eq!(result, Value::String("run-mat".into()));
650    }
651
652    #[test]
653    fn replace_type_preserves_text() {
654        assert_eq!(
655            text_preserve_type(&[Type::String], &ResolveContext::new(Vec::new())),
656            Type::String
657        );
658    }
659}