runmat_runtime/builtins/strings/transform/
strrep.rs

1//! MATLAB-compatible `strrep` builtin with GPU-aware semantics for RunMat.
2
3use runmat_builtins::{CellArray, CharArray, StringArray, Value};
4use runmat_macros::runtime_builtin;
5
6use crate::builtins::common::spec::{
7    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
8    ReductionNaN, ResidencyPolicy, ShapeRequirements,
9};
10use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
11#[cfg(feature = "doc_export")]
12use crate::register_builtin_doc_text;
13use crate::{
14    gather_if_needed, make_cell_with_shape, register_builtin_fusion_spec, register_builtin_gpu_spec,
15};
16
17#[cfg(feature = "doc_export")]
18pub const DOC_MD: &str = r#"---
19title: "strrep"
20category: "strings/transform"
21keywords: ["strrep", "string replace", "character array replace", "text replacement", "substring replace"]
22summary: "Replace substring occurrences in strings, character arrays, and cell arrays while mirroring MATLAB semantics."
23references:
24  - https://www.mathworks.com/help/matlab/ref/strrep.html
25gpu_support:
26  elementwise: false
27  reduction: false
28  precisions: []
29  broadcasting: "none"
30  notes: "Runs on the CPU. RunMat gathers GPU-resident text inputs before performing replacements."
31fusion:
32  elementwise: false
33  reduction: false
34  max_inputs: 3
35  constants: "inline"
36requires_feature: null
37tested:
38  unit: "builtins::strings::transform::strrep::tests"
39  integration: "builtins::strings::transform::strrep::tests::strrep_cell_array_char_vectors, builtins::strings::transform::strrep::tests::strrep_wgpu_provider_fallback"
40---
41
42# What does the `strrep` function do in MATLAB / RunMat?
43`strrep(str, old, new)` replaces every non-overlapping instance of the substring `old` that appears in
44`str` with the text provided in `new`. The builtin accepts string scalars, string arrays, character arrays,
45and cell arrays of character vectors, matching MATLAB behaviour exactly.
46
47## How does the `strrep` function behave in MATLAB / RunMat?
48- String scalars remain strings. Missing string values (`<missing>`) propagate unchanged.
49- String arrays are processed element-wise while preserving their full shape and orientation.
50- Character arrays are handled row by row. Rows expand or shrink as needed and are padded with spaces so
51  the result stays a rectangular char array, just like MATLAB.
52- Cell arrays must contain character vectors or string scalars. The result is a cell array of identical size
53  where each element has had the replacement applied.
54- The `old` and `new` arguments must be string scalars or character vectors of the same data type.
55- `old` can be empty. In that case, `strrep` inserts `new` before the first character, between existing
56  characters, and after the final character.
57
58## `strrep` Function GPU Execution Behaviour
59RunMat treats text replacement as a CPU-first workflow:
60
611. The builtin is registered as an Accelerate *sink*, so the planner gathers any GPU-resident inputs
62   (string arrays, char arrays, or cell contents) back to host memory before work begins.
632. Replacements are computed entirely on the CPU, mirroring MATLAB’s behaviour and avoiding GPU/device
64   divergence in string handling.
653. Results are returned as host values (string array, char array, or cell array). Residency is never pushed
66   back to the GPU, keeping semantics deterministic regardless of the active provider.
67
68## GPU residency in RunMat (Do I need `gpuArray`?)
69No. `strrep` registers as a sink with RunMat Accelerate, so the fusion planner never keeps its inputs or
70outputs on the GPU. Even if you start with GPU data, the runtime gathers it automatically—manual `gpuArray`
71or `gather` calls are unnecessary.
72
73## Examples of using the `strrep` function in MATLAB / RunMat
74
75### Replacing a word inside a string scalar
76```matlab
77txt = "RunMat turbo mode";
78result = strrep(txt, "turbo", "accelerate");
79```
80Expected output:
81```matlab
82result = "RunMat accelerate mode"
83```
84
85### Updating every element of a string array
86```matlab
87labels = ["GPU planner", "CPU planner"];
88updated = strrep(labels, "planner", "pipeline");
89```
90Expected output:
91```matlab
92updated = 2×1 string
93    "GPU pipeline"
94    "CPU pipeline"
95```
96
97### Preserving rectangular shape in character arrays
98```matlab
99chars = char("alpha", "beta ");
100out = strrep(chars, "a", "A");
101```
102Expected output:
103```matlab
104out =
105
106  2×5 char array
107
108    'AlphA'
109    'betA '
110```
111
112### Applying replacements inside a cell array of character vectors
113```matlab
114C = {'Kernel Fusion', 'GPU Planner'};
115renamed = strrep(C, ' ', '_');
116```
117Expected output:
118```matlab
119renamed = 1×2 cell array
120    {'Kernel_Fusion'}    {'GPU_Planner'}
121```
122
123### Inserting text with an empty search pattern
124```matlab
125stub = "abc";
126expanded = strrep(stub, "", "-");
127```
128Expected output:
129```matlab
130expanded = "-a-b-c-"
131```
132
133### Leaving missing string values untouched
134```matlab
135vals = ["RunMat", "<missing>", "Accelerate"];
136out = strrep(vals, "RunMat", "RUNMAT");
137```
138Expected output:
139```matlab
140out = 1×3 string
141    "RUNMAT"    <missing>    "Accelerate"
142```
143
144### Replacing substrings gathered from GPU inputs
145```matlab
146g = gpuArray("Turbine");
147host = strrep(g, "bine", "bo");
148```
149Expected output:
150```matlab
151host = "Turbo"
152```
153
154## FAQ
155
156### Which input types does `strrep` accept?
157String scalars, string arrays, character vectors, character arrays, and cell arrays of character vectors.
158The `old` and `new` arguments must be string scalars or character vectors of the same data type.
159
160### Does `strrep` support multiple search terms at once?
161No. Use the newer `replace` builtin if you need to substitute several search terms in a single call.
162
163### How does `strrep` handle missing strings?
164Missing string scalars remain `<missing>` and are returned unchanged, even when the search pattern matches
165ordinary text.
166
167### Will rows of a character array stay aligned?
168Yes. Each row is replaced individually, then padded with spaces so that the overall array stays rectangular,
169matching MATLAB exactly.
170
171### What happens when `old` is empty?
172RunMat mirrors MATLAB: `new` is inserted before the first character, between every existing character, and
173after the last character.
174
175### Does `strrep` run on the GPU?
176Not today. The builtin gathers GPU-resident data to host memory automatically before performing the
177replacement logic.
178
179### Can I mix strings and character vectors for `old` and `new`?
180No. MATLAB requires `old` and `new` to share the same data type. RunMat enforces the same rule and raises a
181descriptive error when they differ.
182
183### How do I replace text stored inside cell arrays?
184`strrep` traverses the cell array, applying the replacement to each character vector or string scalar element
185and returning a cell array of the same shape.
186
187## See Also
188[replace](./replace), [regexprep](../../regex/regexprep), [string](../core/string), [char](../core/char), [join](./join)
189"#;
190
191pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
192    name: "strrep",
193    op_kind: GpuOpKind::Custom("string-transform"),
194    supported_precisions: &[],
195    broadcast: BroadcastSemantics::None,
196    provider_hooks: &[],
197    constant_strategy: ConstantStrategy::InlineLiteral,
198    residency: ResidencyPolicy::GatherImmediately,
199    nan_mode: ReductionNaN::Include,
200    two_pass_threshold: None,
201    workgroup_size: None,
202    accepts_nan_mode: false,
203    notes: "Executes on the CPU; GPU-resident inputs are gathered before replacements are applied.",
204};
205
206register_builtin_gpu_spec!(GPU_SPEC);
207
208pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
209    name: "strrep",
210    shape: ShapeRequirements::Any,
211    constant_strategy: ConstantStrategy::InlineLiteral,
212    elementwise: None,
213    reduction: None,
214    emits_nan: false,
215    notes: "String transformation builtin; marked as a sink so fusion skips GPU residency.",
216};
217
218register_builtin_fusion_spec!(FUSION_SPEC);
219
220#[cfg(feature = "doc_export")]
221register_builtin_doc_text!("strrep", DOC_MD);
222
223const ARGUMENT_TYPE_ERROR: &str =
224    "strrep: first argument must be a string array, character array, or cell array of character vectors";
225const PATTERN_TYPE_ERROR: &str = "strrep: old and new must be string scalars or character vectors";
226const PATTERN_MISMATCH_ERROR: &str = "strrep: old and new must be the same data type";
227const CELL_ELEMENT_ERROR: &str =
228    "strrep: cell array elements must be string scalars or character vectors";
229
230#[derive(Clone, Copy, PartialEq, Eq)]
231enum PatternKind {
232    String,
233    Char,
234}
235
236#[runtime_builtin(
237    name = "strrep",
238    category = "strings/transform",
239    summary = "Replace substring occurrences with MATLAB-compatible semantics.",
240    keywords = "strrep,replace,strings,character array,text",
241    accel = "sink"
242)]
243fn strrep_builtin(str_value: Value, old_value: Value, new_value: Value) -> Result<Value, String> {
244    let gathered_str = gather_if_needed(&str_value).map_err(|e| format!("strrep: {e}"))?;
245    let gathered_old = gather_if_needed(&old_value).map_err(|e| format!("strrep: {e}"))?;
246    let gathered_new = gather_if_needed(&new_value).map_err(|e| format!("strrep: {e}"))?;
247
248    let (old_text, old_kind) = parse_pattern(gathered_old)?;
249    let (new_text, new_kind) = parse_pattern(gathered_new)?;
250    if old_kind != new_kind {
251        return Err(PATTERN_MISMATCH_ERROR.to_string());
252    }
253
254    match gathered_str {
255        Value::String(text) => Ok(Value::String(strrep_string_value(
256            text, &old_text, &new_text,
257        ))),
258        Value::StringArray(array) => strrep_string_array(array, &old_text, &new_text),
259        Value::CharArray(array) => strrep_char_array(array, &old_text, &new_text),
260        Value::Cell(cell) => strrep_cell_array(cell, &old_text, &new_text),
261        _ => Err(ARGUMENT_TYPE_ERROR.to_string()),
262    }
263}
264
265fn parse_pattern(value: Value) -> Result<(String, PatternKind), String> {
266    match value {
267        Value::String(text) => Ok((text, PatternKind::String)),
268        Value::StringArray(array) => {
269            if array.data.len() == 1 {
270                Ok((array.data[0].clone(), PatternKind::String))
271            } else {
272                Err(PATTERN_TYPE_ERROR.to_string())
273            }
274        }
275        Value::CharArray(array) => {
276            if array.rows <= 1 {
277                let text = if array.rows == 0 {
278                    String::new()
279                } else {
280                    char_row_to_string_slice(&array.data, array.cols, 0)
281                };
282                Ok((text, PatternKind::Char))
283            } else {
284                Err(PATTERN_TYPE_ERROR.to_string())
285            }
286        }
287        _ => Err(PATTERN_TYPE_ERROR.to_string()),
288    }
289}
290
291fn strrep_string_value(text: String, old: &str, new: &str) -> String {
292    if is_missing_string(&text) {
293        text
294    } else {
295        text.replace(old, new)
296    }
297}
298
299fn strrep_string_array(array: StringArray, old: &str, new: &str) -> Result<Value, String> {
300    let StringArray { data, shape, .. } = array;
301    let replaced = data
302        .into_iter()
303        .map(|text| strrep_string_value(text, old, new))
304        .collect::<Vec<_>>();
305    let rebuilt = StringArray::new(replaced, shape).map_err(|e| format!("strrep: {e}"))?;
306    Ok(Value::StringArray(rebuilt))
307}
308
309fn strrep_char_array(array: CharArray, old: &str, new: &str) -> Result<Value, String> {
310    let CharArray { data, rows, cols } = array;
311    if rows == 0 || cols == 0 {
312        return Ok(Value::CharArray(CharArray { data, rows, cols }));
313    }
314
315    let mut replaced_rows = Vec::with_capacity(rows);
316    let mut target_cols = 0usize;
317    for row in 0..rows {
318        let text = char_row_to_string_slice(&data, cols, row);
319        let replaced = text.replace(old, new);
320        target_cols = target_cols.max(replaced.chars().count());
321        replaced_rows.push(replaced);
322    }
323
324    let mut new_data = Vec::with_capacity(rows * target_cols);
325    for row_text in replaced_rows {
326        let mut chars: Vec<char> = row_text.chars().collect();
327        if chars.len() < target_cols {
328            chars.resize(target_cols, ' ');
329        }
330        new_data.extend(chars);
331    }
332
333    CharArray::new(new_data, rows, target_cols)
334        .map(Value::CharArray)
335        .map_err(|e| format!("strrep: {e}"))
336}
337
338fn strrep_cell_array(cell: CellArray, old: &str, new: &str) -> Result<Value, String> {
339    let CellArray { data, shape, .. } = cell;
340    let mut replaced = Vec::with_capacity(data.len());
341    for ptr in &data {
342        replaced.push(strrep_cell_element(ptr, old, new)?);
343    }
344    make_cell_with_shape(replaced, shape).map_err(|e| format!("strrep: {e}"))
345}
346
347fn strrep_cell_element(value: &Value, old: &str, new: &str) -> Result<Value, String> {
348    match value {
349        Value::String(text) => Ok(Value::String(strrep_string_value(text.clone(), old, new))),
350        Value::StringArray(array) => strrep_string_array(array.clone(), old, new),
351        Value::CharArray(array) => strrep_char_array(array.clone(), old, new),
352        _ => Err(CELL_ELEMENT_ERROR.to_string()),
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    #[cfg(feature = "doc_export")]
360    use crate::builtins::common::test_support;
361
362    #[test]
363    fn strrep_string_scalar_basic() {
364        let result = strrep_builtin(
365            Value::String("RunMat Ignite".into()),
366            Value::String("Ignite".into()),
367            Value::String("Interpreter".into()),
368        )
369        .expect("strrep");
370        assert_eq!(result, Value::String("RunMat Interpreter".into()));
371    }
372
373    #[test]
374    fn strrep_string_array_preserves_missing() {
375        let array = StringArray::new(
376            vec![
377                String::from("gpu"),
378                String::from("<missing>"),
379                String::from("planner"),
380            ],
381            vec![3, 1],
382        )
383        .unwrap();
384        let result = strrep_builtin(
385            Value::StringArray(array),
386            Value::String("gpu".into()),
387            Value::String("GPU".into()),
388        )
389        .expect("strrep");
390        match result {
391            Value::StringArray(sa) => {
392                assert_eq!(sa.shape, vec![3, 1]);
393                assert_eq!(
394                    sa.data,
395                    vec![
396                        String::from("GPU"),
397                        String::from("<missing>"),
398                        String::from("planner")
399                    ]
400                );
401            }
402            other => panic!("expected string array, got {other:?}"),
403        }
404    }
405
406    #[test]
407    fn strrep_string_array_with_char_pattern() {
408        let array = StringArray::new(
409            vec![String::from("alpha"), String::from("beta")],
410            vec![2, 1],
411        )
412        .unwrap();
413        let result = strrep_builtin(
414            Value::StringArray(array),
415            Value::CharArray(CharArray::new_row("a")),
416            Value::CharArray(CharArray::new_row("A")),
417        )
418        .expect("strrep");
419        match result {
420            Value::StringArray(sa) => {
421                assert_eq!(sa.shape, vec![2, 1]);
422                assert_eq!(sa.data, vec![String::from("AlphA"), String::from("betA")]);
423            }
424            other => panic!("expected string array, got {other:?}"),
425        }
426    }
427
428    #[test]
429    fn strrep_char_array_padding() {
430        let chars = CharArray::new(vec!['R', 'u', 'n', ' ', 'M', 'a', 't'], 1, 7).unwrap();
431        let result = strrep_builtin(
432            Value::CharArray(chars),
433            Value::String(" ".into()),
434            Value::String("_".into()),
435        )
436        .expect("strrep");
437        match result {
438            Value::CharArray(out) => {
439                assert_eq!(out.rows, 1);
440                assert_eq!(out.cols, 7);
441                let expected: Vec<char> = "Run_Mat".chars().collect();
442                assert_eq!(out.data, expected);
443            }
444            other => panic!("expected char array, got {other:?}"),
445        }
446    }
447
448    #[test]
449    fn strrep_char_array_shrinks_rows_pad_with_spaces() {
450        let mut data: Vec<char> = "alpha".chars().collect();
451        data.extend("beta ".chars());
452        let array = CharArray::new(data, 2, 5).unwrap();
453        let result = strrep_builtin(
454            Value::CharArray(array),
455            Value::String("a".into()),
456            Value::String("".into()),
457        )
458        .expect("strrep");
459        match result {
460            Value::CharArray(out) => {
461                assert_eq!(out.rows, 2);
462                assert_eq!(out.cols, 4);
463                let expected: Vec<char> = vec!['l', 'p', 'h', ' ', 'b', 'e', 't', ' '];
464                assert_eq!(out.data, expected);
465            }
466            other => panic!("expected char array, got {other:?}"),
467        }
468    }
469
470    #[test]
471    fn strrep_cell_array_char_vectors() {
472        let cell = CellArray::new(
473            vec![
474                Value::CharArray(CharArray::new_row("Kernel Fusion")),
475                Value::CharArray(CharArray::new_row("GPU Planner")),
476            ],
477            1,
478            2,
479        )
480        .unwrap();
481        let result = strrep_builtin(
482            Value::Cell(cell),
483            Value::String(" ".into()),
484            Value::String("_".into()),
485        )
486        .expect("strrep");
487        match result {
488            Value::Cell(out) => {
489                assert_eq!(out.rows, 1);
490                assert_eq!(out.cols, 2);
491                assert_eq!(
492                    out.get(0, 0).unwrap(),
493                    Value::CharArray(CharArray::new_row("Kernel_Fusion"))
494                );
495                assert_eq!(
496                    out.get(0, 1).unwrap(),
497                    Value::CharArray(CharArray::new_row("GPU_Planner"))
498                );
499            }
500            other => panic!("expected cell array, got {other:?}"),
501        }
502    }
503
504    #[test]
505    fn strrep_cell_array_string_scalars() {
506        let cell = CellArray::new(
507            vec![
508                Value::String("Planner".into()),
509                Value::String("Profiler".into()),
510            ],
511            1,
512            2,
513        )
514        .unwrap();
515        let result = strrep_builtin(
516            Value::Cell(cell),
517            Value::String("er".into()),
518            Value::String("ER".into()),
519        )
520        .expect("strrep");
521        match result {
522            Value::Cell(out) => {
523                assert_eq!(out.rows, 1);
524                assert_eq!(out.cols, 2);
525                assert_eq!(out.get(0, 0).unwrap(), Value::String("PlannER".into()));
526                assert_eq!(out.get(0, 1).unwrap(), Value::String("ProfilER".into()));
527            }
528            other => panic!("expected cell array, got {other:?}"),
529        }
530    }
531
532    #[test]
533    fn strrep_cell_array_invalid_element_error() {
534        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).unwrap();
535        let err = strrep_builtin(
536            Value::Cell(cell),
537            Value::String("1".into()),
538            Value::String("one".into()),
539        )
540        .expect_err("expected cell element error");
541        assert!(err.contains("cell array elements"));
542    }
543
544    #[test]
545    fn strrep_cell_array_char_matrix_element() {
546        let mut chars: Vec<char> = "alpha".chars().collect();
547        chars.extend("beta ".chars());
548        let element = CharArray::new(chars, 2, 5).unwrap();
549        let cell = CellArray::new(vec![Value::CharArray(element)], 1, 1).unwrap();
550        let result = strrep_builtin(
551            Value::Cell(cell),
552            Value::String("a".into()),
553            Value::String("A".into()),
554        )
555        .expect("strrep");
556        match result {
557            Value::Cell(out) => {
558                let nested = out.get(0, 0).unwrap();
559                match nested {
560                    Value::CharArray(ca) => {
561                        assert_eq!(ca.rows, 2);
562                        assert_eq!(ca.cols, 5);
563                        let expected: Vec<char> =
564                            vec!['A', 'l', 'p', 'h', 'A', 'b', 'e', 't', 'A', ' '];
565                        assert_eq!(ca.data, expected);
566                    }
567                    other => panic!("expected char array element, got {other:?}"),
568                }
569            }
570            other => panic!("expected cell array, got {other:?}"),
571        }
572    }
573
574    #[test]
575    fn strrep_cell_array_string_arrays() {
576        let element = StringArray::new(vec!["alpha".into(), "beta".into()], vec![1, 2]).unwrap();
577        let cell = CellArray::new(vec![Value::StringArray(element)], 1, 1).unwrap();
578        let result = strrep_builtin(
579            Value::Cell(cell),
580            Value::String("a".into()),
581            Value::String("A".into()),
582        )
583        .expect("strrep");
584        match result {
585            Value::Cell(out) => {
586                let nested = out.get(0, 0).unwrap();
587                match nested {
588                    Value::StringArray(sa) => {
589                        assert_eq!(sa.shape, vec![1, 2]);
590                        assert_eq!(sa.data, vec![String::from("AlphA"), String::from("betA")]);
591                    }
592                    other => panic!("expected string array element, got {other:?}"),
593                }
594            }
595            other => panic!("expected cell array, got {other:?}"),
596        }
597    }
598
599    #[test]
600    fn strrep_empty_pattern_inserts_replacement() {
601        let result = strrep_builtin(
602            Value::String("abc".into()),
603            Value::String("".into()),
604            Value::String("-".into()),
605        )
606        .expect("strrep");
607        assert_eq!(result, Value::String("-a-b-c-".into()));
608    }
609
610    #[test]
611    fn strrep_type_mismatch_errors() {
612        let err = strrep_builtin(
613            Value::String("abc".into()),
614            Value::String("a".into()),
615            Value::CharArray(CharArray::new_row("x")),
616        )
617        .expect_err("expected type mismatch");
618        assert!(err.contains("same data type"));
619    }
620
621    #[test]
622    fn strrep_invalid_pattern_type_errors() {
623        let err = strrep_builtin(
624            Value::String("abc".into()),
625            Value::Num(1.0),
626            Value::String("x".into()),
627        )
628        .expect_err("expected pattern error");
629        assert!(err.contains("string scalars or character vectors"));
630    }
631
632    #[test]
633    fn strrep_first_argument_type_error() {
634        let err = strrep_builtin(
635            Value::Num(42.0),
636            Value::String("a".into()),
637            Value::String("b".into()),
638        )
639        .expect_err("expected argument type error");
640        assert!(err.contains("first argument"));
641    }
642
643    #[test]
644    #[cfg(feature = "wgpu")]
645    fn strrep_wgpu_provider_fallback() {
646        if runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
647            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
648        )
649        .is_err()
650        {
651            // Unable to initialize the provider in this environment; skip.
652            return;
653        }
654        let result = strrep_builtin(
655            Value::String("Turbine Engine".into()),
656            Value::String("Engine".into()),
657            Value::String("JIT".into()),
658        )
659        .expect("strrep");
660        assert_eq!(result, Value::String("Turbine JIT".into()));
661    }
662
663    #[test]
664    #[cfg(feature = "doc_export")]
665    fn doc_examples_smoke() {
666        let blocks = test_support::doc_examples(DOC_MD);
667        assert!(!blocks.is_empty());
668    }
669}