1use std::io::{BufRead, BufReader};
8use std::path::{Path, PathBuf};
9
10use runmat_builtins::{
11 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
12 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
13 Tensor, Value,
14};
15use runmat_filesystem::File;
16use runmat_macros::runtime_builtin;
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::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
24
25const BUILTIN_NAME: &str = "csvread";
26
27const CSVREAD_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
28 name: "M",
29 ty: BuiltinParamType::NumericArray,
30 arity: BuiltinParamArity::Required,
31 default: None,
32 description: "Numeric matrix read from the CSV file.",
33}];
34const CSVREAD_INPUTS_FILENAME: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
35 name: "filename",
36 ty: BuiltinParamType::StringScalar,
37 arity: BuiltinParamArity::Required,
38 default: None,
39 description: "CSV file path.",
40}];
41const CSVREAD_INPUTS_FILENAME_ROW_COL: [BuiltinParamDescriptor; 3] = [
42 BuiltinParamDescriptor {
43 name: "filename",
44 ty: BuiltinParamType::StringScalar,
45 arity: BuiltinParamArity::Required,
46 default: None,
47 description: "CSV file path.",
48 },
49 BuiltinParamDescriptor {
50 name: "row",
51 ty: BuiltinParamType::IntegerScalar,
52 arity: BuiltinParamArity::Required,
53 default: None,
54 description: "Zero-based starting row offset.",
55 },
56 BuiltinParamDescriptor {
57 name: "col",
58 ty: BuiltinParamType::IntegerScalar,
59 arity: BuiltinParamArity::Required,
60 default: None,
61 description: "Zero-based starting column offset.",
62 },
63];
64const CSVREAD_INPUTS_FILENAME_ROW_COL_RANGE: [BuiltinParamDescriptor; 4] = [
65 BuiltinParamDescriptor {
66 name: "filename",
67 ty: BuiltinParamType::StringScalar,
68 arity: BuiltinParamArity::Required,
69 default: None,
70 description: "CSV file path.",
71 },
72 BuiltinParamDescriptor {
73 name: "row",
74 ty: BuiltinParamType::IntegerScalar,
75 arity: BuiltinParamArity::Required,
76 default: None,
77 description: "Zero-based starting row offset.",
78 },
79 BuiltinParamDescriptor {
80 name: "col",
81 ty: BuiltinParamType::IntegerScalar,
82 arity: BuiltinParamArity::Required,
83 default: None,
84 description: "Zero-based starting column offset.",
85 },
86 BuiltinParamDescriptor {
87 name: "range",
88 ty: BuiltinParamType::Any,
89 arity: BuiltinParamArity::Required,
90 default: None,
91 description: "A1-style range string or numeric range vector.",
92 },
93];
94const CSVREAD_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
95 BuiltinSignatureDescriptor {
96 label: "M = csvread(filename)",
97 inputs: &CSVREAD_INPUTS_FILENAME,
98 outputs: &CSVREAD_OUTPUT,
99 },
100 BuiltinSignatureDescriptor {
101 label: "M = csvread(filename, row, col)",
102 inputs: &CSVREAD_INPUTS_FILENAME_ROW_COL,
103 outputs: &CSVREAD_OUTPUT,
104 },
105 BuiltinSignatureDescriptor {
106 label: "M = csvread(filename, row, col, range)",
107 inputs: &CSVREAD_INPUTS_FILENAME_ROW_COL_RANGE,
108 outputs: &CSVREAD_OUTPUT,
109 },
110];
111const CSVREAD_ERROR_ARG_CONFIG: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
112 code: "RM.CSVREAD.ARG_CONFIG",
113 identifier: None,
114 when: "Argument list does not match supported csvread call forms.",
115 message: "csvread: invalid argument configuration",
116};
117const CSVREAD_ERROR_INDEX: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
118 code: "RM.CSVREAD.INDEX",
119 identifier: None,
120 when: "Row/column offset arguments are invalid.",
121 message: "csvread: invalid row/column index",
122};
123const CSVREAD_ERROR_RANGE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
124 code: "RM.CSVREAD.RANGE",
125 identifier: None,
126 when: "Range argument is malformed or semantically invalid.",
127 message: "csvread: invalid range",
128};
129const CSVREAD_ERROR_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
130 code: "RM.CSVREAD.FILENAME",
131 identifier: None,
132 when: "Filename argument is not a scalar string/char vector.",
133 message: "csvread: invalid filename input",
134};
135const CSVREAD_ERROR_FILENAME_EMPTY: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
136 code: "RM.CSVREAD.FILENAME_EMPTY",
137 identifier: None,
138 when: "Filename resolves to an empty string.",
139 message: "csvread: filename must not be empty",
140};
141const CSVREAD_ERROR_IO_OPEN: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
142 code: "RM.CSVREAD.IO_OPEN",
143 identifier: None,
144 when: "Input file cannot be opened.",
145 message: "csvread: unable to open file",
146};
147const CSVREAD_ERROR_IO_READ: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
148 code: "RM.CSVREAD.IO_READ",
149 identifier: None,
150 when: "Input file cannot be read.",
151 message: "csvread: failed to read file",
152};
153const CSVREAD_ERROR_NON_NUMERIC_TOKEN: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
154 code: "RM.CSVREAD.NON_NUMERIC_TOKEN",
155 identifier: None,
156 when: "A CSV field cannot be parsed as numeric.",
157 message: "csvread: nonnumeric token encountered",
158};
159const CSVREAD_ERROR_TENSOR_BUILD: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
160 code: "RM.CSVREAD.TENSOR_BUILD",
161 identifier: None,
162 when: "Internal tensor materialization for csvread output fails.",
163 message: "csvread: unable to construct output matrix",
164};
165const CSVREAD_ERRORS: [BuiltinErrorDescriptor; 9] = [
166 CSVREAD_ERROR_ARG_CONFIG,
167 CSVREAD_ERROR_INDEX,
168 CSVREAD_ERROR_RANGE,
169 CSVREAD_ERROR_FILENAME,
170 CSVREAD_ERROR_FILENAME_EMPTY,
171 CSVREAD_ERROR_IO_OPEN,
172 CSVREAD_ERROR_IO_READ,
173 CSVREAD_ERROR_NON_NUMERIC_TOKEN,
174 CSVREAD_ERROR_TENSOR_BUILD,
175];
176pub const CSVREAD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
177 signatures: &CSVREAD_SIGNATURES,
178 output_mode: BuiltinOutputMode::Fixed,
179 completion_policy: BuiltinCompletionPolicy::Public,
180 errors: &CSVREAD_ERRORS,
181};
182
183#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::tabular::csvread")]
184pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
185 name: "csvread",
186 op_kind: GpuOpKind::Custom("io-csvread"),
187 supported_precisions: &[],
188 broadcast: BroadcastSemantics::None,
189 provider_hooks: &[],
190 constant_strategy: ConstantStrategy::InlineLiteral,
191 residency: ResidencyPolicy::GatherImmediately,
192 nan_mode: ReductionNaN::Include,
193 two_pass_threshold: None,
194 workgroup_size: None,
195 accepts_nan_mode: false,
196 notes: "Runs entirely on the host; acceleration providers are not involved.",
197};
198
199fn csvread_error_with(
200 error: &'static BuiltinErrorDescriptor,
201 message: impl Into<String>,
202) -> RuntimeError {
203 let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
204 if let Some(identifier) = error.identifier {
205 builder = builder.with_identifier(identifier);
206 }
207 builder.build()
208}
209
210fn csvread_error_with_source<E>(
211 error: &'static BuiltinErrorDescriptor,
212 message: impl Into<String>,
213 source: E,
214) -> RuntimeError
215where
216 E: std::error::Error + Send + Sync + 'static,
217{
218 let mut builder = build_runtime_error(message)
219 .with_builtin(BUILTIN_NAME)
220 .with_source(source);
221 if let Some(identifier) = error.identifier {
222 builder = builder.with_identifier(identifier);
223 }
224 builder.build()
225}
226
227fn map_control_flow(err: RuntimeError) -> RuntimeError {
228 let identifier = err.identifier().map(|value| value.to_string());
229 let message = err.message().to_string();
230 let mut builder = build_runtime_error(message)
231 .with_builtin(BUILTIN_NAME)
232 .with_source(err);
233 if let Some(identifier) = identifier {
234 builder = builder.with_identifier(identifier);
235 }
236 builder.build()
237}
238
239#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::tabular::csvread")]
240pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
241 name: "csvread",
242 shape: ShapeRequirements::Any,
243 constant_strategy: ConstantStrategy::InlineLiteral,
244 elementwise: None,
245 reduction: None,
246 emits_nan: false,
247 notes: "Not eligible for fusion; executes as a standalone host operation.",
248};
249
250#[runtime_builtin(
251 name = "csvread",
252 category = "io/tabular",
253 summary = "Read numeric data from CSV files.",
254 keywords = "csvread,csv,dlmread,numeric import,range",
255 accel = "cpu",
256 type_resolver(crate::builtins::io::type_resolvers::tensor_type),
257 descriptor(crate::builtins::io::tabular::csvread::CSVREAD_DESCRIPTOR),
258 builtin_path = "crate::builtins::io::tabular::csvread"
259)]
260async fn csvread_builtin(path: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
261 let gathered_path = gather_if_needed_async(&path)
262 .await
263 .map_err(map_control_flow)?;
264 let options = parse_arguments(&rest).await?;
265 let resolved = resolve_path(&gathered_path)?;
266 let (rows, max_cols, skipped_rows) = read_csv_rows(&resolved, &options).await?;
267 let start_row = if options.range.is_none() {
268 options.start_row.saturating_sub(skipped_rows)
269 } else {
270 options.start_row
271 };
272 let subset = if let Some(range) = options.range {
273 let loaded_range = range.relative_to_loaded_rows();
274 apply_range(&rows, max_cols, &loaded_range, 0.0)
275 } else {
276 apply_offsets(&rows, max_cols, start_row, options.start_col, 0.0)
277 };
278 let tensor = rows_to_tensor(subset.rows, subset.row_count, subset.col_count, 0.0)?;
279 Ok(Value::Tensor(tensor))
280}
281
282#[derive(Debug, Default)]
283struct CsvReadOptions {
284 start_row: usize,
285 start_col: usize,
286 range: Option<RangeSpec>,
287}
288
289async fn parse_arguments(args: &[Value]) -> BuiltinResult<CsvReadOptions> {
290 let mut gathered = Vec::with_capacity(args.len());
291 for value in args {
292 gathered.push(
293 gather_if_needed_async(value)
294 .await
295 .map_err(map_control_flow)?,
296 );
297 }
298 match gathered.len() {
299 0 => Ok(CsvReadOptions::default()),
300 2 => {
301 let start_row = value_to_start_index(&gathered[0], "row")?;
302 let start_col = value_to_start_index(&gathered[1], "col")?;
303 Ok(CsvReadOptions {
304 start_row,
305 start_col,
306 range: None,
307 })
308 }
309 3 => {
310 let start_row = value_to_start_index(&gathered[0], "row")?;
311 let start_col = value_to_start_index(&gathered[1], "col")?;
312 let range = parse_range(&gathered[2])?;
313 Ok(CsvReadOptions {
314 start_row,
315 start_col,
316 range: Some(range),
317 })
318 }
319 _ => Err(csvread_error_with(
320 &CSVREAD_ERROR_ARG_CONFIG,
321 "csvread: expected csvread(filename[, row, col[, range]])",
322 )),
323 }
324}
325
326fn value_to_start_index(value: &Value, name: &str) -> BuiltinResult<usize> {
327 match value {
328 Value::Int(i) => {
329 let raw = i.to_i64();
330 if raw < 0 {
331 return Err(csvread_error_with(
332 &CSVREAD_ERROR_INDEX,
333 format!("csvread: {name} must be a non-negative integer"),
334 ));
335 }
336 usize::try_from(raw).map_err(|_| {
337 csvread_error_with(
338 &CSVREAD_ERROR_INDEX,
339 format!("csvread: {name} is too large"),
340 )
341 })
342 }
343 Value::Num(n) => {
344 if !n.is_finite() {
345 return Err(csvread_error_with(
346 &CSVREAD_ERROR_INDEX,
347 format!("csvread: {name} must be a finite integer"),
348 ));
349 }
350 if *n < 0.0 {
351 return Err(csvread_error_with(
352 &CSVREAD_ERROR_INDEX,
353 format!("csvread: {name} must be a non-negative integer"),
354 ));
355 }
356 let rounded = n.round();
357 if (rounded - n).abs() > f64::EPSILON {
358 return Err(csvread_error_with(
359 &CSVREAD_ERROR_INDEX,
360 format!("csvread: {name} must be an integer"),
361 ));
362 }
363 usize::try_from(rounded as i64).map_err(|_| {
364 csvread_error_with(
365 &CSVREAD_ERROR_INDEX,
366 format!("csvread: {name} is too large"),
367 )
368 })
369 }
370 _ => Err(csvread_error_with(
371 &CSVREAD_ERROR_INDEX,
372 format!("csvread: expected {name} as a numeric scalar, got {value:?}"),
373 )),
374 }
375}
376
377fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
378 match value {
379 Value::String(s) => normalize_path(s),
380 Value::CharArray(ca) if ca.rows == 1 => {
381 let text: String = ca.data.iter().collect();
382 normalize_path(&text)
383 }
384 Value::StringArray(sa) => {
385 if sa.data.len() == 1 {
386 normalize_path(&sa.data[0])
387 } else {
388 Err(csvread_error_with(
389 &CSVREAD_ERROR_FILENAME,
390 "csvread: string array inputs must be scalar",
391 ))
392 }
393 }
394 Value::CharArray(_) => Err(csvread_error_with(
395 &CSVREAD_ERROR_FILENAME,
396 "csvread: expected a 1-by-N character vector for the file name",
397 )),
398 other => Err(csvread_error_with(
399 &CSVREAD_ERROR_FILENAME,
400 format!(
401 "csvread: expected filename as string scalar or character vector, got {other:?}"
402 ),
403 )),
404 }
405}
406
407fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
408 if raw.trim().is_empty() {
409 return Err(csvread_error_with(
410 &CSVREAD_ERROR_FILENAME_EMPTY,
411 CSVREAD_ERROR_FILENAME_EMPTY.message,
412 ));
413 }
414 let expanded = expand_user_path(raw, BUILTIN_NAME)
415 .map_err(|msg| csvread_error_with(&CSVREAD_ERROR_FILENAME, msg))?;
416 Ok(Path::new(&expanded).to_path_buf())
417}
418
419async fn read_csv_rows(
420 path: &Path,
421 options: &CsvReadOptions,
422) -> BuiltinResult<(Vec<Vec<f64>>, usize, usize)> {
423 let file = File::open_async(path).await.map_err(|err| {
424 csvread_error_with_source(
425 &CSVREAD_ERROR_IO_OPEN,
426 format!("csvread: unable to open '{}': {err}", path.display()),
427 err,
428 )
429 })?;
430 let mut reader = BufReader::new(file);
431 let mut buffer = Vec::new();
432 let mut rows = Vec::new();
433 let mut max_cols = 0usize;
434 let mut line_index = 0usize;
435 let mut nonempty_row_index = 0usize;
436 let mut skipped_rows = 0usize;
437
438 loop {
439 buffer.clear();
440 let bytes = reader.read_until(b'\n', &mut buffer).map_err(|err| {
441 csvread_error_with_source(
442 &CSVREAD_ERROR_IO_READ,
443 format!("csvread: failed to read '{}': {err}", path.display()),
444 err,
445 )
446 })?;
447 if bytes == 0 {
448 break;
449 }
450 line_index += 1;
451 trim_line_ending(&mut buffer);
452 if buffer.iter().all(u8::is_ascii_whitespace) {
453 continue;
454 }
455 let current_row_index = nonempty_row_index;
456 nonempty_row_index += 1;
457 if let Some(range) = options.range {
458 if let Some(end_row) = range.end_row {
459 if current_row_index > end_row {
460 break;
461 }
462 }
463 if current_row_index < range.start_row {
464 continue;
465 }
466 }
467 if options.range.is_none() && options.start_row > 0 && line_index <= options.start_row {
468 skipped_rows += 1;
469 continue;
470 }
471 let (parse_start_col, parse_end_col) = match options.range {
472 Some(range) => (range.start_col, range.end_col),
473 None => (options.start_col, None),
474 };
475 let line = String::from_utf8_lossy(&buffer);
476 let parsed = parse_csv_row(&line, line_index, parse_start_col, parse_end_col)?;
477 max_cols = max_cols.max(parsed.len());
478 rows.push(parsed);
479 }
480
481 Ok((rows, max_cols, skipped_rows))
482}
483
484fn trim_line_ending(buffer: &mut Vec<u8>) {
485 if buffer.ends_with(b"\n") {
486 buffer.pop();
487 if buffer.ends_with(b"\r") {
488 buffer.pop();
489 }
490 } else if buffer.ends_with(b"\r") {
491 buffer.pop();
492 }
493}
494
495fn parse_csv_row(
496 line: &str,
497 line_index: usize,
498 parse_start_col: usize,
499 parse_end_col: Option<usize>,
500) -> BuiltinResult<Vec<f64>> {
501 let mut values = Vec::new();
502 for (col_index, raw_field) in line.split(',').enumerate() {
503 if col_index < parse_start_col {
504 values.push(0.0);
507 continue;
508 }
509 if parse_end_col.is_some_and(|end_col| col_index > end_col) {
510 break;
511 }
512 let trimmed = raw_field.trim();
513 if trimmed.is_empty() {
514 values.push(0.0);
515 continue;
516 }
517 let unwrapped = if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2
518 {
519 &trimmed[1..trimmed.len() - 1]
520 } else {
521 trimmed
522 };
523 let lowered = unwrapped.to_ascii_lowercase();
524 let value = match lowered.as_str() {
525 "nan" => f64::NAN,
526 "inf" | "+inf" => f64::INFINITY,
527 "-inf" => f64::NEG_INFINITY,
528 _ => unwrapped.parse::<f64>().map_err(|_| {
529 csvread_error_with(
530 &CSVREAD_ERROR_NON_NUMERIC_TOKEN,
531 format!(
532 "csvread: nonnumeric token '{}' at row {} column {}",
533 unwrapped,
534 line_index,
535 col_index + 1
536 ),
537 )
538 })?,
539 };
540 values.push(value);
541 }
542 Ok(values)
543}
544
545#[derive(Clone, Copy, Debug)]
546struct RangeSpec {
547 start_row: usize,
548 start_col: usize,
549 end_row: Option<usize>,
550 end_col: Option<usize>,
551}
552
553impl RangeSpec {
554 fn relative_to_loaded_rows(self) -> Self {
555 if self.end_row.is_some_and(|end_row| end_row < self.start_row) {
556 return Self {
557 start_row: 1,
558 start_col: self.start_col,
559 end_row: Some(0),
560 end_col: self.end_col,
561 };
562 }
563 Self {
564 start_row: 0,
565 start_col: self.start_col,
566 end_row: self.end_row.map(|end_row| end_row - self.start_row),
567 end_col: self.end_col,
568 }
569 }
570}
571
572fn parse_range(value: &Value) -> BuiltinResult<RangeSpec> {
573 match value {
574 Value::String(s) => parse_range_string(s),
575 Value::CharArray(ca) if ca.rows == 1 => {
576 let text: String = ca.data.iter().collect();
577 parse_range_string(&text)
578 }
579 Value::StringArray(sa) => {
580 if sa.data.len() == 1 {
581 parse_range_string(&sa.data[0])
582 } else {
583 Err(csvread_error_with(
584 &CSVREAD_ERROR_RANGE,
585 "csvread: Range string array inputs must be scalar",
586 ))
587 }
588 }
589 Value::Tensor(_) => parse_range_numeric(value),
590 _ => Err(csvread_error_with(
591 &CSVREAD_ERROR_RANGE,
592 "csvread: Range must be provided as a string or numeric vector",
593 )),
594 }
595}
596
597fn parse_range_string(text: &str) -> BuiltinResult<RangeSpec> {
598 let trimmed = text.trim();
599 if trimmed.is_empty() {
600 return Err(csvread_error_with(
601 &CSVREAD_ERROR_RANGE,
602 "csvread: Range string cannot be empty",
603 ));
604 }
605 let parts: Vec<&str> = trimmed.split(':').collect();
606 if parts.len() > 2 {
607 return Err(csvread_error_with(
608 &CSVREAD_ERROR_RANGE,
609 format!("csvread: invalid Range specification '{trimmed}'"),
610 ));
611 }
612 let start = parse_cell_reference(parts[0])?;
613 if start.col.is_none() {
614 return Err(csvread_error_with(
615 &CSVREAD_ERROR_RANGE,
616 "csvread: Range must specify a starting column",
617 ));
618 }
619 let end = if parts.len() == 2 {
620 Some(parse_cell_reference(parts[1])?)
621 } else {
622 None
623 };
624 if let Some(ref end_ref) = end {
625 if end_ref.col.is_none() {
626 return Err(csvread_error_with(
627 &CSVREAD_ERROR_RANGE,
628 "csvread: Range end must include a column reference",
629 ));
630 }
631 }
632 let start_row = start.row.unwrap_or(0);
633 let start_col = start.col.unwrap();
634 let end_row = end.as_ref().and_then(|r| r.row);
635 let end_col = end.as_ref().and_then(|r| r.col);
636 Ok(RangeSpec {
637 start_row,
638 start_col,
639 end_row,
640 end_col,
641 })
642}
643
644fn parse_range_numeric(value: &Value) -> BuiltinResult<RangeSpec> {
645 let elements = match value {
646 Value::Tensor(t) => t.data.clone(),
647 _ => {
648 return Err(csvread_error_with(
649 &CSVREAD_ERROR_RANGE,
650 "csvread: numeric Range must be provided as a vector with 2 or 4 elements",
651 ));
652 }
653 };
654 if elements.len() != 2 && elements.len() != 4 {
655 return Err(csvread_error_with(
656 &CSVREAD_ERROR_RANGE,
657 "csvread: numeric Range must contain exactly 2 or 4 elements",
658 ));
659 }
660 let mut indices = Vec::with_capacity(elements.len());
661 for (idx, element) in elements.iter().enumerate() {
662 indices.push(non_negative_index(*element, idx)?);
663 }
664 let start_row = indices[0];
665 let start_col = indices[1];
666 let (end_row, end_col) = if indices.len() == 4 {
667 (Some(indices[2]), Some(indices[3]))
668 } else {
669 (None, None)
670 };
671 Ok(RangeSpec {
672 start_row,
673 start_col,
674 end_row,
675 end_col,
676 })
677}
678
679fn non_negative_index(value: f64, position: usize) -> BuiltinResult<usize> {
680 if !value.is_finite() {
681 return Err(csvread_error_with(
682 &CSVREAD_ERROR_RANGE,
683 "csvread: Range indices must be finite",
684 ));
685 }
686 if value < 0.0 {
687 return Err(csvread_error_with(
688 &CSVREAD_ERROR_RANGE,
689 "csvread: Range indices must be non-negative",
690 ));
691 }
692 let rounded = value.round();
693 if (rounded - value).abs() > f64::EPSILON {
694 return Err(csvread_error_with(
695 &CSVREAD_ERROR_RANGE,
696 "csvread: Range indices must be integers",
697 ));
698 }
699 usize::try_from(rounded as i64).map_err(|_| {
700 csvread_error_with(
701 &CSVREAD_ERROR_RANGE,
702 format!(
703 "csvread: Range index {} is too large to fit in usize",
704 position + 1
705 ),
706 )
707 })
708}
709
710#[derive(Clone, Copy)]
711struct CellReference {
712 row: Option<usize>,
713 col: Option<usize>,
714}
715
716fn parse_cell_reference(token: &str) -> BuiltinResult<CellReference> {
717 let mut letters = String::new();
718 let mut digits = String::new();
719 for ch in token.trim().chars() {
720 if ch == '$' {
721 continue;
722 }
723 if ch.is_ascii_alphabetic() {
724 letters.push(ch.to_ascii_uppercase());
725 } else if ch.is_ascii_digit() {
726 digits.push(ch);
727 } else {
728 return Err(csvread_error_with(
729 &CSVREAD_ERROR_RANGE,
730 format!("csvread: invalid Range component '{token}'"),
731 ));
732 }
733 }
734 if letters.is_empty() && digits.is_empty() {
735 return Err(csvread_error_with(
736 &CSVREAD_ERROR_RANGE,
737 "csvread: Range references cannot be empty",
738 ));
739 }
740 let col = if letters.is_empty() {
741 None
742 } else {
743 Some(column_index_from_letters(&letters)?)
744 };
745 let row = if digits.is_empty() {
746 None
747 } else {
748 let parsed = digits.parse::<usize>().map_err(|_| {
749 csvread_error_with(
750 &CSVREAD_ERROR_RANGE,
751 format!(
752 "csvread: invalid row index '{}' in Range component '{token}'",
753 digits
754 ),
755 )
756 })?;
757 if parsed == 0 {
758 return Err(csvread_error_with(
759 &CSVREAD_ERROR_RANGE,
760 "csvread: Range rows must be >= 1",
761 ));
762 }
763 Some(parsed - 1)
764 };
765 Ok(CellReference { row, col })
766}
767
768fn column_index_from_letters(letters: &str) -> BuiltinResult<usize> {
769 let mut value: usize = 0;
770 for ch in letters.chars() {
771 if !ch.is_ascii_uppercase() {
772 return Err(csvread_error_with(
773 &CSVREAD_ERROR_RANGE,
774 format!("csvread: invalid column designator '{letters}' in Range"),
775 ));
776 }
777 let digit = (ch as u8 - b'A' + 1) as usize;
778 value = value
779 .checked_mul(26)
780 .and_then(|v| v.checked_add(digit))
781 .ok_or_else(|| {
782 csvread_error_with(
783 &CSVREAD_ERROR_RANGE,
784 "csvread: Range column index overflowed",
785 )
786 })?;
787 }
788 value.checked_sub(1).ok_or_else(|| {
789 csvread_error_with(
790 &CSVREAD_ERROR_RANGE,
791 "csvread: Range column index underflowed",
792 )
793 })
794}
795
796struct SubsetResult {
797 rows: Vec<Vec<f64>>,
798 row_count: usize,
799 col_count: usize,
800}
801
802fn apply_offsets(
803 rows: &[Vec<f64>],
804 max_cols: usize,
805 start_row: usize,
806 start_col: usize,
807 default_fill: f64,
808) -> SubsetResult {
809 if rows.is_empty() || max_cols == 0 {
810 return SubsetResult {
811 rows: Vec::new(),
812 row_count: 0,
813 col_count: 0,
814 };
815 }
816 if start_row >= rows.len() {
817 return SubsetResult {
818 rows: Vec::new(),
819 row_count: 0,
820 col_count: 0,
821 };
822 }
823 if start_col >= max_cols {
824 return SubsetResult {
825 rows: Vec::new(),
826 row_count: 0,
827 col_count: 0,
828 };
829 }
830
831 let mut subset_rows = Vec::new();
832 let mut col_count = 0usize;
833 for row in rows.iter().skip(start_row) {
834 if start_col >= row.len() && row.len() < max_cols {
835 let width = max_cols - start_col;
837 subset_rows.push(vec![default_fill; width]);
838 col_count = col_count.max(width);
839 continue;
840 }
841 let mut extracted = Vec::with_capacity(max_cols - start_col);
842 for col_idx in start_col..max_cols {
843 let value = row.get(col_idx).copied().unwrap_or(default_fill);
844 extracted.push(value);
845 }
846 col_count = col_count.max(extracted.len());
847 subset_rows.push(extracted);
848 }
849 let row_count = subset_rows.len();
850 SubsetResult {
851 rows: subset_rows,
852 row_count,
853 col_count,
854 }
855}
856
857fn apply_range(
858 rows: &[Vec<f64>],
859 max_cols: usize,
860 range: &RangeSpec,
861 default_fill: f64,
862) -> SubsetResult {
863 if rows.is_empty() || max_cols == 0 {
864 return SubsetResult {
865 rows: Vec::new(),
866 row_count: 0,
867 col_count: 0,
868 };
869 }
870 if range.start_row >= rows.len() || range.start_col >= max_cols {
871 return SubsetResult {
872 rows: Vec::new(),
873 row_count: 0,
874 col_count: 0,
875 };
876 }
877 let last_row = rows.len().saturating_sub(1);
878 let mut end_row = range.end_row.unwrap_or(last_row);
879 if end_row > last_row {
880 end_row = last_row;
881 }
882 if end_row < range.start_row {
883 return SubsetResult {
884 rows: Vec::new(),
885 row_count: 0,
886 col_count: 0,
887 };
888 }
889
890 let last_col = max_cols.saturating_sub(1);
891 let mut end_col = range.end_col.unwrap_or(last_col);
892 if end_col > last_col {
893 end_col = last_col;
894 }
895 if end_col < range.start_col {
896 return SubsetResult {
897 rows: Vec::new(),
898 row_count: 0,
899 col_count: 0,
900 };
901 }
902
903 let mut subset_rows = Vec::new();
904 let mut col_count = 0usize;
905 for row_idx in range.start_row..=end_row {
906 if row_idx >= rows.len() {
907 break;
908 }
909 let row = &rows[row_idx];
910 let mut extracted = Vec::with_capacity(end_col - range.start_col + 1);
911 for col_idx in range.start_col..=end_col {
912 if col_idx >= max_cols {
913 break;
914 }
915 let value = row.get(col_idx).copied().unwrap_or(default_fill);
916 extracted.push(value);
917 }
918 col_count = col_count.max(extracted.len());
919 subset_rows.push(extracted);
920 }
921 let row_count = subset_rows.len();
922 SubsetResult {
923 rows: subset_rows,
924 row_count,
925 col_count,
926 }
927}
928
929fn rows_to_tensor(
930 rows: Vec<Vec<f64>>,
931 row_count: usize,
932 col_count: usize,
933 default_fill: f64,
934) -> BuiltinResult<Tensor> {
935 if row_count == 0 || col_count == 0 {
936 return Tensor::new(Vec::new(), vec![0, 0])
937 .map_err(|e| csvread_error_with(&CSVREAD_ERROR_TENSOR_BUILD, format!("csvread: {e}")));
938 }
939 let mut data = vec![default_fill; row_count * col_count];
940 for (row_idx, row) in rows.iter().enumerate().take(row_count) {
941 for col_idx in 0..col_count {
942 let value = row.get(col_idx).copied().unwrap_or(default_fill);
943 data[row_idx + col_idx * row_count] = value;
944 }
945 }
946 Tensor::new(data, vec![row_count, col_count])
947 .map_err(|e| csvread_error_with(&CSVREAD_ERROR_TENSOR_BUILD, format!("csvread: {e}")))
948}
949
950#[cfg(test)]
951pub(crate) mod tests {
952 use super::*;
953 use runmat_time::unix_timestamp_ns;
954 use std::fs;
955 use std::sync::atomic::{AtomicUsize, Ordering};
956
957 use runmat_builtins::{CharArray, IntValue, Tensor as BuiltinTensor};
958
959 fn csvread_builtin(path: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
960 let _provider_lock = runmat_filesystem::provider_override_lock();
961 futures::executor::block_on(super::csvread_builtin(path, rest))
962 }
963
964 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
965 #[test]
966 fn csvread_descriptor_signatures_cover_core_forms() {
967 let labels: Vec<&str> = CSVREAD_DESCRIPTOR
968 .signatures
969 .iter()
970 .map(|sig| sig.label)
971 .collect();
972 assert!(labels.contains(&"M = csvread(filename)"));
973 assert!(labels.contains(&"M = csvread(filename, row, col)"));
974 assert!(labels.contains(&"M = csvread(filename, row, col, range)"));
975 }
976
977 static UNIQUE_COUNTER: AtomicUsize = AtomicUsize::new(0);
978
979 fn unique_path(prefix: &str) -> PathBuf {
980 let nanos = unix_timestamp_ns();
981 let seq = UNIQUE_COUNTER.fetch_add(1, Ordering::Relaxed);
982 let mut path = std::env::temp_dir();
983 path.push(format!(
984 "runmat_csvread_{prefix}_{}_{}_{}",
985 std::process::id(),
986 nanos,
987 seq
988 ));
989 path
990 }
991
992 fn write_temp_file(lines: &[&str]) -> PathBuf {
993 let path = unique_path("input").with_extension("csv");
994 let contents = lines.join("\n");
995 fs::write(&path, contents).expect("write temp csv");
996 path
997 }
998
999 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1000 #[test]
1001 fn csvread_basic_csv_roundtrip() {
1002 let path = write_temp_file(&["1,2,3", "4,5,6"]);
1003 let result = csvread_builtin(Value::from(path.to_string_lossy().to_string()), Vec::new())
1004 .expect("csvread");
1005 match result {
1006 Value::Tensor(t) => {
1007 assert_eq!(t.shape, vec![2, 3]);
1008 assert_eq!(t.data, vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0]);
1009 }
1010 other => panic!("expected tensor, got {other:?}"),
1011 }
1012 fs::remove_file(path).ok();
1013 }
1014
1015 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1016 #[test]
1017 fn csvread_with_offsets() {
1018 let path = write_temp_file(&["0,1,2", "3,4,5", "6,7,8"]);
1019 let args = vec![Value::Int(IntValue::I32(1)), Value::Int(IntValue::I32(1))];
1020 let result =
1021 csvread_builtin(Value::from(path.to_string_lossy().to_string()), args).expect("csv");
1022 match result {
1023 Value::Tensor(t) => {
1024 assert_eq!(t.shape, vec![2, 2]);
1025 assert_eq!(t.data, vec![4.0, 7.0, 5.0, 8.0]);
1026 }
1027 other => panic!("expected tensor, got {other:?}"),
1028 }
1029 fs::remove_file(path).ok();
1030 }
1031
1032 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1033 #[test]
1034 fn csvread_with_numeric_range() {
1035 let path = write_temp_file(&["1,2,3", "4,5,6", "7,8,9"]);
1036 let args = vec![
1037 Value::Int(IntValue::I32(0)),
1038 Value::Int(IntValue::I32(0)),
1039 Value::from(BuiltinTensor::new(vec![1.0, 1.0, 2.0, 2.0], vec![4, 1]).expect("tensor")),
1040 ];
1041 let result =
1042 csvread_builtin(Value::from(path.to_string_lossy().to_string()), args).expect("csv");
1043 match result {
1044 Value::Tensor(t) => {
1045 assert_eq!(t.shape, vec![2, 2]);
1046 assert_eq!(t.data, vec![5.0, 8.0, 6.0, 9.0]);
1047 }
1048 other => panic!("expected tensor, got {other:?}"),
1049 }
1050 fs::remove_file(path).ok();
1051 }
1052
1053 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1054 #[test]
1055 fn csvread_with_string_range() {
1056 let path = write_temp_file(&["1,2,3", "4,5,6", "7,8,9"]);
1057 let args = vec![
1058 Value::Int(IntValue::I32(0)),
1059 Value::Int(IntValue::I32(0)),
1060 Value::from("B2:C3"),
1061 ];
1062 let result =
1063 csvread_builtin(Value::from(path.to_string_lossy().to_string()), args).expect("csv");
1064 match result {
1065 Value::Tensor(t) => {
1066 assert_eq!(t.shape, vec![2, 2]);
1067 assert_eq!(t.data, vec![5.0, 8.0, 6.0, 9.0]);
1068 }
1069 other => panic!("expected tensor, got {other:?}"),
1070 }
1071 fs::remove_file(path).ok();
1072 }
1073
1074 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1075 #[test]
1076 fn csvread_empty_fields_become_zero() {
1077 let path = write_temp_file(&["1,,3", ",5,", "7,8,"]);
1078 let result = csvread_builtin(Value::from(path.to_string_lossy().to_string()), Vec::new())
1079 .expect("csv");
1080 match result {
1081 Value::Tensor(t) => {
1082 assert_eq!(t.shape, vec![3, 3]);
1083 assert_eq!(t.data, vec![1.0, 0.0, 7.0, 0.0, 5.0, 8.0, 3.0, 0.0, 0.0]);
1084 }
1085 other => panic!("expected tensor, got {other:?}"),
1086 }
1087 fs::remove_file(path).ok();
1088 }
1089
1090 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1091 #[test]
1092 fn csvread_errors_on_text() {
1093 let path = write_temp_file(&["1,2,3", "4,error,6"]);
1094 let err = csvread_builtin(Value::from(path.to_string_lossy().to_string()), Vec::new())
1095 .expect_err("should fail");
1096 let message = err.message().to_string();
1097 assert!(
1098 message.contains("nonnumeric token 'error'"),
1099 "unexpected error: {message}"
1100 );
1101 fs::remove_file(path).ok();
1102 }
1103
1104 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1105 #[test]
1106 fn csvread_accepts_char_array_filename() {
1107 let path = write_temp_file(&["1,2"]);
1108 let path_string = path.to_string_lossy().to_string();
1109 let data: Vec<char> = path_string.chars().collect();
1110 let cols = data.len();
1111 let chars = CharArray::new(data, 1, cols).expect("char array");
1112 let result = csvread_builtin(Value::CharArray(chars), Vec::new()).expect("csv");
1113 match result {
1114 Value::Tensor(t) => {
1115 assert_eq!(t.shape, vec![1, 2]);
1116 assert_eq!(t.data, vec![1.0, 2.0]);
1117 }
1118 other => panic!("expected tensor, got {other:?}"),
1119 }
1120 fs::remove_file(path).ok();
1121 }
1122
1123 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1124 #[test]
1125 fn csvread_with_header_and_row_labels_using_offsets() {
1126 let path = write_temp_file(&["Name,Jan,Feb", "alpha,1,2", "beta,3,4"]);
1127 let args = vec![Value::Int(IntValue::I32(1)), Value::Int(IntValue::I32(1))];
1128 let result =
1129 csvread_builtin(Value::from(path.to_string_lossy().to_string()), args).expect("csv");
1130 match result {
1131 Value::Tensor(t) => {
1132 assert_eq!(t.shape, vec![2, 2]);
1133 assert_eq!(t.data, vec![1.0, 3.0, 2.0, 4.0]);
1134 }
1135 other => panic!("expected tensor, got {other:?}"),
1136 }
1137 fs::remove_file(path).ok();
1138 }
1139
1140 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1141 #[test]
1142 fn csvread_skips_non_utf8_bytes_outside_requested_numeric_block() {
1143 let path = unique_path("latin1_offsets").with_extension("csv");
1144 fs::write(&path, b"label,\xFCtemp\n\xFFrow,21.5\n\xFErow,22.0\n").expect("write temp csv");
1145 let args = vec![Value::Int(IntValue::I32(1)), Value::Int(IntValue::I32(1))];
1146 let result =
1147 csvread_builtin(Value::from(path.to_string_lossy().to_string()), args).expect("csv");
1148 match result {
1149 Value::Tensor(t) => {
1150 assert_eq!(t.shape, vec![2, 1]);
1151 assert_eq!(t.data, vec![21.5, 22.0]);
1152 }
1153 other => panic!("expected tensor, got {other:?}"),
1154 }
1155 fs::remove_file(path).ok();
1156 }
1157
1158 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1159 #[test]
1160 fn csvread_numeric_range_ignores_bytes_and_text_outside_rectangle() {
1161 let path = unique_path("latin1_numeric_range").with_extension("csv");
1162 fs::write(
1163 &path,
1164 b"header,\xFCbad,tail\n\xFFlabel,21.5,\xFEtail\n\xFFlabel,22.0,\xFEtail\nafter,\xFCbad,tail\n",
1165 )
1166 .expect("write temp csv");
1167 let range = BuiltinTensor::new(vec![1.0, 1.0, 2.0, 1.0], vec![4, 1]).expect("tensor");
1168 let args = vec![
1169 Value::Int(IntValue::I32(0)),
1170 Value::Int(IntValue::I32(0)),
1171 Value::from(range),
1172 ];
1173 let result =
1174 csvread_builtin(Value::from(path.to_string_lossy().to_string()), args).expect("csv");
1175 match result {
1176 Value::Tensor(t) => {
1177 assert_eq!(t.shape, vec![2, 1]);
1178 assert_eq!(t.data, vec![21.5, 22.0]);
1179 }
1180 other => panic!("expected tensor, got {other:?}"),
1181 }
1182 fs::remove_file(path).ok();
1183 }
1184
1185 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1186 #[test]
1187 fn csvread_a1_range_ignores_bytes_and_text_outside_rectangle() {
1188 let path = unique_path("latin1_a1_range").with_extension("csv");
1189 fs::write(
1190 &path,
1191 b"header,\xFCbad,tail\n\xFFlabel,21.5,\xFEtail\n\xFFlabel,22.0,\xFEtail\nafter,\xFCbad,tail\n",
1192 )
1193 .expect("write temp csv");
1194 let args = vec![
1195 Value::Int(IntValue::I32(0)),
1196 Value::Int(IntValue::I32(0)),
1197 Value::from("B2:B3"),
1198 ];
1199 let result =
1200 csvread_builtin(Value::from(path.to_string_lossy().to_string()), args).expect("csv");
1201 match result {
1202 Value::Tensor(t) => {
1203 assert_eq!(t.shape, vec![2, 1]);
1204 assert_eq!(t.data, vec![21.5, 22.0]);
1205 }
1206 other => panic!("expected tensor, got {other:?}"),
1207 }
1208 fs::remove_file(path).ok();
1209 }
1210}