Skip to main content

runmat_runtime/builtins/strings/transform/
strip.rs

1//! MATLAB-compatible `strip` builtin with GPU-aware semantics for RunMat.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6};
7use runmat_builtins::{BuiltinIntegerAuditDescriptor, BuiltinIntegerAuditKind};
8use runmat_macros::runtime_builtin;
9use runmat_value::{CellArray, CharArray, StringArray, Value};
10
11use crate::builtins::common::map_control_flow_with_builtin;
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::strings::common::{
17    char_row_to_string_slice, contains_resident_text_input, is_missing_string,
18};
19use crate::builtins::strings::type_resolvers::text_preserve_type;
20use crate::{build_runtime_error, gather_if_needed_async, make_cell, BuiltinResult, RuntimeError};
21
22#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::transform::strip")]
23pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
24    name: "strip",
25    op_kind: GpuOpKind::Custom("string-transform"),
26    supported_precisions: &[],
27    broadcast: BroadcastSemantics::None,
28    provider_hooks: &[],
29    constant_strategy: ConstantStrategy::InlineLiteral,
30    residency: ResidencyPolicy::GatherImmediately,
31    nan_mode: ReductionNaN::Include,
32    two_pass_threshold: None,
33    workgroup_size: None,
34    accepts_nan_mode: false,
35    notes:
36        "Executes on the CPU; GPU-resident inputs are gathered to host memory before trimming characters.",
37};
38
39#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::transform::strip")]
40pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
41    name: "strip",
42    shape: ShapeRequirements::Any,
43    constant_strategy: ConstantStrategy::InlineLiteral,
44    elementwise: None,
45    reduction: None,
46    emits_nan: false,
47    notes: "String transformation builtin; not eligible for fusion and always gathers GPU inputs.",
48};
49
50const BUILTIN_NAME: &str = "strip";
51
52const STRIP_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
53    name: "out",
54    ty: BuiltinParamType::Any,
55    arity: BuiltinParamArity::Required,
56    default: None,
57    description: "Stripped text preserving input container kind and shape.",
58}];
59
60const STRIP_INPUTS_BASE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
61    name: "str",
62    ty: BuiltinParamType::Any,
63    arity: BuiltinParamArity::Required,
64    default: None,
65    description: "String/char/cell text input to strip.",
66}];
67
68const STRIP_INPUTS_DIRECTION: [BuiltinParamDescriptor; 2] = [
69    BuiltinParamDescriptor {
70        name: "str",
71        ty: BuiltinParamType::Any,
72        arity: BuiltinParamArity::Required,
73        default: None,
74        description: "String/char/cell text input to strip.",
75    },
76    BuiltinParamDescriptor {
77        name: "direction",
78        ty: BuiltinParamType::StringScalar,
79        arity: BuiltinParamArity::Required,
80        default: Some("\"both\""),
81        description: "Direction (`\"left\"|\"right\"|\"both\"`, plus leading/trailing synonyms).",
82    },
83];
84
85const STRIP_INPUTS_CHARACTERS: [BuiltinParamDescriptor; 2] = [
86    BuiltinParamDescriptor {
87        name: "str",
88        ty: BuiltinParamType::Any,
89        arity: BuiltinParamArity::Required,
90        default: None,
91        description: "String/char/cell text input to strip.",
92    },
93    BuiltinParamDescriptor {
94        name: "stripCharacters",
95        ty: BuiltinParamType::Any,
96        arity: BuiltinParamArity::Required,
97        default: None,
98        description: "Characters to strip (scalar or per-element text container).",
99    },
100];
101
102const STRIP_INPUTS_DIRECTION_CHARACTERS: [BuiltinParamDescriptor; 3] = [
103    BuiltinParamDescriptor {
104        name: "str",
105        ty: BuiltinParamType::Any,
106        arity: BuiltinParamArity::Required,
107        default: None,
108        description: "String/char/cell text input to strip.",
109    },
110    BuiltinParamDescriptor {
111        name: "direction",
112        ty: BuiltinParamType::StringScalar,
113        arity: BuiltinParamArity::Required,
114        default: None,
115        description: "Direction (`\"left\"|\"right\"|\"both\"`, plus leading/trailing synonyms).",
116    },
117    BuiltinParamDescriptor {
118        name: "stripCharacters",
119        ty: BuiltinParamType::Any,
120        arity: BuiltinParamArity::Required,
121        default: None,
122        description: "Characters to strip (scalar or per-element text container).",
123    },
124];
125
126const STRIP_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
127    BuiltinSignatureDescriptor {
128        label: "out = strip(str)",
129        inputs: &STRIP_INPUTS_BASE,
130        outputs: &STRIP_OUTPUT,
131    },
132    BuiltinSignatureDescriptor {
133        label: "out = strip(str, direction)",
134        inputs: &STRIP_INPUTS_DIRECTION,
135        outputs: &STRIP_OUTPUT,
136    },
137    BuiltinSignatureDescriptor {
138        label: "out = strip(str, stripCharacters)",
139        inputs: &STRIP_INPUTS_CHARACTERS,
140        outputs: &STRIP_OUTPUT,
141    },
142    BuiltinSignatureDescriptor {
143        label: "out = strip(str, direction, stripCharacters)",
144        inputs: &STRIP_INPUTS_DIRECTION_CHARACTERS,
145        outputs: &STRIP_OUTPUT,
146    },
147];
148
149const STRIP_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
150    code: "RM.STRIP.INVALID_INPUT",
151    identifier: Some("RunMat:strip:InvalidInput"),
152    when: "Input is not a string array, character array, or cell array of text scalars.",
153    message:
154        "strip: first argument must be a string array, character array, or cell array of character vectors",
155};
156
157const STRIP_ERROR_CELL_ELEMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
158    code: "RM.STRIP.CELL_ELEMENT",
159    identifier: Some("RunMat:strip:CellElement"),
160    when: "Cell array contains a non-text element or non-row char array element.",
161    message: "strip: cell array elements must be string scalars or character vectors",
162};
163
164const STRIP_ERROR_DIRECTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
165    code: "RM.STRIP.DIRECTION",
166    identifier: Some("RunMat:strip:InvalidDirection"),
167    when: "Direction argument is not one of left/right/both (or leading/trailing synonyms).",
168    message: "strip: direction must be 'left', 'right', or 'both'",
169};
170
171const STRIP_ERROR_CHARACTERS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
172    code: "RM.STRIP.CHARACTERS",
173    identifier: Some("RunMat:strip:InvalidCharacters"),
174    when: "stripCharacters argument is not a valid text container.",
175    message:
176        "strip: characters to remove must be a string array, character vector, or cell array of character vectors",
177};
178
179const STRIP_ERROR_SIZE_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
180    code: "RM.STRIP.SIZE_MISMATCH",
181    identifier: Some("RunMat:strip:SizeMismatch"),
182    when: "Per-element stripCharacters does not match input shape/size.",
183    message:
184        "strip: stripCharacters must be the same size as the input when supplying multiple values",
185};
186
187const STRIP_ERROR_ARG_COUNT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
188    code: "RM.STRIP.ARG_COUNT",
189    identifier: Some("RunMat:strip:ArgCount"),
190    when: "More than three input arguments were supplied.",
191    message: "strip: too many input arguments",
192};
193
194const STRIP_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
195    code: "RM.STRIP.INTERNAL",
196    identifier: Some("RunMat:strip:InternalError"),
197    when: "Internal output container construction failed.",
198    message: "strip: internal error",
199};
200
201const STRIP_ERRORS: [BuiltinErrorDescriptor; 7] = [
202    STRIP_ERROR_INVALID_INPUT,
203    STRIP_ERROR_CELL_ELEMENT,
204    STRIP_ERROR_DIRECTION,
205    STRIP_ERROR_CHARACTERS,
206    STRIP_ERROR_SIZE_MISMATCH,
207    STRIP_ERROR_ARG_COUNT,
208    STRIP_ERROR_INTERNAL,
209];
210
211pub const STRIP_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
212    signatures: &STRIP_SIGNATURES,
213    output_mode: BuiltinOutputMode::Fixed,
214    completion_policy: BuiltinCompletionPolicy::Public,
215    errors: &STRIP_ERRORS,
216};
217
218pub const STRIP_INTEGER_AUDIT: BuiltinIntegerAuditDescriptor = BuiltinIntegerAuditDescriptor {
219    kind: BuiltinIntegerAuditKind::NotApplicable,
220    canonical_builtin: None,
221    notes: "strip accepts host string, character, and cell text plus textual direction and strip-character inputs. Numeric, logical, symbolic, and provider-resident values reject before gather or provider access.",
222};
223
224fn map_flow(err: RuntimeError) -> RuntimeError {
225    map_control_flow_with_builtin(err, BUILTIN_NAME)
226}
227
228fn strip_error_with_message(
229    message: impl Into<String>,
230    error: &'static BuiltinErrorDescriptor,
231) -> RuntimeError {
232    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
233    if let Some(identifier) = error.identifier {
234        builder = builder.with_identifier(identifier);
235    }
236    builder.build()
237}
238
239fn strip_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
240    strip_error_with_message(error.message, error)
241}
242
243#[derive(Clone, Copy, Eq, PartialEq)]
244enum StripDirection {
245    Both,
246    Left,
247    Right,
248}
249
250enum PatternSpec {
251    Default,
252    Scalar(Vec<char>),
253    PerElement(Vec<Vec<char>>),
254}
255
256enum PatternRef<'a> {
257    Default,
258    Custom(&'a [char]),
259}
260
261#[derive(Clone)]
262struct PatternExpectation {
263    len: usize,
264    shape: Option<Vec<usize>>,
265}
266
267impl PatternExpectation {
268    fn scalar() -> Self {
269        Self {
270            len: 1,
271            shape: None,
272        }
273    }
274
275    fn with_len(len: usize) -> Self {
276        Self { len, shape: None }
277    }
278
279    fn with_shape(len: usize, shape: &[usize]) -> Self {
280        Self {
281            len,
282            shape: Some(shape.to_vec()),
283        }
284    }
285
286    fn len(&self) -> usize {
287        self.len
288    }
289
290    fn shape(&self) -> Option<&[usize]> {
291        self.shape.as_deref()
292    }
293}
294
295impl PatternSpec {
296    fn pattern_for_index(&self, idx: usize) -> PatternRef<'_> {
297        match self {
298            PatternSpec::Default => PatternRef::Default,
299            PatternSpec::Scalar(chars) => PatternRef::Custom(chars),
300            PatternSpec::PerElement(patterns) => patterns
301                .get(idx)
302                .map(|chars| PatternRef::Custom(chars))
303                .unwrap_or(PatternRef::Default),
304        }
305    }
306}
307
308#[runtime_builtin(
309    name = "strip",
310    category = "strings/transform",
311    summary = "Remove leading and trailing characters from supported text containers.",
312    keywords = "strip,trim,strings,character array,text",
313    accel = "sink",
314    type_resolver(text_preserve_type),
315    descriptor(crate::builtins::strings::transform::strip::STRIP_DESCRIPTOR),
316    integer_audit(crate::builtins::strings::transform::strip::STRIP_INTEGER_AUDIT),
317    builtin_path = "crate::builtins::strings::transform::strip"
318)]
319async fn strip_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
320    if contains_resident_text_input(&value) || rest.iter().any(contains_resident_text_input) {
321        return Err(strip_error(&STRIP_ERROR_INVALID_INPUT));
322    }
323    let gathered = gather_if_needed_async(&value).await.map_err(map_flow)?;
324    match gathered {
325        Value::String(text) => strip_string(text, &rest).await,
326        Value::StringArray(array) => strip_string_array(array, &rest).await,
327        Value::CharArray(array) => strip_char_array(array, &rest).await,
328        Value::Cell(cell) => strip_cell_array(cell, &rest).await,
329        _ => Err(strip_error(&STRIP_ERROR_INVALID_INPUT)),
330    }
331}
332
333async fn strip_string(text: String, args: &[Value]) -> BuiltinResult<Value> {
334    if is_missing_string(&text) {
335        return Ok(Value::String(text));
336    }
337    let expectation = PatternExpectation::scalar();
338    let (direction, pattern_spec) = parse_arguments(args, &expectation).await?;
339    let stripped = strip_text(&text, direction, pattern_spec.pattern_for_index(0));
340    Ok(Value::String(stripped))
341}
342
343async fn strip_string_array(array: StringArray, args: &[Value]) -> BuiltinResult<Value> {
344    let expected_len = array.data.len();
345    let expectation = PatternExpectation::with_shape(expected_len, &array.shape);
346    let (direction, pattern_spec) = parse_arguments(args, &expectation).await?;
347    let StringArray { data, shape, .. } = array;
348    let mut stripped: Vec<String> = Vec::with_capacity(expected_len);
349    for (idx, text) in data.into_iter().enumerate() {
350        if is_missing_string(&text) {
351            stripped.push(text);
352        } else {
353            let pattern = pattern_spec.pattern_for_index(idx);
354            stripped.push(strip_text(&text, direction, pattern));
355        }
356    }
357    let result = StringArray::new(stripped, shape).map_err(|e| {
358        strip_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRIP_ERROR_INTERNAL)
359    })?;
360    Ok(Value::StringArray(result))
361}
362
363async fn strip_char_array(array: CharArray, args: &[Value]) -> BuiltinResult<Value> {
364    let CharArray {
365        data,
366        shape,
367        rows,
368        cols,
369    } = array;
370    let expectation = PatternExpectation::with_len(rows);
371    let (direction, pattern_spec) = parse_arguments(args, &expectation).await?;
372
373    if rows == 0 {
374        return Ok(Value::CharArray(CharArray {
375            data,
376            shape,
377            rows,
378            cols,
379        }));
380    }
381
382    let mut stripped_rows: Vec<String> = Vec::with_capacity(rows);
383    let mut target_cols: usize = 0;
384    for row in 0..rows {
385        let text = char_row_to_string_slice(&data, cols, row);
386        let pattern = pattern_spec.pattern_for_index(row);
387        let stripped = strip_text(&text, direction, pattern);
388        let len = stripped.chars().count();
389        target_cols = target_cols.max(len);
390        stripped_rows.push(stripped);
391    }
392
393    let mut new_data: Vec<char> = Vec::with_capacity(rows * target_cols);
394    for row_text in stripped_rows {
395        let mut chars: Vec<char> = row_text.chars().collect();
396        if chars.len() < target_cols {
397            chars.resize(target_cols, ' ');
398        }
399        new_data.extend(chars.into_iter());
400    }
401
402    CharArray::new(new_data, rows, target_cols)
403        .map(Value::CharArray)
404        .map_err(|e| {
405            strip_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRIP_ERROR_INTERNAL)
406        })
407}
408
409async fn strip_cell_array(cell: CellArray, args: &[Value]) -> BuiltinResult<Value> {
410    let rows = cell.rows;
411    let cols = cell.cols;
412    let dims = [rows, cols];
413    let expectation = PatternExpectation::with_shape(rows * cols, &dims);
414    let (direction, pattern_spec) = parse_arguments(args, &expectation).await?;
415    let total = rows * cols;
416    let mut stripped_values: Vec<Value> = Vec::with_capacity(total);
417    for idx in 0..total {
418        let value = &cell.data[idx];
419        let pattern = pattern_spec.pattern_for_index(idx);
420        let stripped = strip_cell_element(value, direction, pattern).await?;
421        stripped_values.push(stripped);
422    }
423    make_cell(stripped_values, rows, cols).map_err(|e| {
424        strip_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRIP_ERROR_INTERNAL)
425    })
426}
427
428async fn strip_cell_element(
429    value: &Value,
430    direction: StripDirection,
431    pattern: PatternRef<'_>,
432) -> BuiltinResult<Value> {
433    let gathered = gather_if_needed_async(value).await.map_err(map_flow)?;
434    match gathered {
435        Value::String(text) => {
436            if is_missing_string(&text) {
437                Ok(Value::String(text))
438            } else {
439                let stripped = strip_text(&text, direction, pattern);
440                Ok(Value::String(stripped))
441            }
442        }
443        Value::StringArray(sa) if sa.data.len() == 1 => {
444            let text = sa.data.into_iter().next().unwrap();
445            if is_missing_string(&text) {
446                Ok(Value::String(text))
447            } else {
448                let stripped = strip_text(&text, direction, pattern);
449                Ok(Value::String(stripped))
450            }
451        }
452        Value::CharArray(ca) if ca.rows <= 1 => {
453            let source = if ca.rows == 0 {
454                String::new()
455            } else {
456                char_row_to_string_slice(&ca.data, ca.cols, 0)
457            };
458            let stripped = strip_text(&source, direction, pattern);
459            let len = stripped.chars().count();
460            let data: Vec<char> = stripped.chars().collect();
461            let rows = ca.rows;
462            let cols = if rows == 0 { ca.cols } else { len };
463            CharArray::new(data, rows, cols)
464                .map(Value::CharArray)
465                .map_err(|e| {
466                    strip_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRIP_ERROR_INTERNAL)
467                })
468        }
469        Value::CharArray(_) => Err(strip_error(&STRIP_ERROR_CELL_ELEMENT)),
470        _ => Err(strip_error(&STRIP_ERROR_CELL_ELEMENT)),
471    }
472}
473
474async fn parse_arguments(
475    args: &[Value],
476    expectation: &PatternExpectation,
477) -> BuiltinResult<(StripDirection, PatternSpec)> {
478    match args.len() {
479        0 => Ok((StripDirection::Both, PatternSpec::Default)),
480        1 => {
481            if let Some(direction) = try_parse_direction(&args[0], false)? {
482                Ok((direction, PatternSpec::Default))
483            } else {
484                let pattern = parse_pattern(&args[0], expectation).await?;
485                Ok((StripDirection::Both, pattern))
486            }
487        }
488        2 => {
489            let direction = match try_parse_direction(&args[0], true)? {
490                Some(dir) => dir,
491                None => return Err(strip_error(&STRIP_ERROR_DIRECTION)),
492            };
493            let pattern = parse_pattern(&args[1], expectation).await?;
494            Ok((direction, pattern))
495        }
496        _ => Err(strip_error(&STRIP_ERROR_ARG_COUNT)),
497    }
498}
499
500fn try_parse_direction(value: &Value, strict: bool) -> BuiltinResult<Option<StripDirection>> {
501    let Some(text) = value_to_single_string(value) else {
502        return Ok(None);
503    };
504    let trimmed = text.trim();
505    if trimmed.is_empty() {
506        return if strict {
507            Err(strip_error(&STRIP_ERROR_DIRECTION))
508        } else {
509            Ok(None)
510        };
511    }
512    let lowered = trimmed.to_ascii_lowercase();
513    let direction = match lowered.as_str() {
514        "both" => Some(StripDirection::Both),
515        "left" | "leading" => Some(StripDirection::Left),
516        "right" | "trailing" => Some(StripDirection::Right),
517        _ => {
518            if strict {
519                return Err(strip_error(&STRIP_ERROR_DIRECTION));
520            }
521            None
522        }
523    };
524    Ok(direction)
525}
526
527fn value_to_single_string(value: &Value) -> Option<String> {
528    match value {
529        Value::String(text) => Some(text.clone()),
530        Value::StringArray(sa) => {
531            if sa.data.len() == 1 {
532                Some(sa.data[0].clone())
533            } else {
534                None
535            }
536        }
537        Value::CharArray(ca) => {
538            if ca.rows <= 1 {
539                Some(char_row_to_string_slice(&ca.data, ca.cols, 0))
540            } else {
541                None
542            }
543        }
544        _ => None,
545    }
546}
547
548async fn parse_pattern(
549    value: &Value,
550    expectation: &PatternExpectation,
551) -> BuiltinResult<PatternSpec> {
552    let expected_len = expectation.len();
553    match value {
554        Value::String(text) => Ok(PatternSpec::Scalar(text.chars().collect())),
555        Value::StringArray(sa) => {
556            if sa.data.len() <= 1 {
557                if let Some(first) = sa.data.first() {
558                    Ok(PatternSpec::Scalar(first.chars().collect()))
559                } else {
560                    Ok(PatternSpec::Scalar(Vec::new()))
561                }
562            } else if sa.data.len() == expected_len {
563                if let Some(shape) = expectation.shape() {
564                    if sa.shape != shape {
565                        return Err(strip_error(&STRIP_ERROR_SIZE_MISMATCH));
566                    }
567                }
568                let mut patterns = Vec::with_capacity(sa.data.len());
569                for text in &sa.data {
570                    patterns.push(text.chars().collect());
571                }
572                Ok(PatternSpec::PerElement(patterns))
573            } else {
574                Err(strip_error(&STRIP_ERROR_SIZE_MISMATCH))
575            }
576        }
577        Value::CharArray(ca) => {
578            if ca.rows <= 1 {
579                if ca.rows == 0 {
580                    Ok(PatternSpec::Scalar(Vec::new()))
581                } else {
582                    let chars = char_row_to_string_slice(&ca.data, ca.cols, 0);
583                    Ok(PatternSpec::Scalar(chars.chars().collect()))
584                }
585            } else if ca.rows == expected_len {
586                let mut patterns = Vec::with_capacity(ca.rows);
587                for row in 0..ca.rows {
588                    let text = char_row_to_string_slice(&ca.data, ca.cols, row);
589                    patterns.push(text.chars().collect());
590                }
591                Ok(PatternSpec::PerElement(patterns))
592            } else {
593                Err(strip_error(&STRIP_ERROR_SIZE_MISMATCH))
594            }
595        }
596        Value::Cell(cell) => parse_pattern_cell(cell, expectation).await,
597        _ => Err(strip_error(&STRIP_ERROR_CHARACTERS)),
598    }
599}
600
601async fn parse_pattern_cell(
602    cell: &CellArray,
603    expectation: &PatternExpectation,
604) -> BuiltinResult<PatternSpec> {
605    let len = cell.rows * cell.cols;
606    if len == 0 {
607        return Ok(PatternSpec::Scalar(Vec::new()));
608    }
609    if len == 1 {
610        let chars = pattern_chars_from_value(&cell.data[0]).await?;
611        return Ok(PatternSpec::Scalar(chars));
612    }
613    if len != expectation.len() {
614        return Err(strip_error(&STRIP_ERROR_SIZE_MISMATCH));
615    }
616    if let Some(shape) = expectation.shape() {
617        match shape.len() {
618            0 => {}
619            1 => {
620                if cell.rows != shape[0] || cell.cols != 1 {
621                    return Err(strip_error(&STRIP_ERROR_SIZE_MISMATCH));
622                }
623            }
624            _ => {
625                if cell.rows != shape[0] || cell.cols != shape[1] {
626                    return Err(strip_error(&STRIP_ERROR_SIZE_MISMATCH));
627                }
628            }
629        }
630    }
631    let mut patterns = Vec::with_capacity(len);
632    for value in &cell.data {
633        patterns.push(pattern_chars_from_value(value).await?);
634    }
635    Ok(PatternSpec::PerElement(patterns))
636}
637
638async fn pattern_chars_from_value(value: &Value) -> BuiltinResult<Vec<char>> {
639    let gathered = gather_if_needed_async(value).await.map_err(map_flow)?;
640    match gathered {
641        Value::String(text) => Ok(text.chars().collect()),
642        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].chars().collect()),
643        Value::CharArray(ca) if ca.rows <= 1 => {
644            if ca.rows == 0 {
645                Ok(Vec::new())
646            } else {
647                let text = char_row_to_string_slice(&ca.data, ca.cols, 0);
648                Ok(text.chars().collect())
649            }
650        }
651        Value::CharArray(_) => Err(strip_error(&STRIP_ERROR_CHARACTERS)),
652        _ => Err(strip_error(&STRIP_ERROR_CHARACTERS)),
653    }
654}
655
656fn strip_text(text: &str, direction: StripDirection, pattern: PatternRef<'_>) -> String {
657    match pattern {
658        PatternRef::Default => strip_text_with_predicate(text, direction, char::is_whitespace),
659        PatternRef::Custom(chars) => {
660            strip_text_with_predicate(text, direction, |c| chars.contains(&c))
661        }
662    }
663}
664
665fn strip_text_with_predicate<F>(text: &str, direction: StripDirection, mut predicate: F) -> String
666where
667    F: FnMut(char) -> bool,
668{
669    let chars: Vec<char> = text.chars().collect();
670    if chars.is_empty() {
671        return String::new();
672    }
673
674    let mut start = 0usize;
675    let mut end = chars.len();
676
677    if direction != StripDirection::Right {
678        while start < end && predicate(chars[start]) {
679            start += 1;
680        }
681    }
682
683    if direction != StripDirection::Left {
684        while end > start && predicate(chars[end - 1]) {
685            end -= 1;
686        }
687    }
688
689    chars[start..end].iter().collect()
690}
691
692#[cfg(test)]
693pub(crate) mod tests {
694    use super::*;
695    use runmat_builtins::{ResolveContext, Type};
696
697    fn run_strip(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
698        futures::executor::block_on(strip_builtin(value, rest))
699    }
700
701    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
702    #[test]
703    fn strip_string_scalar_default() {
704        let result = run_strip(Value::String("  RunMat  ".into()), Vec::new()).expect("strip");
705        assert_eq!(result, Value::String("RunMat".into()));
706    }
707
708    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
709    #[test]
710    fn strip_string_scalar_direction() {
711        let result = run_strip(
712            Value::String("...data".into()),
713            vec![Value::String("left".into()), Value::String(".".into())],
714        )
715        .expect("strip");
716        assert_eq!(result, Value::String("data".into()));
717    }
718
719    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
720    #[test]
721    fn strip_string_scalar_custom_characters() {
722        let result = run_strip(
723            Value::String("00052".into()),
724            vec![Value::String("left".into()), Value::String("0".into())],
725        )
726        .expect("strip");
727        assert_eq!(result, Value::String("52".into()));
728    }
729
730    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
731    #[test]
732    fn strip_string_scalar_pattern_only() {
733        let result = run_strip(
734            Value::String("xxaccelerationxx".into()),
735            vec![Value::String("x".into())],
736        )
737        .expect("strip");
738        assert_eq!(result, Value::String("acceleration".into()));
739    }
740
741    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
742    #[test]
743    fn strip_empty_pattern_returns_original() {
744        let result = run_strip(
745            Value::String("abc".into()),
746            vec![Value::String(String::new())],
747        )
748        .expect("strip");
749        assert_eq!(result, Value::String("abc".into()));
750    }
751
752    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
753    #[test]
754    fn strip_supports_leading_synonym() {
755        let result = run_strip(
756            Value::String("   data".into()),
757            vec![Value::String("leading".into())],
758        )
759        .expect("strip");
760        assert_eq!(result, Value::String("data".into()));
761    }
762
763    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
764    #[test]
765    fn strip_supports_trailing_synonym() {
766        let result = run_strip(
767            Value::String("data   ".into()),
768            vec![Value::String("trailing".into())],
769        )
770        .expect("strip");
771        assert_eq!(result, Value::String("data".into()));
772    }
773
774    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
775    #[test]
776    fn strip_string_array_per_element_characters() {
777        let strings = StringArray::new(
778            vec!["##ok##".into(), "--warn--".into(), "**fail**".into()],
779            vec![3, 1],
780        )
781        .unwrap();
782        let chars = CharArray::new(vec!['#', '#', '-', '-', '*', '*'], 3, 2).unwrap();
783        let result = run_strip(
784            Value::StringArray(strings),
785            vec![Value::String("both".into()), Value::CharArray(chars)],
786        )
787        .expect("strip");
788        match result {
789            Value::StringArray(sa) => {
790                assert_eq!(
791                    sa.data,
792                    vec![
793                        String::from("ok"),
794                        String::from("warn"),
795                        String::from("fail")
796                    ]
797                );
798            }
799            other => panic!("expected string array, got {other:?}"),
800        }
801    }
802
803    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
804    #[test]
805    fn strip_string_array_cell_pattern_per_element() {
806        let strings =
807            StringArray::new(vec!["__pass__".into(), "--warn--".into()], vec![2, 1]).unwrap();
808        let patterns = CellArray::new(
809            vec![Value::String("_".into()), Value::String("-".into())],
810            2,
811            1,
812        )
813        .unwrap();
814        let result =
815            run_strip(Value::StringArray(strings), vec![Value::Cell(patterns)]).expect("strip");
816        match result {
817            Value::StringArray(sa) => {
818                assert_eq!(sa.data, vec![String::from("pass"), String::from("warn")]);
819            }
820            other => panic!("expected string array, got {other:?}"),
821        }
822    }
823
824    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
825    #[test]
826    fn strip_string_array_preserves_missing() {
827        let strings =
828            StringArray::new(vec!["   data   ".into(), "<missing>".into()], vec![2, 1]).unwrap();
829        let result = run_strip(Value::StringArray(strings), Vec::new()).expect("strip");
830        match result {
831            Value::StringArray(sa) => {
832                assert_eq!(sa.data[0], "data");
833                assert_eq!(sa.data[1], "<missing>");
834            }
835            other => panic!("expected string array, got {other:?}"),
836        }
837    }
838
839    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
840    #[test]
841    fn strip_char_array_shrinks_width() {
842        let source = "  cat  dog  ";
843        let chars: Vec<char> = source.chars().collect();
844        let array = CharArray::new(chars, 1, source.chars().count()).unwrap();
845        let result = run_strip(Value::CharArray(array), Vec::new()).expect("strip");
846        match result {
847            Value::CharArray(ca) => {
848                assert_eq!(ca.rows, 1);
849                assert_eq!(ca.cols, 8);
850                let expected: Vec<char> = "cat  dog".chars().collect();
851                assert_eq!(ca.data, expected);
852            }
853            other => panic!("expected char array, got {other:?}"),
854        }
855    }
856
857    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
858    #[test]
859    fn strip_char_array_supports_trailing_direction() {
860        let array = CharArray::new_row("gpu   ");
861        let result = run_strip(
862            Value::CharArray(array),
863            vec![Value::String("trailing".into())],
864        )
865        .expect("strip");
866        match result {
867            Value::CharArray(ca) => {
868                assert_eq!(ca.rows, 1);
869                assert_eq!(ca.cols, 3);
870                let expected: Vec<char> = "gpu".chars().collect();
871                assert_eq!(ca.data, expected);
872            }
873            other => panic!("expected char array, got {other:?}"),
874        }
875    }
876
877    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
878    #[test]
879    fn strip_cell_array_mixed_content() {
880        let cell = CellArray::new(
881            vec![
882                Value::CharArray(CharArray::new_row("  GPU ")),
883                Value::String("   Accelerate".into()),
884                Value::String("RunMat   ".into()),
885            ],
886            1,
887            3,
888        )
889        .unwrap();
890        let result = run_strip(Value::Cell(cell), Vec::new()).expect("strip");
891        match result {
892            Value::Cell(out) => {
893                assert_eq!(out.rows, 1);
894                assert_eq!(out.cols, 3);
895                assert_eq!(
896                    out.get(0, 0).unwrap(),
897                    Value::CharArray(CharArray::new_row("GPU"))
898                );
899                assert_eq!(out.get(0, 1).unwrap(), Value::String("Accelerate".into()));
900                assert_eq!(out.get(0, 2).unwrap(), Value::String("RunMat".into()));
901            }
902            other => panic!("expected cell array, got {other:?}"),
903        }
904    }
905
906    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
907    #[test]
908    fn strip_preserves_missing_string() {
909        let result = run_strip(Value::String("<missing>".into()), Vec::new()).expect("strip");
910        assert_eq!(result, Value::String("<missing>".into()));
911    }
912
913    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
914    #[test]
915    fn strip_errors_on_invalid_input() {
916        let err = run_strip(Value::Num(1.0), Vec::new()).unwrap_err();
917        assert_eq!(err.to_string(), STRIP_ERROR_INVALID_INPUT.message);
918    }
919
920    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
921    #[test]
922    fn strip_errors_on_invalid_pattern_type() {
923        let err = run_strip(Value::String("abc".into()), vec![Value::Num(1.0)]).unwrap_err();
924        assert_eq!(err.to_string(), STRIP_ERROR_CHARACTERS.message);
925    }
926
927    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
928    #[test]
929    fn strip_errors_on_invalid_direction() {
930        let err = run_strip(
931            Value::String("abc".into()),
932            vec![Value::String("sideways".into()), Value::String("a".into())],
933        )
934        .unwrap_err();
935        assert_eq!(err.to_string(), STRIP_ERROR_DIRECTION.message);
936    }
937
938    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
939    #[test]
940    fn strip_errors_on_pattern_size_mismatch() {
941        let strings = StringArray::new(vec!["one".into(), "two".into()], vec![2, 1]).unwrap();
942        let pattern =
943            StringArray::new(vec!["x".into(), "y".into(), "z".into()], vec![3, 1]).unwrap();
944        let err = run_strip(
945            Value::StringArray(strings),
946            vec![Value::StringArray(pattern)],
947        )
948        .unwrap_err();
949        assert_eq!(err.to_string(), STRIP_ERROR_SIZE_MISMATCH.message);
950    }
951
952    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
953    #[test]
954    fn strip_errors_on_pattern_shape_mismatch() {
955        let strings = StringArray::new(vec!["one".into(), "two".into()], vec![1, 2]).unwrap();
956        let pattern = StringArray::new(vec!["x".into(), "y".into()], vec![2, 1]).unwrap();
957        let err = run_strip(
958            Value::StringArray(strings),
959            vec![Value::StringArray(pattern)],
960        )
961        .unwrap_err();
962        assert_eq!(err.to_string(), STRIP_ERROR_SIZE_MISMATCH.message);
963    }
964
965    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
966    #[test]
967    fn strip_errors_on_cell_pattern_shape_mismatch() {
968        let strings = StringArray::new(vec!["aa".into(), "bb".into()], vec![1, 2]).unwrap();
969        let cell_pattern = CellArray::new(
970            vec![Value::String("a".into()), Value::String("b".into())],
971            2,
972            1,
973        )
974        .unwrap();
975        let err =
976            run_strip(Value::StringArray(strings), vec![Value::Cell(cell_pattern)]).unwrap_err();
977        assert_eq!(err.to_string(), STRIP_ERROR_SIZE_MISMATCH.message);
978    }
979
980    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
981    #[test]
982    fn strip_errors_on_too_many_arguments() {
983        let err = run_strip(
984            Value::String("abc".into()),
985            vec![
986                Value::String("both".into()),
987                Value::String("a".into()),
988                Value::String("b".into()),
989            ],
990        )
991        .unwrap_err();
992        assert_eq!(err.to_string(), STRIP_ERROR_ARG_COUNT.message);
993    }
994
995    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
996    #[test]
997    #[cfg(feature = "wgpu")]
998    fn strip_gpu_tensor_errors() {
999        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
1000            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
1001        );
1002        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
1003        let host_data = [1.0f64, 2.0];
1004        let host_shape = [2usize, 1usize];
1005        let handle = provider
1006            .upload(&runmat_accelerate_api::HostTensorView {
1007                data: &host_data,
1008                shape: &host_shape,
1009            })
1010            .expect("upload");
1011        let err = run_strip(Value::GpuTensor(handle.clone()), Vec::new()).unwrap_err();
1012        assert_eq!(err.to_string(), STRIP_ERROR_INVALID_INPUT.message);
1013        provider.free(&handle).ok();
1014    }
1015
1016    #[test]
1017    fn strip_type_preserves_text() {
1018        assert_eq!(
1019            text_preserve_type(&[Type::String], &ResolveContext::new(Vec::new())),
1020            Type::String
1021        );
1022    }
1023}