runmat_runtime/builtins/strings/transform/
strip.rs

1//! MATLAB-compatible `strip` 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::{gather_if_needed, make_cell, register_builtin_fusion_spec, register_builtin_gpu_spec};
14
15#[cfg(feature = "doc_export")]
16pub const DOC_MD: &str = r###"---
17title: "strip"
18category: "strings/transform"
19keywords: ["strip", "trim", "whitespace", "leading characters", "trailing characters", "character arrays"]
20summary: "Remove leading and trailing characters from strings, character arrays, and cell arrays."
21references:
22  - https://www.mathworks.com/help/matlab/ref/strip.html
23gpu_support:
24  elementwise: false
25  reduction: false
26  precisions: []
27  broadcasting: "none"
28  notes: "Executes on the CPU; GPU-resident inputs are gathered before trimming to match MATLAB semantics."
29fusion:
30  elementwise: false
31  reduction: false
32  max_inputs: 1
33  constants: "inline"
34requires_feature: null
35tested:
36  unit: "builtins::strings::transform::strip::tests"
37  integration: "builtins::strings::transform::strip::tests::strip_cell_array_mixed_content"
38---
39
40# What does the `strip` function do in MATLAB / RunMat?
41`strip(text)` removes consecutive whitespace characters from the beginning and end of `text`. The
42input can be a string scalar, string array, character array, or a cell array of character vectors,
43mirroring MATLAB behaviour. Optional arguments let you control which side to trim (`'left'`,
44`'right'`, or `'both'`) and provide custom characters to remove instead of whitespace.
45
46## How does the `strip` function behave in MATLAB / RunMat?
47- By default, `strip` removes leading and trailing whitespace determined by `isspace`.
48- Direction keywords are case-insensitive. `'left'`/`'leading'` trim the beginning, `'right'`/`'trailing'`
49  trim the end, and `'both'` removes characters on both sides.
50- Provide a second argument containing characters to remove to strip those characters instead of
51  whitespace. Supply a scalar string/char vector to apply the same rule to every element or a string /
52  cell array matching the input size to specify element-wise character sets.
53- Missing string scalars remain `<missing>`.
54- Character arrays shrink or retain their width to match the longest stripped row; shorter rows are
55  padded with spaces so the output stays rectangular.
56- Cell arrays must contain string scalars or character vectors. Results preserve the original cell
57  layout with trimmed elements.
58
59## `strip` Function GPU Execution Behaviour
60`strip` executes on the CPU. When the input or any nested element resides on the GPU, RunMat gathers
61those values to host memory before trimming so the results match MATLAB exactly. Providers do not need
62to implement device kernels for this builtin today.
63
64## GPU residency in RunMat (Do I need `gpuArray`?)
65Text data typically lives on the host. If you deliberately store text on the GPU (for example, by
66keeping character code points in device buffers), RunMat gathers them automatically when `strip` runs.
67You do not need to call `gpuArray` or `gather` manually for this builtin.
68
69## Examples of using the `strip` function in MATLAB / RunMat
70
71### Remove Leading And Trailing Spaces From A String Scalar
72```matlab
73name = "   RunMat   ";
74clean = strip(name);
75```
76Expected output:
77```matlab
78clean = "RunMat"
79```
80
81### Trim Only The Right Side Of Each String
82```matlab
83labels = ["   Alpha   "; "   Beta     "; "   Gamma    "];
84right_stripped = strip(labels, 'right');
85```
86Expected output:
87```matlab
88right_stripped = 3×1 string
89    "   Alpha"
90    "   Beta"
91    "   Gamma"
92```
93
94### Remove Leading Zeros While Preserving Trailing Digits
95```matlab
96codes = ["00095"; "00137"; "00420"];
97numeric = strip(codes, 'left', '0');
98```
99Expected output:
100```matlab
101numeric = 3×1 string
102    "95"
103    "137"
104    "420"
105```
106
107### Strip Character Arrays And Preserve Rectangular Shape
108```matlab
109animals = char("   cat  ", " dog", "cow   ");
110trimmed = strip(animals);
111```
112Expected output:
113```matlab
114trimmed =
115
116  3×4 char array
117
118    'cat '
119    'dog '
120    'cow '
121```
122
123### Supply Per-Element Characters To Remove
124```matlab
125metrics = ["##pass##", "--warn--", "**fail**"];
126per_char = ["#"; "-"; "*"];
127normalized = strip(metrics, 'both', per_char);
128```
129Expected output:
130```matlab
131normalized = 3×1 string
132    "pass"
133    "warn"
134    "fail"
135```
136
137### Trim Cell Array Elements With Mixed Types
138```matlab
139pieces = {'  GPU  ', "   Accelerate", 'RunMat   '};
140out = strip(pieces);
141```
142Expected output:
143```matlab
144out = 1×3 cell array
145    {'GPU'}    {"Accelerate"}    {'RunMat'}
146```
147
148## FAQ
149
150### Which direction keywords are supported?
151`'left'` and `'leading'` trim the beginning of the text, `'right'` and `'trailing'` trim the end, and
152`'both'` (the default) trims both sides.
153
154### How do I remove characters other than whitespace?
155Provide a second argument containing the characters to remove, for example `strip(str, "xyz")` removes
156any leading or trailing `x`, `y`, or `z` characters. Combine it with a direction argument to control
157which side is affected.
158
159### Can I specify different characters for each element?
160Yes. Pass a string array or cell array of character vectors that matches the size of the input. Each
161element is trimmed using the corresponding character set.
162
163### What happens to missing strings?
164Missing string scalars (`string(missing)`) remain `<missing>` exactly as in MATLAB.
165
166### Does `strip` change the shape of character arrays?
167Only the width can change. `strip` keeps the same number of rows and pads shorter rows with spaces so
168the array stays rectangular.
169
170### Will `strip` run on the GPU?
171Not currently. RunMat gathers GPU-resident inputs automatically and performs trimming on the CPU to
172maintain MATLAB compatibility.
173
174## See Also
175[lower](./lower), [upper](./upper), [string](../core/string), [char](../core/char), [compose](../core/compose)
176
177## Source & Feedback
178- Implementation: [`crates/runmat-runtime/src/builtins/strings/transform/strip.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/strings/transform/strip.rs)
179- Found an issue? Please [open a GitHub issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
180"###;
181
182pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
183    name: "strip",
184    op_kind: GpuOpKind::Custom("string-transform"),
185    supported_precisions: &[],
186    broadcast: BroadcastSemantics::None,
187    provider_hooks: &[],
188    constant_strategy: ConstantStrategy::InlineLiteral,
189    residency: ResidencyPolicy::GatherImmediately,
190    nan_mode: ReductionNaN::Include,
191    two_pass_threshold: None,
192    workgroup_size: None,
193    accepts_nan_mode: false,
194    notes:
195        "Executes on the CPU; GPU-resident inputs are gathered to host memory before trimming characters.",
196};
197
198register_builtin_gpu_spec!(GPU_SPEC);
199
200pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
201    name: "strip",
202    shape: ShapeRequirements::Any,
203    constant_strategy: ConstantStrategy::InlineLiteral,
204    elementwise: None,
205    reduction: None,
206    emits_nan: false,
207    notes: "String transformation builtin; not eligible for fusion and always gathers GPU inputs.",
208};
209
210register_builtin_fusion_spec!(FUSION_SPEC);
211
212#[cfg(feature = "doc_export")]
213register_builtin_doc_text!("strip", DOC_MD);
214
215const ARG_TYPE_ERROR: &str =
216    "strip: first argument must be a string array, character array, or cell array of character vectors";
217const CELL_ELEMENT_ERROR: &str =
218    "strip: cell array elements must be string scalars or character vectors";
219const DIRECTION_ERROR: &str = "strip: direction must be 'left', 'right', or 'both'";
220const CHARACTERS_ERROR: &str =
221    "strip: characters to remove must be a string array, character vector, or cell array of character vectors";
222const SIZE_MISMATCH_ERROR: &str =
223    "strip: stripCharacters must be the same size as the input when supplying multiple values";
224
225#[derive(Clone, Copy, Eq, PartialEq)]
226enum StripDirection {
227    Both,
228    Left,
229    Right,
230}
231
232enum PatternSpec {
233    Default,
234    Scalar(Vec<char>),
235    PerElement(Vec<Vec<char>>),
236}
237
238enum PatternRef<'a> {
239    Default,
240    Custom(&'a [char]),
241}
242
243#[derive(Clone)]
244struct PatternExpectation {
245    len: usize,
246    shape: Option<Vec<usize>>,
247}
248
249impl PatternExpectation {
250    fn scalar() -> Self {
251        Self {
252            len: 1,
253            shape: None,
254        }
255    }
256
257    fn with_len(len: usize) -> Self {
258        Self { len, shape: None }
259    }
260
261    fn with_shape(len: usize, shape: &[usize]) -> Self {
262        Self {
263            len,
264            shape: Some(shape.to_vec()),
265        }
266    }
267
268    fn len(&self) -> usize {
269        self.len
270    }
271
272    fn shape(&self) -> Option<&[usize]> {
273        self.shape.as_deref()
274    }
275}
276
277impl PatternSpec {
278    fn pattern_for_index(&self, idx: usize) -> PatternRef<'_> {
279        match self {
280            PatternSpec::Default => PatternRef::Default,
281            PatternSpec::Scalar(chars) => PatternRef::Custom(chars),
282            PatternSpec::PerElement(patterns) => patterns
283                .get(idx)
284                .map(|chars| PatternRef::Custom(chars))
285                .unwrap_or(PatternRef::Default),
286        }
287    }
288}
289
290#[runtime_builtin(
291    name = "strip",
292    category = "strings/transform",
293    summary = "Remove leading and trailing characters from strings, character arrays, and cell arrays.",
294    keywords = "strip,trim,strings,character array,text",
295    accel = "sink"
296)]
297fn strip_builtin(value: Value, rest: Vec<Value>) -> Result<Value, String> {
298    let gathered = gather_if_needed(&value).map_err(|e| format!("strip: {e}"))?;
299    match gathered {
300        Value::String(text) => strip_string(text, &rest),
301        Value::StringArray(array) => strip_string_array(array, &rest),
302        Value::CharArray(array) => strip_char_array(array, &rest),
303        Value::Cell(cell) => strip_cell_array(cell, &rest),
304        _ => Err(ARG_TYPE_ERROR.to_string()),
305    }
306}
307
308fn strip_string(text: String, args: &[Value]) -> Result<Value, String> {
309    if is_missing_string(&text) {
310        return Ok(Value::String(text));
311    }
312    let expectation = PatternExpectation::scalar();
313    let (direction, pattern_spec) = parse_arguments(args, &expectation)?;
314    let stripped = strip_text(&text, direction, pattern_spec.pattern_for_index(0));
315    Ok(Value::String(stripped))
316}
317
318fn strip_string_array(array: StringArray, args: &[Value]) -> Result<Value, String> {
319    let expected_len = array.data.len();
320    let expectation = PatternExpectation::with_shape(expected_len, &array.shape);
321    let (direction, pattern_spec) = parse_arguments(args, &expectation)?;
322    let StringArray { data, shape, .. } = array;
323    let mut stripped: Vec<String> = Vec::with_capacity(expected_len);
324    for (idx, text) in data.into_iter().enumerate() {
325        if is_missing_string(&text) {
326            stripped.push(text);
327        } else {
328            let pattern = pattern_spec.pattern_for_index(idx);
329            stripped.push(strip_text(&text, direction, pattern));
330        }
331    }
332    let result = StringArray::new(stripped, shape).map_err(|e| format!("strip: {e}"))?;
333    Ok(Value::StringArray(result))
334}
335
336fn strip_char_array(array: CharArray, args: &[Value]) -> Result<Value, String> {
337    let CharArray { data, rows, cols } = array;
338    let expectation = PatternExpectation::with_len(rows);
339    let (direction, pattern_spec) = parse_arguments(args, &expectation)?;
340
341    if rows == 0 {
342        return Ok(Value::CharArray(CharArray { data, rows, cols }));
343    }
344
345    let mut stripped_rows: Vec<String> = Vec::with_capacity(rows);
346    let mut target_cols: usize = 0;
347    for row in 0..rows {
348        let text = char_row_to_string_slice(&data, cols, row);
349        let pattern = pattern_spec.pattern_for_index(row);
350        let stripped = strip_text(&text, direction, pattern);
351        let len = stripped.chars().count();
352        target_cols = target_cols.max(len);
353        stripped_rows.push(stripped);
354    }
355
356    let mut new_data: Vec<char> = Vec::with_capacity(rows * target_cols);
357    for row_text in stripped_rows {
358        let mut chars: Vec<char> = row_text.chars().collect();
359        if chars.len() < target_cols {
360            chars.resize(target_cols, ' ');
361        }
362        new_data.extend(chars.into_iter());
363    }
364
365    CharArray::new(new_data, rows, target_cols)
366        .map(Value::CharArray)
367        .map_err(|e| format!("strip: {e}"))
368}
369
370fn strip_cell_array(cell: CellArray, args: &[Value]) -> Result<Value, String> {
371    let rows = cell.rows;
372    let cols = cell.cols;
373    let dims = [rows, cols];
374    let expectation = PatternExpectation::with_shape(rows * cols, &dims);
375    let (direction, pattern_spec) = parse_arguments(args, &expectation)?;
376    let total = rows * cols;
377    let mut stripped_values: Vec<Value> = Vec::with_capacity(total);
378    for idx in 0..total {
379        let value = &cell.data[idx];
380        let pattern = pattern_spec.pattern_for_index(idx);
381        let stripped = strip_cell_element(value, direction, pattern)?;
382        stripped_values.push(stripped);
383    }
384    make_cell(stripped_values, rows, cols).map_err(|e| format!("strip: {e}"))
385}
386
387fn strip_cell_element(
388    value: &Value,
389    direction: StripDirection,
390    pattern: PatternRef<'_>,
391) -> Result<Value, String> {
392    let gathered = gather_if_needed(value).map_err(|e| format!("strip: {e}"))?;
393    match gathered {
394        Value::String(text) => {
395            if is_missing_string(&text) {
396                Ok(Value::String(text))
397            } else {
398                let stripped = strip_text(&text, direction, pattern);
399                Ok(Value::String(stripped))
400            }
401        }
402        Value::StringArray(sa) if sa.data.len() == 1 => {
403            let text = sa.data.into_iter().next().unwrap();
404            if is_missing_string(&text) {
405                Ok(Value::String(text))
406            } else {
407                let stripped = strip_text(&text, direction, pattern);
408                Ok(Value::String(stripped))
409            }
410        }
411        Value::CharArray(ca) if ca.rows <= 1 => {
412            let source = if ca.rows == 0 {
413                String::new()
414            } else {
415                char_row_to_string_slice(&ca.data, ca.cols, 0)
416            };
417            let stripped = strip_text(&source, direction, pattern);
418            let len = stripped.chars().count();
419            let data: Vec<char> = stripped.chars().collect();
420            let rows = ca.rows;
421            let cols = if rows == 0 { ca.cols } else { len };
422            CharArray::new(data, rows, cols)
423                .map(Value::CharArray)
424                .map_err(|e| format!("strip: {e}"))
425        }
426        Value::CharArray(_) => Err(CELL_ELEMENT_ERROR.to_string()),
427        _ => Err(CELL_ELEMENT_ERROR.to_string()),
428    }
429}
430
431fn parse_arguments(
432    args: &[Value],
433    expectation: &PatternExpectation,
434) -> Result<(StripDirection, PatternSpec), String> {
435    match args.len() {
436        0 => Ok((StripDirection::Both, PatternSpec::Default)),
437        1 => {
438            if let Some(direction) = try_parse_direction(&args[0], false)? {
439                Ok((direction, PatternSpec::Default))
440            } else {
441                let pattern = parse_pattern(&args[0], expectation)?;
442                Ok((StripDirection::Both, pattern))
443            }
444        }
445        2 => {
446            let direction = match try_parse_direction(&args[0], true)? {
447                Some(dir) => dir,
448                None => return Err(DIRECTION_ERROR.to_string()),
449            };
450            let pattern = parse_pattern(&args[1], expectation)?;
451            Ok((direction, pattern))
452        }
453        _ => Err("strip: too many input arguments".to_string()),
454    }
455}
456
457fn try_parse_direction(value: &Value, strict: bool) -> Result<Option<StripDirection>, String> {
458    let Some(text) = value_to_single_string(value) else {
459        return Ok(None);
460    };
461    let trimmed = text.trim();
462    if trimmed.is_empty() {
463        return if strict {
464            Err(DIRECTION_ERROR.to_string())
465        } else {
466            Ok(None)
467        };
468    }
469    let lowered = trimmed.to_ascii_lowercase();
470    let direction = match lowered.as_str() {
471        "both" => Some(StripDirection::Both),
472        "left" | "leading" => Some(StripDirection::Left),
473        "right" | "trailing" => Some(StripDirection::Right),
474        _ => {
475            if strict {
476                return Err(DIRECTION_ERROR.to_string());
477            }
478            None
479        }
480    };
481    Ok(direction)
482}
483
484fn value_to_single_string(value: &Value) -> Option<String> {
485    match value {
486        Value::String(text) => Some(text.clone()),
487        Value::StringArray(sa) => {
488            if sa.data.len() == 1 {
489                Some(sa.data[0].clone())
490            } else {
491                None
492            }
493        }
494        Value::CharArray(ca) => {
495            if ca.rows <= 1 {
496                Some(char_row_to_string_slice(&ca.data, ca.cols, 0))
497            } else {
498                None
499            }
500        }
501        _ => None,
502    }
503}
504
505fn parse_pattern(value: &Value, expectation: &PatternExpectation) -> Result<PatternSpec, String> {
506    let expected_len = expectation.len();
507    match value {
508        Value::String(text) => Ok(PatternSpec::Scalar(text.chars().collect())),
509        Value::StringArray(sa) => {
510            if sa.data.len() <= 1 {
511                if let Some(first) = sa.data.first() {
512                    Ok(PatternSpec::Scalar(first.chars().collect()))
513                } else {
514                    Ok(PatternSpec::Scalar(Vec::new()))
515                }
516            } else if sa.data.len() == expected_len {
517                if let Some(shape) = expectation.shape() {
518                    if sa.shape != shape {
519                        return Err(SIZE_MISMATCH_ERROR.to_string());
520                    }
521                }
522                let mut patterns = Vec::with_capacity(sa.data.len());
523                for text in &sa.data {
524                    patterns.push(text.chars().collect());
525                }
526                Ok(PatternSpec::PerElement(patterns))
527            } else {
528                Err(SIZE_MISMATCH_ERROR.to_string())
529            }
530        }
531        Value::CharArray(ca) => {
532            if ca.rows <= 1 {
533                if ca.rows == 0 {
534                    Ok(PatternSpec::Scalar(Vec::new()))
535                } else {
536                    let chars = char_row_to_string_slice(&ca.data, ca.cols, 0);
537                    Ok(PatternSpec::Scalar(chars.chars().collect()))
538                }
539            } else if ca.rows == expected_len {
540                let mut patterns = Vec::with_capacity(ca.rows);
541                for row in 0..ca.rows {
542                    let text = char_row_to_string_slice(&ca.data, ca.cols, row);
543                    patterns.push(text.chars().collect());
544                }
545                Ok(PatternSpec::PerElement(patterns))
546            } else {
547                Err(SIZE_MISMATCH_ERROR.to_string())
548            }
549        }
550        Value::Cell(cell) => parse_pattern_cell(cell, expectation),
551        _ => Err(CHARACTERS_ERROR.to_string()),
552    }
553}
554
555fn parse_pattern_cell(
556    cell: &CellArray,
557    expectation: &PatternExpectation,
558) -> Result<PatternSpec, String> {
559    let len = cell.rows * cell.cols;
560    if len == 0 {
561        return Ok(PatternSpec::Scalar(Vec::new()));
562    }
563    if len == 1 {
564        let chars = pattern_chars_from_value(&cell.data[0])?;
565        return Ok(PatternSpec::Scalar(chars));
566    }
567    if len != expectation.len() {
568        return Err(SIZE_MISMATCH_ERROR.to_string());
569    }
570    if let Some(shape) = expectation.shape() {
571        match shape.len() {
572            0 => {}
573            1 => {
574                if cell.rows != shape[0] || cell.cols != 1 {
575                    return Err(SIZE_MISMATCH_ERROR.to_string());
576                }
577            }
578            _ => {
579                if cell.rows != shape[0] || cell.cols != shape[1] {
580                    return Err(SIZE_MISMATCH_ERROR.to_string());
581                }
582            }
583        }
584    }
585    let mut patterns = Vec::with_capacity(len);
586    for value in &cell.data {
587        patterns.push(pattern_chars_from_value(value)?);
588    }
589    Ok(PatternSpec::PerElement(patterns))
590}
591
592fn pattern_chars_from_value(value: &Value) -> Result<Vec<char>, String> {
593    let gathered = gather_if_needed(value).map_err(|e| format!("strip: {e}"))?;
594    match gathered {
595        Value::String(text) => Ok(text.chars().collect()),
596        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].chars().collect()),
597        Value::CharArray(ca) if ca.rows <= 1 => {
598            if ca.rows == 0 {
599                Ok(Vec::new())
600            } else {
601                let text = char_row_to_string_slice(&ca.data, ca.cols, 0);
602                Ok(text.chars().collect())
603            }
604        }
605        Value::CharArray(_) => Err(CHARACTERS_ERROR.to_string()),
606        _ => Err(CHARACTERS_ERROR.to_string()),
607    }
608}
609
610fn strip_text(text: &str, direction: StripDirection, pattern: PatternRef<'_>) -> String {
611    match pattern {
612        PatternRef::Default => strip_text_with_predicate(text, direction, char::is_whitespace),
613        PatternRef::Custom(chars) => {
614            strip_text_with_predicate(text, direction, |c| chars.contains(&c))
615        }
616    }
617}
618
619fn strip_text_with_predicate<F>(text: &str, direction: StripDirection, mut predicate: F) -> String
620where
621    F: FnMut(char) -> bool,
622{
623    let chars: Vec<char> = text.chars().collect();
624    if chars.is_empty() {
625        return String::new();
626    }
627
628    let mut start = 0usize;
629    let mut end = chars.len();
630
631    if direction != StripDirection::Right {
632        while start < end && predicate(chars[start]) {
633            start += 1;
634        }
635    }
636
637    if direction != StripDirection::Left {
638        while end > start && predicate(chars[end - 1]) {
639            end -= 1;
640        }
641    }
642
643    chars[start..end].iter().collect()
644}
645
646#[cfg(test)]
647mod tests {
648    use super::*;
649    #[cfg(feature = "doc_export")]
650    use crate::builtins::common::test_support;
651
652    #[test]
653    fn strip_string_scalar_default() {
654        let result = strip_builtin(Value::String("  RunMat  ".into()), Vec::new()).expect("strip");
655        assert_eq!(result, Value::String("RunMat".into()));
656    }
657
658    #[test]
659    fn strip_string_scalar_direction() {
660        let result = strip_builtin(
661            Value::String("...data".into()),
662            vec![Value::String("left".into()), Value::String(".".into())],
663        )
664        .expect("strip");
665        assert_eq!(result, Value::String("data".into()));
666    }
667
668    #[test]
669    fn strip_string_scalar_custom_characters() {
670        let result = strip_builtin(
671            Value::String("00052".into()),
672            vec![Value::String("left".into()), Value::String("0".into())],
673        )
674        .expect("strip");
675        assert_eq!(result, Value::String("52".into()));
676    }
677
678    #[test]
679    fn strip_string_scalar_pattern_only() {
680        let result = strip_builtin(
681            Value::String("xxaccelerationxx".into()),
682            vec![Value::String("x".into())],
683        )
684        .expect("strip");
685        assert_eq!(result, Value::String("acceleration".into()));
686    }
687
688    #[test]
689    fn strip_empty_pattern_returns_original() {
690        let result = strip_builtin(
691            Value::String("abc".into()),
692            vec![Value::String(String::new())],
693        )
694        .expect("strip");
695        assert_eq!(result, Value::String("abc".into()));
696    }
697
698    #[test]
699    fn strip_supports_leading_synonym() {
700        let result = strip_builtin(
701            Value::String("   data".into()),
702            vec![Value::String("leading".into())],
703        )
704        .expect("strip");
705        assert_eq!(result, Value::String("data".into()));
706    }
707
708    #[test]
709    fn strip_supports_trailing_synonym() {
710        let result = strip_builtin(
711            Value::String("data   ".into()),
712            vec![Value::String("trailing".into())],
713        )
714        .expect("strip");
715        assert_eq!(result, Value::String("data".into()));
716    }
717
718    #[test]
719    fn strip_string_array_per_element_characters() {
720        let strings = StringArray::new(
721            vec!["##ok##".into(), "--warn--".into(), "**fail**".into()],
722            vec![3, 1],
723        )
724        .unwrap();
725        let chars = CharArray::new(vec!['#', '#', '-', '-', '*', '*'], 3, 2).unwrap();
726        let result = strip_builtin(
727            Value::StringArray(strings),
728            vec![Value::String("both".into()), Value::CharArray(chars)],
729        )
730        .expect("strip");
731        match result {
732            Value::StringArray(sa) => {
733                assert_eq!(
734                    sa.data,
735                    vec![
736                        String::from("ok"),
737                        String::from("warn"),
738                        String::from("fail")
739                    ]
740                );
741            }
742            other => panic!("expected string array, got {other:?}"),
743        }
744    }
745
746    #[test]
747    fn strip_string_array_cell_pattern_per_element() {
748        let strings =
749            StringArray::new(vec!["__pass__".into(), "--warn--".into()], vec![2, 1]).unwrap();
750        let patterns = CellArray::new(
751            vec![Value::String("_".into()), Value::String("-".into())],
752            2,
753            1,
754        )
755        .unwrap();
756        let result =
757            strip_builtin(Value::StringArray(strings), vec![Value::Cell(patterns)]).expect("strip");
758        match result {
759            Value::StringArray(sa) => {
760                assert_eq!(sa.data, vec![String::from("pass"), String::from("warn")]);
761            }
762            other => panic!("expected string array, got {other:?}"),
763        }
764    }
765
766    #[test]
767    fn strip_string_array_preserves_missing() {
768        let strings =
769            StringArray::new(vec!["   data   ".into(), "<missing>".into()], vec![2, 1]).unwrap();
770        let result = strip_builtin(Value::StringArray(strings), Vec::new()).expect("strip");
771        match result {
772            Value::StringArray(sa) => {
773                assert_eq!(sa.data[0], "data");
774                assert_eq!(sa.data[1], "<missing>");
775            }
776            other => panic!("expected string array, got {other:?}"),
777        }
778    }
779
780    #[test]
781    fn strip_char_array_shrinks_width() {
782        let source = "  cat  dog  ";
783        let chars: Vec<char> = source.chars().collect();
784        let array = CharArray::new(chars, 1, source.chars().count()).unwrap();
785        let result = strip_builtin(Value::CharArray(array), Vec::new()).expect("strip");
786        match result {
787            Value::CharArray(ca) => {
788                assert_eq!(ca.rows, 1);
789                assert_eq!(ca.cols, 8);
790                let expected: Vec<char> = "cat  dog".chars().collect();
791                assert_eq!(ca.data, expected);
792            }
793            other => panic!("expected char array, got {other:?}"),
794        }
795    }
796
797    #[test]
798    fn strip_char_array_supports_trailing_direction() {
799        let array = CharArray::new_row("gpu   ");
800        let result = strip_builtin(
801            Value::CharArray(array),
802            vec![Value::String("trailing".into())],
803        )
804        .expect("strip");
805        match result {
806            Value::CharArray(ca) => {
807                assert_eq!(ca.rows, 1);
808                assert_eq!(ca.cols, 3);
809                let expected: Vec<char> = "gpu".chars().collect();
810                assert_eq!(ca.data, expected);
811            }
812            other => panic!("expected char array, got {other:?}"),
813        }
814    }
815
816    #[test]
817    fn strip_cell_array_mixed_content() {
818        let cell = CellArray::new(
819            vec![
820                Value::CharArray(CharArray::new_row("  GPU ")),
821                Value::String("   Accelerate".into()),
822                Value::String("RunMat   ".into()),
823            ],
824            1,
825            3,
826        )
827        .unwrap();
828        let result = strip_builtin(Value::Cell(cell), Vec::new()).expect("strip");
829        match result {
830            Value::Cell(out) => {
831                assert_eq!(out.rows, 1);
832                assert_eq!(out.cols, 3);
833                assert_eq!(
834                    out.get(0, 0).unwrap(),
835                    Value::CharArray(CharArray::new_row("GPU"))
836                );
837                assert_eq!(out.get(0, 1).unwrap(), Value::String("Accelerate".into()));
838                assert_eq!(out.get(0, 2).unwrap(), Value::String("RunMat".into()));
839            }
840            other => panic!("expected cell array, got {other:?}"),
841        }
842    }
843
844    #[test]
845    fn strip_preserves_missing_string() {
846        let result = strip_builtin(Value::String("<missing>".into()), Vec::new()).expect("strip");
847        assert_eq!(result, Value::String("<missing>".into()));
848    }
849
850    #[test]
851    fn strip_errors_on_invalid_input() {
852        let err = strip_builtin(Value::Num(1.0), Vec::new()).unwrap_err();
853        assert_eq!(err, ARG_TYPE_ERROR);
854    }
855
856    #[test]
857    fn strip_errors_on_invalid_pattern_type() {
858        let err = strip_builtin(Value::String("abc".into()), vec![Value::Num(1.0)]).unwrap_err();
859        assert_eq!(err, CHARACTERS_ERROR);
860    }
861
862    #[test]
863    fn strip_errors_on_invalid_direction() {
864        let err = strip_builtin(
865            Value::String("abc".into()),
866            vec![Value::String("sideways".into()), Value::String("a".into())],
867        )
868        .unwrap_err();
869        assert_eq!(err, DIRECTION_ERROR);
870    }
871
872    #[test]
873    fn strip_errors_on_pattern_size_mismatch() {
874        let strings = StringArray::new(vec!["one".into(), "two".into()], vec![2, 1]).unwrap();
875        let pattern =
876            StringArray::new(vec!["x".into(), "y".into(), "z".into()], vec![3, 1]).unwrap();
877        let err = strip_builtin(
878            Value::StringArray(strings),
879            vec![Value::StringArray(pattern)],
880        )
881        .unwrap_err();
882        assert_eq!(err, SIZE_MISMATCH_ERROR);
883    }
884
885    #[test]
886    fn strip_errors_on_pattern_shape_mismatch() {
887        let strings = StringArray::new(vec!["one".into(), "two".into()], vec![1, 2]).unwrap();
888        let pattern = StringArray::new(vec!["x".into(), "y".into()], vec![2, 1]).unwrap();
889        let err = strip_builtin(
890            Value::StringArray(strings),
891            vec![Value::StringArray(pattern)],
892        )
893        .unwrap_err();
894        assert_eq!(err, SIZE_MISMATCH_ERROR);
895    }
896
897    #[test]
898    fn strip_errors_on_cell_pattern_shape_mismatch() {
899        let strings = StringArray::new(vec!["aa".into(), "bb".into()], vec![1, 2]).unwrap();
900        let cell_pattern = CellArray::new(
901            vec![Value::String("a".into()), Value::String("b".into())],
902            2,
903            1,
904        )
905        .unwrap();
906        let err = strip_builtin(Value::StringArray(strings), vec![Value::Cell(cell_pattern)])
907            .unwrap_err();
908        assert_eq!(err, SIZE_MISMATCH_ERROR);
909    }
910
911    #[test]
912    fn strip_errors_on_too_many_arguments() {
913        let err = strip_builtin(
914            Value::String("abc".into()),
915            vec![
916                Value::String("both".into()),
917                Value::String("a".into()),
918                Value::String("b".into()),
919            ],
920        )
921        .unwrap_err();
922        assert_eq!(err, "strip: too many input arguments");
923    }
924
925    #[test]
926    #[cfg(feature = "wgpu")]
927    fn strip_gpu_tensor_errors() {
928        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
929            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
930        );
931        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
932        let host_data = [1.0f64, 2.0];
933        let host_shape = [2usize, 1usize];
934        let handle = provider
935            .upload(&runmat_accelerate_api::HostTensorView {
936                data: &host_data,
937                shape: &host_shape,
938            })
939            .expect("upload");
940        let err = strip_builtin(Value::GpuTensor(handle.clone()), Vec::new()).unwrap_err();
941        assert_eq!(err, ARG_TYPE_ERROR);
942        provider.free(&handle).ok();
943    }
944
945    #[test]
946    #[cfg(feature = "doc_export")]
947    fn doc_examples_present() {
948        let blocks = test_support::doc_examples(DOC_MD);
949        assert!(!blocks.is_empty());
950    }
951}