Skip to main content

runmat_runtime/builtins/io/
importdata.rs

1//! MATLAB-compatible `importdata` builtin for legacy text imports.
2
3use runmat_builtins::{
4    BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor, BuiltinIntegerComputationDomain,
5    BuiltinIntegerInputAvailability, BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule,
6    BuiltinIntegerOverflowRule, BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule,
7};
8use std::path::{Path, PathBuf};
9
10use runmat_builtins::{
11    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
12    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
13};
14use runmat_filesystem as fs;
15use runmat_macros::runtime_builtin;
16use runmat_value::{CellArray, StructValue, Tensor, Value};
17
18use crate::builtins::common::fs::expand_user_path;
19use crate::builtins::common::spec::{
20    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
21    ReductionNaN, ResidencyPolicy, ShapeRequirements,
22};
23use crate::builtins::common::tensor;
24use crate::{build_runtime_error, BuiltinResult, RuntimeError};
25
26const BUILTIN_NAME: &str = "importdata";
27
28const IMPORTDATA_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
29    [BuiltinIntegerInputCapability {
30        name: "headerlinesIn",
31        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
32        availability: BuiltinIntegerInputAvailability::Documented,
33        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
34        notes: "The documented nonnegative scalar header count accepts single, double, and all eight integer classes; logical is excluded and typed values are parsed exactly before file access.",
35    }];
36pub const IMPORTDATA_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
37    [BuiltinIntegerCapabilityDescriptor {
38        form: "[A,delimiterOut,headerlinesOut] = importdata(filename,delimiterIn,integer_headerlinesIn)",
39        inputs: &IMPORTDATA_INTEGER_INPUTS,
40        computation_domain: BuiltinIntegerComputationDomain::Structural,
41        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
42        overflow: BuiltinIntegerOverflowRule::Error,
43        backend: BuiltinIntegerBackendRule::HostOnly,
44        overload: BuiltinIntegerOverloadKind::StructuralParameter,
45        notes: "Header count is structural and does not determine imported payload class. Text numeric data is double; helper-format payloads retain their own documented classes. This implementation remains a host text-import boundary.",
46    }];
47
48const IMPORTDATA_OUTPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
49    name: "A",
50    ty: BuiltinParamType::Any,
51    arity: BuiltinParamArity::Required,
52    default: None,
53    description: "Imported numeric matrix or import structure.",
54}];
55const IMPORTDATA_ALL_OUTPUTS: [BuiltinParamDescriptor; 3] = [
56    BuiltinParamDescriptor {
57        name: "A",
58        ty: BuiltinParamType::Any,
59        arity: BuiltinParamArity::Required,
60        default: None,
61        description: "Imported numeric matrix or import structure.",
62    },
63    BuiltinParamDescriptor {
64        name: "delimiterOut",
65        ty: BuiltinParamType::StringScalar,
66        arity: BuiltinParamArity::Required,
67        default: None,
68        description: "Detected or requested text delimiter.",
69    },
70    BuiltinParamDescriptor {
71        name: "headerlinesOut",
72        ty: BuiltinParamType::NumericScalar,
73        arity: BuiltinParamArity::Required,
74        default: None,
75        description: "Number of imported header lines.",
76    },
77];
78const IMPORTDATA_INPUTS_FILENAME: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
79    name: "filename",
80    ty: BuiltinParamType::StringScalar,
81    arity: BuiltinParamArity::Required,
82    default: None,
83    description: "File to import.",
84}];
85const IMPORTDATA_INPUTS_DELIMITER: [BuiltinParamDescriptor; 2] = [
86    BuiltinParamDescriptor {
87        name: "filename",
88        ty: BuiltinParamType::StringScalar,
89        arity: BuiltinParamArity::Required,
90        default: None,
91        description: "File to import.",
92    },
93    BuiltinParamDescriptor {
94        name: "delimiterIn",
95        ty: BuiltinParamType::StringScalar,
96        arity: BuiltinParamArity::Optional,
97        default: None,
98        description: "Delimiter to use for text files.",
99    },
100];
101const IMPORTDATA_INPUTS_DELIMITER_HEADER: [BuiltinParamDescriptor; 3] = [
102    BuiltinParamDescriptor {
103        name: "filename",
104        ty: BuiltinParamType::StringScalar,
105        arity: BuiltinParamArity::Required,
106        default: None,
107        description: "File to import.",
108    },
109    BuiltinParamDescriptor {
110        name: "delimiterIn",
111        ty: BuiltinParamType::StringScalar,
112        arity: BuiltinParamArity::Optional,
113        default: None,
114        description: "Delimiter to use for text files.",
115    },
116    BuiltinParamDescriptor {
117        name: "headerlinesIn",
118        ty: BuiltinParamType::IntegerScalar,
119        arity: BuiltinParamArity::Optional,
120        default: None,
121        description: "Number of header lines to skip.",
122    },
123];
124const IMPORTDATA_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
125    BuiltinSignatureDescriptor {
126        label: "A = importdata(filename)",
127        inputs: &IMPORTDATA_INPUTS_FILENAME,
128        outputs: &IMPORTDATA_OUTPUTS,
129    },
130    BuiltinSignatureDescriptor {
131        label: "A = importdata(filename, delimiterIn)",
132        inputs: &IMPORTDATA_INPUTS_DELIMITER,
133        outputs: &IMPORTDATA_OUTPUTS,
134    },
135    BuiltinSignatureDescriptor {
136        label: "A = importdata(filename, delimiterIn, headerlinesIn)",
137        inputs: &IMPORTDATA_INPUTS_DELIMITER_HEADER,
138        outputs: &IMPORTDATA_OUTPUTS,
139    },
140    BuiltinSignatureDescriptor {
141        label:
142            "[A, delimiterOut, headerlinesOut] = importdata(filename, delimiterIn, headerlinesIn)",
143        inputs: &IMPORTDATA_INPUTS_DELIMITER_HEADER,
144        outputs: &IMPORTDATA_ALL_OUTPUTS,
145    },
146];
147
148const IMPORTDATA_ERROR_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
149    code: "RM.IMPORTDATA.ARGUMENT",
150    identifier: Some("RunMat:importdata:InvalidArgument"),
151    when: "Filename, delimiter, or header line arguments are malformed.",
152    message: "importdata: invalid argument",
153};
154const IMPORTDATA_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
155    code: "RM.IMPORTDATA.IO",
156    identifier: Some("RunMat:importdata:Io"),
157    when: "The input file cannot be read.",
158    message: "importdata: unable to read file",
159};
160const IMPORTDATA_ERROR_PARSE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
161    code: "RM.IMPORTDATA.PARSE",
162    identifier: Some("RunMat:importdata:Parse"),
163    when: "Text content cannot be imported as supported numeric/header data.",
164    message: "importdata: unable to parse text data",
165};
166const IMPORTDATA_ERRORS: [BuiltinErrorDescriptor; 3] = [
167    IMPORTDATA_ERROR_ARGUMENT,
168    IMPORTDATA_ERROR_IO,
169    IMPORTDATA_ERROR_PARSE,
170];
171
172pub const IMPORTDATA_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
173    signatures: &IMPORTDATA_SIGNATURES,
174    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
175    completion_policy: BuiltinCompletionPolicy::Public,
176    errors: &IMPORTDATA_ERRORS,
177};
178
179#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::importdata")]
180pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
181    name: "importdata",
182    op_kind: GpuOpKind::Custom("io-importdata"),
183    supported_precisions: &[],
184    broadcast: BroadcastSemantics::None,
185    provider_hooks: &[],
186    constant_strategy: ConstantStrategy::InlineLiteral,
187    residency: ResidencyPolicy::GatherImmediately,
188    nan_mode: ReductionNaN::Include,
189    two_pass_threshold: None,
190    workgroup_size: None,
191    accepts_nan_mode: false,
192    notes: "Runs on the host; file import is not an acceleration operation.",
193};
194
195#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::importdata")]
196pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
197    name: "importdata",
198    shape: ShapeRequirements::Any,
199    constant_strategy: ConstantStrategy::InlineLiteral,
200    elementwise: None,
201    reduction: None,
202    emits_nan: false,
203    notes: "Not eligible for fusion; performs host-side file I/O.",
204};
205
206fn importdata_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
207    importdata_error_with(error, error.message)
208}
209
210fn importdata_error_with(
211    error: &'static BuiltinErrorDescriptor,
212    message: impl Into<String>,
213) -> RuntimeError {
214    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
215    if let Some(identifier) = error.identifier {
216        builder = builder.with_identifier(identifier);
217    }
218    builder.build()
219}
220
221fn importdata_error_with_source<E>(
222    error: &'static BuiltinErrorDescriptor,
223    message: impl Into<String>,
224    source: E,
225) -> RuntimeError
226where
227    E: std::error::Error + Send + Sync + 'static,
228{
229    let mut builder = build_runtime_error(message)
230        .with_builtin(BUILTIN_NAME)
231        .with_source(source);
232    if let Some(identifier) = error.identifier {
233        builder = builder.with_identifier(identifier);
234    }
235    builder.build()
236}
237
238#[runtime_builtin(
239    name = "importdata",
240    category = "io/import",
241    summary = "Import numeric text data with optional headers.",
242    keywords = "importdata,text,csv,delimited,header,numeric import",
243    accel = "cpu",
244    type_resolver(crate::builtins::io::type_resolvers::importdata_type),
245    descriptor(crate::builtins::io::importdata::IMPORTDATA_DESCRIPTOR),
246    integer_capabilities(crate::builtins::io::importdata::IMPORTDATA_INTEGER_CAPABILITIES),
247    builtin_path = "crate::builtins::io::importdata"
248)]
249async fn importdata_builtin(filename: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
250    if rest.len() > 2 {
251        return Err(importdata_error(&IMPORTDATA_ERROR_ARGUMENT));
252    }
253    if crate::dispatcher::value_contains_gpu(&filename)
254        || rest.iter().any(crate::dispatcher::value_contains_gpu)
255    {
256        return Err(importdata_error_with(
257            &IMPORTDATA_ERROR_ARGUMENT,
258            "importdata: interactive gpuArray arguments are not supported",
259        ));
260    }
261    let path = resolve_path(&filename)?;
262
263    let delimiter = if let Some(value) = rest.first() {
264        Some(parse_delimiter_arg(value)?)
265    } else {
266        None
267    };
268    let header_lines = if let Some(value) = rest.get(1) {
269        Some(parse_header_lines(value)?)
270    } else {
271        None
272    };
273
274    let text = fs::read_to_string_async(&path).await.map_err(|err| {
275        importdata_error_with_source(
276            &IMPORTDATA_ERROR_IO,
277            format!("importdata: unable to read \"{}\" ({err})", path.display()),
278            err,
279        )
280    })?;
281    let imported = import_text_data(&text, delimiter.as_deref(), header_lines)?;
282    if let Some(output_count) = crate::output_count::current_output_count() {
283        if output_count == 0 {
284            return Ok(Value::OutputList(Vec::new()));
285        }
286        let outputs = vec![
287            imported.value,
288            Value::CharArray(runmat_value::CharArray::new_row(&imported.delimiter)),
289            Value::Num(imported.header_lines as f64),
290        ];
291        return Ok(crate::output_count::output_list_with_padding(
292            output_count,
293            outputs,
294        ));
295    }
296    Ok(imported.value)
297}
298
299struct ImportTextResult {
300    value: Value,
301    delimiter: String,
302    header_lines: usize,
303}
304
305#[derive(Debug, Clone)]
306struct ImportedText {
307    data: Vec<Vec<f64>>,
308    textdata: Vec<Vec<String>>,
309    colheaders: Vec<String>,
310    rowheaders: Vec<String>,
311}
312
313fn import_text_data(
314    text: &str,
315    delimiter: Option<&str>,
316    header_lines: Option<usize>,
317) -> BuiltinResult<ImportTextResult> {
318    let lines: Vec<&str> = text.lines().collect();
319    let nonempty: Vec<(usize, &str)> = lines
320        .iter()
321        .copied()
322        .enumerate()
323        .filter(|(_, line)| !line.trim().is_empty())
324        .collect();
325    if nonempty.is_empty() {
326        return Ok(ImportTextResult {
327            value: Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).map_err(|err| {
328                importdata_error_with(&IMPORTDATA_ERROR_PARSE, format!("importdata: {err}"))
329            })?),
330            delimiter: delimiter.unwrap_or_default().to_string(),
331            header_lines: header_lines.unwrap_or(0),
332        });
333    }
334
335    let delimiter = delimiter
336        .map(Delimiter::Explicit)
337        .unwrap_or_else(|| detect_delimiter(nonempty.iter().map(|(_, line)| *line)));
338    let delimiter_out = match &delimiter {
339        Delimiter::Whitespace => " ".to_string(),
340        Delimiter::Explicit(value) => (*value).to_string(),
341    };
342    let records: Vec<(usize, Vec<String>)> = nonempty
343        .iter()
344        .map(|(idx, line)| (*idx, split_record(line, &delimiter)))
345        .collect();
346
347    let data_start = header_lines.unwrap_or_else(|| infer_header_lines(&records));
348    if data_start > records.len() {
349        return Err(importdata_error_with(
350            &IMPORTDATA_ERROR_ARGUMENT,
351            "importdata: headerlinesIn exceeds number of non-empty lines",
352        ));
353    }
354
355    let header_records: Vec<Vec<String>> = records[..data_start]
356        .iter()
357        .map(|(_, record)| record.clone())
358        .collect();
359    let data_records = &records[data_start..];
360
361    let imported = parse_numeric_records(data_records, &header_records)?;
362    let tensor = rows_to_tensor(&imported.data)?;
363    if imported.textdata.is_empty()
364        && imported.colheaders.is_empty()
365        && imported.rowheaders.is_empty()
366    {
367        return Ok(ImportTextResult {
368            value: Value::Tensor(tensor),
369            delimiter: delimiter_out,
370            header_lines: data_start,
371        });
372    }
373
374    let mut out = StructValue::new();
375    out.insert("data", Value::Tensor(tensor));
376    if !imported.textdata.is_empty() {
377        out.insert("textdata", cell_from_rows(&imported.textdata)?);
378    }
379    if !imported.colheaders.is_empty() {
380        out.insert("colheaders", cell_from_row(&imported.colheaders)?);
381    }
382    if !imported.rowheaders.is_empty() {
383        out.insert("rowheaders", cell_from_col(&imported.rowheaders)?);
384    }
385    Ok(ImportTextResult {
386        value: Value::Struct(out),
387        delimiter: delimiter_out,
388        header_lines: data_start,
389    })
390}
391
392fn parse_numeric_records(
393    data_records: &[(usize, Vec<String>)],
394    header_records: &[Vec<String>],
395) -> BuiltinResult<ImportedText> {
396    if data_records.is_empty() {
397        return Ok(ImportedText {
398            data: Vec::new(),
399            textdata: header_records.to_vec(),
400            colheaders: header_records.last().cloned().unwrap_or_default(),
401            rowheaders: Vec::new(),
402        });
403    }
404
405    let first = &data_records[0].1;
406    let row_header_cols = infer_row_header_cols(data_records);
407    let numeric_cols = first.len().saturating_sub(row_header_cols);
408    if numeric_cols == 0 {
409        return Err(importdata_error_with(
410            &IMPORTDATA_ERROR_PARSE,
411            "importdata: no numeric columns found",
412        ));
413    }
414
415    let mut rows = Vec::with_capacity(data_records.len());
416    let mut rowheaders = Vec::new();
417    for (line_idx, record) in data_records {
418        let expected_cols = row_header_cols + numeric_cols;
419        if record.len() != expected_cols {
420            return Err(importdata_error_with(
421                &IMPORTDATA_ERROR_PARSE,
422                format!(
423                    "importdata: row {} has {} columns, expected {}",
424                    line_idx + 1,
425                    record.len(),
426                    expected_cols
427                ),
428            ));
429        }
430        if row_header_cols > 0 {
431            rowheaders.push(record[..row_header_cols].join(" "));
432        }
433        let mut row = Vec::with_capacity(numeric_cols);
434        for (col, token) in record[row_header_cols..row_header_cols + numeric_cols]
435            .iter()
436            .enumerate()
437        {
438            row.push(parse_numeric_token(token).ok_or_else(|| {
439                importdata_error_with(
440                    &IMPORTDATA_ERROR_PARSE,
441                    format!(
442                        "importdata: nonnumeric token '{}' at row {}, column {}",
443                        token,
444                        line_idx + 1,
445                        row_header_cols + col + 1
446                    ),
447                )
448            })?);
449        }
450        rows.push(row);
451    }
452
453    let mut colheaders = Vec::new();
454    if let Some(last_header) = header_records.last() {
455        if last_header.len() >= row_header_cols + numeric_cols {
456            colheaders = last_header[row_header_cols..row_header_cols + numeric_cols].to_vec();
457        } else if last_header.len() == numeric_cols {
458            colheaders = last_header.clone();
459        }
460    }
461
462    Ok(ImportedText {
463        data: rows,
464        textdata: header_records.to_vec(),
465        colheaders,
466        rowheaders,
467    })
468}
469
470fn infer_row_header_cols(records: &[(usize, Vec<String>)]) -> usize {
471    let Some(first) = records.first() else {
472        return 0;
473    };
474    if first.1.len() < 2 || parse_numeric_token(&first.1[0]).is_some() {
475        return 0;
476    }
477    if records.iter().all(|(_, row)| {
478        row.len() == first.1.len()
479            && parse_numeric_token(&row[0]).is_none()
480            && row[1..]
481                .iter()
482                .all(|token| parse_numeric_token(token).is_some())
483    }) {
484        1
485    } else {
486        0
487    }
488}
489
490fn infer_header_lines(records: &[(usize, Vec<String>)]) -> usize {
491    records
492        .iter()
493        .position(|(_, row)| is_numeric_data_row(row))
494        .unwrap_or(records.len())
495}
496
497fn is_numeric_data_row(row: &[String]) -> bool {
498    if row.is_empty() {
499        return false;
500    }
501    if row.iter().all(|token| parse_numeric_token(token).is_some()) {
502        return true;
503    }
504    row.len() > 1
505        && parse_numeric_token(&row[0]).is_none()
506        && row[1..]
507            .iter()
508            .all(|token| parse_numeric_token(token).is_some())
509}
510
511fn rows_to_tensor(rows: &[Vec<f64>]) -> BuiltinResult<Tensor> {
512    let row_count = rows.len();
513    let col_count = rows.first().map(|row| row.len()).unwrap_or(0);
514    if rows.iter().any(|row| row.len() != col_count) {
515        return Err(importdata_error_with(
516            &IMPORTDATA_ERROR_PARSE,
517            "importdata: numeric rows have inconsistent column counts",
518        ));
519    }
520    let mut data = Vec::with_capacity(row_count * col_count);
521    for col in 0..col_count {
522        for row in rows {
523            data.push(row[col]);
524        }
525    }
526    Tensor::new(data, vec![row_count, col_count])
527        .map_err(|err| importdata_error_with(&IMPORTDATA_ERROR_PARSE, format!("importdata: {err}")))
528}
529
530#[derive(Debug, Clone, PartialEq, Eq)]
531enum Delimiter<'a> {
532    Whitespace,
533    Explicit(&'a str),
534}
535
536fn detect_delimiter<'a>(lines: impl Iterator<Item = &'a str>) -> Delimiter<'static> {
537    let candidates = [",", "\t", ";", "|"];
538    let sample: Vec<&str> = lines.take(12).collect();
539    let mut best: Option<(&str, usize, usize)> = None;
540    for candidate in candidates {
541        let counts: Vec<usize> = sample
542            .iter()
543            .map(|line| split_record(line, &Delimiter::Explicit(candidate)).len())
544            .filter(|count| *count > 1)
545            .collect();
546        if counts.is_empty() {
547            continue;
548        }
549        let consistent = counts.iter().filter(|count| **count == counts[0]).count();
550        let score = (consistent, counts[0]);
551        if best
552            .map(|(_, best_consistent, best_cols)| score > (best_consistent, best_cols))
553            .unwrap_or(true)
554        {
555            best = Some((candidate, consistent, counts[0]));
556        }
557    }
558    best.map(|(candidate, _, _)| Delimiter::Explicit(candidate))
559        .unwrap_or(Delimiter::Whitespace)
560}
561
562fn split_record(line: &str, delimiter: &Delimiter<'_>) -> Vec<String> {
563    match delimiter {
564        Delimiter::Whitespace => line
565            .split_whitespace()
566            .map(|token| unquote(token.trim()))
567            .filter(|token| !token.is_empty())
568            .collect(),
569        Delimiter::Explicit(delimiter) => split_explicit(line, delimiter),
570    }
571}
572
573fn split_explicit(line: &str, delimiter: &str) -> Vec<String> {
574    if delimiter.is_empty() {
575        return vec![line.trim().to_string()];
576    }
577    let mut fields = Vec::new();
578    let mut current = String::new();
579    let mut in_quotes = false;
580    let mut idx = 0usize;
581    while idx < line.len() {
582        let Some(ch) = line[idx..].chars().next() else {
583            break;
584        };
585        if ch == '"' {
586            if in_quotes && line[idx + ch.len_utf8()..].starts_with('"') {
587                current.push('"');
588                idx += ch.len_utf8() * 2;
589                continue;
590            }
591            in_quotes = !in_quotes;
592            idx += ch.len_utf8();
593            continue;
594        }
595        if !in_quotes && line[idx..].starts_with(delimiter) {
596            fields.push(unquote(current.trim()));
597            current.clear();
598            idx += delimiter.len();
599            continue;
600        }
601        current.push(ch);
602        idx += ch.len_utf8();
603    }
604    fields.push(unquote(current.trim()));
605    fields
606}
607
608fn unquote(token: &str) -> String {
609    let trimmed = token.trim();
610    if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
611        trimmed[1..trimmed.len() - 1].replace("\"\"", "\"")
612    } else {
613        trimmed.to_string()
614    }
615}
616
617fn parse_numeric_token(token: &str) -> Option<f64> {
618    let trimmed = token.trim();
619    if trimmed.is_empty() {
620        return Some(f64::NAN);
621    }
622    match trimmed.to_ascii_lowercase().as_str() {
623        "nan" => Some(f64::NAN),
624        "inf" | "+inf" | "infinity" | "+infinity" => Some(f64::INFINITY),
625        "-inf" | "-infinity" => Some(f64::NEG_INFINITY),
626        _ => trimmed.parse::<f64>().ok(),
627    }
628}
629
630fn parse_delimiter_arg(value: &Value) -> BuiltinResult<String> {
631    let text = string_scalar(value, "delimiterIn")?;
632    match text.as_str() {
633        "\\t" => Ok("\t".to_string()),
634        "\\n" => Ok("\n".to_string()),
635        "\\r" => Ok("\r".to_string()),
636        _ => Ok(text),
637    }
638}
639
640fn parse_header_lines(value: &Value) -> BuiltinResult<usize> {
641    if let Some(integer) = tensor::scalar_integer_value(value) {
642        return integer.try_to_usize().ok_or_else(|| {
643            importdata_error_with(
644                &IMPORTDATA_ERROR_ARGUMENT,
645                "importdata: headerlinesIn must be a nonnegative integer scalar",
646            )
647        });
648    }
649
650    let raw = match value {
651        Value::Num(n) => *n,
652        Value::Tensor(t) if tensor::is_scalar_tensor(t) => tensor::tensor_value_f64(t, 0),
653        _ => {
654            return Err(importdata_error_with(
655                &IMPORTDATA_ERROR_ARGUMENT,
656                "importdata: headerlinesIn must be a nonnegative integer scalar",
657            ));
658        }
659    };
660    if !raw.is_finite() || raw < 0.0 || raw.fract() != 0.0 {
661        return Err(importdata_error_with(
662            &IMPORTDATA_ERROR_ARGUMENT,
663            "importdata: headerlinesIn must be a nonnegative integer scalar",
664        ));
665    }
666    if raw > usize::MAX.saturating_sub(1) as f64 {
667        return Err(importdata_error_with(
668            &IMPORTDATA_ERROR_ARGUMENT,
669            "importdata: headerlinesIn is too large",
670        ));
671    }
672    let parsed = raw.round() as usize;
673    if parsed as f64 != raw || parsed == usize::MAX {
674        return Err(importdata_error_with(
675            &IMPORTDATA_ERROR_ARGUMENT,
676            "importdata: headerlinesIn is too large",
677        ));
678    }
679    Ok(parsed)
680}
681
682fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
683    match value {
684        Value::String(s) => normalize_path(s),
685        Value::CharArray(ca) if ca.rows == 1 => {
686            let text: String = ca.data.iter().collect();
687            normalize_path(&text)
688        }
689        Value::StringArray(sa) if sa.data.len() == 1 => normalize_path(&sa.data[0]),
690        _ => Err(importdata_error(&IMPORTDATA_ERROR_ARGUMENT)),
691    }
692}
693
694fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
695    if raw.trim().is_empty() {
696        return Err(importdata_error_with(
697            &IMPORTDATA_ERROR_ARGUMENT,
698            "importdata: filename must not be empty",
699        ));
700    }
701    let expanded = expand_user_path(raw, BUILTIN_NAME)
702        .map_err(|msg| importdata_error_with(&IMPORTDATA_ERROR_ARGUMENT, msg))?;
703    Ok(Path::new(&expanded).to_path_buf())
704}
705
706fn string_scalar(value: &Value, context: &str) -> BuiltinResult<String> {
707    match value {
708        Value::String(s) => Ok(s.clone()),
709        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
710        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
711        _ => Err(importdata_error_with(
712            &IMPORTDATA_ERROR_ARGUMENT,
713            format!("importdata: expected {context} as a string scalar or character vector"),
714        )),
715    }
716}
717
718fn cell_from_rows(rows: &[Vec<String>]) -> BuiltinResult<Value> {
719    let row_count = rows.len();
720    let col_count = rows.iter().map(|row| row.len()).max().unwrap_or(0);
721    let mut values = Vec::with_capacity(row_count * col_count);
722    for row in rows {
723        for col in 0..col_count {
724            values.push(Value::String(row.get(col).cloned().unwrap_or_default()));
725        }
726    }
727    CellArray::new(values, row_count, col_count)
728        .map(Value::Cell)
729        .map_err(|err| importdata_error_with(&IMPORTDATA_ERROR_PARSE, format!("importdata: {err}")))
730}
731
732fn cell_from_row(values: &[String]) -> BuiltinResult<Value> {
733    CellArray::new(
734        values.iter().cloned().map(Value::String).collect(),
735        1,
736        values.len(),
737    )
738    .map(Value::Cell)
739    .map_err(|err| importdata_error_with(&IMPORTDATA_ERROR_PARSE, format!("importdata: {err}")))
740}
741
742fn cell_from_col(values: &[String]) -> BuiltinResult<Value> {
743    CellArray::new(
744        values.iter().cloned().map(Value::String).collect(),
745        values.len(),
746        1,
747    )
748    .map(Value::Cell)
749    .map_err(|err| importdata_error_with(&IMPORTDATA_ERROR_PARSE, format!("importdata: {err}")))
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use futures::executor::block_on;
756    use runmat_time::unix_timestamp_ms;
757    use std::fs;
758    use std::sync::atomic::{AtomicU64, Ordering};
759
760    static NEXT_ID: AtomicU64 = AtomicU64::new(0);
761
762    fn temp_path(ext: &str) -> PathBuf {
763        let millis = unix_timestamp_ms();
764        let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
765        let mut path = std::env::temp_dir();
766        path.push(format!(
767            "runmat_importdata_{}_{}_{}.{}",
768            std::process::id(),
769            millis,
770            unique,
771            ext
772        ));
773        path
774    }
775
776    fn write_fixture(ext: &str, contents: &str) -> PathBuf {
777        let path = temp_path(ext);
778        fs::write(&path, contents).expect("write fixture");
779        path
780    }
781
782    fn struct_field<'a>(value: &'a Value, name: &str) -> &'a Value {
783        let Value::Struct(st) = value else {
784            panic!("expected struct");
785        };
786        st.fields
787            .get(name)
788            .unwrap_or_else(|| panic!("missing {name}"))
789    }
790
791    fn tensor_data(value: &Value) -> (Vec<f64>, Vec<usize>) {
792        let Value::Tensor(tensor) = value else {
793            panic!("expected tensor");
794        };
795        (tensor.materialize_f64(), tensor.shape.clone())
796    }
797
798    fn cell_text(value: &Value, row: usize, col: usize) -> String {
799        let Value::Cell(cell) = value else {
800            panic!("expected cell");
801        };
802        let Value::String(text) = cell.get(row, col).expect("cell value") else {
803            panic!("expected string cell");
804        };
805        text
806    }
807
808    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
809    #[test]
810    fn importdata_descriptor_covers_core_forms() {
811        let labels: Vec<&str> = IMPORTDATA_DESCRIPTOR
812            .signatures
813            .iter()
814            .map(|sig| sig.label)
815            .collect();
816        assert!(labels.contains(&"A = importdata(filename)"));
817        assert!(labels.contains(&"A = importdata(filename, delimiterIn)"));
818        assert!(labels.contains(&"A = importdata(filename, delimiterIn, headerlinesIn)"));
819    }
820
821    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
822    #[test]
823    fn importdata_reads_plain_numeric_matrix() {
824        let path = write_fixture("txt", "1 2 3\n4 5 6\n");
825        let out = block_on(importdata_builtin(
826            Value::from(path.to_string_lossy().into_owned()),
827            Vec::new(),
828        ))
829        .expect("importdata");
830        let (data, shape) = tensor_data(&out);
831        assert_eq!(shape, &[2, 3]);
832        assert_eq!(data, &[1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
833        let _ = fs::remove_file(path);
834    }
835
836    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
837    #[test]
838    fn importdata_headerlines_zero_numeric_input_returns_tensor() {
839        let path = write_fixture("txt", "1 2\n3 4\n");
840        let out = block_on(importdata_builtin(
841            Value::from(path.to_string_lossy().into_owned()),
842            vec![Value::from(" "), Value::Num(0.0)],
843        ))
844        .expect("importdata");
845        let (data, shape) = tensor_data(&out);
846        assert_eq!(shape, &[2, 2]);
847        assert_eq!(data, &[1.0, 3.0, 2.0, 4.0]);
848        let _ = fs::remove_file(path);
849    }
850
851    #[test]
852    fn importdata_headerlines_reads_typed_integer_storage_exactly() {
853        let header_lines =
854            Tensor::new_integer(runmat_value::IntegerStorage::U16(vec![2]), vec![1, 1])
855                .expect("header lines");
856        assert_eq!(
857            parse_header_lines(&Value::Tensor(header_lines)).expect("header lines"),
858            2
859        );
860
861        let negative = Tensor::new_integer(runmat_value::IntegerStorage::I16(vec![-1]), vec![1, 1])
862            .expect("negative header lines");
863        assert!(parse_header_lines(&Value::Tensor(negative)).is_err());
864
865        assert_eq!(
866            parse_header_lines(&Value::Int(runmat_value::IntValue::U64(u64::MAX))).ok(),
867            usize::try_from(u64::MAX).ok()
868        );
869        assert!(parse_header_lines(&Value::Num(1.0e300)).is_err());
870    }
871
872    #[test]
873    fn importdata_headerlines_typed_integer_tensors_ignore_poisoned_f64_mirrors() {
874        let classes = [
875            runmat_value::IntegerStorage::I8(vec![2]),
876            runmat_value::IntegerStorage::I16(vec![2]),
877            runmat_value::IntegerStorage::I32(vec![2]),
878            runmat_value::IntegerStorage::I64(vec![2]),
879            runmat_value::IntegerStorage::U8(vec![2]),
880            runmat_value::IntegerStorage::U16(vec![2]),
881            runmat_value::IntegerStorage::U32(vec![2]),
882            runmat_value::IntegerStorage::U64(vec![2]),
883        ];
884
885        for storage in classes {
886            let tensor = Tensor::new_integer(storage, vec![1, 1]).expect("header lines");
887            assert_eq!(parse_header_lines(&Value::Tensor(tensor)).unwrap(), 2);
888        }
889    }
890
891    #[test]
892    fn importdata_returns_documented_delimiter_and_header_count_outputs() {
893        let path = write_fixture("csv", "name,value\na,1\nb,2\n");
894        let _outputs = crate::output_count::push_output_count(Some(3));
895        let result = block_on(importdata_builtin(
896            Value::from(path.to_string_lossy().into_owned()),
897            Vec::new(),
898        ))
899        .expect("importdata outputs");
900        let Value::OutputList(outputs) = result else {
901            panic!("expected three outputs");
902        };
903        assert_eq!(outputs.len(), 3);
904        assert_eq!(
905            outputs[1],
906            Value::CharArray(runmat_value::CharArray::new_row(","))
907        );
908        assert_eq!(outputs[2], Value::Num(1.0));
909        let _ = fs::remove_file(path);
910    }
911
912    #[test]
913    fn importdata_rejects_resident_controls_before_provider_or_file_access() {
914        let resident = Value::GpuTensor(runmat_accelerate_api::GpuTensorHandle {
915            shape: vec![1, 1],
916            device_id: 98,
917            buffer_id: 419_001,
918            descriptor: Default::default(),
919        });
920        let error = block_on(importdata_builtin(resident, Vec::new()))
921            .expect_err("resident filename must reject");
922        assert_eq!(
923            error.identifier(),
924            Some("RunMat:importdata:InvalidArgument")
925        );
926    }
927
928    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
929    #[test]
930    fn importdata_detects_csv_header_and_colheaders() {
931        let path = write_fixture("csv", "time,value\n0,1.5\n1,2.5\n");
932        let out = block_on(importdata_builtin(
933            Value::from(path.to_string_lossy().into_owned()),
934            Vec::new(),
935        ))
936        .expect("importdata");
937        let data = struct_field(&out, "data");
938        let (values, shape) = tensor_data(data);
939        assert_eq!(shape, &[2, 2]);
940        assert_eq!(values, &[0.0, 1.0, 1.5, 2.5]);
941        assert_eq!(cell_text(struct_field(&out, "colheaders"), 0, 0), "time");
942        assert_eq!(cell_text(struct_field(&out, "colheaders"), 0, 1), "value");
943        let _ = fs::remove_file(path);
944    }
945
946    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
947    #[test]
948    fn importdata_honors_explicit_delimiter_and_header_lines() {
949        let path = write_fixture("dat", "# instrument log\nA|B\n10|20\n30|40\n");
950        let out = block_on(importdata_builtin(
951            Value::from(path.to_string_lossy().into_owned()),
952            vec![Value::from("|"), Value::Num(2.0)],
953        ))
954        .expect("importdata");
955        let data = struct_field(&out, "data");
956        let (values, shape) = tensor_data(data);
957        assert_eq!(shape, &[2, 2]);
958        assert_eq!(values, &[10.0, 30.0, 20.0, 40.0]);
959        assert_eq!(
960            cell_text(struct_field(&out, "textdata"), 0, 0),
961            "# instrument log"
962        );
963        let _ = fs::remove_file(path);
964    }
965
966    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
967    #[test]
968    fn importdata_preserves_rowheaders() {
969        let path = write_fixture("txt", "label x y\nr1 1 2\nr2 3 4\n");
970        let out = block_on(importdata_builtin(
971            Value::from(path.to_string_lossy().into_owned()),
972            Vec::new(),
973        ))
974        .expect("importdata");
975        assert_eq!(cell_text(struct_field(&out, "rowheaders"), 0, 0), "r1");
976        assert_eq!(cell_text(struct_field(&out, "rowheaders"), 1, 0), "r2");
977        assert_eq!(cell_text(struct_field(&out, "colheaders"), 0, 0), "x");
978        assert_eq!(cell_text(struct_field(&out, "colheaders"), 0, 1), "y");
979        let _ = fs::remove_file(path);
980    }
981
982    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
983    #[test]
984    fn importdata_reports_mixed_unsupported_data() {
985        let path = write_fixture("txt", "1 2\n3 nope\n");
986        let err = block_on(importdata_builtin(
987            Value::from(path.to_string_lossy().into_owned()),
988            Vec::new(),
989        ))
990        .expect_err("parse error");
991        assert!(err.message().contains("nonnumeric token"));
992        let _ = fs::remove_file(path);
993    }
994
995    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
996    #[test]
997    fn importdata_rejects_rows_with_extra_numeric_columns() {
998        let path = write_fixture("txt", "1 2\n3 4 5\n");
999        let err = block_on(importdata_builtin(
1000            Value::from(path.to_string_lossy().into_owned()),
1001            Vec::new(),
1002        ))
1003        .expect_err("width mismatch");
1004        assert!(err.message().contains("expected 2"));
1005        let _ = fs::remove_file(path);
1006    }
1007}