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