Skip to main content

runmat_runtime/builtins/strings/transform/
strtrim.rs

1//! MATLAB-compatible `strtrim` 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_numeric_or_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::strtrim")]
23pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
24    name: "strtrim",
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 whitespace.",
37};
38
39#[runmat_macros::register_fusion_spec(
40    builtin_path = "crate::builtins::strings::transform::strtrim"
41)]
42pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
43    name: "strtrim",
44    shape: ShapeRequirements::Any,
45    constant_strategy: ConstantStrategy::InlineLiteral,
46    elementwise: None,
47    reduction: None,
48    emits_nan: false,
49    notes: "String transformation builtin; not eligible for fusion and always gathers GPU inputs.",
50};
51
52const BUILTIN_NAME: &str = "strtrim";
53
54const STRTRIM_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
55    name: "out",
56    ty: BuiltinParamType::Any,
57    arity: BuiltinParamArity::Required,
58    default: None,
59    description: "Trimmed text preserving input container kind and shape.",
60}];
61
62const STRTRIM_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
63    name: "str",
64    ty: BuiltinParamType::Any,
65    arity: BuiltinParamArity::Required,
66    default: None,
67    description: "String/char/cell text input to trim.",
68}];
69
70const STRTRIM_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
71    label: "out = strtrim(str)",
72    inputs: &STRTRIM_INPUTS,
73    outputs: &STRTRIM_OUTPUT,
74}];
75
76const STRTRIM_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
77    code: "RM.STRTRIM.INVALID_INPUT",
78    identifier: Some("RunMat:strtrim:InvalidInput"),
79    when: "Input is not a string array, character array, or cell array of text scalars.",
80    message:
81        "strtrim: first argument must be a string array, character array, or cell array of character vectors",
82};
83
84const STRTRIM_ERROR_CELL_ELEMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
85    code: "RM.STRTRIM.CELL_ELEMENT",
86    identifier: Some("RunMat:strtrim:CellElement"),
87    when: "Cell array contains a non-text element or non-row char array element.",
88    message: "strtrim: cell array elements must be string scalars or character vectors",
89};
90
91const STRTRIM_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
92    code: "RM.STRTRIM.INTERNAL",
93    identifier: Some("RunMat:strtrim:InternalError"),
94    when: "Internal output container construction failed.",
95    message: "strtrim: internal error",
96};
97
98const STRTRIM_ERRORS: [BuiltinErrorDescriptor; 3] = [
99    STRTRIM_ERROR_INVALID_INPUT,
100    STRTRIM_ERROR_CELL_ELEMENT,
101    STRTRIM_ERROR_INTERNAL,
102];
103
104pub const STRTRIM_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
105    signatures: &STRTRIM_SIGNATURES,
106    output_mode: BuiltinOutputMode::Fixed,
107    completion_policy: BuiltinCompletionPolicy::Public,
108    errors: &STRTRIM_ERRORS,
109};
110
111pub const STRTRIM_INTEGER_AUDIT: BuiltinIntegerAuditDescriptor = BuiltinIntegerAuditDescriptor {
112    kind: BuiltinIntegerAuditKind::NotApplicable,
113    canonical_builtin: None,
114    notes: "strtrim removes whitespace from text containers. Numeric, integer, and provider-resident values reject before provider access and are not interpreted as character codes.",
115};
116
117fn map_flow(err: RuntimeError) -> RuntimeError {
118    map_control_flow_with_builtin(err, BUILTIN_NAME)
119}
120
121fn strtrim_error_with_message(
122    message: impl Into<String>,
123    error: &'static BuiltinErrorDescriptor,
124) -> RuntimeError {
125    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
126    if let Some(identifier) = error.identifier {
127        builder = builder.with_identifier(identifier);
128    }
129    builder.build()
130}
131
132fn strtrim_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
133    strtrim_error_with_message(error.message, error)
134}
135
136#[runtime_builtin(
137    name = "strtrim",
138    category = "strings/transform",
139    summary = "Remove leading and trailing whitespace from text inputs.",
140    keywords = "strtrim,trim,whitespace,strings,character array,text",
141    accel = "sink",
142    type_resolver(text_preserve_type),
143    descriptor(crate::builtins::strings::transform::strtrim::STRTRIM_DESCRIPTOR),
144    integer_audit(crate::builtins::strings::transform::strtrim::STRTRIM_INTEGER_AUDIT),
145    builtin_path = "crate::builtins::strings::transform::strtrim"
146)]
147async fn strtrim_builtin(value: Value) -> BuiltinResult<Value> {
148    if contains_numeric_or_resident_text_input(&value) {
149        return Err(strtrim_error(&STRTRIM_ERROR_INVALID_INPUT));
150    }
151    let gathered = gather_if_needed_async(&value).await.map_err(map_flow)?;
152    match gathered {
153        Value::String(text) => Ok(Value::String(trim_string(text))),
154        Value::StringArray(array) => strtrim_string_array(array),
155        Value::CharArray(array) => strtrim_char_array(array),
156        Value::Cell(cell) => strtrim_cell_array(cell).await,
157        _ => Err(strtrim_error(&STRTRIM_ERROR_INVALID_INPUT)),
158    }
159}
160
161fn strtrim_string_array(array: StringArray) -> BuiltinResult<Value> {
162    let StringArray { data, shape, .. } = array;
163    let trimmed = data.into_iter().map(trim_string).collect::<Vec<_>>();
164    let out = StringArray::new(trimmed, shape).map_err(|e| {
165        strtrim_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRTRIM_ERROR_INTERNAL)
166    })?;
167    Ok(Value::StringArray(out))
168}
169
170fn strtrim_char_array(array: CharArray) -> BuiltinResult<Value> {
171    let CharArray {
172        data,
173        shape,
174        rows,
175        cols,
176    } = array;
177    if rows == 0 {
178        return Ok(Value::CharArray(CharArray {
179            data,
180            shape,
181            rows,
182            cols,
183        }));
184    }
185
186    let mut trimmed_rows: Vec<Vec<char>> = Vec::with_capacity(rows);
187    let mut target_cols: usize = 0;
188    for row in 0..rows {
189        let text = char_row_to_string_slice(&data, cols, row);
190        let trimmed = trim_whitespace(&text);
191        let chars: Vec<char> = trimmed.chars().collect();
192        target_cols = target_cols.max(chars.len());
193        trimmed_rows.push(chars);
194    }
195
196    let mut new_data: Vec<char> = Vec::with_capacity(rows * target_cols);
197    for mut chars in trimmed_rows {
198        if chars.len() < target_cols {
199            chars.resize(target_cols, ' ');
200        }
201        new_data.extend(chars);
202    }
203
204    CharArray::new(new_data, rows, target_cols)
205        .map(Value::CharArray)
206        .map_err(|e| {
207            strtrim_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRTRIM_ERROR_INTERNAL)
208        })
209}
210
211async fn strtrim_cell_array(cell: CellArray) -> BuiltinResult<Value> {
212    let CellArray {
213        data, rows, cols, ..
214    } = cell;
215    let mut trimmed_values = Vec::with_capacity(rows * cols);
216    for value in &data {
217        let trimmed = strtrim_cell_element(value).await?;
218        trimmed_values.push(trimmed);
219    }
220    make_cell(trimmed_values, rows, cols).map_err(|e| {
221        strtrim_error_with_message(format!("{BUILTIN_NAME}: {e}"), &STRTRIM_ERROR_INTERNAL)
222    })
223}
224
225async fn strtrim_cell_element(value: &Value) -> BuiltinResult<Value> {
226    match gather_if_needed_async(value).await.map_err(map_flow)? {
227        Value::String(text) => Ok(Value::String(trim_string(text))),
228        Value::StringArray(sa) if sa.data.len() == 1 => {
229            let text = sa.data.into_iter().next().unwrap();
230            Ok(Value::String(trim_string(text)))
231        }
232        Value::CharArray(ca) if ca.rows <= 1 => {
233            if ca.rows == 0 {
234                return Ok(Value::CharArray(ca));
235            }
236            let source = char_row_to_string_slice(&ca.data, ca.cols, 0);
237            let trimmed = trim_whitespace(&source);
238            let chars: Vec<char> = trimmed.chars().collect();
239            let cols = chars.len();
240            CharArray::new(chars, ca.rows, cols)
241                .map(Value::CharArray)
242                .map_err(|e| {
243                    strtrim_error_with_message(
244                        format!("{BUILTIN_NAME}: {e}"),
245                        &STRTRIM_ERROR_INTERNAL,
246                    )
247                })
248        }
249        Value::CharArray(_) => Err(strtrim_error(&STRTRIM_ERROR_CELL_ELEMENT)),
250        _ => Err(strtrim_error(&STRTRIM_ERROR_CELL_ELEMENT)),
251    }
252}
253
254fn trim_string(text: String) -> String {
255    if is_missing_string(&text) {
256        text
257    } else {
258        trim_whitespace(&text)
259    }
260}
261
262fn trim_whitespace(text: &str) -> String {
263    let trimmed = text.trim_matches(|c: char| c.is_whitespace());
264    trimmed.to_string()
265}
266
267#[cfg(test)]
268pub(crate) mod tests {
269    use super::*;
270    use runmat_builtins::{ResolveContext, Type};
271
272    fn run_strtrim(value: Value) -> BuiltinResult<Value> {
273        futures::executor::block_on(strtrim_builtin(value))
274    }
275
276    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
277    #[test]
278    fn strtrim_string_scalar_trims_whitespace() {
279        let result =
280            run_strtrim(Value::String("  RunMat  ".into())).expect("strtrim string scalar");
281        assert_eq!(result, Value::String("RunMat".into()));
282    }
283
284    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
285    #[test]
286    fn strtrim_string_array_preserves_shape() {
287        let array = StringArray::new(
288            vec![
289                " one ".into(),
290                "<missing>".into(),
291                "two".into(),
292                " three ".into(),
293            ],
294            vec![2, 2],
295        )
296        .unwrap();
297        let result = run_strtrim(Value::StringArray(array)).expect("strtrim string array");
298        match result {
299            Value::StringArray(sa) => {
300                assert_eq!(sa.shape, vec![2, 2]);
301                assert_eq!(
302                    sa.data,
303                    vec![
304                        String::from("one"),
305                        String::from("<missing>"),
306                        String::from("two"),
307                        String::from("three")
308                    ]
309                );
310            }
311            other => panic!("expected string array, got {other:?}"),
312        }
313    }
314
315    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
316    #[test]
317    fn strtrim_char_array_multiple_rows() {
318        let data: Vec<char> = "  cat  ".chars().chain(" dog   ".chars()).collect();
319        let array = CharArray::new(data, 2, 7).unwrap();
320        let result = run_strtrim(Value::CharArray(array)).expect("strtrim char array");
321        match result {
322            Value::CharArray(ca) => {
323                assert_eq!(ca.rows, 2);
324                assert_eq!(ca.cols, 3);
325                assert_eq!(ca.data, vec!['c', 'a', 't', 'd', 'o', 'g']);
326            }
327            other => panic!("expected char array, got {other:?}"),
328        }
329    }
330
331    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
332    #[test]
333    fn strtrim_char_array_all_whitespace_yields_zero_width() {
334        let array = CharArray::new("   ".chars().collect(), 1, 3).unwrap();
335        let result = run_strtrim(Value::CharArray(array)).expect("strtrim char whitespace");
336        match result {
337            Value::CharArray(ca) => {
338                assert_eq!(ca.rows, 1);
339                assert_eq!(ca.cols, 0);
340                assert!(ca.data.is_empty());
341            }
342            other => panic!("expected empty char array, got {other:?}"),
343        }
344    }
345
346    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
347    #[test]
348    fn strtrim_cell_array_mixed_content() {
349        let cell = CellArray::new(
350            vec![
351                Value::CharArray(CharArray::new_row("  GPU  ")),
352                Value::String(" Accelerate ".into()),
353            ],
354            1,
355            2,
356        )
357        .unwrap();
358        let result = run_strtrim(Value::Cell(cell)).expect("strtrim cell array");
359        match result {
360            Value::Cell(out) => {
361                let first = out.get(0, 0).unwrap();
362                let second = out.get(0, 1).unwrap();
363                assert_eq!(first, Value::CharArray(CharArray::new_row("GPU")));
364                assert_eq!(second, Value::String("Accelerate".into()));
365            }
366            other => panic!("expected cell array, got {other:?}"),
367        }
368    }
369
370    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
371    #[test]
372    fn strtrim_preserves_missing_strings() {
373        let result =
374            run_strtrim(Value::String("<missing>".into())).expect("strtrim missing string");
375        assert_eq!(result, Value::String("<missing>".into()));
376    }
377
378    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
379    #[test]
380    fn strtrim_handles_tabs_and_newlines() {
381        let input = Value::String("\tMetrics \n".into());
382        let result = run_strtrim(input).expect("strtrim tab/newline");
383        assert_eq!(result, Value::String("Metrics".into()));
384    }
385
386    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
387    #[test]
388    fn strtrim_trims_unicode_whitespace() {
389        let input = Value::String("\u{00A0}RunMat\u{2003}".into());
390        let result = run_strtrim(input).expect("strtrim unicode whitespace");
391        assert_eq!(result, Value::String("RunMat".into()));
392    }
393
394    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
395    #[test]
396    fn strtrim_char_array_zero_rows_stable() {
397        let array = CharArray::new(Vec::new(), 0, 0).unwrap();
398        let result = run_strtrim(Value::CharArray(array.clone())).expect("strtrim 0x0 char");
399        assert_eq!(result, Value::CharArray(array));
400    }
401
402    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
403    #[test]
404    fn strtrim_cell_array_accepts_string_scalar() {
405        let scalar = StringArray::new(vec![" padded ".into()], vec![1, 1]).unwrap();
406        let cell = CellArray::new(vec![Value::StringArray(scalar)], 1, 1).unwrap();
407        let trimmed = run_strtrim(Value::Cell(cell)).expect("strtrim cell string scalar");
408        match trimmed {
409            Value::Cell(out) => {
410                let value = out.get(0, 0).expect("cell element");
411                assert_eq!(value, Value::String("padded".into()));
412            }
413            other => panic!("expected cell array, got {other:?}"),
414        }
415    }
416
417    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
418    #[test]
419    fn strtrim_cell_array_rejects_non_text() {
420        let cell = CellArray::new(vec![Value::Num(5.0)], 1, 1).unwrap();
421        let err = run_strtrim(Value::Cell(cell)).expect_err("strtrim cell non-text");
422        assert!(err.to_string().contains("cell array elements"));
423    }
424
425    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
426    #[test]
427    fn strtrim_errors_on_invalid_input() {
428        let err = run_strtrim(Value::Num(1.0)).unwrap_err();
429        assert!(err.to_string().contains("strtrim"));
430    }
431
432    #[test]
433    fn strtrim_type_preserves_text() {
434        assert_eq!(
435            text_preserve_type(&[Type::String], &ResolveContext::new(Vec::new())),
436            Type::String
437        );
438    }
439}