Skip to main content

runmat_runtime/builtins/strings/core/
char.rs

1//! MATLAB-compatible `char` builtin with GPU-aware conversion semantics for RunMat.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
5    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
6    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
7    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
8    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
9    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10};
11use runmat_macros::runtime_builtin;
12use runmat_value::{
13    CellArray, CharArray, IntValue, LogicalArray, NumericScalar, SparseTensor, StringArray,
14    SymbolicArray, Tensor, Value,
15};
16
17use crate::builtins::common::map_control_flow_with_builtin;
18use crate::builtins::common::spec::{
19    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
20    ReductionNaN, ResidencyPolicy, ShapeRequirements,
21};
22use crate::builtins::strings::type_resolvers::string_array_type;
23use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
24
25#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::char")]
26pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
27    name: "char",
28    op_kind: GpuOpKind::Custom("conversion"),
29    supported_precisions: &[],
30    broadcast: BroadcastSemantics::None,
31    provider_hooks: &[],
32    constant_strategy: ConstantStrategy::InlineLiteral,
33    residency: ResidencyPolicy::GatherImmediately,
34    nan_mode: ReductionNaN::Include,
35    two_pass_threshold: None,
36    workgroup_size: None,
37    accepts_nan_mode: false,
38    notes: "Conversion always runs on the CPU. Interactive resident numeric input is undocumented and therefore mode-gated before the gather fallback; output is host character data.",
39};
40
41#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::char")]
42pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
43    name: "char",
44    shape: ShapeRequirements::Any,
45    constant_strategy: ConstantStrategy::InlineLiteral,
46    elementwise: None,
47    reduction: None,
48    emits_nan: false,
49    notes: "Character materialisation runs outside of fusion; results always live on the host.",
50};
51
52const BUILTIN_NAME: &str = "char";
53const CHAR_SPARSE_DENSE_ELEMENT_LIMIT: usize = 10_000_000;
54
55pub(crate) const CHAR_RESIDENT_NUMERIC_EXTENSION: BuiltinExtensionDescriptor =
56    BuiltinExtensionDescriptor {
57        id: "char-resident-numeric-input",
58        mode: BuiltinExtensionMode::RunMatOnly,
59        description: "char with interactive resident numeric input is a RunMat extension",
60        error_identifier: Some("RunMat:compatibility:CharResidentNumericInputExtension"),
61    };
62pub(crate) const CHAR_LOGICAL_INPUT_EXTENSION: BuiltinExtensionDescriptor =
63    BuiltinExtensionDescriptor {
64        id: "char-logical-input",
65        mode: BuiltinExtensionMode::RunMatOnly,
66        description: "char with logical input is a RunMat extension",
67        error_identifier: Some("RunMat:compatibility:CharLogicalInputExtension"),
68    };
69
70pub const CHAR_EXTENSIONS: [BuiltinExtensionDescriptor; 2] = [
71    CHAR_LOGICAL_INPUT_EXTENSION,
72    CHAR_RESIDENT_NUMERIC_EXTENSION,
73];
74
75const CHAR_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
76    name: "C",
77    ty: BuiltinParamType::Any,
78    arity: BuiltinParamArity::Required,
79    default: None,
80    description: "Character array result.",
81}];
82
83const CHAR_INPUT_SINGLE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
84    name: "X",
85    ty: BuiltinParamType::Any,
86    arity: BuiltinParamArity::Required,
87    default: None,
88    description: "Input value to convert into character data.",
89}];
90
91const CHAR_INPUT_VARIADIC: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
92    name: "X...",
93    ty: BuiltinParamType::Any,
94    arity: BuiltinParamArity::Variadic,
95    default: None,
96    description: "Multiple inputs converted row-wise and padded.",
97}];
98
99const CHAR_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
100    BuiltinSignatureDescriptor {
101        label: "C = char()",
102        inputs: &[],
103        outputs: &CHAR_OUTPUT,
104    },
105    BuiltinSignatureDescriptor {
106        label: "C = char(X)",
107        inputs: &CHAR_INPUT_SINGLE,
108        outputs: &CHAR_OUTPUT,
109    },
110    BuiltinSignatureDescriptor {
111        label: "C = char(X...)",
112        inputs: &CHAR_INPUT_VARIADIC,
113        outputs: &CHAR_OUTPUT,
114    },
115];
116
117const CHAR_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
118    code: "RM.CHAR.INVALID_INPUT",
119    identifier: Some("RunMat:char:InvalidInput"),
120    when: "Input type cannot be converted to character data.",
121    message: "char: invalid input",
122};
123
124const CHAR_ERROR_INVALID_CODEPOINT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
125    code: "RM.CHAR.INVALID_CODEPOINT",
126    identifier: Some("RunMat:char:InvalidCodePoint"),
127    when: "Numeric input cannot be represented by RunMat's scalar-value character storage.",
128    message: "char: numeric input cannot be represented as a RunMat character",
129};
130
131const CHAR_ERROR_DIMENSION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
132    code: "RM.CHAR.INVALID_DIMENSION",
133    identifier: Some("RunMat:char:InvalidDimension"),
134    when: "Array inputs are not 2-D (or trailing singleton dimensions).",
135    message: "char: inputs must be 2-D",
136};
137
138const CHAR_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
139    code: "RM.CHAR.INTERNAL",
140    identifier: Some("RunMat:char:InternalError"),
141    when: "Internal character array construction failed.",
142    message: "char: internal error",
143};
144
145const CHAR_ERRORS: [BuiltinErrorDescriptor; 4] = [
146    CHAR_ERROR_INVALID_INPUT,
147    CHAR_ERROR_INVALID_CODEPOINT,
148    CHAR_ERROR_DIMENSION,
149    CHAR_ERROR_INTERNAL,
150];
151
152pub const CHAR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
153    signatures: &CHAR_SIGNATURES,
154    output_mode: BuiltinOutputMode::Fixed,
155    completion_policy: BuiltinCompletionPolicy::Public,
156    errors: &CHAR_ERRORS,
157};
158
159const CHAR_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] = [BuiltinIntegerInputCapability {
160    name: "X",
161    classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
162    availability: BuiltinIntegerInputAvailability::Documented,
163    scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
164    notes: "All eight integer classes are documented numeric-code inputs. Floating values truncate toward zero and all numeric codes clamp to the UTF-16 code-unit interval 0..65535.",
165}];
166
167pub const CHAR_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
168    [BuiltinIntegerCapabilityDescriptor {
169        form: "C = char(integer_X) or char(X1, ..., XN)",
170        inputs: &CHAR_INTEGER_INPUTS,
171        computation_domain: BuiltinIntegerComputationDomain::ExactInteger,
172        output_class: BuiltinIntegerOutputClassRule::NotApplicable,
173        overflow: BuiltinIntegerOverflowRule::Saturate,
174        backend: BuiltinIntegerBackendRule::GatherFallback,
175        overload: BuiltinIntegerOverloadKind::Multiple,
176        notes: "Host integer values are decoded exactly, then clamped to 0..65535. RunMat CharArray stores Rust Unicode scalar values, so isolated UTF-16 surrogate code units U+D800..U+DFFF cannot yet be represented and produce an explicit error. Interactive resident numeric input is a mode-gated RunMat extension before gather.",
177    }];
178
179fn char_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
180    char_error_with_message(error.message, error)
181}
182
183fn char_error_with_message(
184    message: impl Into<String>,
185    error: &'static BuiltinErrorDescriptor,
186) -> RuntimeError {
187    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
188    if let Some(identifier) = error.identifier {
189        builder = builder.with_identifier(identifier);
190    }
191    builder.build()
192}
193
194fn char_flow(message: impl Into<String>) -> RuntimeError {
195    char_error_with_message(message, &CHAR_ERROR_INTERNAL)
196}
197
198fn remap_char_flow(err: RuntimeError) -> RuntimeError {
199    map_control_flow_with_builtin(err, BUILTIN_NAME)
200}
201
202#[runtime_builtin(
203    name = "char",
204    category = "strings/core",
205    summary = "Convert numeric codes and text values into character arrays.",
206    keywords = "char,character,string,gpu",
207    accel = "conversion",
208    type_resolver(string_array_type),
209    descriptor(crate::builtins::strings::core::char::CHAR_DESCRIPTOR),
210    extensions(crate::builtins::strings::core::char::CHAR_EXTENSIONS),
211    integer_capabilities(crate::builtins::strings::core::char::CHAR_INTEGER_CAPABILITIES),
212    builtin_path = "crate::builtins::strings::core::char"
213)]
214async fn char_builtin(rest: Vec<Value>) -> crate::BuiltinResult<Value> {
215    if rest.is_empty() {
216        let empty =
217            CharArray::new(Vec::new(), 0, 0).map_err(|_| char_error(&CHAR_ERROR_INTERNAL))?;
218        return Ok(Value::CharArray(empty));
219    }
220
221    let mut rows: Vec<Vec<char>> = Vec::new();
222    let mut max_width = 0usize;
223
224    for arg in rest {
225        if matches!(&arg, Value::Bool(_) | Value::LogicalArray(_))
226            || matches!(&arg, Value::GpuTensor(handle) if runmat_accelerate_api::handle_is_logical(handle))
227        {
228            crate::compatibility::ensure_builtin_extension_enabled(
229                &CHAR_LOGICAL_INPUT_EXTENSION,
230                BUILTIN_NAME,
231            )?;
232        }
233        if matches!(&arg, Value::GpuTensor(_)) {
234            crate::compatibility::ensure_builtin_extension_enabled(
235                &CHAR_RESIDENT_NUMERIC_EXTENSION,
236                BUILTIN_NAME,
237            )?;
238        }
239        let gathered = gather_if_needed_async(&arg)
240            .await
241            .map_err(remap_char_flow)?;
242        let mut produced = value_to_char_rows(&gathered)?;
243        for row in &produced {
244            if row.len() > max_width {
245                max_width = row.len();
246            }
247        }
248        rows.append(&mut produced);
249    }
250
251    if rows.is_empty() {
252        let empty =
253            CharArray::new(Vec::new(), 0, 0).map_err(|_| char_error(&CHAR_ERROR_INTERNAL))?;
254        return Ok(Value::CharArray(empty));
255    }
256
257    let cols = max_width;
258    let total_rows = rows.len();
259    let mut data = vec![' '; total_rows * cols];
260    for (row_idx, row) in rows.into_iter().enumerate() {
261        for (col_idx, ch) in row.into_iter().enumerate() {
262            if col_idx < cols {
263                data[row_idx * cols + col_idx] = ch;
264            }
265        }
266    }
267
268    let array =
269        CharArray::new(data, total_rows, cols).map_err(|_| char_error(&CHAR_ERROR_INTERNAL))?;
270    Ok(Value::CharArray(array))
271}
272
273fn value_to_char_rows(value: &Value) -> BuiltinResult<Vec<Vec<char>>> {
274    if let Some(array) = crate::builtins::datetime::datetime_char_array(value)
275        .map_err(|err| char_flow(err.message().to_string()))?
276    {
277        return Ok(char_array_rows(&array));
278    }
279    if let Some(array) = crate::builtins::duration::duration_char_array(value)
280        .map_err(|err| char_flow(err.message().to_string()))?
281    {
282        return Ok(char_array_rows(&array));
283    }
284    match value {
285        Value::CharArray(ca) => Ok(char_array_rows(ca)),
286        Value::String(s) => Ok(vec![s.chars().collect()]),
287        Value::Symbolic(expr) => Ok(vec![expr.to_string().chars().collect()]),
288        Value::SymbolicArray(array) => symbolic_array_rows(array),
289        Value::StringArray(sa) => string_array_rows(sa),
290        Value::Num(n) => Ok(vec![vec![number_to_char(*n)?]]),
291        Value::Int(i) => Ok(vec![vec![integer_value_to_char(i)?]]),
292        Value::Bool(b) => {
293            let code = if *b { 1.0 } else { 0.0 };
294            Ok(vec![vec![number_to_char(code)?]])
295        }
296        Value::Tensor(t) => tensor_rows(t),
297        Value::SparseTensor(s) => {
298            ensure_sparse_dense_conversion(s)?;
299            let dense = s.to_dense().map_err(char_flow)?;
300            tensor_rows(&dense)
301        }
302        Value::LogicalArray(la) => logical_rows(la),
303        Value::Cell(ca) => cell_rows(ca),
304        Value::GpuTensor(_) => Err(char_error(&CHAR_ERROR_INVALID_INPUT)),
305        Value::Complex(_, _) | Value::ComplexTensor(_) => Err(char_error_with_message(
306            "char: complex inputs are not supported",
307            &CHAR_ERROR_INVALID_INPUT,
308        )),
309        Value::Struct(_)
310        | Value::ObjectArray(_)
311        | Value::Object(_)
312        | Value::HandleObject(_)
313        | Value::Listener(_)
314        | Value::FunctionHandle(_)
315        | Value::ExternalFunctionHandle(_)
316        | Value::MethodFunctionHandle(_)
317        | Value::BoundFunctionHandle { .. }
318        | Value::Closure(_)
319        | Value::ClassRef(_)
320        | Value::MException(_)
321        | Value::Future(_)
322        | Value::Task(_)
323        | Value::Pool(_)
324        | Value::Job(_)
325        | Value::Foreign(_)
326        | Value::OutputList(_) => Err(char_error_with_message(
327            format!("char: unsupported input type {:?}", value),
328            &CHAR_ERROR_INVALID_INPUT,
329        )),
330    }
331}
332
333fn char_array_rows(ca: &CharArray) -> Vec<Vec<char>> {
334    let mut rows = Vec::with_capacity(ca.rows);
335    for r in 0..ca.rows {
336        let mut row = Vec::with_capacity(ca.cols);
337        for c in 0..ca.cols {
338            row.push(ca.data[r * ca.cols + c]);
339        }
340        rows.push(row);
341    }
342    rows
343}
344
345fn string_array_rows(sa: &StringArray) -> BuiltinResult<Vec<Vec<char>>> {
346    ensure_two_dimensional(&sa.shape, "char")?;
347    if sa.data.is_empty() {
348        return Ok(Vec::new());
349    }
350    let mut rows = Vec::with_capacity(sa.data.len());
351    let rows_count = sa.rows();
352    let cols_count = sa.cols();
353    if rows_count == 0 || cols_count == 0 {
354        return Ok(Vec::new());
355    }
356    for c in 0..cols_count {
357        for r in 0..rows_count {
358            let idx = r + c * rows_count;
359            rows.push(sa.data[idx].chars().collect());
360        }
361    }
362    Ok(rows)
363}
364
365fn symbolic_array_rows(array: &SymbolicArray) -> BuiltinResult<Vec<Vec<char>>> {
366    ensure_two_dimensional(&array.shape, "char")?;
367    let (rows, cols) = infer_rows_cols(&array.shape, array.data.len());
368    if rows == 0 {
369        return Ok(Vec::new());
370    }
371    let mut out = Vec::with_capacity(rows);
372    for r in 0..rows {
373        let mut row = Vec::new();
374        for c in 0..cols {
375            if cols == 0 {
376                continue;
377            }
378            let idx = r + c * rows;
379            row.extend(array.data[idx].to_string().chars());
380        }
381        out.push(row);
382    }
383    Ok(out)
384}
385
386fn tensor_rows(t: &Tensor) -> BuiltinResult<Vec<Vec<char>>> {
387    ensure_two_dimensional(&t.shape, "char")?;
388    let element_len = t.len();
389    let (rows, cols) = infer_rows_cols(&t.shape, element_len);
390    if rows == 0 {
391        return Ok(Vec::new());
392    }
393    let mut out = Vec::with_capacity(rows);
394    for r in 0..rows {
395        let mut row = Vec::with_capacity(cols);
396        for c in 0..cols {
397            if cols == 0 {
398                continue;
399            }
400            let idx = r + c * rows;
401            let value = t.numeric_value_at(idx).ok_or_else(|| {
402                char_error_with_message(
403                    "char: numeric storage length does not match tensor shape",
404                    &CHAR_ERROR_INTERNAL,
405                )
406            })?;
407            let ch = match value {
408                NumericScalar::F64(value) => number_to_char(value)?,
409                NumericScalar::F32(value) => number_to_char(f64::from(value))?,
410                value => integer_value_to_char(
411                    &value
412                        .into_int_value()
413                        .expect("non-floating numeric scalar is integer"),
414                )?,
415            };
416            row.push(ch);
417        }
418        out.push(row);
419    }
420    Ok(out)
421}
422
423fn logical_rows(la: &LogicalArray) -> BuiltinResult<Vec<Vec<char>>> {
424    ensure_two_dimensional(&la.shape, "char")?;
425    let (rows, cols) = infer_rows_cols(&la.shape, la.data.len());
426    if rows == 0 {
427        return Ok(Vec::new());
428    }
429    let mut out = Vec::with_capacity(rows);
430    for r in 0..rows {
431        let mut row = Vec::with_capacity(cols);
432        for c in 0..cols {
433            if cols == 0 {
434                continue;
435            }
436            let idx = r + c * rows;
437            let code = if la.data[idx] != 0 { 1.0 } else { 0.0 };
438            row.push(number_to_char(code)?);
439        }
440        out.push(row);
441    }
442    Ok(out)
443}
444
445fn cell_rows(ca: &CellArray) -> BuiltinResult<Vec<Vec<char>>> {
446    let mut rows = Vec::with_capacity(ca.data.len());
447    for ptr in &ca.data {
448        let element = (ptr).clone();
449        let mut converted = value_to_char_rows(&element)?;
450        match converted.len() {
451            0 => rows.push(Vec::new()),
452            1 => rows.push(converted.remove(0)),
453            _ => {
454                return Err(char_error_with_message(
455                    "char: cell elements must be character vectors or string scalars",
456                    &CHAR_ERROR_INVALID_INPUT,
457                ))
458            }
459        }
460    }
461    Ok(rows)
462}
463
464fn ensure_sparse_dense_conversion(sparse: &SparseTensor) -> BuiltinResult<()> {
465    let total_elements = sparse.rows.checked_mul(sparse.cols).ok_or_else(|| {
466        char_error_with_message(
467            "char: sparse matrix dimensions overflow",
468            &CHAR_ERROR_INVALID_INPUT,
469        )
470    })?;
471    if total_elements > CHAR_SPARSE_DENSE_ELEMENT_LIMIT {
472        return Err(char_error_with_message(
473            format!(
474                "char: cannot convert sparse tensor {}x{} with {} stored entries to dense character array ({} elements exceeds safe threshold)",
475                sparse.rows,
476                sparse.cols,
477                sparse.nnz(),
478                total_elements
479            ),
480            &CHAR_ERROR_INVALID_INPUT,
481        ));
482    }
483    Ok(())
484}
485
486fn number_to_char(value: f64) -> BuiltinResult<char> {
487    if !value.is_finite() {
488        return Err(char_error_with_message(
489            "char: numeric inputs must be finite",
490            &CHAR_ERROR_INVALID_CODEPOINT,
491        ));
492    }
493    let code_unit = value.trunc().clamp(0.0, u16::MAX as f64) as u16;
494    utf16_code_unit_to_char(code_unit)
495}
496
497fn integer_value_to_char(value: &IntValue) -> BuiltinResult<char> {
498    let code_unit = match value {
499        IntValue::I8(value) => signed_integer_to_code_unit(*value as i128),
500        IntValue::I16(value) => signed_integer_to_code_unit(*value as i128),
501        IntValue::I32(value) => signed_integer_to_code_unit(*value as i128),
502        IntValue::I64(value) => signed_integer_to_code_unit(*value as i128),
503        IntValue::U8(value) => unsigned_integer_to_code_unit(*value as u128),
504        IntValue::U16(value) => unsigned_integer_to_code_unit(*value as u128),
505        IntValue::U32(value) => unsigned_integer_to_code_unit(*value as u128),
506        IntValue::U64(value) => unsigned_integer_to_code_unit(*value as u128),
507    };
508    utf16_code_unit_to_char(code_unit)
509}
510
511fn utf16_code_unit_to_char(code_unit: u16) -> BuiltinResult<char> {
512    char::from_u32(u32::from(code_unit)).ok_or_else(|| {
513        char_error_with_message(
514            format!(
515                "char: UTF-16 surrogate code unit U+{code_unit:04X} cannot be represented by the current RunMat CharArray scalar-value storage"
516            ),
517            &CHAR_ERROR_INVALID_CODEPOINT,
518        )
519    })
520}
521
522fn signed_integer_to_code_unit(value: i128) -> u16 {
523    value.clamp(0, u16::MAX as i128) as u16
524}
525
526fn unsigned_integer_to_code_unit(value: u128) -> u16 {
527    value.min(u16::MAX as u128) as u16
528}
529
530fn ensure_two_dimensional(shape: &[usize], context: &str) -> BuiltinResult<()> {
531    if shape.len() <= 2 {
532        return Ok(());
533    }
534    if shape.iter().skip(2).all(|&d| d == 1) {
535        return Ok(());
536    }
537    Err(char_error_with_message(
538        format!("{context}: inputs must be 2-D"),
539        &CHAR_ERROR_DIMENSION,
540    ))
541}
542
543fn infer_rows_cols(shape: &[usize], len: usize) -> (usize, usize) {
544    match shape.len() {
545        0 => {
546            if len == 0 {
547                (0, 0)
548            } else {
549                (1, 1)
550            }
551        }
552        1 => (1, shape[0]),
553        2 => (shape[0], shape[1]),
554        _ => {
555            let rows = shape[0];
556            let cols = if shape.len() > 1 { shape[1] } else { 1 };
557            (rows, cols)
558        }
559    }
560}
561
562#[cfg(test)]
563pub(crate) mod tests {
564    use super::*;
565    use crate::builtins::common::test_support;
566    use runmat_builtins::{ResolveContext, Type};
567    use runmat_value::{IntegerStorage, NumericStorage, SymbolicArray, SymbolicExpr};
568
569    fn char_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
570        futures::executor::block_on(super::char_builtin(rest))
571    }
572    use runmat_value::StringArray;
573
574    fn error_message(err: crate::RuntimeError) -> String {
575        err.message().to_string()
576    }
577
578    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
579    #[test]
580    fn char_no_arguments_returns_empty() {
581        let result = char_builtin(Vec::new()).expect("char");
582        match result {
583            Value::CharArray(ca) => {
584                assert_eq!(ca.rows, 0);
585                assert_eq!(ca.cols, 0);
586                assert!(ca.data.is_empty());
587            }
588            other => panic!("expected char array, got {other:?}"),
589        }
590    }
591
592    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
593    #[test]
594    fn char_from_string_scalar() {
595        let value = Value::String("RunMat".to_string());
596        let result = char_builtin(vec![value]).expect("char");
597        match result {
598            Value::CharArray(ca) => {
599                assert_eq!(ca.rows, 1);
600                assert_eq!(ca.cols, 6);
601                assert_eq!(ca.data, "RunMat".chars().collect::<Vec<_>>());
602            }
603            other => panic!("expected char array, got {other:?}"),
604        }
605    }
606
607    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
608    #[test]
609    fn char_from_numeric_tensor() {
610        let tensor =
611            Tensor::new(vec![82.0, 85.0, 78.0, 77.0, 65.0, 84.0], vec![1, 6]).expect("tensor");
612        let result = char_builtin(vec![Value::Tensor(tensor)]).expect("char");
613        match result {
614            Value::CharArray(ca) => {
615                assert_eq!(ca.rows, 1);
616                assert_eq!(ca.cols, 6);
617                assert_eq!(ca.data, "RUNMAT".chars().collect::<Vec<_>>());
618            }
619            other => panic!("expected char array, got {other:?}"),
620        }
621    }
622
623    #[test]
624    fn char_from_native_single_tensor_reads_authoritative_storage() {
625        let tensor =
626            Tensor::from_numeric_storage(NumericStorage::F32(vec![82.0, 77.0]), vec![1, 2])
627                .expect("single tensor");
628        let result = char_builtin(vec![Value::Tensor(tensor)]).expect("char");
629        match result {
630            Value::CharArray(array) => assert_eq!(array.data, vec!['R', 'M']),
631            other => panic!("expected char array, got {other:?}"),
632        }
633    }
634
635    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
636    #[test]
637    fn char_from_typed_integer_tensor_reads_exact_storage_without_mirror() {
638        let tensor = Tensor::new_integer(IntegerStorage::U64(vec![82, u64::MAX]), vec![1, 2])
639            .expect("typed tensor");
640
641        let result = char_builtin(vec![Value::Tensor(tensor)]).expect("char");
642        match result {
643            Value::CharArray(ca) => {
644                assert_eq!(ca.rows, 1);
645                assert_eq!(ca.cols, 2);
646                assert_eq!(ca.data, vec!['R', '\u{FFFF}']);
647            }
648            other => panic!("expected char array, got {other:?}"),
649        }
650    }
651
652    #[test]
653    fn char_reads_every_integer_tensor_storage_class() {
654        for storage in [
655            IntegerStorage::I8(vec![65]),
656            IntegerStorage::I16(vec![65]),
657            IntegerStorage::I32(vec![65]),
658            IntegerStorage::I64(vec![65]),
659            IntegerStorage::U8(vec![65]),
660            IntegerStorage::U16(vec![65]),
661            IntegerStorage::U32(vec![65]),
662            IntegerStorage::U64(vec![65]),
663        ] {
664            let tensor = Tensor::new_integer(storage, vec![1, 1]).expect("typed tensor");
665            let Value::CharArray(array) = char_builtin(vec![Value::Tensor(tensor)]).expect("char")
666            else {
667                panic!("expected char array");
668            };
669            assert_eq!(array.data, vec!['A']);
670        }
671    }
672
673    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
674    #[test]
675    fn char_clamps_negative_typed_integer_storage_without_mirror() {
676        let tensor =
677            Tensor::new_integer(IntegerStorage::I16(vec![-1]), vec![1, 1]).expect("typed tensor");
678
679        let Value::CharArray(array) = char_builtin(vec![Value::Tensor(tensor)]).expect("char")
680        else {
681            panic!("expected char array");
682        };
683        assert_eq!(array.data, vec!['\0']);
684    }
685
686    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
687    #[test]
688    fn char_clamps_out_of_range_uint64_storage_without_mirror() {
689        let tensor = Tensor::new_integer(IntegerStorage::U64(vec![0x110000]), vec![1, 1])
690            .expect("typed tensor");
691
692        let Value::CharArray(array) = char_builtin(vec![Value::Tensor(tensor)]).expect("char")
693        else {
694            panic!("expected char array");
695        };
696        assert_eq!(array.data, vec!['\u{FFFF}']);
697    }
698
699    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
700    #[test]
701    fn char_from_string_array_with_padding() {
702        let data = vec!["cat".to_string(), "giraffe".to_string()];
703        let sa = StringArray::new(data, vec![2, 1]).expect("string array");
704        let result = char_builtin(vec![Value::StringArray(sa)]).expect("char from string array");
705        match result {
706            Value::CharArray(ca) => {
707                assert_eq!(ca.rows, 2);
708                assert_eq!(ca.cols, 7);
709                assert_eq!(
710                    ca.data,
711                    vec!['c', 'a', 't', ' ', ' ', ' ', ' ', 'g', 'i', 'r', 'a', 'f', 'f', 'e']
712                );
713            }
714            other => panic!("expected char array, got {other:?}"),
715        }
716    }
717
718    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
719    #[test]
720    fn char_from_cell_array_of_strings() {
721        let cell = CellArray::new(
722            vec![
723                Value::from("north"),
724                Value::from("east"),
725                Value::from("west"),
726            ],
727            3,
728            1,
729        )
730        .expect("cell array");
731        let result = char_builtin(vec![Value::Cell(cell)]).expect("char");
732        match result {
733            Value::CharArray(ca) => {
734                assert_eq!(ca.rows, 3);
735                assert_eq!(ca.cols, 5);
736                assert_eq!(
737                    ca.data,
738                    vec!['n', 'o', 'r', 't', 'h', 'e', 'a', 's', 't', ' ', 'w', 'e', 's', 't', ' ']
739                );
740            }
741            other => panic!("expected char array, got {other:?}"),
742        }
743    }
744
745    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
746    #[test]
747    fn char_numeric_and_text_arguments_concatenate() {
748        let text = Value::String("hi".to_string());
749        let codes = Tensor::new(vec![65.0, 66.0], vec![1, 2]).expect("tensor");
750        let result = char_builtin(vec![text, Value::Tensor(codes)]).expect("char");
751        match result {
752            Value::CharArray(ca) => {
753                assert_eq!(ca.rows, 2);
754                assert_eq!(ca.cols, 2);
755                assert_eq!(ca.data, vec!['h', 'i', 'A', 'B']);
756            }
757            other => panic!("expected char array, got {other:?}"),
758        }
759    }
760
761    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
762    #[test]
763    fn char_gpu_tensor_round_trip() {
764        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
765        test_support::with_test_provider(|provider| {
766            let tensor = Tensor::new(vec![82.0, 85.0, 78.0], vec![1, 3]).expect("tensor");
767            let view = runmat_accelerate_api::HostTensorView {
768                data: &tensor.materialize_f64(),
769                shape: &tensor.shape,
770            };
771            let handle = provider.upload(&view).expect("upload");
772            let result = char_builtin(vec![Value::GpuTensor(handle)]).expect("char");
773            match result {
774                Value::CharArray(ca) => {
775                    assert_eq!(ca.rows, 1);
776                    assert_eq!(ca.cols, 3);
777                    assert_eq!(ca.data, vec!['R', 'U', 'N']);
778                }
779                other => panic!("expected char array, got {other:?}"),
780            }
781        });
782    }
783
784    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
785    #[test]
786    fn char_truncates_floating_numeric_toward_zero() {
787        let Value::CharArray(array) = char_builtin(vec![Value::Num(65.9)]).expect("char") else {
788            panic!("expected char array");
789        };
790        assert_eq!(array.data, vec!['A']);
791    }
792
793    #[test]
794    fn char_supports_all_integer_classes_and_reports_surrogate_gap() {
795        let values = [
796            IntValue::I8(65),
797            IntValue::I16(65),
798            IntValue::I32(65),
799            IntValue::I64(65),
800            IntValue::U8(65),
801            IntValue::U16(65),
802            IntValue::U32(65),
803            IntValue::U64(65),
804        ];
805        for value in values {
806            let Value::CharArray(array) = char_builtin(vec![Value::Int(value)]).expect("char")
807            else {
808                panic!("expected char array");
809            };
810            assert_eq!(array.data, vec!['A']);
811        }
812
813        let err = char_builtin(vec![Value::Int(IntValue::U16(0xD800))])
814            .expect_err("surrogate is not a Rust char");
815        assert_eq!(err.identifier(), Some("RunMat:char:InvalidCodePoint"));
816        assert!(err.message().contains("surrogate code unit"));
817    }
818
819    #[test]
820    fn char_logical_extension_is_ordered_and_mode_gated_before_conversion() {
821        assert_eq!(CHAR_EXTENSIONS[0].id, "char-logical-input");
822        assert_eq!(CHAR_EXTENSIONS[1].id, "char-resident-numeric-input");
823
824        let _guard = crate::compatibility::push_runmat_extensions_enabled(false);
825        let err = char_builtin(vec![Value::Bool(true)]).expect_err("logical extension gate");
826        assert_eq!(
827            err.identifier(),
828            CHAR_LOGICAL_INPUT_EXTENSION.error_identifier
829        );
830    }
831
832    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
833    #[test]
834    fn char_rejects_high_dimension_tensor() {
835        let tensor =
836            Tensor::new(vec![65.0, 66.0], vec![1, 1, 2]).expect("tensor construction failed");
837        let err = error_message(
838            char_builtin(vec![Value::Tensor(tensor)]).expect_err("should reject >2D tensor"),
839        );
840        assert!(err.contains("2-D"), "expected dimension error, got {err}");
841    }
842
843    #[test]
844    fn char_rejects_oversized_sparse_tensor_before_densifying() {
845        let sparse = SparseTensor::zeros(CHAR_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
846        let err = char_builtin(vec![Value::SparseTensor(sparse)]).unwrap_err();
847
848        assert_eq!(err.identifier(), Some("RunMat:char:InvalidInput"));
849        assert!(err.message().contains("exceeds safe threshold"));
850    }
851
852    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
853    #[test]
854    fn char_string_array_column_major_order() {
855        let data = vec![
856            "c0r0".to_string(),
857            "c0r1".to_string(),
858            "c1r0".to_string(),
859            "c1r1".to_string(),
860        ];
861        let sa = StringArray::new(data, vec![2, 2]).expect("string array");
862        let result = char_builtin(vec![Value::StringArray(sa)]).expect("char");
863        match result {
864            Value::CharArray(ca) => {
865                assert_eq!(ca.rows, 4);
866                assert_eq!(ca.cols, 4);
867                assert_eq!(ca.data, "c0r0c0r1c1r0c1r1".chars().collect::<Vec<char>>());
868            }
869            other => panic!("expected char array, got {other:?}"),
870        }
871    }
872
873    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
874    #[test]
875    fn char_symbolic_array_preserves_matrix_rows() {
876        let array = SymbolicArray::new(
877            vec![
878                SymbolicExpr::variable("x"),
879                SymbolicExpr::variable("z"),
880                SymbolicExpr::variable("y"),
881                SymbolicExpr::variable("w"),
882            ],
883            vec![2, 2],
884        )
885        .expect("symbolic array");
886
887        let result = char_builtin(vec![Value::SymbolicArray(array)]).expect("char");
888
889        match result {
890            Value::CharArray(ca) => {
891                assert_eq!(ca.rows, 2);
892                assert_eq!(ca.cols, 2);
893                assert_eq!(ca.data, vec!['x', 'y', 'z', 'w']);
894            }
895            other => panic!("expected char array, got {other:?}"),
896        }
897    }
898
899    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
900    #[test]
901    fn char_symbolic_array_pads_multi_character_rows() {
902        let array = SymbolicArray::new(
903            vec![
904                SymbolicExpr::variable("x1"),
905                SymbolicExpr::variable("y"),
906                SymbolicExpr::variable("theta"),
907                SymbolicExpr::variable("z"),
908            ],
909            vec![2, 2],
910        )
911        .expect("symbolic array");
912
913        let result = char_builtin(vec![Value::SymbolicArray(array)]).expect("char");
914
915        match result {
916            Value::CharArray(ca) => {
917                assert_eq!(ca.rows, 2);
918                assert_eq!(ca.cols, 7);
919                assert_eq!(ca.data.iter().collect::<String>(), "x1thetayz     ");
920            }
921            other => panic!("expected char array, got {other:?}"),
922        }
923    }
924
925    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
926    #[test]
927    fn char_symbolic_one_dimensional_array_is_row_vector() {
928        let array = SymbolicArray::new(
929            vec![SymbolicExpr::variable("x"), SymbolicExpr::variable("y")],
930            vec![2],
931        )
932        .expect("symbolic array");
933
934        let result = char_builtin(vec![Value::SymbolicArray(array)]).expect("char");
935
936        match result {
937            Value::CharArray(ca) => {
938                assert_eq!(ca.rows, 1);
939                assert_eq!(ca.cols, 2);
940                assert_eq!(ca.data, vec!['x', 'y']);
941            }
942            other => panic!("expected char array, got {other:?}"),
943        }
944    }
945
946    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
947    #[test]
948    fn char_rejects_high_dimension_string_array() {
949        let sa = StringArray::new(vec!["a".to_string(), "b".to_string()], vec![1, 1, 2])
950            .expect("string array");
951        let err = error_message(
952            char_builtin(vec![Value::StringArray(sa)]).expect_err("should reject >2D string array"),
953        );
954        assert!(err.contains("2-D"), "expected dimension error, got {err}");
955    }
956
957    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
958    #[test]
959    fn char_rejects_complex_input() {
960        let err =
961            error_message(char_builtin(vec![Value::Complex(1.0, 2.0)]).expect_err("complex input"));
962        assert!(
963            err.contains("complex"),
964            "expected complex error message, got {err}"
965        );
966    }
967
968    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
969    #[test]
970    #[cfg(feature = "wgpu")]
971    fn char_wgpu_numeric_codes_matches_cpu() {
972        use runmat_accelerate::backend::wgpu::provider::{
973            register_wgpu_provider, WgpuProviderOptions,
974        };
975
976        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
977        let _ = register_wgpu_provider(WgpuProviderOptions::default());
978
979        let tensor = Tensor::new(vec![82.0, 85.0, 78.0], vec![1, 3]).unwrap();
980        let cpu = char_builtin(vec![Value::Tensor(tensor.clone())]).expect("char cpu");
981
982        let view = runmat_accelerate_api::HostTensorView {
983            data: &tensor.materialize_f64(),
984            shape: &tensor.shape,
985        };
986        let handle = runmat_accelerate_api::provider()
987            .expect("wgpu provider")
988            .upload(&view)
989            .expect("upload");
990        let gpu = char_builtin(vec![Value::GpuTensor(handle)]).expect("char gpu");
991
992        match (cpu, gpu) {
993            (Value::CharArray(expected), Value::CharArray(actual)) => {
994                assert_eq!(actual, expected);
995            }
996            other => panic!("unexpected results {other:?}"),
997        }
998    }
999
1000    #[test]
1001    fn char_type_is_string_array() {
1002        assert_eq!(
1003            string_array_type(&[Type::Num], &ResolveContext::new(Vec::new())),
1004            Type::cell_of(Type::String)
1005        );
1006    }
1007}