Skip to main content

runmat_runtime/builtins/strings/core/
compat.rs

1//! MATLAB text compatibility helpers that do not warrant larger domain modules yet.
2
3use encoding_rs::{Encoding, UTF_8};
4use runmat_builtins::{
5    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
6    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
7    CharArray, LogicalArray, ObjectInstance, ResolveContext, StringArray, Tensor, Type, Value,
8};
9use runmat_macros::runtime_builtin;
10
11use crate::builtins::common::broadcast as matlab_broadcast;
12use crate::builtins::common::map_control_flow_with_builtin;
13use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
14use crate::{build_runtime_error, gather_if_needed_async, make_cell_with_shape, BuiltinResult};
15
16const PATTERN_CLASS: &str = "pattern";
17
18const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
19    name: "out",
20    ty: BuiltinParamType::Any,
21    arity: BuiltinParamArity::Required,
22    default: None,
23    description: "Output value.",
24}];
25
26const OUT_BOOL: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
27    name: "tf",
28    ty: BuiltinParamType::LogicalArray,
29    arity: BuiltinParamArity::Required,
30    default: None,
31    description: "Logical result.",
32}];
33
34const IN_VALUE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
35    name: "value",
36    ty: BuiltinParamType::Any,
37    arity: BuiltinParamArity::Required,
38    default: None,
39    description: "Input value.",
40}];
41
42const IN_TEXT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
43    name: "text",
44    ty: BuiltinParamType::Any,
45    arity: BuiltinParamArity::Required,
46    default: None,
47    description: "Input text.",
48}];
49
50const IN_BOUNDARY_TYPE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
51    name: "type",
52    ty: BuiltinParamType::StringScalar,
53    arity: BuiltinParamArity::Required,
54    default: Some("\"either\""),
55    description: "Boundary type: \"either\", \"start\", or \"end\".",
56}];
57
58const IN_TEXT_REST: [BuiltinParamDescriptor; 2] = [
59    BuiltinParamDescriptor {
60        name: "text",
61        ty: BuiltinParamType::Any,
62        arity: BuiltinParamArity::Required,
63        default: None,
64        description: "Input text.",
65    },
66    BuiltinParamDescriptor {
67        name: "arg",
68        ty: BuiltinParamType::Any,
69        arity: BuiltinParamArity::Variadic,
70        default: None,
71        description: "Additional arguments.",
72    },
73];
74
75const IN_A_B_N: [BuiltinParamDescriptor; 3] = [
76    BuiltinParamDescriptor {
77        name: "A",
78        ty: BuiltinParamType::Any,
79        arity: BuiltinParamArity::Required,
80        default: None,
81        description: "First text input.",
82    },
83    BuiltinParamDescriptor {
84        name: "B",
85        ty: BuiltinParamType::Any,
86        arity: BuiltinParamArity::Required,
87        default: None,
88        description: "Second text input.",
89    },
90    BuiltinParamDescriptor {
91        name: "N",
92        ty: BuiltinParamType::IntegerScalar,
93        arity: BuiltinParamArity::Required,
94        default: None,
95        description: "Number of leading characters to compare.",
96    },
97];
98
99const NO_INPUTS: [BuiltinParamDescriptor; 0] = [];
100const NO_ERRORS: [BuiltinErrorDescriptor; 0] = [];
101
102macro_rules! descriptor {
103    ($name:ident, $label:expr, $inputs:expr, $outputs:expr) => {
104        const $name: BuiltinDescriptor = BuiltinDescriptor {
105            signatures: &[BuiltinSignatureDescriptor {
106                label: $label,
107                inputs: $inputs,
108                outputs: $outputs,
109            }],
110            output_mode: BuiltinOutputMode::Fixed,
111            completion_policy: BuiltinCompletionPolicy::Public,
112            errors: &NO_ERRORS,
113        };
114    };
115}
116
117macro_rules! descriptor_by_outputs {
118    ($name:ident, $label:expr, $inputs:expr, $outputs:expr) => {
119        const $name: BuiltinDescriptor = BuiltinDescriptor {
120            signatures: &[BuiltinSignatureDescriptor {
121                label: $label,
122                inputs: $inputs,
123                outputs: $outputs,
124            }],
125            output_mode: BuiltinOutputMode::ByRequestedOutputCount,
126            completion_policy: BuiltinCompletionPolicy::Public,
127            errors: &NO_ERRORS,
128        };
129    };
130}
131
132descriptor!(NEWLINE_DESCRIPTOR, "s = newline", &NO_INPUTS, &OUT_ANY);
133descriptor!(BLANKS_DESCRIPTOR, "s = blanks(n)", &IN_VALUE, &OUT_ANY);
134descriptor!(
135    IS_STRING_SCALAR_DESCRIPTOR,
136    "tf = isStringScalar(value)",
137    &IN_VALUE,
138    &OUT_BOOL
139);
140descriptor_by_outputs!(
141    CONVERT_STRINGS_TO_CHARS_DESCRIPTOR,
142    "[out1, ...] = convertStringsToChars(value1, ...)",
143    &IN_TEXT_REST,
144    &OUT_ANY
145);
146descriptor!(
147    CONVERT_CHARS_TO_STRINGS_DESCRIPTOR,
148    "out = convertCharsToStrings(value)",
149    &IN_VALUE,
150    &OUT_ANY
151);
152descriptor!(
153    CONVERT_CONTAINED_STRINGS_TO_CHARS_DESCRIPTOR,
154    "out = convertContainedStringsToChars(value)",
155    &IN_VALUE,
156    &OUT_ANY
157);
158descriptor!(
159    STRNCMPI_DESCRIPTOR,
160    "tf = strncmpi(A, B, N)",
161    &IN_A_B_N,
162    &OUT_BOOL
163);
164descriptor!(
165    ISSTRPROP_DESCRIPTOR,
166    "tf = isstrprop(text, category)",
167    &IN_TEXT_REST,
168    &OUT_BOOL
169);
170descriptor!(
171    ISLETTER_DESCRIPTOR,
172    "tf = isletter(text)",
173    &IN_TEXT,
174    &OUT_BOOL
175);
176descriptor!(
177    ISSPACE_DESCRIPTOR,
178    "tf = isspace(text)",
179    &IN_TEXT,
180    &OUT_BOOL
181);
182descriptor_by_outputs!(
183    STRTOK_DESCRIPTOR,
184    "[tok, rem] = strtok(text, delimiters)",
185    &IN_TEXT_REST,
186    &OUT_ANY
187);
188descriptor_by_outputs!(
189    STR2NUM_DESCRIPTOR,
190    "[x, tf] = str2num(text)",
191    &IN_TEXT,
192    &OUT_ANY
193);
194descriptor!(
195    MAT2STR_DESCRIPTOR,
196    "s = mat2str(A)",
197    &IN_TEXT_REST,
198    &OUT_ANY
199);
200descriptor!(
201    NATIVE2UNICODE_DESCRIPTOR,
202    "s = native2unicode(bytes, encoding)",
203    &IN_TEXT_REST,
204    &OUT_ANY
205);
206descriptor_by_outputs!(
207    SSCANF_DESCRIPTOR,
208    "[A, count, errmsg, nextindex] = sscanf(text, format, size)",
209    &IN_TEXT_REST,
210    &OUT_ANY
211);
212descriptor!(
213    PATTERN_DESCRIPTOR,
214    "pat = pattern(text)",
215    &IN_TEXT,
216    &OUT_ANY
217);
218descriptor!(
219    REGEXP_PATTERN_DESCRIPTOR,
220    "pat = regexpPattern(expr)",
221    &IN_TEXT,
222    &OUT_ANY
223);
224descriptor!(
225    DIGITS_PATTERN_DESCRIPTOR,
226    "pat = digitsPattern(N)",
227    &IN_TEXT_REST,
228    &OUT_ANY
229);
230descriptor!(
231    LETTERS_PATTERN_DESCRIPTOR,
232    "pat = lettersPattern(N)",
233    &IN_TEXT_REST,
234    &OUT_ANY
235);
236descriptor!(
237    WILDCARD_PATTERN_DESCRIPTOR,
238    "pat = wildcardPattern",
239    &IN_TEXT_REST,
240    &OUT_ANY
241);
242const TEXT_BOUNDARY_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
243    BuiltinSignatureDescriptor {
244        label: "pat = textBoundary",
245        inputs: &[],
246        outputs: &OUT_ANY,
247    },
248    BuiltinSignatureDescriptor {
249        label: "pat = textBoundary(type)",
250        inputs: &IN_BOUNDARY_TYPE,
251        outputs: &OUT_ANY,
252    },
253];
254pub const TEXT_BOUNDARY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
255    signatures: &TEXT_BOUNDARY_SIGNATURES,
256    output_mode: BuiltinOutputMode::Fixed,
257    completion_policy: BuiltinCompletionPolicy::Public,
258    errors: &NO_ERRORS,
259};
260
261fn any_type(_args: &[Type], _context: &ResolveContext) -> Type {
262    Type::Unknown
263}
264
265fn string_type(_args: &[Type], _context: &ResolveContext) -> Type {
266    Type::String
267}
268
269fn bool_type(_args: &[Type], _context: &ResolveContext) -> Type {
270    Type::Bool
271}
272
273fn tensor_type(_args: &[Type], _context: &ResolveContext) -> Type {
274    Type::tensor()
275}
276
277fn compat_error(name: &str, message: impl Into<String>) -> crate::RuntimeError {
278    build_runtime_error(message).with_builtin(name).build()
279}
280
281fn map_flow(name: &'static str) -> impl Fn(crate::RuntimeError) -> crate::RuntimeError {
282    move |err| map_control_flow_with_builtin(err, name)
283}
284
285#[runtime_builtin(
286    name = "newline",
287    category = "strings/core",
288    summary = "Return a newline string scalar.",
289    keywords = "newline,string,text,line break",
290    accel = "metadata",
291    type_resolver(string_type),
292    descriptor(crate::builtins::strings::core::compat::NEWLINE_DESCRIPTOR),
293    builtin_path = "crate::builtins::strings::core::compat"
294)]
295fn newline_builtin() -> BuiltinResult<Value> {
296    Ok(Value::String("\n".to_string()))
297}
298
299#[runtime_builtin(
300    name = "blanks",
301    category = "strings/core",
302    summary = "Return a character row vector of spaces.",
303    keywords = "blanks,char,space,text",
304    accel = "metadata",
305    type_resolver(string_type),
306    descriptor(crate::builtins::strings::core::compat::BLANKS_DESCRIPTOR),
307    builtin_path = "crate::builtins::strings::core::compat"
308)]
309async fn blanks_builtin(n: Value) -> BuiltinResult<Value> {
310    let n = gather_if_needed_async(&n)
311        .await
312        .map_err(map_flow("blanks"))?;
313    let n = parse_nonnegative_usize(&n, "blanks")?;
314    Ok(Value::CharArray(CharArray::new_row(&" ".repeat(n))))
315}
316
317#[runtime_builtin(
318    name = "isStringScalar",
319    category = "strings/core",
320    summary = "Return true for a scalar MATLAB string.",
321    keywords = "isStringScalar,string scalar,type predicate",
322    accel = "metadata",
323    type_resolver(bool_type),
324    descriptor(crate::builtins::strings::core::compat::IS_STRING_SCALAR_DESCRIPTOR),
325    builtin_path = "crate::builtins::strings::core::compat"
326)]
327fn is_string_scalar_builtin(value: Value) -> BuiltinResult<Value> {
328    Ok(Value::Bool(match value {
329        Value::String(_) => true,
330        Value::StringArray(array) => array.data.len() == 1,
331        _ => false,
332    }))
333}
334
335#[runtime_builtin(
336    name = "convertStringsToChars",
337    category = "strings/core",
338    summary = "Convert string scalars and arrays to character vectors.",
339    keywords = "convertStringsToChars,string,char,compatibility",
340    accel = "sink",
341    type_resolver(any_type),
342    descriptor(crate::builtins::strings::core::compat::CONVERT_STRINGS_TO_CHARS_DESCRIPTOR),
343    builtin_path = "crate::builtins::strings::core::compat"
344)]
345async fn convert_strings_to_chars_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
346    let mut inputs = Vec::with_capacity(rest.len() + 1);
347    inputs.push(value);
348    inputs.extend(rest);
349
350    let mut outputs = Vec::with_capacity(inputs.len());
351    for value in inputs {
352        let value = gather_if_needed_async(&value)
353            .await
354            .map_err(map_flow("convertStringsToChars"))?;
355        outputs.push(convert_strings_to_chars(value, false)?);
356    }
357
358    match crate::output_count::current_output_count() {
359        Some(0) => Ok(Value::OutputList(Vec::new())),
360        Some(n) => Ok(crate::output_count::output_list_with_padding(n, outputs)),
361        None => Ok(outputs
362            .into_iter()
363            .next()
364            .unwrap_or(Value::String(String::new()))),
365    }
366}
367
368#[runtime_builtin(
369    name = "convertCharsToStrings",
370    category = "strings/core",
371    summary = "Convert character arrays and cellstr values to string arrays.",
372    keywords = "convertCharsToStrings,char,string,compatibility",
373    accel = "sink",
374    type_resolver(any_type),
375    descriptor(crate::builtins::strings::core::compat::CONVERT_CHARS_TO_STRINGS_DESCRIPTOR),
376    builtin_path = "crate::builtins::strings::core::compat"
377)]
378async fn convert_chars_to_strings_builtin(value: Value) -> BuiltinResult<Value> {
379    let value = gather_if_needed_async(&value)
380        .await
381        .map_err(map_flow("convertCharsToStrings"))?;
382    convert_chars_to_strings(value)
383}
384
385#[runtime_builtin(
386    name = "convertContainedStringsToChars",
387    category = "strings/core",
388    summary = "Convert string values contained in cells and structs to character vectors.",
389    keywords = "convertContainedStringsToChars,string,char,cell,struct",
390    accel = "sink",
391    type_resolver(any_type),
392    descriptor(
393        crate::builtins::strings::core::compat::CONVERT_CONTAINED_STRINGS_TO_CHARS_DESCRIPTOR
394    ),
395    builtin_path = "crate::builtins::strings::core::compat"
396)]
397async fn convert_contained_strings_to_chars_builtin(value: Value) -> BuiltinResult<Value> {
398    let value = gather_if_needed_async(&value)
399        .await
400        .map_err(map_flow("convertContainedStringsToChars"))?;
401    convert_strings_to_chars(value, true)
402}
403
404#[runtime_builtin(
405    name = "strncmpi",
406    category = "strings/core",
407    summary = "Compare text inputs case-insensitively up to N leading characters.",
408    keywords = "strncmpi,string compare,prefix,text equality",
409    accel = "sink",
410    type_resolver(bool_type),
411    descriptor(crate::builtins::strings::core::compat::STRNCMPI_DESCRIPTOR),
412    builtin_path = "crate::builtins::strings::core::compat"
413)]
414async fn strncmpi_builtin(a: Value, b: Value, n: Value) -> BuiltinResult<Value> {
415    let a = gather_if_needed_async(&a)
416        .await
417        .map_err(map_flow("strncmpi"))?;
418    let b = gather_if_needed_async(&b)
419        .await
420        .map_err(map_flow("strncmpi"))?;
421    let n = gather_if_needed_async(&n)
422        .await
423        .map_err(map_flow("strncmpi"))?;
424    let n = parse_nonnegative_usize(&n, "strncmpi")?;
425    let left = TextList::from_value(a, "strncmpi")?;
426    let right = TextList::from_value(b, "strncmpi")?;
427    let shape = broadcast_shape(&left.shape, &right.shape, "strncmpi")?;
428    let total: usize = shape.iter().product();
429    let mut out = Vec::with_capacity(total);
430    for idx in 0..total {
431        let li = broadcast_flat_index(idx, &shape, &left.shape);
432        let ri = broadcast_flat_index(idx, &shape, &right.shape);
433        let matched = match (&left.items[li], &right.items[ri]) {
434            (Some(a), Some(b)) => prefix_eq_ignore_case(a, b, n),
435            _ => false,
436        };
437        out.push(u8::from(matched));
438    }
439    logical_value(out, shape, "strncmpi")
440}
441
442#[runtime_builtin(
443    name = "isstrprop",
444    category = "strings/core",
445    summary = "Classify characters in text by character property.",
446    keywords = "isstrprop,isletter,isspace,char classification,text",
447    accel = "sink",
448    type_resolver(tensor_type),
449    descriptor(crate::builtins::strings::core::compat::ISSTRPROP_DESCRIPTOR),
450    builtin_path = "crate::builtins::strings::core::compat"
451)]
452async fn isstrprop_builtin(text: Value, prop: Value) -> BuiltinResult<Value> {
453    let text = gather_if_needed_async(&text)
454        .await
455        .map_err(map_flow("isstrprop"))?;
456    let prop = gather_if_needed_async(&prop)
457        .await
458        .map_err(map_flow("isstrprop"))?;
459    let prop = scalar_text(&prop, "isstrprop")?.to_ascii_lowercase();
460    classify_text_value(text, "isstrprop", |ch| char_matches_prop(ch, &prop))
461}
462
463#[runtime_builtin(
464    name = "isletter",
465    category = "strings/core",
466    summary = "Return true for letters in text.",
467    keywords = "isletter,letter,char classification,text",
468    accel = "sink",
469    type_resolver(tensor_type),
470    descriptor(crate::builtins::strings::core::compat::ISLETTER_DESCRIPTOR),
471    builtin_path = "crate::builtins::strings::core::compat"
472)]
473async fn isletter_builtin(text: Value) -> BuiltinResult<Value> {
474    let text = gather_if_needed_async(&text)
475        .await
476        .map_err(map_flow("isletter"))?;
477    classify_text_value(text, "isletter", |ch| ch.is_alphabetic())
478}
479
480#[runtime_builtin(
481    name = "isspace",
482    category = "strings/core",
483    summary = "Return true for whitespace characters in text.",
484    keywords = "isspace,whitespace,char classification,text",
485    accel = "sink",
486    type_resolver(tensor_type),
487    descriptor(crate::builtins::strings::core::compat::ISSPACE_DESCRIPTOR),
488    builtin_path = "crate::builtins::strings::core::compat"
489)]
490async fn isspace_builtin(text: Value) -> BuiltinResult<Value> {
491    let text = gather_if_needed_async(&text)
492        .await
493        .map_err(map_flow("isspace"))?;
494    classify_text_value(text, "isspace", |ch| ch.is_whitespace())
495}
496
497#[runtime_builtin(
498    name = "strtok",
499    category = "strings/core",
500    summary = "Return the first token from text using delimiter characters.",
501    keywords = "strtok,tokenize,delimiter,text",
502    accel = "sink",
503    type_resolver(any_type),
504    descriptor(crate::builtins::strings::core::compat::STRTOK_DESCRIPTOR),
505    builtin_path = "crate::builtins::strings::core::compat"
506)]
507async fn strtok_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
508    let text = gather_if_needed_async(&text)
509        .await
510        .map_err(map_flow("strtok"))?;
511    let delimiters = if let Some(value) = rest.first() {
512        let value = gather_if_needed_async(value)
513            .await
514            .map_err(map_flow("strtok"))?;
515        scalar_text(&value, "strtok")?
516    } else {
517        " \t\n\r".to_string()
518    };
519    let (tokens, remainders) =
520        map_text_pair_preserve(text, "strtok", |s| strtok_pair(s, &delimiters))?;
521    match crate::output_count::current_output_count() {
522        Some(0) => Ok(Value::OutputList(Vec::new())),
523        Some(1) => Ok(Value::OutputList(vec![tokens])),
524        Some(n) => Ok(crate::output_count::output_list_with_padding(
525            n,
526            vec![tokens, remainders],
527        )),
528        None => Ok(tokens),
529    }
530}
531
532#[runtime_builtin(
533    name = "str2num",
534    category = "strings/core",
535    summary = "Convert text containing numeric literals to a numeric array.",
536    keywords = "str2num,string numeric conversion,text",
537    accel = "sink",
538    type_resolver(tensor_type),
539    descriptor(crate::builtins::strings::core::compat::STR2NUM_DESCRIPTOR),
540    builtin_path = "crate::builtins::strings::core::compat"
541)]
542async fn str2num_builtin(text: Value) -> BuiltinResult<Value> {
543    let text = gather_if_needed_async(&text)
544        .await
545        .map_err(map_flow("str2num"))?;
546    let text = scalar_text(&text, "str2num")?;
547    let (value, ok) = parse_str2num_matrix(&text);
548    match crate::output_count::current_output_count() {
549        Some(0) => Ok(Value::OutputList(Vec::new())),
550        Some(1) => Ok(Value::OutputList(vec![value])),
551        Some(n) => Ok(crate::output_count::output_list_with_padding(
552            n,
553            vec![value, Value::Bool(ok)],
554        )),
555        None => Ok(value),
556    }
557}
558
559#[runtime_builtin(
560    name = "mat2str",
561    category = "strings/core",
562    summary = "Convert numeric, logical, character, and string values to MATLAB expression text.",
563    keywords = "mat2str,array string conversion,text",
564    accel = "sink",
565    type_resolver(string_type),
566    descriptor(crate::builtins::strings::core::compat::MAT2STR_DESCRIPTOR),
567    builtin_path = "crate::builtins::strings::core::compat"
568)]
569async fn mat2str_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
570    let value = gather_if_needed_async(&value)
571        .await
572        .map_err(map_flow("mat2str"))?;
573    let precision = if let Some(arg) = rest.first() {
574        let arg = gather_if_needed_async(arg)
575            .await
576            .map_err(map_flow("mat2str"))?;
577        Some(parse_nonnegative_usize(&arg, "mat2str")?)
578    } else {
579        None
580    };
581    Ok(Value::String(mat2str_value(&value, precision)))
582}
583
584#[runtime_builtin(
585    name = "native2unicode",
586    category = "strings/core",
587    summary = "Decode native byte values into Unicode text.",
588    keywords = "native2unicode,unicode,encoding,text,uint8",
589    accel = "sink",
590    type_resolver(string_type),
591    descriptor(crate::builtins::strings::core::compat::NATIVE2UNICODE_DESCRIPTOR),
592    builtin_path = "crate::builtins::strings::core::compat"
593)]
594async fn native2unicode_builtin(bytes: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
595    let bytes = gather_if_needed_async(&bytes)
596        .await
597        .map_err(map_flow("native2unicode"))?;
598    let encoding = if let Some(value) = rest.first() {
599        let value = gather_if_needed_async(value)
600            .await
601            .map_err(map_flow("native2unicode"))?;
602        scalar_text(&value, "native2unicode")?
603    } else {
604        "UTF-8".to_string()
605    };
606    let bytes = bytes_from_value(&bytes, "native2unicode")?;
607    decode_bytes(&bytes, &encoding)
608}
609
610#[runtime_builtin(
611    name = "sscanf",
612    category = "strings/core",
613    summary = "Parse formatted numeric values from text.",
614    keywords = "sscanf,scan,format,text,numeric",
615    accel = "sink",
616    type_resolver(tensor_type),
617    descriptor(crate::builtins::strings::core::compat::SSCANF_DESCRIPTOR),
618    builtin_path = "crate::builtins::strings::core::compat"
619)]
620async fn sscanf_builtin(text: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
621    let text = gather_if_needed_async(&text)
622        .await
623        .map_err(map_flow("sscanf"))?;
624    let text = scalar_text(&text, "sscanf")?;
625    let format = if let Some(fmt) = rest.first() {
626        let fmt = gather_if_needed_async(fmt)
627            .await
628            .map_err(map_flow("sscanf"))?;
629        scalar_text(&fmt, "sscanf")?
630    } else {
631        "%f".to_string()
632    };
633    let size = if let Some(size) = rest.get(1) {
634        let size = gather_if_needed_async(size)
635            .await
636            .map_err(map_flow("sscanf"))?;
637        Some(scan_size_from_value(&size)?)
638    } else {
639        None
640    };
641    let scan = sscanf_scan(&text, &format, size)?;
642    match crate::output_count::current_output_count() {
643        Some(0) => Ok(Value::OutputList(Vec::new())),
644        Some(1) => Ok(Value::OutputList(vec![scan.value])),
645        Some(n) => Ok(crate::output_count::output_list_with_padding(
646            n,
647            vec![
648                scan.value,
649                Value::Num(scan.count as f64),
650                Value::String(scan.errmsg),
651                Value::Num(scan.next_index as f64),
652            ],
653        )),
654        None => Ok(scan.value),
655    }
656}
657
658#[runtime_builtin(
659    name = "pattern",
660    category = "strings/pattern",
661    summary = "Create a literal string pattern.",
662    keywords = "pattern,string pattern,text",
663    accel = "metadata",
664    type_resolver(any_type),
665    descriptor(crate::builtins::strings::core::compat::PATTERN_DESCRIPTOR),
666    builtin_path = "crate::builtins::strings::core::compat"
667)]
668async fn pattern_builtin(text: Value) -> BuiltinResult<Value> {
669    let text = gather_if_needed_async(&text)
670        .await
671        .map_err(map_flow("pattern"))?;
672    Ok(pattern_object(&regex::escape(&scalar_text(
673        &text, "pattern",
674    )?)))
675}
676
677#[runtime_builtin(
678    name = "regexpPattern",
679    category = "strings/pattern",
680    summary = "Create a regular expression string pattern.",
681    keywords = "regexpPattern,pattern,regular expression,text",
682    accel = "metadata",
683    type_resolver(any_type),
684    descriptor(crate::builtins::strings::core::compat::REGEXP_PATTERN_DESCRIPTOR),
685    builtin_path = "crate::builtins::strings::core::compat"
686)]
687async fn regexp_pattern_builtin(text: Value) -> BuiltinResult<Value> {
688    let text = gather_if_needed_async(&text)
689        .await
690        .map_err(map_flow("regexpPattern"))?;
691    Ok(pattern_object(&scalar_text(&text, "regexpPattern")?))
692}
693
694#[runtime_builtin(
695    name = "digitsPattern",
696    category = "strings/pattern",
697    summary = "Create a pattern matching digit characters.",
698    keywords = "digitsPattern,pattern,digits,text",
699    accel = "metadata",
700    type_resolver(any_type),
701    descriptor(crate::builtins::strings::core::compat::DIGITS_PATTERN_DESCRIPTOR),
702    builtin_path = "crate::builtins::strings::core::compat"
703)]
704async fn digits_pattern_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
705    bounded_pattern(rest, "\\d", "digitsPattern").await
706}
707
708#[runtime_builtin(
709    name = "lettersPattern",
710    category = "strings/pattern",
711    summary = "Create a pattern matching letter characters.",
712    keywords = "lettersPattern,pattern,letters,text",
713    accel = "metadata",
714    type_resolver(any_type),
715    descriptor(crate::builtins::strings::core::compat::LETTERS_PATTERN_DESCRIPTOR),
716    builtin_path = "crate::builtins::strings::core::compat"
717)]
718async fn letters_pattern_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
719    bounded_pattern(rest, r"\p{Alphabetic}", "lettersPattern").await
720}
721
722#[runtime_builtin(
723    name = "wildcardPattern",
724    category = "strings/pattern",
725    summary = "Create a pattern matching arbitrary text.",
726    keywords = "wildcardPattern,pattern,wildcard,text",
727    accel = "metadata",
728    type_resolver(any_type),
729    descriptor(crate::builtins::strings::core::compat::WILDCARD_PATTERN_DESCRIPTOR),
730    builtin_path = "crate::builtins::strings::core::compat"
731)]
732async fn wildcard_pattern_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
733    if rest.is_empty() {
734        return Ok(pattern_object(".*"));
735    }
736    bounded_pattern(rest, ".", "wildcardPattern").await
737}
738
739#[runtime_builtin(
740    name = "textBoundary",
741    category = "strings/pattern",
742    summary = "Create a pattern matching the start or end of text.",
743    keywords = "textBoundary,pattern,boundary,start,end,text",
744    accel = "metadata",
745    type_resolver(any_type),
746    descriptor(crate::builtins::strings::core::compat::TEXT_BOUNDARY_DESCRIPTOR),
747    builtin_path = "crate::builtins::strings::core::compat"
748)]
749async fn text_boundary_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
750    let boundary_type = match rest.as_slice() {
751        [] => "either".to_string(),
752        [value] => {
753            let value = gather_if_needed_async(value)
754                .await
755                .map_err(map_flow("textBoundary"))?;
756            scalar_text(&value, "textBoundary")?
757        }
758        _ => {
759            return Err(compat_error(
760                "textBoundary",
761                "textBoundary: expected zero inputs or one boundary type",
762            ))
763        }
764    };
765    let regex = match boundary_type.to_ascii_lowercase().as_str() {
766        "either" => r"(?:^|$)",
767        "start" => r"^",
768        "end" => r"$",
769        other => {
770            return Err(compat_error(
771                "textBoundary",
772                format!("textBoundary: unsupported boundary type '{other}'"),
773            ))
774        }
775    };
776    Ok(pattern_object(regex))
777}
778
779pub(crate) fn scalar_text(value: &Value, fn_name: &str) -> BuiltinResult<String> {
780    match value {
781        Value::String(text) => Ok(text.clone()),
782        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
783        Value::CharArray(array) if array.rows == 0 => Ok(String::new()),
784        Value::CharArray(array) if array.rows == 1 => {
785            Ok(char_row_to_string_slice(&array.data, array.cols, 0))
786        }
787        other => Err(compat_error(
788            fn_name,
789            format!("{fn_name}: expected a text scalar, got {other:?}"),
790        )),
791    }
792}
793
794pub(crate) fn pattern_regex(value: &Value, fn_name: &str) -> BuiltinResult<String> {
795    match value {
796        Value::Object(object) if object.is_class(PATTERN_CLASS) => {
797            match object.properties.get("Regex") {
798                Some(Value::String(regex)) => Ok(regex.clone()),
799                _ => Err(compat_error(
800                    fn_name,
801                    format!("{fn_name}: invalid pattern object"),
802                )),
803            }
804        }
805        Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
806            Ok(regex::escape(&scalar_text(value, fn_name)?))
807        }
808        other => Err(compat_error(
809            fn_name,
810            format!("{fn_name}: expected text or pattern, got {other:?}"),
811        )),
812    }
813}
814
815pub(crate) fn pattern_object(regex: &str) -> Value {
816    let mut object = ObjectInstance::new(PATTERN_CLASS.to_string());
817    object
818        .properties
819        .insert("Regex".to_string(), Value::String(regex.to_string()));
820    Value::Object(object)
821}
822
823pub(crate) fn text_items(value: Value, fn_name: &str) -> BuiltinResult<TextList> {
824    TextList::from_value(value, fn_name)
825}
826
827pub(crate) fn logical_value(
828    data: Vec<u8>,
829    shape: Vec<usize>,
830    fn_name: &str,
831) -> BuiltinResult<Value> {
832    if data.len() == 1 {
833        Ok(Value::Bool(data[0] != 0))
834    } else {
835        LogicalArray::new(data, shape)
836            .map(Value::LogicalArray)
837            .map_err(|e| compat_error(fn_name, format!("{fn_name}: {e}")))
838    }
839}
840
841pub(crate) struct TextList {
842    pub(crate) items: Vec<Option<String>>,
843    pub(crate) shape: Vec<usize>,
844}
845
846impl TextList {
847    fn from_value(value: Value, fn_name: &str) -> BuiltinResult<Self> {
848        match value {
849            Value::String(text) => Ok(Self {
850                items: vec![missing_to_none(text)],
851                shape: vec![1, 1],
852            }),
853            Value::StringArray(array) => Ok(Self {
854                items: array.data.into_iter().map(missing_to_none).collect(),
855                shape: array.shape,
856            }),
857            Value::CharArray(array) => {
858                let mut items = Vec::with_capacity(array.rows.max(1));
859                if array.rows == 0 {
860                    return Ok(Self {
861                        items,
862                        shape: vec![0, 1],
863                    });
864                }
865                for row in 0..array.rows {
866                    items.push(Some(char_row_to_string_slice(&array.data, array.cols, row)));
867                }
868                Ok(Self {
869                    items,
870                    shape: vec![array.rows, 1],
871                })
872            }
873            Value::Cell(cell) => {
874                let mut items = Vec::with_capacity(cell.data.len());
875                for value in cell.data {
876                    items.push(Some(scalar_text(&value, fn_name)?));
877                }
878                Ok(Self {
879                    items,
880                    shape: cell.shape,
881                })
882            }
883            other => Err(compat_error(
884                fn_name,
885                format!("{fn_name}: expected text input, got {other:?}"),
886            )),
887        }
888    }
889}
890
891pub(crate) fn broadcast_shape(
892    a: &[usize],
893    b: &[usize],
894    fn_name: &str,
895) -> BuiltinResult<Vec<usize>> {
896    matlab_broadcast::broadcast_shapes(fn_name, a, b).map_err(|err| compat_error(fn_name, err))
897}
898
899pub(crate) fn broadcast_flat_index(
900    linear: usize,
901    shape: &[usize],
902    source_shape: &[usize],
903) -> usize {
904    if source_shape.iter().product::<usize>() <= 1 {
905        return 0;
906    }
907    let mut extended = Vec::with_capacity(shape.len());
908    extended.extend(std::iter::repeat_n(
909        1,
910        shape.len().saturating_sub(source_shape.len()),
911    ));
912    extended.extend_from_slice(source_shape);
913    let strides = matlab_broadcast::compute_strides(&extended);
914    matlab_broadcast::broadcast_index(linear, shape, &extended, &strides)
915}
916
917fn missing_to_none(text: String) -> Option<String> {
918    if is_missing_string(&text) {
919        None
920    } else {
921        Some(text)
922    }
923}
924
925fn parse_nonnegative_usize(value: &Value, fn_name: &str) -> BuiltinResult<usize> {
926    let number = match value {
927        Value::Num(n) => *n,
928        Value::Int(i) => i.to_i64() as f64,
929        Value::Tensor(tensor) if tensor.data.len() == 1 => tensor.data[0],
930        _ => {
931            return Err(compat_error(
932                fn_name,
933                format!("{fn_name}: expected a nonnegative integer scalar"),
934            ))
935        }
936    };
937    if !number.is_finite() || number < 0.0 || number.fract() != 0.0 || number > usize::MAX as f64 {
938        return Err(compat_error(
939            fn_name,
940            format!("{fn_name}: expected a nonnegative integer scalar"),
941        ));
942    }
943    Ok(number as usize)
944}
945
946fn convert_strings_to_chars(value: Value, contained_only: bool) -> BuiltinResult<Value> {
947    match value {
948        Value::String(text) => Ok(Value::CharArray(CharArray::new_row(&text))),
949        Value::StringArray(array) if array.data.len() == 1 && !contained_only => {
950            Ok(Value::CharArray(CharArray::new_row(&array.data[0])))
951        }
952        Value::StringArray(array) if !contained_only => {
953            let values = array
954                .data
955                .into_iter()
956                .map(|text| Value::CharArray(CharArray::new_row(&text)))
957                .collect();
958            make_cell_with_shape(values, array.shape)
959                .map_err(|e| compat_error("convertStringsToChars", e))
960        }
961        Value::Cell(cell) => {
962            let values = cell
963                .data
964                .into_iter()
965                .map(|value| convert_strings_to_chars(value, true))
966                .collect::<BuiltinResult<Vec<_>>>()?;
967            make_cell_with_shape(values, cell.shape)
968                .map_err(|e| compat_error("convertContainedStringsToChars", e))
969        }
970        Value::Struct(mut st) => {
971            for value in st.fields.values_mut() {
972                *value = convert_strings_to_chars(value.clone(), true)?;
973            }
974            Ok(Value::Struct(st))
975        }
976        other => Ok(other),
977    }
978}
979
980fn convert_chars_to_strings(value: Value) -> BuiltinResult<Value> {
981    match value {
982        Value::CharArray(array) => {
983            let data = (0..array.rows)
984                .map(|row| {
985                    char_row_to_string_slice(&array.data, array.cols, row)
986                        .trim_end()
987                        .to_string()
988                })
989                .collect::<Vec<_>>();
990            StringArray::new(data, vec![array.rows, 1])
991                .map(Value::StringArray)
992                .map_err(|e| compat_error("convertCharsToStrings", e))
993        }
994        Value::Cell(cell) => {
995            let values = cell
996                .data
997                .into_iter()
998                .map(convert_chars_to_strings)
999                .collect::<BuiltinResult<Vec<_>>>()?;
1000            make_cell_with_shape(values, cell.shape)
1001                .map_err(|e| compat_error("convertCharsToStrings", e))
1002        }
1003        other => Ok(other),
1004    }
1005}
1006
1007fn prefix_eq_ignore_case(a: &str, b: &str, n: usize) -> bool {
1008    a.chars()
1009        .take(n)
1010        .map(|ch| ch.to_lowercase().collect::<String>())
1011        .eq(b
1012            .chars()
1013            .take(n)
1014            .map(|ch| ch.to_lowercase().collect::<String>()))
1015        && a.chars().count() >= n
1016        && b.chars().count() >= n
1017}
1018
1019fn classify_text_value(
1020    value: Value,
1021    fn_name: &str,
1022    pred: impl Fn(char) -> bool + Copy,
1023) -> BuiltinResult<Value> {
1024    match value {
1025        Value::String(text) => {
1026            let data = text
1027                .chars()
1028                .map(|ch| u8::from(pred(ch)))
1029                .collect::<Vec<_>>();
1030            logical_value(data, vec![1, text.chars().count()], fn_name)
1031        }
1032        Value::StringArray(array) => {
1033            let values = array
1034                .data
1035                .into_iter()
1036                .map(|text| classify_text_value(Value::String(text), fn_name, pred))
1037                .collect::<BuiltinResult<Vec<_>>>()?;
1038            make_cell_with_shape(values, array.shape).map_err(|e| compat_error(fn_name, e))
1039        }
1040        Value::CharArray(array) => {
1041            let data = array
1042                .data
1043                .iter()
1044                .map(|ch| u8::from(pred(*ch)))
1045                .collect::<Vec<_>>();
1046            logical_value(data, vec![array.rows, array.cols], fn_name)
1047        }
1048        Value::Cell(cell) => {
1049            let values = cell
1050                .data
1051                .into_iter()
1052                .map(|value| classify_text_value(value, fn_name, pred))
1053                .collect::<BuiltinResult<Vec<_>>>()?;
1054            make_cell_with_shape(values, cell.shape).map_err(|e| compat_error(fn_name, e))
1055        }
1056        other => Err(compat_error(
1057            fn_name,
1058            format!("{fn_name}: expected text input, got {other:?}"),
1059        )),
1060    }
1061}
1062
1063fn char_matches_prop(ch: char, prop: &str) -> bool {
1064    match prop {
1065        "alpha" | "letter" | "walpha" => ch.is_alphabetic(),
1066        "alphanum" | "alphanumeric" | "walphanum" => ch.is_alphanumeric(),
1067        "digit" | "wdigit" => ch.is_ascii_digit(),
1068        "xdigit" => ch.is_ascii_hexdigit(),
1069        "space" | "wspace" => ch.is_whitespace(),
1070        "upper" | "wupper" => ch.is_uppercase(),
1071        "lower" | "wlower" => ch.is_lowercase(),
1072        "punct" | "wpunct" => ch.is_ascii_punctuation(),
1073        "cntrl" | "control" => ch.is_control(),
1074        "graphic" | "wgraphic" | "print" | "wprint" => !ch.is_control(),
1075        _ => false,
1076    }
1077}
1078
1079fn map_text_pair_preserve(
1080    value: Value,
1081    fn_name: &str,
1082    map: impl Fn(&str) -> (String, String) + Copy,
1083) -> BuiltinResult<(Value, Value)> {
1084    match value {
1085        Value::String(text) => {
1086            let (first, second) = map(&text);
1087            Ok((Value::String(first), Value::String(second)))
1088        }
1089        Value::StringArray(array) => {
1090            let mut first = Vec::with_capacity(array.data.len());
1091            let mut second = Vec::with_capacity(array.data.len());
1092            for text in array.data {
1093                let (a, b) = map(&text);
1094                first.push(a);
1095                second.push(b);
1096            }
1097            let first = StringArray::new(first, array.shape.clone())
1098                .map(Value::StringArray)
1099                .map_err(|e| compat_error(fn_name, e))?;
1100            let second = StringArray::new(second, array.shape)
1101                .map(Value::StringArray)
1102                .map_err(|e| compat_error(fn_name, e))?;
1103            Ok((first, second))
1104        }
1105        Value::CharArray(array) => {
1106            let mut first = Vec::with_capacity(array.rows);
1107            let mut second = Vec::with_capacity(array.rows);
1108            for row in 0..array.rows {
1109                let (a, b) = map(&char_row_to_string_slice(&array.data, array.cols, row));
1110                first.push(a);
1111                second.push(b);
1112            }
1113            Ok((
1114                char_rows_from_strings(first, fn_name)?,
1115                char_rows_from_strings(second, fn_name)?,
1116            ))
1117        }
1118        Value::Cell(cell) => {
1119            let mut first = Vec::with_capacity(cell.data.len());
1120            let mut second = Vec::with_capacity(cell.data.len());
1121            for value in cell.data {
1122                let (a, b) = map_text_pair_preserve(value, fn_name, map)?;
1123                first.push(a);
1124                second.push(b);
1125            }
1126            Ok((
1127                make_cell_with_shape(first, cell.shape.clone())
1128                    .map_err(|e| compat_error(fn_name, e))?,
1129                make_cell_with_shape(second, cell.shape).map_err(|e| compat_error(fn_name, e))?,
1130            ))
1131        }
1132        other => Err(compat_error(
1133            fn_name,
1134            format!("{fn_name}: expected text input, got {other:?}"),
1135        )),
1136    }
1137}
1138
1139fn strtok_pair(text: &str, delimiters: &str) -> (String, String) {
1140    let start = text
1141        .char_indices()
1142        .find_map(|(idx, ch)| (!delimiters.contains(ch)).then_some(idx))
1143        .unwrap_or(text.len());
1144    if start == text.len() {
1145        return (String::new(), String::new());
1146    }
1147    let token_end = text[start..]
1148        .char_indices()
1149        .find_map(|(idx, ch)| delimiters.contains(ch).then_some(start + idx))
1150        .unwrap_or(text.len());
1151    (
1152        text[start..token_end].to_string(),
1153        text[token_end..].to_string(),
1154    )
1155}
1156
1157fn char_rows_from_strings(rows: Vec<String>, fn_name: &str) -> BuiltinResult<Value> {
1158    let row_count = rows.len();
1159    let cols = rows.iter().map(|s| s.chars().count()).max().unwrap_or(0);
1160    let mut data = Vec::with_capacity(row_count * cols);
1161    for row in rows {
1162        let mut chars = row.chars().collect::<Vec<_>>();
1163        chars.resize(cols, ' ');
1164        data.extend(chars);
1165    }
1166    CharArray::new(data, row_count, cols)
1167        .map(Value::CharArray)
1168        .map_err(|e| compat_error(fn_name, e))
1169}
1170
1171fn parse_str2num_matrix(text: &str) -> (Value, bool) {
1172    match parse_numeric_matrix(text, "str2num") {
1173        Ok(value) => (value, true),
1174        Err(_) => (Value::Tensor(Tensor::zeros(vec![0, 0])), false),
1175    }
1176}
1177
1178fn parse_numeric_matrix(text: &str, fn_name: &str) -> BuiltinResult<Value> {
1179    let text = text.trim();
1180    let text = text.strip_prefix('[').unwrap_or(text);
1181    let text = text.strip_suffix(']').unwrap_or(text).trim();
1182    let rows = text
1183        .split(';')
1184        .map(|row| {
1185            row.split(|ch: char| ch.is_whitespace() || ch == ',')
1186                .filter(|part| !part.is_empty())
1187                .map(|part| {
1188                    part.parse::<f64>().map_err(|_| {
1189                        compat_error(
1190                            fn_name,
1191                            format!("{fn_name}: invalid numeric literal '{part}'"),
1192                        )
1193                    })
1194                })
1195                .collect::<BuiltinResult<Vec<_>>>()
1196        })
1197        .collect::<BuiltinResult<Vec<_>>>()?;
1198    if rows.is_empty() || rows.iter().all(Vec::is_empty) {
1199        return Ok(Value::Tensor(Tensor::zeros(vec![0, 0])));
1200    }
1201    let cols = rows.iter().map(Vec::len).max().unwrap_or(0);
1202    if rows.iter().any(|row| row.len() != cols) {
1203        return Err(compat_error(
1204            fn_name,
1205            format!("{fn_name}: rows must have the same number of columns"),
1206        ));
1207    }
1208    let mut data = Vec::with_capacity(rows.len() * cols);
1209    for col in 0..cols {
1210        for row in &rows {
1211            data.push(row[col]);
1212        }
1213    }
1214    Tensor::new(data, vec![rows.len(), cols])
1215        .map(Value::Tensor)
1216        .map_err(|e| compat_error(fn_name, e))
1217}
1218
1219fn mat2str_value(value: &Value, precision: Option<usize>) -> String {
1220    match value {
1221        Value::Num(n) => format_number(*n, precision),
1222        Value::Int(i) => i.to_i64().to_string(),
1223        Value::Bool(b) => {
1224            if *b {
1225                "true".into()
1226            } else {
1227                "false".into()
1228            }
1229        }
1230        Value::String(text) => format!("\"{}\"", text.replace('"', "\"\"")),
1231        Value::CharArray(array) if array.rows <= 1 => {
1232            format!(
1233                "'{}'",
1234                char_row_to_string_slice(&array.data, array.cols, 0).replace('\'', "''")
1235            )
1236        }
1237        Value::Tensor(tensor) => {
1238            matrix_to_string(&tensor.data, tensor.rows(), tensor.cols(), precision)
1239        }
1240        Value::LogicalArray(array) => {
1241            let rows = array.shape.first().copied().unwrap_or(array.data.len());
1242            let cols = array.shape.get(1).copied().unwrap_or(1);
1243            let data = array
1244                .data
1245                .iter()
1246                .map(|v| f64::from(*v != 0))
1247                .collect::<Vec<_>>();
1248            matrix_to_string(&data, rows, cols, precision)
1249        }
1250        _ => value.to_string(),
1251    }
1252}
1253
1254fn matrix_to_string(data: &[f64], rows: usize, cols: usize, precision: Option<usize>) -> String {
1255    let mut out = String::from("[");
1256    for row in 0..rows {
1257        if row > 0 {
1258            out.push(';');
1259        }
1260        for col in 0..cols {
1261            if col > 0 {
1262                out.push(' ');
1263            }
1264            out.push_str(&format_number(data[row + col * rows], precision));
1265        }
1266    }
1267    out.push(']');
1268    out
1269}
1270
1271fn format_number(value: f64, precision: Option<usize>) -> String {
1272    if let Some(precision) = precision {
1273        format!("{value:.precision$}")
1274    } else if value.fract() == 0.0 && value.is_finite() {
1275        format!("{value:.0}")
1276    } else {
1277        format!("{value:.15}")
1278            .trim_end_matches('0')
1279            .trim_end_matches('.')
1280            .to_string()
1281    }
1282}
1283
1284fn bytes_from_value(value: &Value, fn_name: &str) -> BuiltinResult<Vec<u8>> {
1285    match value {
1286        Value::Tensor(tensor) => tensor
1287            .data
1288            .iter()
1289            .map(|n| byte_from_f64(*n, fn_name))
1290            .collect(),
1291        Value::Int(i) => Ok(vec![i.to_i64().clamp(0, 255) as u8]),
1292        Value::Num(n) => Ok(vec![byte_from_f64(*n, fn_name)?]),
1293        Value::CharArray(array) => {
1294            Ok(char_row_to_string_slice(&array.data, array.cols, 0).into_bytes())
1295        }
1296        Value::String(text) => Ok(text.as_bytes().to_vec()),
1297        other => Err(compat_error(
1298            fn_name,
1299            format!("{fn_name}: expected bytes or text, got {other:?}"),
1300        )),
1301    }
1302}
1303
1304fn byte_from_f64(value: f64, fn_name: &str) -> BuiltinResult<u8> {
1305    if !value.is_finite() {
1306        return Err(compat_error(
1307            fn_name,
1308            format!("{fn_name}: byte values must be finite"),
1309        ));
1310    }
1311    Ok(value.round().clamp(0.0, 255.0) as u8)
1312}
1313
1314fn decode_bytes(bytes: &[u8], encoding: &str) -> BuiltinResult<Value> {
1315    let encoding = Encoding::for_label(encoding.as_bytes()).unwrap_or(UTF_8);
1316    let (text, _, _) = encoding.decode(bytes);
1317    Ok(Value::String(text.into_owned()))
1318}
1319
1320struct SscanfResult {
1321    value: Value,
1322    count: usize,
1323    errmsg: String,
1324    next_index: usize,
1325}
1326
1327#[derive(Clone, Copy)]
1328enum ScanKind {
1329    Float,
1330    Integer,
1331    String,
1332    Char,
1333}
1334
1335#[derive(Clone)]
1336enum ScanToken {
1337    Whitespace,
1338    Literal(char),
1339    Spec {
1340        kind: ScanKind,
1341        width: Option<usize>,
1342        suppress: bool,
1343    },
1344}
1345
1346fn sscanf_scan(text: &str, format: &str, size: Option<Vec<usize>>) -> BuiltinResult<SscanfResult> {
1347    let tokens = parse_scan_format(format)?;
1348    if tokens.is_empty() {
1349        return Err(compat_error("sscanf", "sscanf: format must not be empty"));
1350    }
1351
1352    let mut values = Vec::new();
1353    let mut pos = 0usize;
1354    let mut last_success = 0usize;
1355    loop {
1356        let start_pos = pos;
1357        let start_len = values.len();
1358        let mut matched_all = true;
1359        for token in &tokens {
1360            match token {
1361                ScanToken::Whitespace => {
1362                    pos = skip_whitespace(text, pos);
1363                }
1364                ScanToken::Literal(ch) => {
1365                    let Some(next) = text[pos..].chars().next() else {
1366                        matched_all = false;
1367                        break;
1368                    };
1369                    if next != *ch {
1370                        matched_all = false;
1371                        break;
1372                    }
1373                    pos += next.len_utf8();
1374                }
1375                ScanToken::Spec {
1376                    kind,
1377                    width,
1378                    suppress,
1379                } => {
1380                    if !matches!(kind, ScanKind::Char) {
1381                        pos = skip_whitespace(text, pos);
1382                    }
1383                    let Some((parsed, next_pos)) = scan_one(text, pos, *kind, *width) else {
1384                        matched_all = false;
1385                        break;
1386                    };
1387                    pos = next_pos;
1388                    if !*suppress {
1389                        values.extend(parsed);
1390                    }
1391                }
1392            }
1393        }
1394        if !matched_all {
1395            break;
1396        }
1397        if pos == start_pos || values.len() == start_len && pos >= text.len() {
1398            break;
1399        }
1400        last_success = pos;
1401        if pos >= text.len() {
1402            break;
1403        }
1404    }
1405
1406    let count = values.len();
1407    let mut shape = size.unwrap_or_else(|| vec![count, 1]);
1408    let limit = shape.iter().product::<usize>();
1409    if limit > 0 && values.len() > limit {
1410        values.truncate(limit);
1411    }
1412    if shape.iter().product::<usize>() != values.len() {
1413        shape = vec![values.len(), 1];
1414    }
1415    let value = Tensor::new(values, shape)
1416        .map(Value::Tensor)
1417        .map_err(|e| compat_error("sscanf", e))?;
1418    Ok(SscanfResult {
1419        value,
1420        count,
1421        errmsg: String::new(),
1422        next_index: last_success.saturating_add(1),
1423    })
1424}
1425
1426fn parse_scan_format(format: &str) -> BuiltinResult<Vec<ScanToken>> {
1427    let mut chars = format.chars().peekable();
1428    let mut tokens = Vec::new();
1429    while let Some(ch) = chars.next() {
1430        if ch.is_whitespace() {
1431            while chars.peek().is_some_and(|next| next.is_whitespace()) {
1432                chars.next();
1433            }
1434            tokens.push(ScanToken::Whitespace);
1435            continue;
1436        }
1437        if ch != '%' {
1438            tokens.push(ScanToken::Literal(ch));
1439            continue;
1440        }
1441        if chars.peek() == Some(&'%') {
1442            chars.next();
1443            tokens.push(ScanToken::Literal('%'));
1444            continue;
1445        }
1446        let suppress = if chars.peek() == Some(&'*') {
1447            chars.next();
1448            true
1449        } else {
1450            false
1451        };
1452        let mut width = String::new();
1453        while chars.peek().is_some_and(|next| next.is_ascii_digit()) {
1454            width.push(chars.next().unwrap());
1455        }
1456        let width = if width.is_empty() {
1457            None
1458        } else {
1459            Some(
1460                width
1461                    .parse::<usize>()
1462                    .map_err(|_| compat_error("sscanf", "sscanf: invalid field width"))?,
1463            )
1464        };
1465        let Some(specifier) = chars.next() else {
1466            return Err(compat_error(
1467                "sscanf",
1468                "sscanf: incomplete format specifier",
1469            ));
1470        };
1471        let kind = match specifier {
1472            'f' | 'e' | 'E' | 'g' | 'G' => ScanKind::Float,
1473            'd' | 'i' | 'u' => ScanKind::Integer,
1474            's' => ScanKind::String,
1475            'c' => ScanKind::Char,
1476            other => {
1477                return Err(compat_error(
1478                    "sscanf",
1479                    format!("sscanf: unsupported format specifier %{other}"),
1480                ))
1481            }
1482        };
1483        tokens.push(ScanToken::Spec {
1484            kind,
1485            width,
1486            suppress,
1487        });
1488    }
1489    Ok(tokens)
1490}
1491
1492fn scan_one(
1493    text: &str,
1494    pos: usize,
1495    kind: ScanKind,
1496    width: Option<usize>,
1497) -> Option<(Vec<f64>, usize)> {
1498    if pos > text.len() {
1499        return None;
1500    }
1501    let end_limit = width
1502        .and_then(|w| byte_index_after_n_chars(&text[pos..], w).map(|idx| pos + idx))
1503        .unwrap_or(text.len());
1504    match kind {
1505        ScanKind::Float | ScanKind::Integer => {
1506            let fragment = &text[pos..end_limit];
1507            let len = numeric_prefix_len(fragment, matches!(kind, ScanKind::Integer))?;
1508            let token = &fragment[..len];
1509            let value = if matches!(kind, ScanKind::Integer) {
1510                token
1511                    .parse::<i64>()
1512                    .map(|value| value as f64)
1513                    .or_else(|_| token.parse::<f64>())
1514                    .ok()?
1515            } else {
1516                token.parse::<f64>().ok()?
1517            };
1518            Some((vec![value], pos + len))
1519        }
1520        ScanKind::String => {
1521            let fragment = &text[pos..end_limit];
1522            let len = fragment
1523                .char_indices()
1524                .find_map(|(idx, ch)| ch.is_whitespace().then_some(idx))
1525                .unwrap_or(fragment.len());
1526            if len == 0 {
1527                None
1528            } else {
1529                Some((
1530                    fragment[..len].chars().map(|ch| ch as u32 as f64).collect(),
1531                    pos + len,
1532                ))
1533            }
1534        }
1535        ScanKind::Char => {
1536            let count = width.unwrap_or(1);
1537            let len = byte_index_after_n_chars(&text[pos..], count)?;
1538            Some((
1539                text[pos..pos + len]
1540                    .chars()
1541                    .map(|ch| ch as u32 as f64)
1542                    .collect(),
1543                pos + len,
1544            ))
1545        }
1546    }
1547}
1548
1549fn numeric_prefix_len(text: &str, integer: bool) -> Option<usize> {
1550    let mut end = 0usize;
1551    for (idx, ch) in text.char_indices() {
1552        let allowed = if integer {
1553            ch.is_ascii_digit() || ((ch == '+' || ch == '-') && idx == 0)
1554        } else {
1555            ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E')
1556        };
1557        if !allowed {
1558            break;
1559        }
1560        end = idx + ch.len_utf8();
1561    }
1562    (end > 0).then_some(end)
1563}
1564
1565fn skip_whitespace(text: &str, mut pos: usize) -> usize {
1566    while pos < text.len() {
1567        let Some(ch) = text[pos..].chars().next() else {
1568            break;
1569        };
1570        if !ch.is_whitespace() {
1571            break;
1572        }
1573        pos += ch.len_utf8();
1574    }
1575    pos
1576}
1577
1578fn byte_index_after_n_chars(text: &str, count: usize) -> Option<usize> {
1579    if count == 0 {
1580        return Some(0);
1581    }
1582    text.char_indices()
1583        .nth(count)
1584        .map(|(idx, _)| idx)
1585        .or_else(|| (text.chars().count() == count).then_some(text.len()))
1586}
1587
1588fn scan_size_from_value(value: &Value) -> BuiltinResult<Vec<usize>> {
1589    match value {
1590        Value::Num(n) if n.is_finite() && *n >= 0.0 && n.fract() == 0.0 => Ok(vec![*n as usize, 1]),
1591        Value::Tensor(tensor) if tensor.data.len() == 1 => {
1592            Ok(vec![scan_size_dim(tensor.data[0])?, 1])
1593        }
1594        Value::Tensor(tensor) if tensor.data.len() == 2 => Ok(vec![
1595            scan_size_dim(tensor.data[0])?,
1596            scan_size_dim(tensor.data[1])?,
1597        ]),
1598        other => Err(compat_error(
1599            "sscanf",
1600            format!("sscanf: invalid size argument {other:?}"),
1601        )),
1602    }
1603}
1604
1605fn scan_size_dim(value: f64) -> BuiltinResult<usize> {
1606    if value.is_infinite() && value.is_sign_positive() {
1607        return Ok(usize::MAX / 2);
1608    }
1609    if !value.is_finite() || value < 0.0 || value.fract() != 0.0 {
1610        return Err(compat_error(
1611            "sscanf",
1612            "sscanf: size dimensions must be nonnegative integers",
1613        ));
1614    }
1615    Ok(value as usize)
1616}
1617
1618async fn bounded_pattern(
1619    rest: Vec<Value>,
1620    atom: &str,
1621    fn_name: &'static str,
1622) -> BuiltinResult<Value> {
1623    let regex = if let Some(value) = rest.first() {
1624        let value = gather_if_needed_async(value)
1625            .await
1626            .map_err(map_flow(fn_name))?;
1627        let n = parse_nonnegative_usize(&value, fn_name)?;
1628        format!("{atom}{{{n}}}")
1629    } else {
1630        format!("{atom}+")
1631    };
1632    Ok(pattern_object(&regex))
1633}
1634
1635#[cfg(test)]
1636mod tests {
1637    use super::*;
1638    use runmat_builtins::NumericDType;
1639
1640    fn block(
1641        value: impl std::future::Future<Output = BuiltinResult<Value>>,
1642    ) -> BuiltinResult<Value> {
1643        futures::executor::block_on(value)
1644    }
1645
1646    #[test]
1647    fn basic_text_core_helpers_work() {
1648        assert_eq!(newline_builtin().unwrap(), Value::String("\n".into()));
1649        assert_eq!(
1650            block(blanks_builtin(Value::Num(3.0))).unwrap(),
1651            Value::CharArray(CharArray::new_row("   "))
1652        );
1653        assert_eq!(
1654            is_string_scalar_builtin(Value::String("x".into())).unwrap(),
1655            Value::Bool(true)
1656        );
1657    }
1658
1659    #[test]
1660    fn strncmpi_and_classifiers_work() {
1661        assert_eq!(
1662            block(strncmpi_builtin(
1663                Value::String("RunMat".into()),
1664                Value::String("runway".into()),
1665                Value::Num(3.0),
1666            ))
1667            .unwrap(),
1668            Value::Bool(true)
1669        );
1670        assert_eq!(
1671            block(isletter_builtin(Value::CharArray(CharArray::new_row("a1")))).unwrap(),
1672            Value::LogicalArray(LogicalArray::new(vec![1, 0], vec![1, 2]).unwrap())
1673        );
1674    }
1675
1676    #[test]
1677    fn conversions_and_numeric_parsing_work() {
1678        assert_eq!(
1679            block(convert_strings_to_chars_builtin(
1680                Value::String("abc".into()),
1681                Vec::new(),
1682            ))
1683            .unwrap(),
1684            Value::CharArray(CharArray::new_row("abc"))
1685        );
1686        let _guard = crate::output_count::push_output_count(Some(2));
1687        assert!(matches!(
1688            block(convert_strings_to_chars_builtin(
1689                Value::String("a".into()),
1690                vec![Value::String("b".into())],
1691            ))
1692            .unwrap(),
1693            Value::OutputList(outputs) if outputs.len() == 2
1694        ));
1695        drop(_guard);
1696        assert_eq!(
1697            block(convert_chars_to_strings_builtin(Value::CharArray(
1698                CharArray::new_row("abc")
1699            )))
1700            .unwrap(),
1701            Value::StringArray(StringArray::new(vec!["abc".into()], vec![1, 1]).unwrap())
1702        );
1703        assert_eq!(
1704            block(str2num_builtin(Value::String("1 2; 3 4".into()))).unwrap(),
1705            Value::Tensor(Tensor::new(vec![1.0, 3.0, 2.0, 4.0], vec![2, 2]).unwrap())
1706        );
1707        assert_eq!(
1708            block(mat2str_builtin(
1709                Value::Tensor(Tensor::new(vec![1.0, 3.0, 2.0, 4.0], vec![2, 2]).unwrap()),
1710                Vec::new(),
1711            ))
1712            .unwrap(),
1713            Value::String("[1 2;3 4]".into())
1714        );
1715    }
1716
1717    #[test]
1718    fn tokenizing_encoding_and_scanning_work() {
1719        assert_eq!(
1720            block(strtok_builtin(
1721                Value::String("  alpha,beta".into()),
1722                vec![Value::String(" ,".into())],
1723            ))
1724            .unwrap(),
1725            Value::String("alpha".into())
1726        );
1727        assert_eq!(
1728            block(native2unicode_builtin(
1729                Value::Tensor(
1730                    Tensor::new_with_dtype(vec![104.0, 105.0], vec![1, 2], NumericDType::U8)
1731                        .unwrap()
1732                ),
1733                Vec::new(),
1734            ))
1735            .unwrap(),
1736            Value::String("hi".into())
1737        );
1738        assert_eq!(
1739            block(sscanf_builtin(
1740                Value::String("1 2 x".into()),
1741                vec![Value::String("%f".into())],
1742            ))
1743            .unwrap(),
1744            Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap())
1745        );
1746    }
1747
1748    #[test]
1749    fn pattern_constructors_store_regex() {
1750        let value = block(digits_pattern_builtin(vec![Value::Num(2.0)])).unwrap();
1751        assert_eq!(pattern_regex(&value, "test").unwrap(), "\\d{2}");
1752        assert_eq!(
1753            pattern_regex(&block(letters_pattern_builtin(Vec::new())).unwrap(), "test").unwrap(),
1754            r"\p{Alphabetic}+"
1755        );
1756        assert_eq!(
1757            pattern_regex(
1758                &block(wildcard_pattern_builtin(Vec::new())).unwrap(),
1759                "test"
1760            )
1761            .unwrap(),
1762            ".*"
1763        );
1764        assert_eq!(
1765            pattern_regex(&block(text_boundary_builtin(Vec::new())).unwrap(), "test").unwrap(),
1766            r"(?:^|$)"
1767        );
1768        assert_eq!(
1769            pattern_regex(
1770                &block(text_boundary_builtin(vec![Value::String("start".into())])).unwrap(),
1771                "test"
1772            )
1773            .unwrap(),
1774            r"^"
1775        );
1776        assert_eq!(
1777            pattern_regex(
1778                &block(text_boundary_builtin(vec![Value::String("end".into())])).unwrap(),
1779                "test"
1780            )
1781            .unwrap(),
1782            r"$"
1783        );
1784    }
1785
1786    #[test]
1787    fn text_boundary_rejects_invalid_type() {
1788        let err = block(text_boundary_builtin(vec![Value::String("middle".into())]))
1789            .expect_err("expected invalid boundary type");
1790        assert!(err.to_string().contains("unsupported boundary type"));
1791    }
1792
1793    #[test]
1794    fn text_boundary_pattern_works_with_replace() {
1795        let pattern = block(text_boundary_builtin(vec![Value::String("start".into())])).unwrap();
1796        let result = block(crate::call_builtin_async(
1797            "replace",
1798            &[
1799                Value::String("abc".into()),
1800                pattern,
1801                Value::String(">".into()),
1802            ],
1803        ))
1804        .expect("replace");
1805        assert_eq!(result, Value::String(">abc".into()));
1806    }
1807}