1use 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}