Skip to main content

runmat_runtime/builtins/io/tabular/
csvread.rs

1//! MATLAB-compatible `csvread` builtin for RunMat.
2//!
3//! `csvread` is largely superseded by `readmatrix`, but MATLAB users still rely on
4//! its terse API for numeric CSV imports. This implementation mirrors MATLAB's
5//! zero-based range semantics while integrating with the modern builtin template.
6
7use 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            // Respect csvread(..., row, col) offsets by skipping validation for
505            // columns that will be dropped before materializing the output.
506            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            // Entire row missing required columns; fill zeros of remaining width.
836            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}