1use std::io::Write;
10use std::path::{Path, PathBuf};
11
12use runmat_builtins::{
13 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
14 BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
15 BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
16 BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
17 BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
18 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
19};
20use runmat_filesystem::OpenOptions;
21use runmat_macros::runtime_builtin;
22use runmat_value::{ComplexTensor, Tensor, Value};
23
24use crate::builtins::common::fs::expand_user_path;
25use crate::builtins::common::spec::{
26 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
27 ReductionNaN, ResidencyPolicy, ShapeRequirements,
28};
29use crate::builtins::common::tensor;
30use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
31
32const BUILTIN_NAME: &str = "csvwrite";
33
34const CSVWRITE_BYTES_OUTPUT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
35 id: "csvwrite-bytes-written-output",
36 mode: BuiltinExtensionMode::RunMatOnly,
37 description: "requesting a bytes-written output from csvwrite is a RunMat extension",
38 error_identifier: Some("RunMat:compatibility:CsvwriteBytesOutputExtension"),
39};
40const CSVWRITE_RESIDENT_INPUT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
41 id: "csvwrite-resident-input",
42 mode: BuiltinExtensionMode::RunMatOnly,
43 description: "direct csvwrite of resident data or controls is a RunMat extension",
44 error_identifier: Some("RunMat:compatibility:CsvwriteResidentInputExtension"),
45};
46pub const CSVWRITE_EXTENSIONS: [BuiltinExtensionDescriptor; 2] = [
47 CSVWRITE_BYTES_OUTPUT_EXTENSION,
48 CSVWRITE_RESIDENT_INPUT_EXTENSION,
49];
50const CSVWRITE_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 3] = [
51 BuiltinIntegerInputCapability { name: "M", classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::Allowed, notes: "All eight real integer classes are documented and serialize directly from authoritative storage." },
52 BuiltinIntegerInputCapability { name: "row", classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::Allowed, notes: "The zero-based row offset accepts all eight integer classes and is checked exactly." },
53 BuiltinIntegerInputCapability { name: "col", classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES, availability: BuiltinIntegerInputAvailability::Documented, scalar_double: BuiltinIntegerScalarDoubleRule::Allowed, notes: "The zero-based column offset accepts all eight integer classes and is checked exactly." },
54];
55pub const CSVWRITE_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] = [BuiltinIntegerCapabilityDescriptor { form: "csvwrite(filename, integer_M, integer_row?, integer_col?)", inputs: &CSVWRITE_INTEGER_INPUTS, computation_domain: BuiltinIntegerComputationDomain::ExactInteger, output_class: BuiltinIntegerOutputClassRule::FunctionSpecific, overflow: BuiltinIntegerOverflowRule::NotApplicable, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::Multiple, notes: "Integer matrix elements serialize with their exact values. Resident input is independently gated and gathered through the handle owner." }];
56
57const CSVWRITE_INPUTS_FILENAME_DATA: [BuiltinParamDescriptor; 2] = [
58 BuiltinParamDescriptor {
59 name: "filename",
60 ty: BuiltinParamType::StringScalar,
61 arity: BuiltinParamArity::Required,
62 default: None,
63 description: "CSV output path.",
64 },
65 BuiltinParamDescriptor {
66 name: "M",
67 ty: BuiltinParamType::Any,
68 arity: BuiltinParamArity::Required,
69 default: None,
70 description: "Numeric/logical matrix data to write.",
71 },
72];
73const CSVWRITE_INPUTS_FILENAME_DATA_ROW_COL: [BuiltinParamDescriptor; 4] = [
74 BuiltinParamDescriptor {
75 name: "filename",
76 ty: BuiltinParamType::StringScalar,
77 arity: BuiltinParamArity::Required,
78 default: None,
79 description: "CSV output path.",
80 },
81 BuiltinParamDescriptor {
82 name: "M",
83 ty: BuiltinParamType::Any,
84 arity: BuiltinParamArity::Required,
85 default: None,
86 description: "Numeric/logical matrix data to write.",
87 },
88 BuiltinParamDescriptor {
89 name: "row",
90 ty: BuiltinParamType::IntegerScalar,
91 arity: BuiltinParamArity::Required,
92 default: None,
93 description: "Zero-based row offset before writing values.",
94 },
95 BuiltinParamDescriptor {
96 name: "col",
97 ty: BuiltinParamType::IntegerScalar,
98 arity: BuiltinParamArity::Required,
99 default: None,
100 description: "Zero-based column offset before writing values.",
101 },
102];
103const CSVWRITE_NO_OUTPUT: [BuiltinParamDescriptor; 0] = [];
104const CSVWRITE_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
105 BuiltinSignatureDescriptor {
106 label: "csvwrite(filename, M)",
107 inputs: &CSVWRITE_INPUTS_FILENAME_DATA,
108 outputs: &CSVWRITE_NO_OUTPUT,
109 },
110 BuiltinSignatureDescriptor {
111 label: "csvwrite(filename, M, row, col)",
112 inputs: &CSVWRITE_INPUTS_FILENAME_DATA_ROW_COL,
113 outputs: &CSVWRITE_NO_OUTPUT,
114 },
115];
116const CSVWRITE_ERROR_FILENAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
117 code: "RM.CSVWRITE.FILENAME",
118 identifier: None,
119 when: "Filename argument is not a scalar string/char vector.",
120 message: "csvwrite: invalid filename input",
121};
122const CSVWRITE_ERROR_FILENAME_EMPTY: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
123 code: "RM.CSVWRITE.FILENAME_EMPTY",
124 identifier: None,
125 when: "Filename resolves to an empty string.",
126 message: "csvwrite: filename must not be empty",
127};
128const CSVWRITE_ERROR_OFFSETS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
129 code: "RM.CSVWRITE.OFFSETS",
130 identifier: None,
131 when: "Offset arguments are missing, malformed, or out of bounds.",
132 message: "csvwrite: invalid row/column offsets",
133};
134const CSVWRITE_ERROR_DATA_SHAPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
135 code: "RM.CSVWRITE.DATA_SHAPE",
136 identifier: None,
137 when: "Input data is not a 2-D matrix.",
138 message: "csvwrite: input must be 2-D",
139};
140const CSVWRITE_ERROR_DATA_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
141 code: "RM.CSVWRITE.DATA_INPUT",
142 identifier: None,
143 when: "Input data cannot be converted to a numeric/logical tensor.",
144 message: "csvwrite: input must be numeric or logical",
145};
146const CSVWRITE_ERROR_IO_OPEN: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
147 code: "RM.CSVWRITE.IO_OPEN",
148 identifier: None,
149 when: "Output file cannot be opened.",
150 message: "csvwrite: unable to open file for writing",
151};
152const CSVWRITE_ERROR_IO_WRITE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
153 code: "RM.CSVWRITE.IO_WRITE",
154 identifier: None,
155 when: "Output file write/flush fails.",
156 message: "csvwrite: write failed",
157};
158const CSVWRITE_ERRORS: [BuiltinErrorDescriptor; 7] = [
159 CSVWRITE_ERROR_FILENAME,
160 CSVWRITE_ERROR_FILENAME_EMPTY,
161 CSVWRITE_ERROR_OFFSETS,
162 CSVWRITE_ERROR_DATA_INPUT,
163 CSVWRITE_ERROR_DATA_SHAPE,
164 CSVWRITE_ERROR_IO_OPEN,
165 CSVWRITE_ERROR_IO_WRITE,
166];
167pub const CSVWRITE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
168 signatures: &CSVWRITE_SIGNATURES,
169 output_mode: BuiltinOutputMode::Fixed,
170 completion_policy: BuiltinCompletionPolicy::Public,
171 errors: &CSVWRITE_ERRORS,
172};
173
174#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::tabular::csvwrite")]
175pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
176 name: "csvwrite",
177 op_kind: GpuOpKind::Custom("io-csvwrite"),
178 supported_precisions: &[],
179 broadcast: BroadcastSemantics::None,
180 provider_hooks: &[],
181 constant_strategy: ConstantStrategy::InlineLiteral,
182 residency: ResidencyPolicy::GatherImmediately,
183 nan_mode: ReductionNaN::Include,
184 two_pass_threshold: None,
185 workgroup_size: None,
186 accepts_nan_mode: false,
187 notes: "Runs entirely on the host; gpuArray inputs are gathered before serialisation.",
188};
189
190#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::tabular::csvwrite")]
191pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
192 name: "csvwrite",
193 shape: ShapeRequirements::Any,
194 constant_strategy: ConstantStrategy::InlineLiteral,
195 elementwise: None,
196 reduction: None,
197 emits_nan: false,
198 notes: "Not eligible for fusion; performs host-side file I/O.",
199};
200
201fn csvwrite_error_with(
202 error: &'static BuiltinErrorDescriptor,
203 message: impl Into<String>,
204) -> RuntimeError {
205 let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
206 if let Some(identifier) = error.identifier {
207 builder = builder.with_identifier(identifier);
208 }
209 builder.build()
210}
211
212fn csvwrite_error_with_source<E>(
213 error: &'static BuiltinErrorDescriptor,
214 message: impl Into<String>,
215 source: E,
216) -> RuntimeError
217where
218 E: std::error::Error + Send + Sync + 'static,
219{
220 let mut builder = build_runtime_error(message)
221 .with_builtin(BUILTIN_NAME)
222 .with_source(source);
223 if let Some(identifier) = error.identifier {
224 builder = builder.with_identifier(identifier);
225 }
226 builder.build()
227}
228
229fn map_control_flow(err: RuntimeError) -> RuntimeError {
230 let identifier = err.identifier().map(|value| value.to_string());
231 let message = err.message().to_string();
232 let mut builder = build_runtime_error(message)
233 .with_builtin(BUILTIN_NAME)
234 .with_source(err);
235 if let Some(identifier) = identifier {
236 builder = builder.with_identifier(identifier);
237 }
238 builder.build()
239}
240
241#[runtime_builtin(
242 name = "csvwrite",
243 category = "io/tabular",
244 summary = "Write numeric matrices to CSV files.",
245 keywords = "csvwrite,csv,write,row offset,column offset",
246 accel = "cpu",
247 type_resolver(crate::builtins::io::type_resolvers::num_type),
248 descriptor(crate::builtins::io::tabular::csvwrite::CSVWRITE_DESCRIPTOR),
249 extensions(crate::builtins::io::tabular::csvwrite::CSVWRITE_EXTENSIONS),
250 integer_capabilities(crate::builtins::io::tabular::csvwrite::CSVWRITE_INTEGER_CAPABILITIES),
251 builtin_path = "crate::builtins::io::tabular::csvwrite"
252)]
253async fn csvwrite_builtin(
254 filename: Value,
255 data: Value,
256 rest: Vec<Value>,
257) -> crate::BuiltinResult<Value> {
258 let requested_outputs = crate::output_count::current_output_count();
259 if requested_outputs.is_some_and(|count| count > 1) {
260 return Err(csvwrite_error_with(
261 &CSVWRITE_ERROR_DATA_INPUT,
262 "csvwrite: too many output arguments",
263 ));
264 }
265 if requested_outputs.is_some_and(|count| count > 0) {
266 crate::compatibility::ensure_builtin_extension_enabled(
267 &CSVWRITE_BYTES_OUTPUT_EXTENSION,
268 BUILTIN_NAME,
269 )?;
270 }
271 if matches!(filename, Value::GpuTensor(_))
272 || matches!(data, Value::GpuTensor(_))
273 || rest
274 .iter()
275 .any(|value| matches!(value, Value::GpuTensor(_)))
276 {
277 crate::compatibility::ensure_builtin_extension_enabled(
278 &CSVWRITE_RESIDENT_INPUT_EXTENSION,
279 BUILTIN_NAME,
280 )?;
281 }
282 let filename_value = gather_if_needed_async(&filename)
283 .await
284 .map_err(map_control_flow)?;
285 let path = resolve_path(&filename_value)?;
286
287 let mut gathered_offsets = Vec::with_capacity(rest.len());
288 for value in &rest {
289 gathered_offsets.push(
290 gather_if_needed_async(value)
291 .await
292 .map_err(map_control_flow)?,
293 );
294 }
295 let (row_offset, col_offset) = parse_offsets(&gathered_offsets)?;
296
297 let gathered_data = gather_if_needed_async(&data)
298 .await
299 .map_err(map_control_flow)?;
300 let matrix = CsvMatrix::from_value(gathered_data)?;
301 matrix.ensure_matrix_shape()?;
302
303 let bytes = write_csv(&path, &matrix, row_offset, col_offset).await?;
304 match requested_outputs {
305 Some(0) => Ok(Value::OutputList(Vec::new())),
306 Some(1) => Ok(Value::OutputList(vec![Value::Num(bytes as f64)])),
307 Some(_) => Err(csvwrite_error_with(
308 &CSVWRITE_ERROR_DATA_INPUT,
309 "csvwrite: too many output arguments",
310 )),
311 None => Ok(Value::Num(bytes as f64)),
312 }
313}
314
315enum CsvMatrix {
316 Real(Tensor),
317 Complex(ComplexTensor),
318}
319
320impl CsvMatrix {
321 fn from_value(value: Value) -> BuiltinResult<Self> {
322 match value {
323 Value::Complex(re, im) => ComplexTensor::new(vec![(re, im)], vec![1, 1])
324 .map(Self::Complex)
325 .map_err(|e| {
326 csvwrite_error_with(&CSVWRITE_ERROR_DATA_INPUT, format!("csvwrite: {e}"))
327 }),
328 Value::ComplexTensor(tensor) if tensor.integer_storage().is_none() => {
329 Ok(Self::Complex(tensor))
330 }
331 Value::ComplexTensor(_) => Err(csvwrite_error_with(
332 &CSVWRITE_ERROR_DATA_INPUT,
333 "csvwrite: complex integer input is not supported",
334 )),
335 value => tensor::value_into_tensor_for("csvwrite", value)
336 .map(Self::Real)
337 .map_err(|msg| {
338 csvwrite_error_with(&CSVWRITE_ERROR_DATA_INPUT, format!("csvwrite: {msg}"))
339 }),
340 }
341 }
342
343 fn shape(&self) -> &[usize] {
344 match self {
345 Self::Real(t) => &t.shape,
346 Self::Complex(t) => &t.shape,
347 }
348 }
349
350 fn ensure_matrix_shape(&self) -> BuiltinResult<()> {
351 ensure_matrix_dims(self.shape())
352 }
353
354 fn rows(&self) -> usize {
355 self.shape().first().copied().unwrap_or(1)
356 }
357 fn cols(&self) -> usize {
358 self.shape().get(1).copied().unwrap_or(1)
359 }
360
361 fn format_at(&self, idx: usize) -> String {
362 match self {
363 Self::Real(tensor) => format_tensor_value(tensor, idx),
364 Self::Complex(tensor) => {
365 let (re, im) = tensor
366 .numeric_value_at(idx)
367 .expect("index within authoritative complex storage");
368 format_complex(re.materialize_f64(), im.materialize_f64())
369 }
370 }
371 }
372}
373
374fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
375 let raw = match value {
376 Value::String(s) => s.clone(),
377 Value::CharArray(ca) if ca.rows == 1 => ca.data.iter().collect(),
378 Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].clone(),
379 _ => Err(csvwrite_error_with(
380 &CSVWRITE_ERROR_FILENAME,
381 "csvwrite: filename must be a string scalar or character vector",
382 ))?,
383 };
384
385 if raw.trim().is_empty() {
386 return Err(csvwrite_error_with(
387 &CSVWRITE_ERROR_FILENAME_EMPTY,
388 CSVWRITE_ERROR_FILENAME_EMPTY.message,
389 ));
390 }
391
392 let expanded = expand_user_path(&raw, BUILTIN_NAME)
393 .map_err(|msg| csvwrite_error_with(&CSVWRITE_ERROR_FILENAME, msg))?;
394 Ok(Path::new(&expanded).to_path_buf())
395}
396
397fn parse_offsets(args: &[Value]) -> BuiltinResult<(usize, usize)> {
398 match args.len() {
399 0 => Ok((0, 0)),
400 2 => {
401 let row = parse_offset(&args[0], "row offset")?;
402 let col = parse_offset(&args[1], "column offset")?;
403 Ok((row, col))
404 }
405 _ => Err(csvwrite_error_with(
406 &CSVWRITE_ERROR_OFFSETS,
407 "csvwrite: offsets must be provided as two numeric arguments (row, column)",
408 )),
409 }
410}
411
412fn parse_offset(value: &Value, context: &str) -> BuiltinResult<usize> {
413 match value {
414 Value::Int(i) => i.try_to_usize().ok_or_else(|| {
415 csvwrite_error_with(
416 &CSVWRITE_ERROR_OFFSETS,
417 format!("csvwrite: {context} must be >= 0"),
418 )
419 }),
420 Value::Num(n) => coerce_offset_from_float(*n, context),
421 Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
422 Value::Tensor(t) => {
423 let len = tensor::tensor_element_len(t);
424 if len != 1 {
425 return Err(csvwrite_error_with(
426 &CSVWRITE_ERROR_OFFSETS,
427 format!("csvwrite: {context} must be a scalar, got {} elements", len),
428 ));
429 }
430 let value = t
431 .numeric_value_at(0)
432 .expect("one-element authoritative numeric storage");
433 if let Some(value) = value.into_int_value() {
434 return value.try_to_usize().ok_or_else(|| {
435 csvwrite_error_with(
436 &CSVWRITE_ERROR_OFFSETS,
437 format!("csvwrite: {context} must be >= 0"),
438 )
439 });
440 }
441 coerce_offset_from_float(value.materialize_f64(), context)
442 }
443 Value::LogicalArray(logical) => {
444 if logical.data.len() != 1 {
445 return Err(csvwrite_error_with(
446 &CSVWRITE_ERROR_OFFSETS,
447 format!(
448 "csvwrite: {context} must be a scalar, got {} elements",
449 logical.data.len()
450 ),
451 ));
452 }
453 Ok(if logical.data[0] != 0 { 1 } else { 0 })
454 }
455 other => Err(csvwrite_error_with(
456 &CSVWRITE_ERROR_OFFSETS,
457 format!("csvwrite: {context} must be numeric, got {:?}", other),
458 )),
459 }
460}
461
462fn coerce_offset_from_float(value: f64, context: &str) -> BuiltinResult<usize> {
463 if !value.is_finite() {
464 return Err(csvwrite_error_with(
465 &CSVWRITE_ERROR_OFFSETS,
466 format!("csvwrite: {context} must be finite"),
467 ));
468 }
469 let rounded = value.round();
470 if (rounded - value).abs() > 1e-9 {
471 return Err(csvwrite_error_with(
472 &CSVWRITE_ERROR_OFFSETS,
473 format!("csvwrite: {context} must be an integer"),
474 ));
475 }
476 if rounded < 0.0 {
477 return Err(csvwrite_error_with(
478 &CSVWRITE_ERROR_OFFSETS,
479 format!("csvwrite: {context} must be >= 0"),
480 ));
481 }
482 if rounded > usize::MAX as f64 || (usize::BITS == 64 && rounded == usize::MAX as f64) {
483 return Err(csvwrite_error_with(
484 &CSVWRITE_ERROR_OFFSETS,
485 format!("csvwrite: {context} is too large"),
486 ));
487 }
488 Ok(rounded as usize)
489}
490
491fn ensure_matrix_dims(shape: &[usize]) -> BuiltinResult<()> {
492 if shape.len() <= 2 {
493 return Ok(());
494 }
495 if shape[2..].iter().all(|&dim| dim == 1) {
496 return Ok(());
497 }
498 Err(csvwrite_error_with(
499 &CSVWRITE_ERROR_DATA_SHAPE,
500 "csvwrite: input must be 2-D; reshape before writing",
501 ))
502}
503
504async fn write_csv(
505 path: &Path,
506 matrix: &CsvMatrix,
507 row_offset: usize,
508 col_offset: usize,
509) -> BuiltinResult<usize> {
510 let mut options = OpenOptions::new();
511 options.create(true).write(true).truncate(true);
512 let mut file = options.open_async(path).await.map_err(|err| {
513 csvwrite_error_with_source(
514 &CSVWRITE_ERROR_IO_OPEN,
515 format!(
516 "csvwrite: unable to open \"{}\" for writing ({err})",
517 path.display()
518 ),
519 err,
520 )
521 })?;
522
523 let line_ending = "\n";
524 let rows = matrix.rows();
525 let cols = matrix.cols();
526
527 let mut bytes_written = 0usize;
528
529 for _ in 0..row_offset {
530 file.write_all(line_ending.as_bytes()).map_err(|err| {
531 csvwrite_error_with_source(
532 &CSVWRITE_ERROR_IO_WRITE,
533 format!("csvwrite: failed to write line ending ({err})"),
534 err,
535 )
536 })?;
537 bytes_written += line_ending.len();
538 }
539
540 if rows == 0 || cols == 0 {
541 file.flush_async().await.map_err(|err| {
542 csvwrite_error_with_source(
543 &CSVWRITE_ERROR_IO_WRITE,
544 format!("csvwrite: failed to flush output ({err})"),
545 err,
546 )
547 })?;
548 return Ok(bytes_written);
549 }
550
551 for row in 0..rows {
552 let mut fields = Vec::with_capacity(col_offset + cols);
553 for _ in 0..col_offset {
554 fields.push(String::new());
555 }
556 for col in 0..cols {
557 let idx = row + col * rows;
558 fields.push(matrix.format_at(idx));
559 }
560 let line = fields.join(",");
561 if !line.is_empty() {
562 file.write_all(line.as_bytes()).map_err(|err| {
563 csvwrite_error_with_source(
564 &CSVWRITE_ERROR_IO_WRITE,
565 format!("csvwrite: failed to write value ({err})"),
566 err,
567 )
568 })?;
569 bytes_written += line.len();
570 }
571 file.write_all(line_ending.as_bytes()).map_err(|err| {
572 csvwrite_error_with_source(
573 &CSVWRITE_ERROR_IO_WRITE,
574 format!("csvwrite: failed to write line ending ({err})"),
575 err,
576 )
577 })?;
578 bytes_written += line_ending.len();
579 }
580
581 file.flush_async().await.map_err(|err| {
582 csvwrite_error_with_source(
583 &CSVWRITE_ERROR_IO_WRITE,
584 format!("csvwrite: failed to flush output ({err})"),
585 err,
586 )
587 })?;
588
589 Ok(bytes_written)
590}
591
592fn format_complex(re: f64, im: f64) -> String {
593 let real = format_numeric(re);
594 let imag = format_numeric(im.abs());
595 if im.is_sign_negative() {
596 format!("{real}-{imag}i")
597 } else {
598 format!("{real}+{imag}i")
599 }
600}
601
602fn format_numeric(value: f64) -> String {
603 if value.is_nan() {
604 return "NaN".to_string();
605 }
606 if value.is_infinite() {
607 return if value.is_sign_negative() {
608 "-Inf".to_string()
609 } else {
610 "Inf".to_string()
611 };
612 }
613 if value == 0.0 {
614 return "0".to_string();
615 }
616
617 let precision: i32 = 5;
618 let abs = value.abs();
619 let exp10 = abs.log10().floor() as i32;
620 let use_scientific = exp10 < -4 || exp10 >= precision;
621
622 let raw = if use_scientific {
623 let digits_after = (precision - 1).max(0) as usize;
624 format!("{:.*e}", digits_after, value)
625 } else {
626 let decimals = (precision - 1 - exp10).max(0) as usize;
627 format!("{:.*}", decimals, value)
628 };
629
630 let mut trimmed = trim_trailing_zeros(raw);
631 if trimmed == "-0" {
632 trimmed = "0".to_string();
633 }
634 trimmed
635}
636
637fn format_tensor_value(tensor: &Tensor, idx: usize) -> String {
638 let value = tensor
639 .numeric_value_at(idx)
640 .expect("index within authoritative numeric storage");
641 if let Some(value) = value.into_int_value() {
642 return value.decimal_string();
643 }
644 format_numeric(value.materialize_f64())
645}
646
647fn trim_trailing_zeros(mut value: String) -> String {
648 if let Some(exp_pos) = value.find(['e', 'E']) {
649 let exponent = value.split_off(exp_pos);
650 while value.ends_with('0') {
651 value.pop();
652 }
653 if value.ends_with('.') {
654 value.pop();
655 }
656 value.push_str(&normalize_exponent(&exponent));
657 value
658 } else {
659 if value.contains('.') {
660 while value.ends_with('0') {
661 value.pop();
662 }
663 if value.ends_with('.') {
664 value.pop();
665 }
666 }
667 if value.is_empty() {
668 "0".to_string()
669 } else {
670 value
671 }
672 }
673}
674
675fn normalize_exponent(exponent: &str) -> String {
676 if exponent.len() <= 1 {
677 return exponent.to_string();
678 }
679 let mut chars = exponent.chars();
680 let marker = chars.next().unwrap();
681 let rest: String = chars.collect();
682 match rest.parse::<i32>() {
683 Ok(parsed) => format!("{}{:+03}", marker, parsed),
684 Err(_) => exponent.to_string(),
685 }
686}
687
688#[cfg(test)]
689pub(crate) mod tests {
690 use super::*;
691 use runmat_time::unix_timestamp_ms;
692 use std::fs;
693 use std::sync::atomic::{AtomicU64, Ordering};
694
695 use runmat_accelerate_api::HostTensorView;
696 use runmat_value::{IntValue, IntegerStorage, LogicalArray};
697
698 use crate::builtins::common::fs as fs_helpers;
699 use crate::builtins::common::test_support;
700
701 fn csvwrite_builtin(filename: Value, data: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
702 let _provider_lock = runmat_filesystem::provider_override_lock();
703 futures::executor::block_on(super::csvwrite_builtin(filename, data, rest))
704 }
705
706 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
707 #[test]
708 fn csvwrite_descriptor_signatures_cover_core_forms() {
709 let labels: Vec<&str> = CSVWRITE_DESCRIPTOR
710 .signatures
711 .iter()
712 .map(|sig| sig.label)
713 .collect();
714 assert!(labels.contains(&"csvwrite(filename, M)"));
715 assert!(labels.contains(&"csvwrite(filename, M, row, col)"));
716 }
717
718 static NEXT_ID: AtomicU64 = AtomicU64::new(0);
719
720 fn temp_path(ext: &str) -> PathBuf {
721 let millis = unix_timestamp_ms();
722 let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
723 let mut path = std::env::temp_dir();
724 path.push(format!(
725 "runmat_csvwrite_{}_{}_{}.{}",
726 std::process::id(),
727 millis,
728 unique,
729 ext
730 ));
731 path
732 }
733
734 fn line_ending() -> &'static str {
735 "\n"
736 }
737
738 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
739 #[test]
740 fn csvwrite_writes_basic_matrix() {
741 let path = temp_path("csv");
742 let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
743 let filename = path.to_string_lossy().into_owned();
744
745 csvwrite_builtin(Value::from(filename), Value::Tensor(tensor), Vec::new())
746 .expect("csvwrite");
747
748 let contents = fs::read_to_string(&path).expect("read contents");
749 assert_eq!(contents, format!("1,2,3{le}4,5,6{le}", le = line_ending()));
750 let _ = fs::remove_file(path);
751 }
752
753 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
754 #[test]
755 fn csvwrite_preserves_typed_integer_matrix_values_exactly() {
756 let path = temp_path("csv");
757 let tensor = Tensor::new_integer(
758 IntegerStorage::U64(vec![u64::MAX, 17, (1_u64 << 53) + 1, 29]),
759 vec![2, 2],
760 )
761 .expect("typed integer matrix");
762 let filename = path.to_string_lossy().into_owned();
763
764 csvwrite_builtin(Value::from(filename), Value::Tensor(tensor), Vec::new())
765 .expect("csvwrite");
766
767 let contents = fs::read_to_string(&path).expect("read contents");
768 assert_eq!(
769 contents,
770 format!(
771 "18446744073709551615,9007199254740993{le}17,29{le}",
772 le = line_ending()
773 )
774 );
775 let _ = fs::remove_file(path);
776 }
777
778 #[test]
779 fn csvwrite_serializes_all_eight_integer_classes() {
780 let cases = [
781 (IntegerStorage::I8(vec![-8]), "-8\n"),
782 (IntegerStorage::I16(vec![-16]), "-16\n"),
783 (IntegerStorage::I32(vec![-32]), "-32\n"),
784 (
785 IntegerStorage::I64(vec![i64::MIN]),
786 "-9223372036854775808\n",
787 ),
788 (IntegerStorage::U8(vec![8]), "8\n"),
789 (IntegerStorage::U16(vec![16]), "16\n"),
790 (IntegerStorage::U32(vec![32]), "32\n"),
791 (
792 IntegerStorage::U64(vec![u64::MAX]),
793 "18446744073709551615\n",
794 ),
795 ];
796 for (storage, expected) in cases {
797 let path = temp_path("csv");
798 let tensor = Tensor::new_integer(storage, vec![1, 1]).unwrap();
799 csvwrite_builtin(
800 Value::from(path.to_string_lossy().into_owned()),
801 Value::Tensor(tensor),
802 Vec::new(),
803 )
804 .unwrap();
805 assert_eq!(fs::read_to_string(&path).unwrap(), expected);
806 let _ = fs::remove_file(path);
807 }
808 }
809
810 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
811 #[test]
812 fn csvwrite_honours_offsets() {
813 let path = temp_path("csv");
814 let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
815 let filename = path.to_string_lossy().into_owned();
816
817 csvwrite_builtin(
818 Value::from(filename),
819 Value::Tensor(tensor),
820 vec![Value::Int(IntValue::I32(1)), Value::Int(IntValue::I32(2))],
821 )
822 .expect("csvwrite");
823
824 let contents = fs::read_to_string(&path).expect("read contents");
825 assert_eq!(
826 contents,
827 format!("{le},,1,3{le},,2,4{le}", le = line_ending())
828 );
829 let _ = fs::remove_file(path);
830 }
831
832 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
833 #[test]
834 fn csvwrite_handles_gpu_tensors() {
835 let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
836 test_support::with_test_provider(|provider| {
837 let path = temp_path("csv");
838 let tensor = Tensor::new(vec![0.5, 1.5], vec![1, 2]).unwrap();
839 let view = HostTensorView {
840 data: &tensor.materialize_f64(),
841 shape: &tensor.shape,
842 };
843 let handle = provider.upload(&view).expect("upload");
844 let filename = path.to_string_lossy().into_owned();
845
846 csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
847 .expect("csvwrite");
848
849 let contents = fs::read_to_string(&path).expect("read contents");
850 assert_eq!(contents, format!("0.5,1.5{le}", le = line_ending()));
851 let _ = fs::remove_file(path);
852 });
853 }
854
855 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
856 #[test]
857 fn csvwrite_formats_with_short_g_precision() {
858 let path = temp_path("csv");
859 let values =
860 Tensor::new(vec![12.3456, 1_234_567.0, 0.000123456, -0.0], vec![1, 4]).unwrap();
861 let filename = path.to_string_lossy().into_owned();
862
863 csvwrite_builtin(Value::from(filename), Value::Tensor(values), Vec::new())
864 .expect("csvwrite");
865
866 let contents = fs::read_to_string(&path).expect("read contents");
867 assert_eq!(
868 contents,
869 format!("12.346,1.2346e+06,0.00012346,0{le}", le = line_ending())
870 );
871 let _ = fs::remove_file(path);
872 }
873
874 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
875 #[test]
876 fn csvwrite_rejects_negative_offsets() {
877 let path = temp_path("csv");
878 let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
879 let filename = path.to_string_lossy().into_owned();
880 let err = csvwrite_builtin(
881 Value::from(filename),
882 Value::Tensor(tensor),
883 vec![Value::Num(-1.0), Value::Num(0.0)],
884 )
885 .expect_err("negative offsets should be rejected");
886 let message = err.message().to_string();
887 assert!(
888 message.contains("row offset"),
889 "unexpected error message: {message}"
890 );
891 }
892
893 #[test]
894 fn csvwrite_offset_parser_preserves_typed_integer_tensor_bounds() {
895 let offset =
896 Tensor::new_integer(IntegerStorage::U16(vec![7]), vec![1, 1]).expect("typed offset");
897 assert_eq!(
898 parse_offset(&Value::Tensor(offset), "row offset").unwrap(),
899 7
900 );
901
902 let negative =
903 Tensor::new_integer(IntegerStorage::I16(vec![-1]), vec![1, 1]).expect("negative");
904 assert!(parse_offset(&Value::Tensor(negative), "row offset").is_err());
905
906 let too_large = Tensor::new_integer(IntegerStorage::U64(vec![u64::MAX]), vec![1, 1])
907 .expect("too large");
908 let parsed = parse_offset(&Value::Tensor(too_large), "row offset");
909 if usize::try_from(u64::MAX).is_ok() {
910 assert_eq!(parsed.unwrap(), usize::MAX);
911 } else {
912 assert!(parsed.is_err());
913 }
914
915 assert!(parse_offset(&Value::Num(usize::MAX as f64), "row offset").is_err());
916 assert!(parse_offset(&Value::Num((usize::MAX as f64) + 1.0), "row offset").is_err());
917 }
918
919 #[cfg(feature = "wgpu")]
920 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
921 #[test]
922 fn csvwrite_handles_wgpu_provider_gather() {
923 let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
924 let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
925 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
926 );
927 let Some(provider) = runmat_accelerate_api::provider() else {
928 panic!("wgpu provider not registered");
929 };
930
931 let path = temp_path("csv");
932 let tensor = Tensor::new(vec![2.0, 4.0], vec![1, 2]).unwrap();
933 let view = HostTensorView {
934 data: &tensor.materialize_f64(),
935 shape: &tensor.shape,
936 };
937 let handle = provider.upload(&view).expect("upload");
938 let filename = path.to_string_lossy().into_owned();
939
940 csvwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new())
941 .expect("csvwrite");
942
943 let contents = fs::read_to_string(&path).expect("read contents");
944 assert_eq!(contents, format!("2,4{le}", le = line_ending()));
945 let _ = fs::remove_file(path);
946 }
947
948 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
949 #[test]
950 fn csvwrite_expands_home_directory() {
951 let Some(mut home) = fs_helpers::home_directory() else {
952 return;
954 };
955 let filename = format!(
956 "runmat_csvwrite_home_{}_{}.csv",
957 std::process::id(),
958 NEXT_ID.fetch_add(1, Ordering::Relaxed)
959 );
960 home.push(&filename);
961
962 let tilde_path = format!("~/{}", filename);
963 let tensor = Tensor::new(vec![42.0], vec![1, 1]).unwrap();
964
965 if let Err(error) =
966 csvwrite_builtin(Value::from(tilde_path), Value::Tensor(tensor), Vec::new())
967 {
968 if error.message().contains("Operation not permitted")
969 || error.message().contains("Permission denied")
970 {
971 return;
972 }
973 panic!("csvwrite: {error}");
974 }
975
976 let contents = fs::read_to_string(&home).expect("read contents");
977 assert_eq!(contents, format!("42{le}", le = line_ending()));
978 let _ = fs::remove_file(home);
979 }
980
981 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
982 #[test]
983 fn csvwrite_rejects_non_numeric_inputs() {
984 let path = temp_path("csv");
985 let filename = path.to_string_lossy().into_owned();
986 let err = csvwrite_builtin(
987 Value::from(filename),
988 Value::String("abc".into()),
989 Vec::new(),
990 )
991 .expect_err("csvwrite should fail");
992 let message = err.message().to_string();
993 assert!(
994 message.contains("csvwrite"),
995 "unexpected error message: {message}"
996 );
997 }
998
999 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1000 #[test]
1001 fn csvwrite_accepts_logical_arrays() {
1002 let path = temp_path("csv");
1003 let logical = LogicalArray::new(vec![1, 0, 1, 0], vec![2, 2]).unwrap();
1004 let filename = path.to_string_lossy().into_owned();
1005
1006 csvwrite_builtin(
1007 Value::from(filename),
1008 Value::LogicalArray(logical),
1009 Vec::new(),
1010 )
1011 .expect("csvwrite");
1012
1013 let contents = fs::read_to_string(&path).expect("read contents");
1014 assert_eq!(contents, format!("1,1{le}0,0{le}", le = line_ending()));
1015 let _ = fs::remove_file(path);
1016 }
1017
1018 #[test]
1019 fn csvwrite_writes_complex_double_and_single_with_lf() {
1020 for complex in [
1021 ComplexTensor::new(vec![(1.0, 2.0), (3.0, -4.0)], vec![1, 2]).unwrap(),
1022 ComplexTensor::from_complex_storage(
1023 runmat_value::ComplexStorage::F32(vec![(1.0, 2.0), (3.0, -4.0)]),
1024 vec![1, 2],
1025 )
1026 .unwrap(),
1027 ] {
1028 let path = temp_path("csv");
1029 csvwrite_builtin(
1030 Value::from(path.to_string_lossy().into_owned()),
1031 Value::ComplexTensor(complex),
1032 Vec::new(),
1033 )
1034 .expect("complex csvwrite");
1035 assert_eq!(fs::read(&path).unwrap(), b"1+2i,3-4i\n");
1036 let _ = fs::remove_file(path);
1037 }
1038 }
1039
1040 #[test]
1041 fn csvwrite_declares_independent_output_and_resident_extensions() {
1042 assert_eq!(CSVWRITE_EXTENSIONS[0].id, "csvwrite-bytes-written-output");
1043 assert_eq!(CSVWRITE_EXTENSIONS[1].id, "csvwrite-resident-input");
1044 assert_eq!(CSVWRITE_INTEGER_CAPABILITIES[0].inputs.len(), 3);
1045 assert!(CSVWRITE_INTEGER_CAPABILITIES[0]
1046 .inputs
1047 .iter()
1048 .all(|input| input.classes.len() == 8));
1049 }
1050
1051 #[test]
1052 fn csvwrite_output_and_resident_gates_run_before_side_effects_or_provider_access() {
1053 let path = temp_path("csv");
1054 let filename = Value::from(path.to_string_lossy().into_owned());
1055 let strict = crate::compatibility::push_runmat_extensions_enabled(false);
1056 let outputs = crate::output_count::push_output_count(Some(1));
1057 let error = csvwrite_builtin(
1058 filename.clone(),
1059 Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).unwrap()),
1060 Vec::new(),
1061 )
1062 .unwrap_err();
1063 assert_eq!(
1064 error.identifier(),
1065 CSVWRITE_BYTES_OUTPUT_EXTENSION.error_identifier
1066 );
1067 assert!(!path.exists(), "output gate must precede file creation");
1068 drop(outputs);
1069
1070 let resident = Value::GpuTensor(runmat_accelerate_api::GpuTensorHandle {
1071 shape: vec![1, 1],
1072 device_id: u32::MAX,
1073 buffer_id: u64::MAX - 396,
1074 descriptor: Default::default(),
1075 });
1076 let error = csvwrite_builtin(filename.clone(), resident, Vec::new()).unwrap_err();
1077 assert_eq!(
1078 error.identifier(),
1079 CSVWRITE_RESIDENT_INPUT_EXTENSION.error_identifier
1080 );
1081 assert!(!path.exists(), "resident gate must precede file creation");
1082 drop(strict);
1083
1084 let no_outputs = crate::output_count::push_output_count(Some(0));
1085 let value = csvwrite_builtin(
1086 filename,
1087 Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).unwrap()),
1088 Vec::new(),
1089 )
1090 .unwrap();
1091 assert_eq!(value, Value::OutputList(Vec::new()));
1092 drop(no_outputs);
1093 let _ = fs::remove_file(path);
1094
1095 let path = temp_path("csv");
1096 let filename = Value::from(path.to_string_lossy().into_owned());
1097 let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1098 let too_many_outputs = crate::output_count::push_output_count(Some(2));
1099 let error = csvwrite_builtin(
1100 filename,
1101 Value::Tensor(Tensor::new(vec![1.0], vec![1, 1]).unwrap()),
1102 Vec::new(),
1103 )
1104 .unwrap_err();
1105 assert!(error.message().contains("too many output arguments"));
1106 assert!(!path.exists(), "output arity must precede file creation");
1107 drop(too_many_outputs);
1108 }
1109}