Skip to main content

runmat_runtime/builtins/io/tabular/
readmatrix.rs

1//! MATLAB-compatible `readmatrix` builtin for RunMat.
2
3use std::collections::HashSet;
4use std::convert::TryFrom;
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7
8use runmat_accelerate_api::HostTensorView;
9use runmat_builtins::{LogicalArray, Tensor, Value};
10use runmat_filesystem::File;
11use runmat_macros::runtime_builtin;
12
13use crate::builtins::common::spec::{
14    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
15    ReductionNaN, ResidencyPolicy, ShapeRequirements,
16};
17use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
18
19const BUILTIN_NAME: &str = "readmatrix";
20
21#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::tabular::readmatrix")]
22pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
23    name: "readmatrix",
24    op_kind: GpuOpKind::Custom("io-readmatrix"),
25    supported_precisions: &[],
26    broadcast: BroadcastSemantics::None,
27    provider_hooks: &[],
28    constant_strategy: ConstantStrategy::InlineLiteral,
29    residency: ResidencyPolicy::GatherImmediately,
30    nan_mode: ReductionNaN::Include,
31    two_pass_threshold: None,
32    workgroup_size: None,
33    accepts_nan_mode: false,
34    notes: "Runs entirely on the host; acceleration providers are not involved.",
35};
36
37fn readmatrix_error(message: impl Into<String>) -> RuntimeError {
38    build_runtime_error(message)
39        .with_builtin(BUILTIN_NAME)
40        .build()
41}
42
43fn readmatrix_error_with_source<E>(message: impl Into<String>, source: E) -> RuntimeError
44where
45    E: std::error::Error + Send + Sync + 'static,
46{
47    build_runtime_error(message)
48        .with_builtin(BUILTIN_NAME)
49        .with_source(source)
50        .build()
51}
52
53fn map_control_flow(err: RuntimeError) -> RuntimeError {
54    let identifier = err.identifier().map(|value| value.to_string());
55    let message = err.message().to_string();
56    let mut builder = build_runtime_error(message)
57        .with_builtin(BUILTIN_NAME)
58        .with_source(err);
59    if let Some(identifier) = identifier {
60        builder = builder.with_identifier(identifier);
61    }
62    builder.build()
63}
64
65#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::tabular::readmatrix")]
66pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
67    name: "readmatrix",
68    shape: ShapeRequirements::Any,
69    constant_strategy: ConstantStrategy::InlineLiteral,
70    elementwise: None,
71    reduction: None,
72    emits_nan: false,
73    notes: "Not eligible for fusion; executes as a standalone host operation.",
74};
75
76#[runtime_builtin(
77    name = "readmatrix",
78    category = "io/tabular",
79    summary = "Import numeric data from delimited text files into a RunMat matrix.",
80    keywords = "readmatrix,csv,delimited text,numeric import,table",
81    accel = "cpu",
82    type_resolver(crate::builtins::io::type_resolvers::readmatrix_type),
83    builtin_path = "crate::builtins::io::tabular::readmatrix"
84)]
85async fn readmatrix_builtin(path: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
86    let path_value = gather_if_needed_async(&path)
87        .await
88        .map_err(map_control_flow)?;
89    let options = parse_options(&rest).await?;
90    options.validate()?;
91    let resolved = resolve_path(&path_value)?;
92    let tensor = read_numeric_matrix(&resolved, &options)?;
93    finalize_output(tensor, &options)
94}
95
96async fn parse_options(args: &[Value]) -> BuiltinResult<ReadMatrixOptions> {
97    let mut options = ReadMatrixOptions::default();
98    let mut index = 0usize;
99    if let Some(Value::Struct(struct_value)) = args.get(index) {
100        parse_struct_options(struct_value, &mut options).await?;
101        index += 1;
102    }
103    while index < args.len() {
104        if index + 1 >= args.len() {
105            return Err(readmatrix_error(
106                "readmatrix: name/value inputs must appear in pairs",
107            ));
108        }
109        let name_value = gather_if_needed_async(&args[index])
110            .await
111            .map_err(map_control_flow)?;
112        let name = option_name_from_value(&name_value)?;
113        let value = &args[index + 1];
114        apply_option(&mut options, &name, value).await?;
115        index += 2;
116    }
117    Ok(options)
118}
119
120async fn parse_struct_options(
121    struct_value: &runmat_builtins::StructValue,
122    options: &mut ReadMatrixOptions,
123) -> BuiltinResult<()> {
124    for (name, value) in &struct_value.fields {
125        apply_option(options, name, value).await?;
126    }
127    Ok(())
128}
129
130async fn apply_option(
131    options: &mut ReadMatrixOptions,
132    name: &str,
133    value: &Value,
134) -> BuiltinResult<()> {
135    let lowered = name.trim().to_ascii_lowercase();
136    let is_like = lowered == "like";
137    let effective_value = if is_like {
138        value.clone()
139    } else {
140        gather_if_needed_async(value)
141            .await
142            .map_err(map_control_flow)?
143    };
144    if name.eq_ignore_ascii_case("Delimiter") {
145        let delimiter = parse_delimiter(&effective_value)?;
146        options.delimiter = Some(delimiter);
147        return Ok(());
148    }
149    if name.eq_ignore_ascii_case("NumHeaderLines") {
150        let header_lines = value_to_usize(&effective_value, "NumHeaderLines")?;
151        options.num_header_lines = header_lines;
152        return Ok(());
153    }
154    if name.eq_ignore_ascii_case("TreatAsMissing") {
155        let tokens = parse_treat_as_missing(&effective_value).await?;
156        for token in tokens {
157            options.add_missing_token(&token);
158        }
159        return Ok(());
160    }
161    if name.eq_ignore_ascii_case("DecimalSeparator") {
162        let sep = parse_separator_char(&effective_value, "DecimalSeparator")?;
163        options.decimal_separator = sep;
164        return Ok(());
165    }
166    if name.eq_ignore_ascii_case("ThousandsSeparator") {
167        let sep = parse_separator_char(&effective_value, "ThousandsSeparator")?;
168        options.thousands_separator = Some(sep);
169        return Ok(());
170    }
171    if name.eq_ignore_ascii_case("EmptyValue") {
172        let numeric = value_to_f64(&effective_value, "EmptyValue")?;
173        options.empty_value = Some(numeric);
174        return Ok(());
175    }
176    if name.eq_ignore_ascii_case("OutputType") {
177        let text = value_to_string_scalar(&effective_value, "OutputType")?;
178        options.set_output_type(&text)?;
179        return Ok(());
180    }
181    if name.eq_ignore_ascii_case("Range") {
182        let range = parse_range(&effective_value)?;
183        options.range = Some(range);
184        return Ok(());
185    }
186    if is_like {
187        options.set_like(effective_value)?;
188        return Ok(());
189    }
190    // Unknown options are ignored for forward compatibility.
191    Ok(())
192}
193
194fn option_name_from_value(value: &Value) -> BuiltinResult<String> {
195    value_to_string_scalar(value, "option name")
196}
197
198fn parse_delimiter(value: &Value) -> BuiltinResult<Delimiter> {
199    let text = value_to_string_scalar(value, "Delimiter")?;
200    if text.is_empty() {
201        return Err(readmatrix_error("readmatrix: Delimiter cannot be empty"));
202    }
203    let trimmed_lower = text.trim().to_ascii_lowercase();
204    match trimmed_lower.as_str() {
205        "tab" => Ok(Delimiter::Char('\t')),
206        "space" | "whitespace" => Ok(Delimiter::Whitespace),
207        "comma" => Ok(Delimiter::Char(',')),
208        "semicolon" => Ok(Delimiter::Char(';')),
209        "pipe" => Ok(Delimiter::Char('|')),
210        _ => {
211            if text.chars().count() == 1 {
212                Ok(Delimiter::Char(text.chars().next().unwrap()))
213            } else {
214                Ok(Delimiter::String(text))
215            }
216        }
217    }
218}
219
220fn parse_separator_char(value: &Value, option_name: &str) -> BuiltinResult<char> {
221    let text = value_to_string_scalar(value, option_name)?;
222    if text.is_empty() {
223        return Err(readmatrix_error(format!(
224            "readmatrix: {option_name} must be a single character"
225        )));
226    }
227    let mut chars = text.chars();
228    let ch = chars.next().unwrap();
229    if chars.next().is_some() {
230        return Err(readmatrix_error(format!(
231            "readmatrix: {option_name} must be a single character"
232        )));
233    }
234    if ch == '\n' || ch == '\r' {
235        return Err(readmatrix_error(format!(
236            "readmatrix: {option_name} cannot be a newline character"
237        )));
238    }
239    Ok(ch)
240}
241
242fn value_to_string_scalar(value: &Value, context: &str) -> BuiltinResult<String> {
243    match value {
244        Value::String(s) => Ok(s.clone()),
245        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
246        Value::StringArray(sa) => {
247            if sa.data.len() == 1 {
248                Ok(sa.data[0].clone())
249            } else {
250                Err(readmatrix_error(format!(
251                    "readmatrix: {context} must be a scalar string array"
252                )))
253            }
254        }
255        _ => Err(readmatrix_error(format!(
256            "readmatrix: expected {context} as a string scalar or character vector"
257        ))),
258    }
259}
260
261fn value_to_usize(value: &Value, context: &str) -> BuiltinResult<usize> {
262    match value {
263        Value::Int(i) => {
264            let num = i.to_i64();
265            if num < 0 {
266                Err(readmatrix_error(format!(
267                    "readmatrix: {context} must be a non-negative integer"
268                )))
269            } else {
270                Ok(num as usize)
271            }
272        }
273        Value::Num(n) => {
274            if !n.is_finite() {
275                return Err(readmatrix_error(format!(
276                    "readmatrix: {context} must be a finite non-negative integer"
277                )));
278            }
279            if *n < 0.0 {
280                return Err(readmatrix_error(format!(
281                    "readmatrix: {context} must be a non-negative integer"
282                )));
283            }
284            if (n.round() - n).abs() > f64::EPSILON {
285                return Err(readmatrix_error(format!(
286                    "readmatrix: {context} must be an integer value"
287                )));
288            }
289            Ok(n.round() as usize)
290        }
291        _ => Err(readmatrix_error(format!(
292            "readmatrix: {context} must be provided as a numeric scalar"
293        ))),
294    }
295}
296
297fn value_to_f64(value: &Value, context: &str) -> BuiltinResult<f64> {
298    match value {
299        Value::Num(n) => {
300            if n.is_finite() {
301                Ok(*n)
302            } else {
303                Err(readmatrix_error(format!(
304                    "readmatrix: {context} must be a finite numeric scalar"
305                )))
306            }
307        }
308        Value::Int(i) => Ok(i.to_f64()),
309        Value::Tensor(t) => {
310            if t.data.len() == 1 {
311                let v = t.data[0];
312                if v.is_finite() {
313                    Ok(v)
314                } else {
315                    Err(readmatrix_error(format!(
316                        "readmatrix: {context} must be a finite numeric scalar"
317                    )))
318                }
319            } else {
320                Err(readmatrix_error(format!(
321                    "readmatrix: {context} must be a numeric scalar"
322                )))
323            }
324        }
325        _ => Err(readmatrix_error(format!(
326            "readmatrix: {context} must be a numeric scalar"
327        ))),
328    }
329}
330
331async fn parse_treat_as_missing(value: &Value) -> BuiltinResult<Vec<String>> {
332    let mut out = Vec::new();
333    let mut stack = vec![value.clone()];
334    while let Some(value) = stack.pop() {
335        match value {
336            Value::String(s) => out.push(s),
337            Value::CharArray(ca) if ca.rows == 1 => out.push(ca.data.iter().collect()),
338            Value::StringArray(sa) => out.extend(sa.data),
339            Value::Num(n) => out.push(format_numeric_token(n)),
340            Value::Int(i) => out.push(format!("{}", i.to_i64())),
341            Value::Tensor(t) => {
342                if t.data.len() == 1 {
343                    out.push(format_numeric_token(t.data[0]));
344                } else {
345                    return Err(readmatrix_error(
346                        "readmatrix: TreatAsMissing entries must be scalar values",
347                    ));
348                }
349            }
350            Value::Cell(cell) => {
351                for handle in &cell.data {
352                    let inner = unsafe { &*handle.as_raw() };
353                    let gathered = gather_if_needed_async(inner)
354                        .await
355                        .map_err(map_control_flow)?;
356                    stack.push(gathered);
357                }
358            }
359            _ => {
360                return Err(readmatrix_error(
361                    "readmatrix: TreatAsMissing values must be strings or numeric scalars",
362                ))
363            }
364        }
365    }
366    Ok(out)
367}
368
369fn format_numeric_token(value: f64) -> String {
370    if value == 0.0 {
371        "0".to_string()
372    } else {
373        format!("{}", value)
374    }
375}
376
377#[derive(Clone)]
378struct ReadMatrixOptions {
379    delimiter: Option<Delimiter>,
380    num_header_lines: usize,
381    decimal_separator: char,
382    thousands_separator: Option<char>,
383    treat_as_missing: HashSet<String>,
384    empty_value: Option<f64>,
385    range: Option<RangeSpec>,
386    output_template: OutputTemplate,
387}
388
389impl Default for ReadMatrixOptions {
390    fn default() -> Self {
391        Self {
392            delimiter: None,
393            num_header_lines: 0,
394            decimal_separator: '.',
395            thousands_separator: None,
396            treat_as_missing: HashSet::new(),
397            empty_value: None,
398            range: None,
399            output_template: OutputTemplate::Double,
400        }
401    }
402}
403
404impl ReadMatrixOptions {
405    fn add_missing_token(&mut self, token: &str) {
406        let normalized = normalize_missing_token(token);
407        self.treat_as_missing.insert(normalized);
408    }
409
410    fn is_missing_token(&self, token: &str) -> bool {
411        if self.treat_as_missing.is_empty() {
412            return false;
413        }
414        let norm = normalize_missing_token(token);
415        self.treat_as_missing.contains(&norm)
416    }
417
418    fn empty_value(&self) -> f64 {
419        self.empty_value.unwrap_or(f64::NAN)
420    }
421
422    fn validate(&self) -> BuiltinResult<()> {
423        if let Some(range) = &self.range {
424            range.validate()?;
425        }
426        if let Some(sep) = self.thousands_separator {
427            if sep == self.decimal_separator {
428                return Err(readmatrix_error(
429                    "readmatrix: DecimalSeparator and ThousandsSeparator must differ",
430                ));
431            }
432        }
433        Ok(())
434    }
435
436    fn set_output_type(&mut self, spec: &str) -> BuiltinResult<()> {
437        if matches!(self.output_template, OutputTemplate::Like(_)) {
438            return Err(readmatrix_error(
439                "readmatrix: cannot combine 'Like' with OutputType",
440            ));
441        }
442        if spec.eq_ignore_ascii_case("double") {
443            self.output_template = OutputTemplate::Double;
444            return Ok(());
445        }
446        if spec.eq_ignore_ascii_case("logical") {
447            self.output_template = OutputTemplate::Logical;
448            return Ok(());
449        }
450        Err(readmatrix_error(format!(
451            "readmatrix: unsupported OutputType '{}'",
452            spec
453        )))
454    }
455
456    fn set_like(&mut self, proto: Value) -> BuiltinResult<()> {
457        if matches!(self.output_template, OutputTemplate::Like(_)) {
458            return Err(readmatrix_error(
459                "readmatrix: multiple 'Like' specifications are not supported",
460            ));
461        }
462        if !matches!(self.output_template, OutputTemplate::Double) {
463            return Err(readmatrix_error(
464                "readmatrix: cannot combine 'Like' with OutputType overrides",
465            ));
466        }
467        self.output_template = OutputTemplate::Like(proto);
468        Ok(())
469    }
470}
471
472#[derive(Clone)]
473enum Delimiter {
474    Char(char),
475    String(String),
476    Whitespace,
477}
478
479#[derive(Clone)]
480enum OutputTemplate {
481    Double,
482    Logical,
483    Like(Value),
484}
485
486#[derive(Clone)]
487struct RangeSpec {
488    start_row: usize,
489    start_col: usize,
490    end_row: Option<usize>,
491    end_col: Option<usize>,
492}
493
494impl RangeSpec {
495    fn validate(&self) -> BuiltinResult<()> {
496        if let Some(end_row) = self.end_row {
497            if end_row < self.start_row {
498                return Err(readmatrix_error(
499                    "readmatrix: Range end row must be >= start row",
500                ));
501            }
502        }
503        if let Some(end_col) = self.end_col {
504            if end_col < self.start_col {
505                return Err(readmatrix_error(
506                    "readmatrix: Range end column must be >= start column",
507                ));
508            }
509        }
510        Ok(())
511    }
512}
513
514fn normalize_missing_token(token: &str) -> String {
515    token.trim().to_ascii_lowercase()
516}
517
518fn parse_range(value: &Value) -> BuiltinResult<RangeSpec> {
519    match value {
520        Value::String(s) => parse_range_string(s),
521        Value::CharArray(ca) if ca.rows == 1 => {
522            let text: String = ca.data.iter().collect();
523            parse_range_string(&text)
524        }
525        Value::StringArray(sa) => {
526            if sa.data.len() == 1 {
527                parse_range_string(&sa.data[0])
528            } else {
529                Err(readmatrix_error(
530                    "readmatrix: Range string array inputs must be scalar",
531                ))
532            }
533        }
534        Value::Tensor(_) => parse_range_numeric(value),
535        _ => Err(readmatrix_error(
536            "readmatrix: Range must be provided as a string or numeric vector",
537        )),
538    }
539}
540
541fn parse_range_string(text: &str) -> BuiltinResult<RangeSpec> {
542    let trimmed = text.trim();
543    if trimmed.is_empty() {
544        return Err(readmatrix_error("readmatrix: Range string cannot be empty"));
545    }
546    let parts: Vec<&str> = trimmed.split(':').collect();
547    if parts.len() > 2 {
548        return Err(readmatrix_error(format!(
549            "readmatrix: invalid Range specification '{}'",
550            text
551        )));
552    }
553    let start = parse_cell_reference(parts[0])?;
554    if start.col.is_none() {
555        return Err(readmatrix_error(
556            "readmatrix: Range must specify a starting column",
557        ));
558    }
559    let end_ref = if parts.len() == 2 {
560        Some(parse_cell_reference(parts[1])?)
561    } else {
562        None
563    };
564    if let Some(ref end) = end_ref {
565        if end.col.is_none() {
566            return Err(readmatrix_error(
567                "readmatrix: Range end must include a column reference",
568            ));
569        }
570    }
571    let start_row = start.row.unwrap_or(0);
572    let start_col = start.col.unwrap();
573    let end_row = end_ref.as_ref().and_then(|r| r.row);
574    let end_col = end_ref.as_ref().and_then(|r| r.col);
575    Ok(RangeSpec {
576        start_row,
577        start_col,
578        end_row,
579        end_col,
580    })
581}
582
583fn parse_range_numeric(value: &Value) -> BuiltinResult<RangeSpec> {
584    let elements = match value {
585        Value::Tensor(t) => t.data.clone(),
586        _ => {
587            return Err(readmatrix_error(
588                "readmatrix: numeric Range must be provided as a vector with 2 or 4 elements",
589            ))
590        }
591    };
592    if elements.len() != 2 && elements.len() != 4 {
593        return Err(readmatrix_error(
594            "readmatrix: numeric Range must contain exactly 2 or 4 elements",
595        ));
596    }
597    let mut indices = Vec::with_capacity(elements.len());
598    for (idx, value) in elements.iter().enumerate() {
599        let converted = positive_index(*value, idx)?;
600        indices.push(converted);
601    }
602    let start_row = indices[0];
603    let start_col = indices[1];
604    let (end_row, end_col) = if indices.len() == 4 {
605        (Some(indices[2]), Some(indices[3]))
606    } else {
607        (None, None)
608    };
609    Ok(RangeSpec {
610        start_row,
611        start_col,
612        end_row,
613        end_col,
614    })
615}
616
617fn positive_index(value: f64, position: usize) -> BuiltinResult<usize> {
618    if !value.is_finite() {
619        return Err(readmatrix_error("readmatrix: Range indices must be finite"));
620    }
621    if value < 1.0 {
622        return Err(readmatrix_error("readmatrix: Range indices must be >= 1"));
623    }
624    let rounded = value.round();
625    if (rounded - value).abs() > f64::EPSILON {
626        return Err(readmatrix_error(
627            "readmatrix: Range indices must be integers",
628        ));
629    }
630    let zero_based = (rounded as i64) - 1;
631    if zero_based < 0 {
632        return Err(readmatrix_error("readmatrix: Range indices must be >= 1"));
633    }
634    usize::try_from(zero_based).map_err(|_| {
635        readmatrix_error(format!(
636            "readmatrix: Range index {} is too large to fit in usize",
637            position + 1
638        ))
639    })
640}
641
642#[derive(Clone, Copy)]
643struct CellReference {
644    row: Option<usize>,
645    col: Option<usize>,
646}
647
648fn parse_cell_reference(token: &str) -> BuiltinResult<CellReference> {
649    let mut letters = String::new();
650    let mut digits = String::new();
651    for ch in token.trim().chars() {
652        if ch == '$' {
653            continue;
654        }
655        if ch.is_ascii_alphabetic() {
656            letters.push(ch.to_ascii_uppercase());
657        } else if ch.is_ascii_digit() {
658            digits.push(ch);
659        } else {
660            return Err(readmatrix_error(format!(
661                "readmatrix: invalid Range component '{}'",
662                token
663            )));
664        }
665    }
666    if letters.is_empty() && digits.is_empty() {
667        return Err(readmatrix_error(
668            "readmatrix: Range references cannot be empty",
669        ));
670    }
671    let col = if letters.is_empty() {
672        None
673    } else {
674        Some(column_index_from_letters(&letters)?)
675    };
676    let row = if digits.is_empty() {
677        None
678    } else {
679        let parsed = digits.parse::<usize>().map_err(|_| {
680            readmatrix_error(format!(
681                "readmatrix: invalid row index '{}' in Range component '{}'",
682                digits, token
683            ))
684        })?;
685        if parsed == 0 {
686            return Err(readmatrix_error("readmatrix: Range rows must be >= 1"));
687        }
688        Some(parsed - 1)
689    };
690    Ok(CellReference { row, col })
691}
692
693fn column_index_from_letters(letters: &str) -> BuiltinResult<usize> {
694    let mut value: usize = 0;
695    for ch in letters.chars() {
696        if !ch.is_ascii_uppercase() {
697            return Err(readmatrix_error(format!(
698                "readmatrix: invalid column designator '{}' in Range",
699                letters
700            )));
701        }
702        let digit = (ch as u8 - b'A' + 1) as usize;
703        value = value
704            .checked_mul(26)
705            .and_then(|v| v.checked_add(digit))
706            .ok_or_else(|| readmatrix_error("readmatrix: Range column index overflowed"))?;
707    }
708    value
709        .checked_sub(1)
710        .ok_or_else(|| readmatrix_error("readmatrix: Range column index underflowed"))
711}
712
713fn apply_range(
714    rows: &[Vec<f64>],
715    max_cols: usize,
716    range: &RangeSpec,
717    default_fill: f64,
718) -> (Vec<Vec<f64>>, usize) {
719    if rows.is_empty() || max_cols == 0 {
720        return (Vec::new(), 0);
721    }
722    if range.start_row >= rows.len() || range.start_col >= max_cols {
723        return (Vec::new(), 0);
724    }
725    let last_row = rows.len().saturating_sub(1);
726    let mut end_row = range.end_row.unwrap_or(last_row);
727    if end_row > last_row {
728        end_row = last_row;
729    }
730    if end_row < range.start_row {
731        return (Vec::new(), 0);
732    }
733
734    let last_col = max_cols.saturating_sub(1);
735    let mut end_col = range.end_col.unwrap_or(last_col);
736    if end_col > last_col {
737        end_col = last_col;
738    }
739    if end_col < range.start_col {
740        return (Vec::new(), 0);
741    }
742
743    let mut subset = Vec::new();
744    let mut subset_max_cols = 0usize;
745    for row_idx in range.start_row..=end_row {
746        if row_idx >= rows.len() {
747            break;
748        }
749        let row = &rows[row_idx];
750        let mut extracted = Vec::with_capacity(end_col - range.start_col + 1);
751        for col_idx in range.start_col..=end_col {
752            if col_idx >= max_cols {
753                break;
754            }
755            let value = row.get(col_idx).copied().unwrap_or(default_fill);
756            extracted.push(value);
757        }
758        subset_max_cols = subset_max_cols.max(extracted.len());
759        subset.push(extracted);
760    }
761
762    if subset_max_cols == 0 {
763        (Vec::new(), 0)
764    } else {
765        (subset, subset_max_cols)
766    }
767}
768
769fn finalize_output(tensor: Tensor, options: &ReadMatrixOptions) -> BuiltinResult<Value> {
770    match &options.output_template {
771        OutputTemplate::Double => Ok(Value::Tensor(tensor)),
772        OutputTemplate::Logical => tensor_to_logical(tensor),
773        OutputTemplate::Like(proto) => finalize_like(tensor, proto),
774    }
775}
776
777fn tensor_to_logical(tensor: Tensor) -> BuiltinResult<Value> {
778    let mut data = Vec::with_capacity(tensor.data.len());
779    for value in &tensor.data {
780        let bit = if *value == 0.0 { 0 } else { 1 };
781        data.push(bit);
782    }
783    let logical = LogicalArray::new(data, tensor.shape.clone())
784        .map_err(|e| readmatrix_error(format!("readmatrix: {e}")))?;
785    Ok(Value::LogicalArray(logical))
786}
787
788fn finalize_like(tensor: Tensor, proto: &Value) -> BuiltinResult<Value> {
789    match proto {
790        Value::LogicalArray(_) | Value::Bool(_) => tensor_to_logical(tensor),
791        Value::GpuTensor(handle) => tensor_to_gpu(tensor, handle),
792        Value::Tensor(_) | Value::Num(_) | Value::Int(_) => Ok(Value::Tensor(tensor)),
793        Value::ComplexTensor(_) | Value::Complex(_, _) => Ok(Value::Tensor(tensor)),
794        Value::CharArray(_) | Value::String(_) | Value::StringArray(_) => Ok(Value::Tensor(tensor)),
795        Value::Cell(_) => Ok(Value::Tensor(tensor)),
796        _ => Ok(Value::Tensor(tensor)),
797    }
798}
799
800fn tensor_to_gpu(
801    tensor: Tensor,
802    _handle: &runmat_accelerate_api::GpuTensorHandle,
803) -> BuiltinResult<Value> {
804    if let Some(provider) = runmat_accelerate_api::provider() {
805        let view = HostTensorView {
806            data: &tensor.data,
807            shape: &tensor.shape,
808        };
809        if let Ok(uploaded) = provider.upload(&view) {
810            return Ok(Value::GpuTensor(uploaded));
811        }
812    }
813    Ok(Value::Tensor(tensor))
814}
815
816fn read_numeric_matrix(path: &Path, options: &ReadMatrixOptions) -> BuiltinResult<Tensor> {
817    let file = File::open(path).map_err(|err| {
818        readmatrix_error_with_source(
819            format!("readmatrix: unable to read '{}': {err}", path.display()),
820            err,
821        )
822    })?;
823    let reader = BufReader::new(file);
824    let mut data_lines: Vec<(usize, String)> = Vec::new();
825    for (idx, line_result) in reader.lines().enumerate() {
826        let line_number = idx + 1;
827        let line = line_result.map_err(|err| {
828            readmatrix_error_with_source(
829                format!("readmatrix: error reading '{}': {err}", path.display()),
830                err,
831            )
832        })?;
833        let cleaned = line.trim_end_matches('\r');
834        if line_number <= options.num_header_lines {
835            continue;
836        }
837        if cleaned.trim().is_empty() {
838            continue;
839        }
840        data_lines.push((line_number, cleaned.to_string()));
841    }
842
843    if data_lines.is_empty() {
844        return Tensor::new(Vec::new(), vec![0, 0])
845            .map_err(|e| readmatrix_error(format!("readmatrix: {e}")));
846    }
847
848    if let Some((_, first_line)) = data_lines.first_mut() {
849        if first_line.starts_with('\u{FEFF}') {
850            let stripped = first_line.trim_start_matches('\u{FEFF}').to_string();
851            *first_line = stripped;
852        }
853    }
854
855    let delimiter = options
856        .delimiter
857        .clone()
858        .or_else(|| detect_delimiter(&data_lines))
859        .unwrap_or(Delimiter::Whitespace);
860
861    let mut rows: Vec<Vec<f64>> = Vec::new();
862    let mut max_cols = 0usize;
863
864    for (line_number, text) in &data_lines {
865        let fields = split_fields(text, &delimiter);
866        if fields.is_empty() {
867            continue;
868        }
869        let mut row = Vec::with_capacity(fields.len());
870        for (index, field) in fields.iter().enumerate() {
871            let value = parse_numeric_token(field, options, *line_number, index + 1)?;
872            row.push(value);
873        }
874        if row.len() > max_cols {
875            max_cols = row.len();
876        }
877        rows.push(row);
878    }
879
880    if rows.is_empty() {
881        return Tensor::new(Vec::new(), vec![0, 0])
882            .map_err(|e| readmatrix_error(format!("readmatrix: {e}")));
883    }
884
885    let default_fill = options.empty_value();
886    if let Some(range) = &options.range {
887        let (subset_rows, subset_cols) = apply_range(&rows, max_cols, range, default_fill);
888        rows = subset_rows;
889        max_cols = subset_cols;
890    }
891
892    if rows.is_empty() || max_cols == 0 {
893        return Tensor::new(Vec::new(), vec![0, 0])
894            .map_err(|e| readmatrix_error(format!("readmatrix: {e}")));
895    }
896    let row_count = rows.len();
897    let mut data = vec![default_fill; row_count * max_cols];
898
899    for (row_index, row) in rows.iter().enumerate() {
900        for col_index in 0..max_cols {
901            let value = row.get(col_index).copied().unwrap_or(default_fill);
902            data[col_index * row_count + row_index] = value;
903        }
904    }
905
906    Tensor::new(data, vec![row_count, max_cols])
907        .map_err(|e| readmatrix_error(format!("readmatrix: {e}")))
908}
909
910fn detect_delimiter(lines: &[(usize, String)]) -> Option<Delimiter> {
911    if lines.is_empty() {
912        return None;
913    }
914    let sample: Vec<&str> = lines
915        .iter()
916        .take(32)
917        .map(|(_, line)| line.as_str())
918        .collect();
919    let candidates = [',', '\t', ';', '|'];
920    let mut best: Option<(f64, Delimiter)> = None;
921
922    for candidate in candidates {
923        let mut counts = Vec::new();
924        for line in &sample {
925            if !line.contains(candidate) {
926                continue;
927            }
928            let fields = line.split(candidate).count();
929            if fields >= 2 {
930                counts.push(fields);
931            }
932        }
933        if counts.is_empty() {
934            continue;
935        }
936        let average = counts.iter().copied().sum::<usize>() as f64 / counts.len() as f64;
937        if average < 2.0 {
938            continue;
939        }
940        if best
941            .as_ref()
942            .map(|(best_avg, _)| average > *best_avg)
943            .unwrap_or(true)
944        {
945            best = Some((average, Delimiter::Char(candidate)));
946        }
947    }
948
949    if let Some((_, delimiter)) = best {
950        return Some(delimiter);
951    }
952
953    let mut whitespace_hits = 0usize;
954    for line in &sample {
955        if line.split_whitespace().count() > 1 {
956            whitespace_hits += 1;
957        }
958    }
959    if whitespace_hits > 0 {
960        Some(Delimiter::Whitespace)
961    } else {
962        None
963    }
964}
965
966fn split_fields(line: &str, delimiter: &Delimiter) -> Vec<String> {
967    match delimiter {
968        Delimiter::Char(ch) => split_with_char_delim(line, *ch),
969        Delimiter::String(pattern) => line.split(pattern).map(|s| s.to_string()).collect(),
970        Delimiter::Whitespace => line.split_whitespace().map(|s| s.to_string()).collect(),
971    }
972}
973
974fn split_with_char_delim(line: &str, delimiter: char) -> Vec<String> {
975    let mut fields = Vec::new();
976    let mut current = String::new();
977    let mut in_quotes = false;
978    let mut chars = line.chars().peekable();
979    while let Some(ch) = chars.next() {
980        if ch == '"' {
981            if in_quotes && chars.peek() == Some(&'"') {
982                current.push('"');
983                chars.next();
984            } else {
985                in_quotes = !in_quotes;
986            }
987            continue;
988        }
989        if ch == delimiter && !in_quotes {
990            fields.push(current.clone());
991            current.clear();
992        } else {
993            current.push(ch);
994        }
995    }
996    fields.push(current);
997    fields
998}
999
1000fn parse_numeric_token(
1001    token: &str,
1002    options: &ReadMatrixOptions,
1003    line_number: usize,
1004    column_number: usize,
1005) -> BuiltinResult<f64> {
1006    let trimmed = token.trim();
1007    if trimmed.is_empty() {
1008        return Ok(options.empty_value());
1009    }
1010    let unquoted = unquote(trimmed);
1011    let inner = unquoted.trim();
1012    if inner.is_empty() {
1013        return Ok(options.empty_value());
1014    }
1015    if options.is_missing_token(inner) {
1016        return Ok(f64::NAN);
1017    }
1018    let normalized = normalize_numeric_token(inner, options);
1019    if normalized.is_empty() {
1020        return Ok(options.empty_value());
1021    }
1022    let lower = normalized.to_ascii_lowercase();
1023    if lower == "nan" {
1024        return Ok(f64::NAN);
1025    }
1026    if matches!(lower.as_str(), "inf" | "+inf" | "infinity" | "+infinity") {
1027        return Ok(f64::INFINITY);
1028    }
1029    if matches!(lower.as_str(), "-inf" | "-infinity") {
1030        return Ok(f64::NEG_INFINITY);
1031    }
1032    normalized.parse::<f64>().map_err(|_| {
1033        readmatrix_error(format!(
1034            "readmatrix: unable to parse numeric value '{}' on line {} column {}",
1035            inner, line_number, column_number
1036        ))
1037    })
1038}
1039
1040fn normalize_numeric_token(token: &str, options: &ReadMatrixOptions) -> String {
1041    let mut text = token.to_string();
1042    if let Some(thousands) = options.thousands_separator {
1043        if thousands != options.decimal_separator {
1044            text = text.chars().filter(|ch| *ch != thousands).collect();
1045        }
1046    }
1047    if options.decimal_separator != '.' {
1048        text = text.replace(options.decimal_separator, ".");
1049    }
1050    text
1051}
1052
1053fn unquote(token: &str) -> &str {
1054    if token.len() >= 2 {
1055        let bytes = token.as_bytes();
1056        if (bytes[0] == b'"' && bytes[token.len() - 1] == b'"')
1057            || (bytes[0] == b'\'' && bytes[token.len() - 1] == b'\'')
1058        {
1059            return &token[1..token.len() - 1];
1060        }
1061    }
1062    token
1063}
1064
1065fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
1066    match value {
1067        Value::String(s) => normalize_path(s),
1068        Value::CharArray(ca) if ca.rows == 1 => {
1069            let text: String = ca.data.iter().collect();
1070            normalize_path(&text)
1071        }
1072        Value::CharArray(_) => Err(readmatrix_error(
1073            "readmatrix: expected a 1-by-N character vector for the file name",
1074        )),
1075        Value::StringArray(sa) => {
1076            if sa.data.len() == 1 {
1077                normalize_path(&sa.data[0])
1078            } else {
1079                Err(readmatrix_error(
1080                    "readmatrix: string array inputs must be scalar",
1081                ))
1082            }
1083        }
1084        other => Err(readmatrix_error(format!(
1085            "readmatrix: expected filename as string scalar or character vector, got {other:?}"
1086        ))),
1087    }
1088}
1089
1090fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
1091    if raw.is_empty() {
1092        return Err(readmatrix_error("readmatrix: filename must not be empty"));
1093    }
1094    Ok(Path::new(raw).to_path_buf())
1095}
1096
1097#[cfg(test)]
1098pub(crate) mod tests {
1099    use super::*;
1100    use futures::executor::block_on;
1101    use runmat_time::unix_timestamp_ms;
1102    use std::fs;
1103
1104    use crate::builtins::common::test_support;
1105    use runmat_accelerate_api::HostTensorView;
1106    use runmat_builtins::{CharArray, IntValue, LogicalArray, StringArray, Tensor};
1107
1108    fn unique_path(prefix: &str) -> PathBuf {
1109        let millis = unix_timestamp_ms();
1110        let mut path = std::env::temp_dir();
1111        path.push(format!("runmat_{prefix}_{}_{}", std::process::id(), millis));
1112        path
1113    }
1114
1115    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1116    #[test]
1117    fn readmatrix_reads_csv_data() {
1118        let path = unique_path("readmatrix_csv");
1119        fs::write(&path, "1,2,3\n4,5,6\n").expect("write sample file");
1120        let result = block_on(readmatrix_builtin(
1121            Value::from(path.to_string_lossy().to_string()),
1122            Vec::new(),
1123        ))
1124        .expect("readmatrix");
1125        match result {
1126            Value::Tensor(t) => {
1127                assert_eq!(t.shape, vec![2, 3]);
1128                assert_eq!(t.data, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
1129            }
1130            other => panic!("expected tensor result, got {other:?}"),
1131        }
1132        let _ = fs::remove_file(&path);
1133    }
1134
1135    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1136    #[test]
1137    fn readmatrix_skips_header_lines() {
1138        let path = unique_path("readmatrix_header");
1139        fs::write(&path, "time,value\n0,10\n1,12\n").expect("write sample file");
1140        let args = vec![Value::from("NumHeaderLines"), Value::Int(IntValue::I32(1))];
1141        let result = block_on(readmatrix_builtin(
1142            Value::from(path.to_string_lossy().to_string()),
1143            args,
1144        ))
1145        .expect("readmatrix");
1146        match result {
1147            Value::Tensor(t) => {
1148                assert_eq!(t.shape, vec![2, 2]);
1149                assert_eq!(t.data, vec![0.0, 1.0, 10.0, 12.0]);
1150            }
1151            other => panic!("expected tensor result, got {other:?}"),
1152        }
1153        let _ = fs::remove_file(&path);
1154    }
1155
1156    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1157    #[test]
1158    fn readmatrix_respects_delimiter_option() {
1159        let path = unique_path("readmatrix_tab");
1160        fs::write(&path, "1\t2\t3\n4\t5\t6\n").expect("write sample file");
1161        let args = vec![Value::from("Delimiter"), Value::from("tab")];
1162        let result = block_on(readmatrix_builtin(
1163            Value::from(path.to_string_lossy().to_string()),
1164            args,
1165        ))
1166        .expect("readmatrix");
1167        match result {
1168            Value::Tensor(t) => {
1169                assert_eq!(t.shape, vec![2, 3]);
1170                assert_eq!(t.data, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
1171            }
1172            other => panic!("expected tensor result, got {other:?}"),
1173        }
1174        let _ = fs::remove_file(&path);
1175    }
1176
1177    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1178    #[test]
1179    fn readmatrix_respects_range_string() {
1180        let path = unique_path("readmatrix_range_string");
1181        fs::write(&path, "11,12,13\n21,22,23\n31,32,33\n").expect("write sample file");
1182        let args = vec![Value::from("Range"), Value::from("B2:C3")];
1183        let result = block_on(readmatrix_builtin(
1184            Value::from(path.to_string_lossy().to_string()),
1185            args,
1186        ))
1187        .expect("readmatrix");
1188        match result {
1189            Value::Tensor(t) => {
1190                assert_eq!(t.shape, vec![2, 2]);
1191                assert_eq!(t.data, vec![22.0, 32.0, 23.0, 33.0]);
1192            }
1193            other => panic!("expected tensor result, got {other:?}"),
1194        }
1195        let _ = fs::remove_file(&path);
1196    }
1197
1198    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1199    #[test]
1200    fn readmatrix_respects_range_numeric_vector() {
1201        let path = unique_path("readmatrix_range_numeric");
1202        fs::write(&path, "11,12,13\n21,22,23\n31,32,33\n").expect("write sample file");
1203        let range = Tensor::new(vec![2.0, 2.0, 3.0, 3.0], vec![1, 4]).expect("range tensor");
1204        let args = vec![Value::from("Range"), Value::Tensor(range)];
1205        let result = block_on(readmatrix_builtin(
1206            Value::from(path.to_string_lossy().to_string()),
1207            args,
1208        ))
1209        .expect("readmatrix");
1210        match result {
1211            Value::Tensor(t) => {
1212                assert_eq!(t.shape, vec![2, 2]);
1213                assert_eq!(t.data, vec![22.0, 32.0, 23.0, 33.0]);
1214            }
1215            other => panic!("expected tensor result, got {other:?}"),
1216        }
1217        let _ = fs::remove_file(&path);
1218    }
1219
1220    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1221    #[test]
1222    fn readmatrix_treats_custom_missing_tokens() {
1223        let path = unique_path("readmatrix_missing");
1224        fs::write(&path, "1,NA,3\nNA,5,missing\n").expect("write file");
1225        let strings = StringArray::new(vec!["NA".to_string(), "missing".to_string()], vec![1, 2])
1226            .expect("string array");
1227        let args = vec![Value::from("TreatAsMissing"), Value::StringArray(strings)];
1228        let result = block_on(readmatrix_builtin(
1229            Value::from(path.to_string_lossy().to_string()),
1230            args,
1231        ))
1232        .expect("readmatrix");
1233        match result {
1234            Value::Tensor(t) => {
1235                assert_eq!(t.shape, vec![2, 3]);
1236                assert!(t.data[1].is_nan()); // column 1, row 2
1237                assert!(t.data[2].is_nan()); // column 2, row 1
1238                assert!(t.data[5].is_nan()); // column 3, row 2
1239            }
1240            other => panic!("expected tensor result, got {other:?}"),
1241        }
1242        let _ = fs::remove_file(&path);
1243    }
1244
1245    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1246    #[test]
1247    fn readmatrix_uses_decimal_and_thousands_separators() {
1248        let path = unique_path("readmatrix_decimal");
1249        fs::write(&path, "1.234,56;7.890,12\n").expect("write sample file");
1250        let args = vec![
1251            Value::from("Delimiter"),
1252            Value::from(";"),
1253            Value::from("DecimalSeparator"),
1254            Value::from(","),
1255            Value::from("ThousandsSeparator"),
1256            Value::from("."),
1257        ];
1258        let result = block_on(readmatrix_builtin(
1259            Value::from(path.to_string_lossy().to_string()),
1260            args,
1261        ))
1262        .expect("readmatrix");
1263        match result {
1264            Value::Tensor(t) => {
1265                assert_eq!(t.shape, vec![1, 2]);
1266                assert!((t.data[0] - 1234.56).abs() < 1e-9);
1267                assert!((t.data[1] - 7890.12).abs() < 1e-9);
1268            }
1269            other => panic!("expected tensor result, got {other:?}"),
1270        }
1271        let _ = fs::remove_file(&path);
1272    }
1273
1274    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1275    #[test]
1276    fn readmatrix_applies_empty_value() {
1277        let path = unique_path("readmatrix_empty_value");
1278        fs::write(&path, "1,,3\n4,,6\n").expect("write sample file");
1279        let args = vec![Value::from("EmptyValue"), Value::Num(0.0)];
1280        let result = block_on(readmatrix_builtin(
1281            Value::from(path.to_string_lossy().to_string()),
1282            args,
1283        ))
1284        .expect("readmatrix");
1285        match result {
1286            Value::Tensor(t) => {
1287                assert_eq!(t.shape, vec![2, 3]);
1288                assert_eq!(t.data, vec![1.0, 4.0, 0.0, 0.0, 3.0, 6.0]);
1289            }
1290            other => panic!("expected tensor result, got {other:?}"),
1291        }
1292        let _ = fs::remove_file(&path);
1293    }
1294
1295    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1296    #[test]
1297    fn readmatrix_accepts_struct_options() {
1298        let path = unique_path("readmatrix_struct_opts");
1299        fs::write(&path, "header1,header2\n9,10\n11,12\n").expect("write sample file");
1300        let mut options_struct = runmat_builtins::StructValue::new();
1301        options_struct
1302            .fields
1303            .insert("Delimiter".to_string(), Value::from(","));
1304        options_struct
1305            .fields
1306            .insert("NumHeaderLines".to_string(), Value::Int(IntValue::I32(1)));
1307        let args = vec![Value::Struct(options_struct)];
1308        let result = block_on(readmatrix_builtin(
1309            Value::from(path.to_string_lossy().to_string()),
1310            args,
1311        ))
1312        .expect("readmatrix");
1313        match result {
1314            Value::Tensor(t) => {
1315                assert_eq!(t.shape, vec![2, 2]);
1316                assert_eq!(t.data, vec![9.0, 11.0, 10.0, 12.0]);
1317            }
1318            other => panic!("expected tensor result, got {other:?}"),
1319        }
1320        let _ = fs::remove_file(&path);
1321    }
1322
1323    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1324    #[test]
1325    fn readmatrix_errors_on_non_numeric_field() {
1326        let path = unique_path("readmatrix_error");
1327        fs::write(&path, "1,abc,3\n").expect("write sample file");
1328        let err = block_on(readmatrix_builtin(
1329            Value::from(path.to_string_lossy().to_string()),
1330            Vec::new(),
1331        ))
1332        .expect_err("readmatrix should fail");
1333        let message = err.message().to_string();
1334        assert!(
1335            message.contains("unable to parse numeric value"),
1336            "unexpected error message: {message}"
1337        );
1338        let _ = fs::remove_file(&path);
1339    }
1340
1341    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1342    #[test]
1343    fn readmatrix_returns_empty_on_no_data() {
1344        let path = unique_path("readmatrix_empty");
1345        File::create(&path).expect("create file");
1346        let result = block_on(readmatrix_builtin(
1347            Value::from(path.to_string_lossy().to_string()),
1348            Vec::new(),
1349        ))
1350        .expect("readmatrix");
1351        match result {
1352            Value::Tensor(t) => {
1353                assert_eq!(t.shape, vec![0, 0]);
1354                assert!(t.data.is_empty());
1355            }
1356            other => panic!("expected tensor result, got {other:?}"),
1357        }
1358        let _ = fs::remove_file(&path);
1359    }
1360
1361    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1362    #[test]
1363    fn readmatrix_output_type_logical() {
1364        let path = unique_path("readmatrix_output_logical");
1365        fs::write(&path, "0,1,-3\nNaN,0,5\n").expect("write sample file");
1366        let args = vec![Value::from("OutputType"), Value::from("logical")];
1367        let result = block_on(readmatrix_builtin(
1368            Value::from(path.to_string_lossy().to_string()),
1369            args,
1370        ))
1371        .expect("readmatrix");
1372        match result {
1373            Value::LogicalArray(arr) => {
1374                assert_eq!(arr.shape, vec![2, 3]);
1375                assert_eq!(arr.data, vec![0, 1, 1, 0, 1, 1]);
1376            }
1377            other => panic!("expected logical array, got {other:?}"),
1378        }
1379        let _ = fs::remove_file(&path);
1380    }
1381
1382    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1383    #[test]
1384    fn readmatrix_like_logical_proto() {
1385        let path = unique_path("readmatrix_like_logical");
1386        fs::write(&path, "1,0\n0,5\n").expect("write sample file");
1387        let proto = LogicalArray::new(vec![1], vec![1]).expect("logical prototype");
1388        let args = vec![Value::from("Like"), Value::LogicalArray(proto)];
1389        let result = block_on(readmatrix_builtin(
1390            Value::from(path.to_string_lossy().to_string()),
1391            args,
1392        ))
1393        .expect("readmatrix");
1394        match result {
1395            Value::LogicalArray(arr) => {
1396                assert_eq!(arr.shape, vec![2, 2]);
1397                assert_eq!(arr.data, vec![1, 0, 0, 1]);
1398            }
1399            other => panic!("expected logical array, got {other:?}"),
1400        }
1401        let _ = fs::remove_file(&path);
1402    }
1403
1404    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1405    #[test]
1406    fn readmatrix_like_gpu_proto() {
1407        test_support::with_test_provider(|provider| {
1408            let path = unique_path("readmatrix_like_gpu");
1409            fs::write(&path, "1,2\n3,4\n").expect("write sample file");
1410            let proto_tensor = Tensor::new(vec![0.0, 0.0], vec![1, 2]).expect("tensor");
1411            let view = HostTensorView {
1412                data: &proto_tensor.data,
1413                shape: &proto_tensor.shape,
1414            };
1415            let handle = provider.upload(&view).expect("upload prototype");
1416            let args = vec![Value::from("Like"), Value::GpuTensor(handle.clone())];
1417            let result = block_on(readmatrix_builtin(
1418                Value::from(path.to_string_lossy().to_string()),
1419                args,
1420            ))
1421            .expect("readmatrix");
1422            assert!(
1423                matches!(result, Value::GpuTensor(_)),
1424                "expected GPU tensor result, got {result:?}"
1425            );
1426            let gathered = test_support::gather(result).expect("gather result");
1427            assert_eq!(gathered.shape, vec![2, 2]);
1428            assert_eq!(gathered.data, vec![1.0, 3.0, 2.0, 4.0]);
1429            let _ = fs::remove_file(&path);
1430        });
1431    }
1432
1433    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1434    #[test]
1435    fn readmatrix_accepts_character_vector_path() {
1436        let path = unique_path("readmatrix_char_path");
1437        fs::write(&path, "1 2 3\n").expect("write sample file");
1438        let text = path.to_string_lossy().to_string();
1439        let chars: Vec<char> = text.chars().collect();
1440        let len = chars.len();
1441        let char_array = CharArray::new(chars, 1, len).expect("char array");
1442        let result = block_on(readmatrix_builtin(Value::CharArray(char_array), Vec::new()))
1443            .expect("readmatrix");
1444        match result {
1445            Value::Tensor(t) => {
1446                assert_eq!(t.shape, vec![1, 3]);
1447                assert_eq!(t.data, vec![1.0, 2.0, 3.0]);
1448            }
1449            other => panic!("expected tensor result, got {other:?}"),
1450        }
1451        let _ = fs::remove_file(&path);
1452    }
1453
1454    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1455    #[test]
1456    fn readmatrix_handles_quoted_fields() {
1457        let path = unique_path("readmatrix_quotes");
1458        fs::write(&path, "\"1\",\"2\",\"3\"\n").expect("write sample file");
1459        let result = block_on(readmatrix_builtin(
1460            Value::from(path.to_string_lossy().to_string()),
1461            Vec::new(),
1462        ))
1463        .expect("readmatrix");
1464        match result {
1465            Value::Tensor(t) => {
1466                assert_eq!(t.shape, vec![1, 3]);
1467                assert_eq!(t.data, vec![1.0, 2.0, 3.0]);
1468            }
1469            other => panic!("expected tensor result, got {other:?}"),
1470        }
1471        let _ = fs::remove_file(&path);
1472    }
1473
1474    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1475    #[test]
1476    fn readmatrix_preserves_negative_infinity() {
1477        let path = unique_path("readmatrix_infinity");
1478        fs::write(&path, "-Inf,Inf,NaN\n").expect("write sample file");
1479        let result = block_on(readmatrix_builtin(
1480            Value::from(path.to_string_lossy().to_string()),
1481            Vec::new(),
1482        ))
1483        .expect("readmatrix");
1484        match result {
1485            Value::Tensor(t) => {
1486                assert_eq!(t.shape, vec![1, 3]);
1487                assert!(t.data[0].is_infinite() && t.data[0].is_sign_negative());
1488                assert!(t.data[1].is_infinite() && t.data[1].is_sign_positive());
1489                assert!(t.data[2].is_nan());
1490            }
1491            other => panic!("expected tensor result, got {other:?}"),
1492        }
1493        let _ = fs::remove_file(&path);
1494    }
1495
1496    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1497    #[test]
1498    fn readmatrix_supports_whitespace_delimiter() {
1499        let path = unique_path("readmatrix_whitespace");
1500        fs::write(&path, "1 2 3\n4 5 6\n").expect("write sample file");
1501        let result = block_on(readmatrix_builtin(
1502            Value::from(path.to_string_lossy().to_string()),
1503            Vec::new(),
1504        ))
1505        .expect("readmatrix");
1506        match result {
1507            Value::Tensor(t) => {
1508                assert_eq!(t.shape, vec![2, 3]);
1509                assert_eq!(t.data, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
1510            }
1511            other => panic!("expected tensor result, got {other:?}"),
1512        }
1513        let _ = fs::remove_file(&path);
1514    }
1515}