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