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