Skip to main content

runmat_runtime/builtins/strings/transform/
compat.rs

1//! MATLAB text transformation compatibility helpers.
2
3use regex::Regex;
4use runmat_builtins::{
5    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
6    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
7    CharArray, ResolveContext, StringArray, Type, Value,
8};
9use runmat_macros::runtime_builtin;
10
11use crate::builtins::common::map_control_flow_with_builtin;
12use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
13use crate::builtins::strings::core::compat::{
14    broadcast_flat_index, broadcast_shape, logical_value, pattern_regex, scalar_text, text_items,
15};
16use crate::builtins::strings::text_analytics::documents::erase_punctuation_tokenized_document;
17use crate::{build_runtime_error, gather_if_needed_async, make_cell_with_shape, BuiltinResult};
18
19const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
20    name: "out",
21    ty: BuiltinParamType::Any,
22    arity: BuiltinParamArity::Required,
23    default: None,
24    description: "Output value.",
25}];
26
27const OUT_BOOL: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
28    name: "tf",
29    ty: BuiltinParamType::LogicalArray,
30    arity: BuiltinParamArity::Required,
31    default: None,
32    description: "Logical result.",
33}];
34
35const IN_TEXT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
36    name: "text",
37    ty: BuiltinParamType::Any,
38    arity: BuiltinParamArity::Required,
39    default: None,
40    description: "Input text.",
41}];
42
43const IN_TEXT_REST: [BuiltinParamDescriptor; 2] = [
44    BuiltinParamDescriptor {
45        name: "text",
46        ty: BuiltinParamType::Any,
47        arity: BuiltinParamArity::Required,
48        default: None,
49        description: "Input text.",
50    },
51    BuiltinParamDescriptor {
52        name: "arg",
53        ty: BuiltinParamType::Any,
54        arity: BuiltinParamArity::Variadic,
55        default: None,
56        description: "Additional arguments.",
57    },
58];
59
60const REST: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
61    name: "arg",
62    ty: BuiltinParamType::Any,
63    arity: BuiltinParamArity::Variadic,
64    default: None,
65    description: "Values to append.",
66}];
67
68const NO_ERRORS: [BuiltinErrorDescriptor; 0] = [];
69
70macro_rules! descriptor {
71    ($name:ident, $label:expr, $inputs:expr, $outputs:expr) => {
72        const $name: BuiltinDescriptor = BuiltinDescriptor {
73            signatures: &[BuiltinSignatureDescriptor {
74                label: $label,
75                inputs: $inputs,
76                outputs: $outputs,
77            }],
78            output_mode: BuiltinOutputMode::Fixed,
79            completion_policy: BuiltinCompletionPolicy::Public,
80            errors: &NO_ERRORS,
81        };
82    };
83}
84
85descriptor!(APPEND_DESCRIPTOR, "s = append(arg1, ...)", &REST, &OUT_ANY);
86descriptor!(REVERSE_DESCRIPTOR, "s = reverse(text)", &IN_TEXT, &OUT_ANY);
87descriptor!(DEBLANK_DESCRIPTOR, "s = deblank(text)", &IN_TEXT, &OUT_ANY);
88descriptor!(
89    STRJUST_DESCRIPTOR,
90    "s = strjust(text, side)",
91    &IN_TEXT_REST,
92    &OUT_ANY
93);
94descriptor!(
95    SPLITLINES_DESCRIPTOR,
96    "s = splitlines(text)",
97    &IN_TEXT,
98    &OUT_ANY
99);
100descriptor!(
101    EXTRACT_BEFORE_DESCRIPTOR,
102    "s = extractBefore(text, boundary)",
103    &IN_TEXT_REST,
104    &OUT_ANY
105);
106descriptor!(
107    EXTRACT_AFTER_DESCRIPTOR,
108    "s = extractAfter(text, boundary)",
109    &IN_TEXT_REST,
110    &OUT_ANY
111);
112descriptor!(
113    INSERT_BEFORE_DESCRIPTOR,
114    "s = insertBefore(text, boundary, newText)",
115    &IN_TEXT_REST,
116    &OUT_ANY
117);
118descriptor!(
119    INSERT_AFTER_DESCRIPTOR,
120    "s = insertAfter(text, boundary, newText)",
121    &IN_TEXT_REST,
122    &OUT_ANY
123);
124descriptor!(
125    REPLACE_BETWEEN_DESCRIPTOR,
126    "s = replaceBetween(text, start, end, newText)",
127    &IN_TEXT_REST,
128    &OUT_ANY
129);
130descriptor!(
131    ERASE_URLS_DESCRIPTOR,
132    "s = eraseURLs(text)",
133    &IN_TEXT,
134    &OUT_ANY
135);
136descriptor!(
137    ERASE_PUNCTUATION_DESCRIPTOR,
138    "out = erasePunctuation(textOrDocuments, Name, Value, ...)",
139    &IN_TEXT_REST,
140    &OUT_ANY
141);
142descriptor!(
143    MATCHES_DESCRIPTOR,
144    "tf = matches(text, pattern)",
145    &IN_TEXT_REST,
146    &OUT_BOOL
147);
148
149fn any_type(_args: &[Type], _context: &ResolveContext) -> Type {
150    Type::Unknown
151}
152
153fn bool_type(_args: &[Type], _context: &ResolveContext) -> Type {
154    Type::Bool
155}
156
157fn transform_error(name: &str, message: impl Into<String>) -> crate::RuntimeError {
158    build_runtime_error(message).with_builtin(name).build()
159}
160
161fn map_flow(name: &'static str) -> impl Fn(crate::RuntimeError) -> crate::RuntimeError {
162    move |err| map_control_flow_with_builtin(err, name)
163}
164
165#[runtime_builtin(
166    name = "append",
167    category = "strings/transform",
168    summary = "Append text inputs elementwise.",
169    keywords = "append,string,char,text,concatenate",
170    accel = "sink",
171    type_resolver(any_type),
172    descriptor(crate::builtins::strings::transform::compat::APPEND_DESCRIPTOR),
173    builtin_path = "crate::builtins::strings::transform::compat"
174)]
175async fn append_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
176    if rest.is_empty() {
177        return Ok(Value::String(String::new()));
178    }
179    let mut lists = Vec::with_capacity(rest.len());
180    for value in rest {
181        let value = gather_if_needed_async(&value)
182            .await
183            .map_err(map_flow("append"))?;
184        lists.push(text_items(value, "append")?);
185    }
186    let shape = lists
187        .iter()
188        .skip(1)
189        .try_fold(lists[0].shape.clone(), |shape, list| {
190            broadcast_shape(&shape, &list.shape, "append")
191        })?;
192    let total: usize = shape.iter().product();
193    let mut out = Vec::with_capacity(total);
194    for idx in 0..total {
195        let mut text = String::new();
196        for list in &lists {
197            let source = broadcast_flat_index(idx, &shape, &list.shape);
198            if let Some(part) = &list.items[source] {
199                text.push_str(part);
200            }
201        }
202        out.push(text);
203    }
204    string_array_or_scalar(out, shape, "append")
205}
206
207#[runtime_builtin(
208    name = "reverse",
209    category = "strings/transform",
210    summary = "Reverse characters in text values.",
211    keywords = "reverse,string,char,text",
212    accel = "sink",
213    type_resolver(any_type),
214    descriptor(crate::builtins::strings::transform::compat::REVERSE_DESCRIPTOR),
215    builtin_path = "crate::builtins::strings::transform::compat"
216)]
217async fn reverse_builtin(text: Value) -> BuiltinResult<Value> {
218    let text = gather_if_needed_async(&text)
219        .await
220        .map_err(map_flow("reverse"))?;
221    map_text_preserve(text, "reverse", |s| s.chars().rev().collect())
222}
223
224#[runtime_builtin(
225    name = "deblank",
226    category = "strings/transform",
227    summary = "Remove trailing whitespace from text.",
228    keywords = "deblank,trailing whitespace,string,char,text",
229    accel = "sink",
230    type_resolver(any_type),
231    descriptor(crate::builtins::strings::transform::compat::DEBLANK_DESCRIPTOR),
232    builtin_path = "crate::builtins::strings::transform::compat"
233)]
234async fn deblank_builtin(text: Value) -> BuiltinResult<Value> {
235    let text = gather_if_needed_async(&text)
236        .await
237        .map_err(map_flow("deblank"))?;
238    map_text_preserve(text, "deblank", |s| {
239        s.trim_end_matches(char::is_whitespace).to_string()
240    })
241}
242
243#[runtime_builtin(
244    name = "strjust",
245    category = "strings/transform",
246    summary = "Justify character rows left, right, or center.",
247    keywords = "strjust,justify,char,text",
248    accel = "sink",
249    type_resolver(any_type),
250    descriptor(crate::builtins::strings::transform::compat::STRJUST_DESCRIPTOR),
251    builtin_path = "crate::builtins::strings::transform::compat"
252)]
253async fn strjust_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
254    let text = gather_if_needed_async(&text)
255        .await
256        .map_err(map_flow("strjust"))?;
257    let side = if let Some(value) = rest.first() {
258        let value = gather_if_needed_async(value)
259            .await
260            .map_err(map_flow("strjust"))?;
261        scalar_text(&value, "strjust")?.to_ascii_lowercase()
262    } else {
263        "right".to_string()
264    };
265    map_text_preserve(text, "strjust", |s| justify(s, &side))
266}
267
268#[runtime_builtin(
269    name = "splitlines",
270    category = "strings/transform",
271    summary = "Split text at newline boundaries.",
272    keywords = "splitlines,newline,string,text",
273    accel = "sink",
274    type_resolver(any_type),
275    descriptor(crate::builtins::strings::transform::compat::SPLITLINES_DESCRIPTOR),
276    builtin_path = "crate::builtins::strings::transform::compat"
277)]
278async fn splitlines_builtin(text: Value) -> BuiltinResult<Value> {
279    let text = gather_if_needed_async(&text)
280        .await
281        .map_err(map_flow("splitlines"))?;
282    match text {
283        Value::String(text) => strings_from_lines(&text),
284        Value::StringArray(array) if array.data.len() == 1 => strings_from_lines(&array.data[0]),
285        Value::CharArray(array) if array.rows <= 1 => {
286            strings_from_lines(&char_row_to_string_slice(&array.data, array.cols, 0))
287        }
288        other => {
289            let list = text_items(other, "splitlines")?;
290            let values = list
291                .items
292                .into_iter()
293                .map(|text| strings_from_lines(&text.unwrap_or_default()))
294                .collect::<BuiltinResult<Vec<_>>>()?;
295            make_cell_with_shape(values, list.shape).map_err(|e| transform_error("splitlines", e))
296        }
297    }
298}
299
300#[runtime_builtin(
301    name = "extractBefore",
302    category = "strings/transform",
303    summary = "Extract text before a position or boundary.",
304    keywords = "extractBefore,string,text,boundary",
305    accel = "sink",
306    type_resolver(any_type),
307    descriptor(crate::builtins::strings::transform::compat::EXTRACT_BEFORE_DESCRIPTOR),
308    builtin_path = "crate::builtins::strings::transform::compat"
309)]
310async fn extract_before_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
311    boundary_transform(text, rest, "extractBefore", |s, boundary| {
312        let (start, _) = locate_boundary(s, boundary)?;
313        Ok(s[..start].to_string())
314    })
315    .await
316}
317
318#[runtime_builtin(
319    name = "extractAfter",
320    category = "strings/transform",
321    summary = "Extract text after a position or boundary.",
322    keywords = "extractAfter,string,text,boundary",
323    accel = "sink",
324    type_resolver(any_type),
325    descriptor(crate::builtins::strings::transform::compat::EXTRACT_AFTER_DESCRIPTOR),
326    builtin_path = "crate::builtins::strings::transform::compat"
327)]
328async fn extract_after_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
329    boundary_transform(text, rest, "extractAfter", |s, boundary| {
330        let (_, end) = locate_boundary(s, boundary)?;
331        Ok(s[end..].to_string())
332    })
333    .await
334}
335
336#[runtime_builtin(
337    name = "insertBefore",
338    category = "strings/transform",
339    summary = "Insert text before a position or boundary.",
340    keywords = "insertBefore,string,text,boundary",
341    accel = "sink",
342    type_resolver(any_type),
343    descriptor(crate::builtins::strings::transform::compat::INSERT_BEFORE_DESCRIPTOR),
344    builtin_path = "crate::builtins::strings::transform::compat"
345)]
346async fn insert_before_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
347    insert_transform(text, rest, "insertBefore", false).await
348}
349
350#[runtime_builtin(
351    name = "insertAfter",
352    category = "strings/transform",
353    summary = "Insert text after a position or boundary.",
354    keywords = "insertAfter,string,text,boundary",
355    accel = "sink",
356    type_resolver(any_type),
357    descriptor(crate::builtins::strings::transform::compat::INSERT_AFTER_DESCRIPTOR),
358    builtin_path = "crate::builtins::strings::transform::compat"
359)]
360async fn insert_after_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
361    insert_transform(text, rest, "insertAfter", true).await
362}
363
364#[runtime_builtin(
365    name = "replaceBetween",
366    category = "strings/transform",
367    summary = "Replace text between positions or boundary markers.",
368    keywords = "replaceBetween,string,text,boundary",
369    accel = "sink",
370    type_resolver(any_type),
371    descriptor(crate::builtins::strings::transform::compat::REPLACE_BETWEEN_DESCRIPTOR),
372    builtin_path = "crate::builtins::strings::transform::compat"
373)]
374async fn replace_between_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
375    if rest.len() < 3 {
376        return Err(transform_error(
377            "replaceBetween",
378            "replaceBetween: expected start, end, and replacement text",
379        ));
380    }
381    let start = gather_if_needed_async(&rest[0])
382        .await
383        .map_err(map_flow("replaceBetween"))?;
384    let stop = gather_if_needed_async(&rest[1])
385        .await
386        .map_err(map_flow("replaceBetween"))?;
387    let replacement = gather_if_needed_async(&rest[2])
388        .await
389        .map_err(map_flow("replaceBetween"))?;
390    let replacement = scalar_text(&replacement, "replaceBetween")?;
391    let start = Boundary::from_value(&start, "replaceBetween")?;
392    let stop = Boundary::from_value(&stop, "replaceBetween")?;
393    let text = gather_if_needed_async(&text)
394        .await
395        .map_err(map_flow("replaceBetween"))?;
396    map_text_try_preserve(text, "replaceBetween", |s| {
397        let (replace_start, replace_end) = replacement_span_between(s, &start, &stop)?;
398        Ok(format!(
399            "{}{}{}",
400            &s[..replace_start],
401            replacement,
402            &s[replace_end..]
403        ))
404    })
405}
406
407#[runtime_builtin(
408    name = "eraseURLs",
409    category = "strings/transform",
410    summary = "Remove URL substrings from text.",
411    keywords = "eraseURLs,url,text analytics,string",
412    accel = "sink",
413    type_resolver(any_type),
414    descriptor(crate::builtins::strings::transform::compat::ERASE_URLS_DESCRIPTOR),
415    builtin_path = "crate::builtins::strings::transform::compat"
416)]
417async fn erase_urls_builtin(text: Value) -> BuiltinResult<Value> {
418    let text = gather_if_needed_async(&text)
419        .await
420        .map_err(map_flow("eraseURLs"))?;
421    let regex = Regex::new(r"https?://[^\s]+|www\.[^\s]+")
422        .map_err(|e| transform_error("eraseURLs", e.to_string()))?;
423    map_text_preserve(text, "eraseURLs", |s| regex.replace_all(s, "").to_string())
424}
425
426#[runtime_builtin(
427    name = "erasePunctuation",
428    category = "strings/transform",
429    summary = "Remove Unicode punctuation and symbol characters from text.",
430    keywords = "erasePunctuation,punctuation,symbol,text analytics,string",
431    accel = "sink",
432    type_resolver(any_type),
433    descriptor(crate::builtins::strings::transform::compat::ERASE_PUNCTUATION_DESCRIPTOR),
434    builtin_path = "crate::builtins::strings::transform::compat"
435)]
436async fn erase_punctuation_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
437    let text = gather_if_needed_async(&text)
438        .await
439        .map_err(map_flow("erasePunctuation"))?;
440    if let Value::Object(object) = text {
441        let mut gathered = Vec::with_capacity(rest.len());
442        for value in rest {
443            gathered.push(
444                gather_if_needed_async(&value)
445                    .await
446                    .map_err(map_flow("erasePunctuation"))?,
447            );
448        }
449        return erase_punctuation_tokenized_document(object, gathered);
450    }
451    if !rest.is_empty() {
452        return Err(transform_error(
453            "erasePunctuation",
454            "erasePunctuation: name-value options are only supported for tokenizedDocument input",
455        ));
456    }
457    let regex = Regex::new(r"[\p{P}\p{S}]")
458        .map_err(|e| transform_error("erasePunctuation", e.to_string()))?;
459    map_text_preserve(text, "erasePunctuation", |s| {
460        regex.replace_all(s, "").to_string()
461    })
462}
463
464#[runtime_builtin(
465    name = "matches",
466    category = "strings/search",
467    summary = "Return true when text fully matches a pattern.",
468    keywords = "matches,string pattern,text,regular expression",
469    accel = "sink",
470    type_resolver(bool_type),
471    descriptor(crate::builtins::strings::transform::compat::MATCHES_DESCRIPTOR),
472    builtin_path = "crate::builtins::strings::transform::compat"
473)]
474async fn matches_builtin(text: Value, pattern: Value) -> BuiltinResult<Value> {
475    let text = gather_if_needed_async(&text)
476        .await
477        .map_err(map_flow("matches"))?;
478    let pattern = gather_if_needed_async(&pattern)
479        .await
480        .map_err(map_flow("matches"))?;
481    let pattern = pattern_regex(&pattern, "matches")?;
482    let regex = Regex::new(&format!("^(?:{pattern})$"))
483        .map_err(|e| transform_error("matches", e.to_string()))?;
484    let list = text_items(text, "matches")?;
485    let out = list
486        .items
487        .iter()
488        .map(|text| u8::from(text.as_ref().is_some_and(|text| regex.is_match(text))))
489        .collect::<Vec<_>>();
490    logical_value(out, list.shape, "matches")
491}
492
493async fn boundary_transform(
494    text: Value,
495    rest: Vec<Value>,
496    fn_name: &'static str,
497    op: impl Fn(&str, &Boundary) -> BuiltinResult<String> + Copy,
498) -> BuiltinResult<Value> {
499    if rest.is_empty() {
500        return Err(transform_error(
501            fn_name,
502            format!("{fn_name}: expected a boundary argument"),
503        ));
504    }
505    let text = gather_if_needed_async(&text)
506        .await
507        .map_err(map_flow(fn_name))?;
508    let boundary = gather_if_needed_async(&rest[0])
509        .await
510        .map_err(map_flow(fn_name))?;
511    let boundary = Boundary::from_value(&boundary, fn_name)?;
512    map_text_try_preserve(text, fn_name, |s| op(s, &boundary))
513}
514
515async fn insert_transform(
516    text: Value,
517    rest: Vec<Value>,
518    fn_name: &'static str,
519    after: bool,
520) -> BuiltinResult<Value> {
521    if rest.len() < 2 {
522        return Err(transform_error(
523            fn_name,
524            format!("{fn_name}: expected boundary and new text"),
525        ));
526    }
527    let boundary = gather_if_needed_async(&rest[0])
528        .await
529        .map_err(map_flow(fn_name))?;
530    let new_text = gather_if_needed_async(&rest[1])
531        .await
532        .map_err(map_flow(fn_name))?;
533    let boundary = Boundary::from_value(&boundary, fn_name)?;
534    let new_text = scalar_text(&new_text, fn_name)?;
535    let text = gather_if_needed_async(&text)
536        .await
537        .map_err(map_flow(fn_name))?;
538    map_text_try_preserve(text, fn_name, |s| {
539        let (start, end) = locate_boundary(s, &boundary)?;
540        let idx = if after { end } else { start };
541        Ok(format!("{}{}{}", &s[..idx], new_text, &s[idx..]))
542    })
543}
544
545#[derive(Clone)]
546enum Boundary {
547    Position(usize),
548    Text(String),
549    Pattern(String),
550}
551
552impl Boundary {
553    fn from_value(value: &Value, fn_name: &str) -> BuiltinResult<Self> {
554        match value {
555            Value::Num(n) if n.is_finite() && *n > 0.0 && n.fract() == 0.0 => {
556                Ok(Self::Position(*n as usize))
557            }
558            Value::Num(_) => Err(transform_error(
559                fn_name,
560                format!("{fn_name}: numeric boundaries must be positive integer scalars"),
561            )),
562            Value::Object(_) => Ok(Self::Pattern(pattern_regex(value, fn_name)?)),
563            _ => Ok(Self::Text(scalar_text(value, fn_name)?)),
564        }
565    }
566}
567
568fn locate_boundary(text: &str, boundary: &Boundary) -> BuiltinResult<(usize, usize)> {
569    match boundary {
570        Boundary::Position(pos) => {
571            let char_len = text.chars().count();
572            if *pos > char_len.saturating_add(1) {
573                return Err(transform_error(
574                    "text boundary",
575                    format!("boundary position {pos} exceeds text length {char_len}"),
576                ));
577            }
578            let start = byte_index_for_char_position(text, *pos);
579            let end = if *pos > char_len {
580                text.len()
581            } else {
582                byte_index_after_char_position(text, *pos)
583            };
584            Ok((start, end))
585        }
586        Boundary::Text(needle) => text
587            .find(needle)
588            .map(|idx| (idx, idx + needle.len()))
589            .ok_or_else(|| transform_error("text boundary", "boundary not found")),
590        Boundary::Pattern(pattern) => Regex::new(pattern)
591            .map_err(|e| transform_error("text boundary", e.to_string()))?
592            .find(text)
593            .map(|m| (m.start(), m.end()))
594            .ok_or_else(|| transform_error("text boundary", "boundary not found")),
595    }
596}
597
598fn replacement_span_between(
599    text: &str,
600    start: &Boundary,
601    stop: &Boundary,
602) -> BuiltinResult<(usize, usize)> {
603    match (start, stop) {
604        (Boundary::Position(start), Boundary::Position(stop)) => {
605            if start > stop {
606                return Err(transform_error(
607                    "replaceBetween",
608                    "replaceBetween: start position must be less than or equal to end position",
609                ));
610            }
611            let char_len = text.chars().count();
612            if *stop > char_len {
613                return Err(transform_error(
614                    "replaceBetween",
615                    format!("replaceBetween: end position {stop} exceeds text length {char_len}"),
616                ));
617            }
618            Ok((
619                byte_index_for_char_position(text, *start),
620                byte_index_after_char_position(text, *stop),
621            ))
622        }
623        _ => {
624            let (_, start_end) = locate_boundary(text, start)?;
625            let (stop_start, _) = locate_boundary(&text[start_end..], stop)
626                .map(|(a, b)| (a + start_end, b + start_end))?;
627            Ok((start_end, stop_start))
628        }
629    }
630}
631
632fn byte_index_for_char_position(text: &str, pos: usize) -> usize {
633    if pos == 0 {
634        return 0;
635    }
636    text.char_indices()
637        .nth(pos.saturating_sub(1))
638        .map(|(idx, _)| idx)
639        .unwrap_or(text.len())
640}
641
642fn byte_index_after_char_position(text: &str, pos: usize) -> usize {
643    if pos == 0 {
644        return 0;
645    }
646    text.char_indices()
647        .nth(pos)
648        .map(|(idx, _)| idx)
649        .unwrap_or(text.len())
650}
651
652fn map_text_preserve(
653    value: Value,
654    fn_name: &str,
655    map: impl Fn(&str) -> String + Copy,
656) -> BuiltinResult<Value> {
657    match value {
658        Value::String(text) => {
659            if is_missing_string(&text) {
660                Ok(Value::String(text))
661            } else {
662                Ok(Value::String(map(&text)))
663            }
664        }
665        Value::StringArray(array) => StringArray::new(
666            array
667                .data
668                .into_iter()
669                .map(|text| {
670                    if is_missing_string(&text) {
671                        text
672                    } else {
673                        map(&text)
674                    }
675                })
676                .collect(),
677            array.shape,
678        )
679        .map(Value::StringArray)
680        .map_err(|e| transform_error(fn_name, e)),
681        Value::CharArray(array) => {
682            let rows = (0..array.rows)
683                .map(|row| map(&char_row_to_string_slice(&array.data, array.cols, row)))
684                .collect::<Vec<_>>();
685            char_rows(rows, fn_name)
686        }
687        Value::Cell(cell) => {
688            let values = cell
689                .data
690                .into_iter()
691                .map(|value| map_text_preserve(value, fn_name, map))
692                .collect::<BuiltinResult<Vec<_>>>()?;
693            make_cell_with_shape(values, cell.shape).map_err(|e| transform_error(fn_name, e))
694        }
695        other => Err(transform_error(
696            fn_name,
697            format!("{fn_name}: expected text input, got {other:?}"),
698        )),
699    }
700}
701
702fn map_text_try_preserve(
703    value: Value,
704    fn_name: &str,
705    map: impl Fn(&str) -> BuiltinResult<String> + Copy,
706) -> BuiltinResult<Value> {
707    match value {
708        Value::String(text) => {
709            if is_missing_string(&text) {
710                Ok(Value::String(text))
711            } else {
712                Ok(Value::String(map(&text)?))
713            }
714        }
715        Value::StringArray(array) => StringArray::new(
716            array
717                .data
718                .into_iter()
719                .map(|text| {
720                    if is_missing_string(&text) {
721                        Ok(text)
722                    } else {
723                        map(&text)
724                    }
725                })
726                .collect::<BuiltinResult<Vec<_>>>()?,
727            array.shape,
728        )
729        .map(Value::StringArray)
730        .map_err(|e| transform_error(fn_name, e)),
731        Value::CharArray(array) => {
732            let rows = (0..array.rows)
733                .map(|row| map(&char_row_to_string_slice(&array.data, array.cols, row)))
734                .collect::<BuiltinResult<Vec<_>>>()?;
735            char_rows(rows, fn_name)
736        }
737        Value::Cell(cell) => {
738            let values = cell
739                .data
740                .into_iter()
741                .map(|value| map_text_try_preserve(value, fn_name, map))
742                .collect::<BuiltinResult<Vec<_>>>()?;
743            make_cell_with_shape(values, cell.shape).map_err(|e| transform_error(fn_name, e))
744        }
745        other => Err(transform_error(
746            fn_name,
747            format!("{fn_name}: expected text input, got {other:?}"),
748        )),
749    }
750}
751
752fn char_rows(rows: Vec<String>, fn_name: &str) -> BuiltinResult<Value> {
753    let row_count = rows.len();
754    let cols = rows
755        .iter()
756        .map(|row| row.chars().count())
757        .max()
758        .unwrap_or(0);
759    let mut data = Vec::with_capacity(row_count * cols);
760    for row in rows {
761        let mut chars = row.chars().collect::<Vec<_>>();
762        chars.resize(cols, ' ');
763        data.extend(chars);
764    }
765    CharArray::new(data, row_count, cols)
766        .map(Value::CharArray)
767        .map_err(|e| transform_error(fn_name, e))
768}
769
770fn string_array_or_scalar(
771    values: Vec<String>,
772    shape: Vec<usize>,
773    fn_name: &str,
774) -> BuiltinResult<Value> {
775    if values.len() == 1 {
776        Ok(Value::String(values.into_iter().next().unwrap()))
777    } else {
778        StringArray::new(values, shape)
779            .map(Value::StringArray)
780            .map_err(|e| transform_error(fn_name, e))
781    }
782}
783
784fn strings_from_lines(text: &str) -> BuiltinResult<Value> {
785    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
786    let lines = normalized
787        .split('\n')
788        .map(str::to_string)
789        .collect::<Vec<_>>();
790    let rows = lines.len();
791    StringArray::new(lines, vec![rows, 1])
792        .map(Value::StringArray)
793        .map_err(|e| transform_error("splitlines", e))
794}
795
796fn justify(text: &str, side: &str) -> String {
797    let width = text.chars().count();
798    let trimmed = text.trim().to_string();
799    let pad = width.saturating_sub(trimmed.chars().count());
800    match side {
801        "left" => format!("{trimmed}{}", " ".repeat(pad)),
802        "center" | "centre" => {
803            let left = pad / 2;
804            let right = pad - left;
805            format!("{}{}{}", " ".repeat(left), trimmed, " ".repeat(right))
806        }
807        _ => format!("{}{}", " ".repeat(pad), trimmed),
808    }
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use runmat_builtins::CellArray;
815
816    fn block(
817        value: impl std::future::Future<Output = BuiltinResult<Value>>,
818    ) -> BuiltinResult<Value> {
819        futures::executor::block_on(value)
820    }
821
822    #[test]
823    fn append_extract_insert_and_replace_work() {
824        assert_eq!(
825            block(append_builtin(vec![
826                Value::String("run".into()),
827                Value::String("mat".into())
828            ]))
829            .unwrap(),
830            Value::String("runmat".into())
831        );
832        assert_eq!(
833            block(extract_before_builtin(
834                Value::String("alpha.beta".into()),
835                vec![Value::String(".".into())],
836            ))
837            .unwrap(),
838            Value::String("alpha".into())
839        );
840        assert_eq!(
841            block(insert_after_builtin(
842                Value::String("run".into()),
843                vec![Value::Num(3.0), Value::String("mat".into())],
844            ))
845            .unwrap(),
846            Value::String("runmat".into())
847        );
848        assert_eq!(
849            block(extract_after_builtin(
850                Value::String("alpha.beta".into()),
851                vec![Value::String(".".into())],
852            ))
853            .unwrap(),
854            Value::String("beta".into())
855        );
856        assert_eq!(
857            block(insert_before_builtin(
858                Value::String("run".into()),
859                vec![Value::Num(1.0), Value::String("pre".into())],
860            ))
861            .unwrap(),
862            Value::String("prerun".into())
863        );
864        assert_eq!(
865            block(replace_between_builtin(
866                Value::String("a[old]b".into()),
867                vec![
868                    Value::String("[".into()),
869                    Value::String("]".into()),
870                    Value::String("new".into()),
871                ],
872            ))
873            .unwrap(),
874            Value::String("a[new]b".into())
875        );
876    }
877
878    #[test]
879    fn splitlines_reverse_deblank_and_matches_work() {
880        assert_eq!(
881            block(reverse_builtin(Value::String("abc".into()))).unwrap(),
882            Value::String("cba".into())
883        );
884        assert_eq!(
885            block(deblank_builtin(Value::CharArray(CharArray::new_row(
886                "abc   "
887            ))))
888            .unwrap(),
889            Value::CharArray(CharArray::new_row("abc"))
890        );
891        assert_eq!(
892            block(matches_builtin(
893                Value::String("A12".into()),
894                crate::builtins::strings::core::compat::pattern_object(r"A\d+"),
895            ))
896            .unwrap(),
897            Value::Bool(true)
898        );
899        assert_eq!(
900            block(erase_urls_builtin(Value::String(
901                "see https://example.com now".into()
902            )))
903            .unwrap(),
904            Value::String("see  now".into())
905        );
906        assert_eq!(
907            block(erase_punctuation_builtin(
908                Value::String("it's one and/or two.".into()),
909                vec![]
910            ))
911            .unwrap(),
912            Value::String("its one andor two".into())
913        );
914        assert_eq!(
915            block(erase_punctuation_builtin(
916                Value::CharArray(CharArray::new_row("cost: $5.00!")),
917                vec![]
918            ))
919            .unwrap(),
920            Value::CharArray(CharArray::new_row("cost 500"))
921        );
922        let cell = CellArray::new(
923            vec![
924                Value::CharArray(CharArray::new_row("alpha,beta!")),
925                Value::String("C++ and C#".into()),
926            ],
927            1,
928            2,
929        )
930        .unwrap();
931        match block(erase_punctuation_builtin(Value::Cell(cell), vec![])).unwrap() {
932            Value::Cell(out) => {
933                assert_eq!(out.shape, vec![1, 2]);
934                assert_eq!(
935                    out.data[0],
936                    Value::CharArray(CharArray::new_row("alphabeta"))
937                );
938                assert_eq!(out.data[1], Value::String("C and C".into()));
939            }
940            other => panic!("expected cell array, got {other:?}"),
941        }
942        assert_eq!(
943            block(strjust_builtin(
944                Value::CharArray(CharArray::new_row("  x")),
945                vec![Value::String("left".into())],
946            ))
947            .unwrap(),
948            Value::CharArray(CharArray::new_row("x  "))
949        );
950    }
951}