1#[cfg(not(target_arch = "wasm32"))]
4use core::ffi::{c_char, c_int};
5#[cfg(any(
6 all(not(target_arch = "wasm32"), not(windows)),
7 all(windows, target_env = "gnu")
8))]
9use libc;
10use runmat_builtins::{Tensor, Value};
11use runmat_filesystem::{self as vfs, File, OpenOptions};
12use runmat_macros::runtime_builtin;
13#[cfg(not(target_arch = "wasm32"))]
14use std::ffi::CString;
15use std::io::{self, Read, Seek, SeekFrom, Write};
16use std::path::{Path, PathBuf};
17
18use crate::builtins::common::fs::expand_user_path;
19use crate::builtins::common::spec::{
20 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
21 ReductionNaN, ResidencyPolicy, ShapeRequirements,
22};
23use crate::builtins::common::tensor;
24use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
25
26const BUILTIN_NAME: &str = "dlmwrite";
27
28#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::tabular::dlmwrite")]
29pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
30 name: "dlmwrite",
31 op_kind: GpuOpKind::Custom("io-dlmwrite"),
32 supported_precisions: &[],
33 broadcast: BroadcastSemantics::None,
34 provider_hooks: &[],
35 constant_strategy: ConstantStrategy::InlineLiteral,
36 residency: ResidencyPolicy::GatherImmediately,
37 nan_mode: ReductionNaN::Include,
38 two_pass_threshold: None,
39 workgroup_size: None,
40 accepts_nan_mode: false,
41 notes: "Runs entirely on the host; gpuArray inputs are gathered before formatting.",
42};
43
44fn dlmwrite_error(message: impl Into<String>) -> RuntimeError {
45 build_runtime_error(message)
46 .with_builtin(BUILTIN_NAME)
47 .build()
48}
49
50fn dlmwrite_error_with_source<E>(message: impl Into<String>, source: E) -> RuntimeError
51where
52 E: std::error::Error + Send + Sync + 'static,
53{
54 build_runtime_error(message)
55 .with_builtin(BUILTIN_NAME)
56 .with_source(source)
57 .build()
58}
59
60fn map_control_flow(err: RuntimeError) -> RuntimeError {
61 let identifier = err.identifier().map(|value| value.to_string());
62 let message = err.message().to_string();
63 let mut builder = build_runtime_error(message)
64 .with_builtin(BUILTIN_NAME)
65 .with_source(err);
66 if let Some(identifier) = identifier {
67 builder = builder.with_identifier(identifier);
68 }
69 builder.build()
70}
71
72#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::tabular::dlmwrite")]
73pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
74 name: "dlmwrite",
75 shape: ShapeRequirements::Any,
76 constant_strategy: ConstantStrategy::InlineLiteral,
77 elementwise: None,
78 reduction: None,
79 emits_nan: false,
80 notes: "Not eligible for fusion; performs synchronous file I/O.",
81};
82
83#[runtime_builtin(
84 name = "dlmwrite",
85 category = "io/tabular",
86 summary = "Write numeric matrices to delimiter-separated text files.",
87 keywords = "dlmwrite,delimiter,precision,append,roffset,coffset",
88 accel = "cpu",
89 type_resolver(crate::builtins::io::type_resolvers::num_type),
90 builtin_path = "crate::builtins::io::tabular::dlmwrite"
91)]
92async fn dlmwrite_builtin(
93 filename: Value,
94 data: Value,
95 rest: Vec<Value>,
96) -> crate::BuiltinResult<Value> {
97 let gathered_path = gather_if_needed_async(&filename)
98 .await
99 .map_err(map_control_flow)?;
100 let path = resolve_path(&gathered_path)?;
101
102 let mut gathered_args = Vec::with_capacity(rest.len());
103 for value in &rest {
104 gathered_args.push(
105 gather_if_needed_async(value)
106 .await
107 .map_err(map_control_flow)?,
108 );
109 }
110 let options = parse_arguments(&gathered_args)?;
111
112 let gathered_data = gather_if_needed_async(&data)
113 .await
114 .map_err(map_control_flow)?;
115 let tensor =
116 tensor::value_into_tensor_for("dlmwrite", gathered_data).map_err(dlmwrite_error)?;
117 ensure_matrix_shape(&tensor)?;
118
119 let bytes = write_dlm(&path, &tensor, &options).await?;
120 Ok(Value::Num(bytes as f64))
121}
122
123#[derive(Clone, Debug)]
124struct DlmWriteOptions {
125 delimiter: String,
126 newline: LineEnding,
127 roffset: usize,
128 coffset: usize,
129 precision: PrecisionSpec,
130 append: bool,
131}
132
133impl Default for DlmWriteOptions {
134 fn default() -> Self {
135 Self {
136 delimiter: ",".to_string(),
137 newline: LineEnding::platform_default(),
138 roffset: 0,
139 coffset: 0,
140 precision: PrecisionSpec::Significant(5),
141 append: false,
142 }
143 }
144}
145
146#[derive(Clone, Copy, Debug)]
147enum LineEnding {
148 Unix,
149 Pc,
150 Mac,
151}
152
153impl LineEnding {
154 fn as_str(&self) -> &'static str {
155 match self {
156 LineEnding::Unix => "\n",
157 LineEnding::Pc => "\r\n",
158 LineEnding::Mac => "\r",
159 }
160 }
161
162 fn platform_default() -> Self {
163 if cfg!(windows) {
164 LineEnding::Pc
165 } else {
166 LineEnding::Unix
167 }
168 }
169}
170
171#[derive(Clone, Debug)]
172enum PrecisionSpec {
173 Significant(u32),
174 Format(String),
175}
176
177fn parse_arguments(args: &[Value]) -> BuiltinResult<DlmWriteOptions> {
178 let mut options = DlmWriteOptions::default();
179 if args.is_empty() {
180 return Ok(options);
181 }
182
183 let mut idx = 0usize;
184 while idx < args.len() {
186 if is_append_flag(&args[idx]) {
187 options.append = true;
188 idx += 1;
189 } else {
190 break;
191 }
192 }
193
194 if idx < args.len() && is_positional_delimiter_candidate(&args[idx]) {
196 options.delimiter = parse_delimiter_value(&args[idx])?;
197 idx += 1;
198 if idx + 1 < args.len()
200 && is_numeric_scalar(&args[idx])
201 && is_numeric_scalar(&args[idx + 1])
202 {
203 options.roffset = parse_offset_value(&args[idx], "row offset")?;
204 options.coffset = parse_offset_value(&args[idx + 1], "column offset")?;
205 idx += 2;
206 }
207 while idx < args.len() {
209 if is_append_flag(&args[idx]) {
210 options.append = true;
211 idx += 1;
212 } else {
213 break;
214 }
215 }
216 }
217
218 while idx < args.len() {
220 if is_append_flag(&args[idx]) {
221 options.append = true;
222 idx += 1;
223 continue;
224 }
225
226 let name = value_to_lowercase_string(&args[idx]).ok_or_else(|| {
227 dlmwrite_error(format!(
228 "dlmwrite: expected name-value pair, got {:?}",
229 args[idx]
230 ))
231 })?;
232 idx += 1;
233 if idx >= args.len() {
234 return Err(dlmwrite_error(
235 "dlmwrite: name-value arguments must appear in pairs",
236 ));
237 }
238 let value = &args[idx];
239 idx += 1;
240
241 match name.as_str() {
242 "delimiter" => {
243 options.delimiter = parse_delimiter_value(value)?;
244 }
245 "precision" => {
246 options.precision = parse_precision_value(value)?;
247 }
248 "newline" => {
249 options.newline = parse_newline_value(value)?;
250 }
251 "roffset" => {
252 options.roffset = parse_offset_value(value, "row offset")?;
253 }
254 "coffset" => {
255 options.coffset = parse_offset_value(value, "column offset")?;
256 }
257 "append" => {
258 options.append = parse_append_value(value)?;
259 }
260 other => {
261 return Err(dlmwrite_error(format!(
262 "dlmwrite: unsupported name-value pair '{other}'"
263 )));
264 }
265 }
266 }
267
268 Ok(options)
269}
270
271fn is_append_flag(value: &Value) -> bool {
272 match value {
273 Value::String(s) => s.trim().eq_ignore_ascii_case("-append"),
274 Value::CharArray(ca) if ca.rows == 1 => {
275 let text: String = ca.data.iter().collect();
276 text.trim().eq_ignore_ascii_case("-append")
277 }
278 Value::StringArray(sa) if sa.data.len() == 1 => {
279 sa.data[0].trim().eq_ignore_ascii_case("-append")
280 }
281 _ => false,
282 }
283}
284
285fn is_positional_delimiter_candidate(value: &Value) -> bool {
286 match value {
287 Value::String(_) | Value::CharArray(_) | Value::StringArray(_) => {
288 if let Some(lower) = value_to_lowercase_string(value) {
289 match lower.as_str() {
290 "delimiter" | "precision" | "newline" | "roffset" | "coffset" | "append"
291 | "-append" => return false,
292 _ => {}
293 }
294 }
295 true
296 }
297 _ => false,
298 }
299}
300
301fn parse_delimiter_value(value: &Value) -> BuiltinResult<String> {
302 match value {
303 Value::String(s) => interpret_delimiter_string(s),
304 Value::CharArray(ca) if ca.rows == 1 => {
305 let text: String = ca.data.iter().collect();
306 interpret_delimiter_string(&text)
307 }
308 Value::StringArray(sa) if sa.data.len() == 1 => interpret_delimiter_string(&sa.data[0]),
309 _ => Err(dlmwrite_error(
310 "dlmwrite: delimiter must be a string scalar or character vector",
311 )),
312 }
313}
314
315fn interpret_delimiter_string(raw: &str) -> BuiltinResult<String> {
316 if raw.is_empty() {
317 return Err(dlmwrite_error("dlmwrite: delimiter must not be empty"));
318 }
319 if raw == r"\t" {
320 return Ok("\t".to_string());
321 }
322 if raw == r"\n" {
323 return Ok("\n".to_string());
324 }
325 if raw == r"\r" {
326 return Ok("\r".to_string());
327 }
328 Ok(raw.to_string())
329}
330
331fn is_numeric_scalar(value: &Value) -> bool {
332 match value {
333 Value::Int(_) | Value::Num(_) | Value::Bool(_) => true,
334 Value::Tensor(t) => t.data.len() == 1,
335 Value::LogicalArray(logical) => logical.data.len() == 1,
336 _ => false,
337 }
338}
339
340fn parse_offset_value(value: &Value, context: &str) -> BuiltinResult<usize> {
341 let scalar =
342 extract_scalar(value).map_err(|e| dlmwrite_error(format!("dlmwrite: {context} {e}")))?;
343 if !scalar.is_finite() {
344 return Err(dlmwrite_error(format!(
345 "dlmwrite: {context} must be finite"
346 )));
347 }
348 let rounded = scalar.round();
349 if (rounded - scalar).abs() > 1e-9 {
350 return Err(dlmwrite_error(format!(
351 "dlmwrite: {context} must be an integer, got {scalar}"
352 )));
353 }
354 if rounded < 0.0 {
355 return Err(dlmwrite_error(format!("dlmwrite: {context} must be >= 0")));
356 }
357 Ok(rounded as usize)
358}
359
360fn parse_precision_value(value: &Value) -> BuiltinResult<PrecisionSpec> {
361 match value {
362 Value::Int(i) => {
363 let digits = i.to_i64();
364 if digits <= 0 {
365 return Err(dlmwrite_error(
366 "dlmwrite: precision must be a positive integer",
367 ));
368 }
369 Ok(PrecisionSpec::Significant(digits as u32))
370 }
371 Value::Num(n) => {
372 if !n.is_finite() {
373 return Err(dlmwrite_error("dlmwrite: precision scalar must be finite"));
374 }
375 let rounded = n.round();
376 if (rounded - n).abs() > 1e-9 {
377 return Err(dlmwrite_error(
378 "dlmwrite: precision scalar must be an integer",
379 ));
380 }
381 if rounded <= 0.0 {
382 return Err(dlmwrite_error(
383 "dlmwrite: precision must be a positive integer",
384 ));
385 }
386 Ok(PrecisionSpec::Significant(rounded as u32))
387 }
388 Value::Tensor(t) if t.data.len() == 1 => parse_precision_value(&Value::Num(t.data[0])),
389 Value::LogicalArray(logical) if logical.data.len() == 1 => {
390 if logical.data[0] != 0 {
391 Ok(PrecisionSpec::Significant(1))
392 } else {
393 Err(dlmwrite_error(
394 "dlmwrite: precision must be a positive integer",
395 ))
396 }
397 }
398 Value::Bool(b) => {
399 if *b {
400 Ok(PrecisionSpec::Significant(1))
401 } else {
402 Err(dlmwrite_error(
403 "dlmwrite: precision must be a positive integer",
404 ))
405 }
406 }
407 Value::String(s) => parse_precision_format(s),
408 Value::CharArray(ca) if ca.rows == 1 => {
409 let text: String = ca.data.iter().collect();
410 parse_precision_format(&text)
411 }
412 Value::StringArray(sa) if sa.data.len() == 1 => parse_precision_format(&sa.data[0]),
413 _ => Err(dlmwrite_error(
414 "dlmwrite: precision must be numeric or a format string",
415 )),
416 }
417}
418
419fn parse_precision_format(text: &str) -> BuiltinResult<PrecisionSpec> {
420 if text.is_empty() {
421 return Err(dlmwrite_error(
422 "dlmwrite: precision format string must not be empty",
423 ));
424 }
425 Ok(PrecisionSpec::Format(text.to_string()))
426}
427
428fn parse_newline_value(value: &Value) -> BuiltinResult<LineEnding> {
429 let text = value_to_lowercase_string(value).ok_or_else(|| {
430 dlmwrite_error("dlmwrite: newline must be a string scalar or character vector")
431 })?;
432 match text.as_str() {
433 "pc" | "windows" | "crlf" => Ok(LineEnding::Pc),
434 "unix" | "lf" => Ok(LineEnding::Unix),
435 "mac" | "cr" => Ok(LineEnding::Mac),
436 other => Err(dlmwrite_error(format!(
437 "dlmwrite: unsupported newline setting '{other}' (expected 'pc' or 'unix')"
438 ))),
439 }
440}
441
442fn parse_append_value(value: &Value) -> BuiltinResult<bool> {
443 match value {
444 Value::Bool(b) => Ok(*b),
445 Value::Int(i) => Ok(i.to_i64() != 0),
446 Value::Num(n) => Ok(*n != 0.0),
447 Value::String(s) => parse_bool_string(s),
448 Value::CharArray(ca) if ca.rows == 1 => {
449 let text: String = ca.data.iter().collect();
450 parse_bool_string(&text)
451 }
452 Value::StringArray(sa) if sa.data.len() == 1 => parse_bool_string(&sa.data[0]),
453 _ => Err(dlmwrite_error("dlmwrite: append value must be logical")),
454 }
455}
456
457fn parse_bool_string(text: &str) -> BuiltinResult<bool> {
458 let lowered = text.trim().to_ascii_lowercase();
459 match lowered.as_str() {
460 "true" | "on" | "yes" | "1" => Ok(true),
461 "false" | "off" | "no" | "0" => Ok(false),
462 _ => Err(dlmwrite_error("dlmwrite: append value must be logical")),
463 }
464}
465
466fn extract_scalar(value: &Value) -> BuiltinResult<f64> {
467 match value {
468 Value::Num(n) => Ok(*n),
469 Value::Int(i) => Ok(i.to_f64()),
470 Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
471 Value::Tensor(t) if t.data.len() == 1 => Ok(t.data[0]),
472 Value::LogicalArray(logical) if logical.data.len() == 1 => {
473 Ok(if logical.data[0] != 0 { 1.0 } else { 0.0 })
474 }
475 _ => Err(dlmwrite_error("must be numeric scalar")),
476 }
477}
478
479fn value_to_lowercase_string(value: &Value) -> Option<String> {
480 tensor::value_to_string(value).map(|s| s.trim().to_ascii_lowercase())
481}
482
483fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
484 let raw = match value {
485 Value::String(s) => s.clone(),
486 Value::CharArray(ca) if ca.rows == 1 => ca.data.iter().collect(),
487 Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].clone(),
488 _ => {
489 return Err(dlmwrite_error(
490 "dlmwrite: filename must be a string scalar or character vector",
491 ))
492 }
493 };
494 if raw.trim().is_empty() {
495 return Err(dlmwrite_error("dlmwrite: filename must not be empty"));
496 }
497 let expanded = expand_user_path(&raw, BUILTIN_NAME).map_err(dlmwrite_error)?;
498 Ok(Path::new(&expanded).to_path_buf())
499}
500
501fn ensure_matrix_shape(tensor: &Tensor) -> BuiltinResult<()> {
502 if tensor.shape.len() <= 2 {
503 return Ok(());
504 }
505 if tensor.shape[2..].iter().all(|&dim| dim == 1) {
506 return Ok(());
507 }
508 Err(dlmwrite_error(
509 "dlmwrite: input must be 2-D; reshape before writing",
510 ))
511}
512
513async fn write_dlm(
514 path: &Path,
515 tensor: &Tensor,
516 options: &DlmWriteOptions,
517) -> BuiltinResult<usize> {
518 let rows = tensor.rows();
519 let cols = tensor.cols();
520 let newline = options.newline.as_str();
521
522 let (existing_nonempty, ends_with_newline) = if options.append {
523 match vfs::metadata_async(path).await {
524 Ok(meta) if !meta.is_empty() => {
525 let ends = file_ends_with_newline(path).await.map_err(|err| {
526 dlmwrite_error_with_source(
527 format!(
528 "dlmwrite: failed to inspect existing file \"{}\" ({err})",
529 path.display()
530 ),
531 err,
532 )
533 })?;
534 (true, ends)
535 }
536 Ok(_) => (false, false),
537 Err(err) => {
538 if err.kind() == io::ErrorKind::NotFound {
539 (false, false)
540 } else {
541 return Err(dlmwrite_error_with_source(
542 format!("dlmwrite: unable to inspect \"{}\" ({err})", path.display()),
543 err,
544 ));
545 }
546 }
547 }
548 } else {
549 (false, false)
550 };
551
552 let mut open = OpenOptions::new();
553 open.create(true);
554 if options.append {
555 open.append(true);
556 } else {
557 open.write(true).truncate(true);
558 }
559
560 let mut file = open.open(path).map_err(|err| {
561 dlmwrite_error_with_source(
562 format!(
563 "dlmwrite: unable to open \"{}\" for writing ({err})",
564 path.display()
565 ),
566 err,
567 )
568 })?;
569
570 let mut bytes = 0usize;
571
572 if options.append && existing_nonempty && !ends_with_newline {
573 file.write_all(newline.as_bytes()).map_err(|err| {
574 dlmwrite_error_with_source(
575 format!("dlmwrite: failed to insert newline before append ({err})"),
576 err,
577 )
578 })?;
579 bytes += newline.len();
580 }
581
582 for _ in 0..options.roffset {
583 bytes += write_blank_row(
584 &mut file,
585 cols,
586 options.coffset,
587 &options.delimiter,
588 newline,
589 )?;
590 }
591
592 if rows == 0 || cols == 0 {
593 file.flush().map_err(|err| {
594 dlmwrite_error_with_source(format!("dlmwrite: failed to flush output ({err})"), err)
595 })?;
596 return Ok(bytes);
597 }
598
599 for row in 0..rows {
600 let mut fields = Vec::with_capacity(options.coffset + cols);
601 for _ in 0..options.coffset {
602 fields.push(String::new());
603 }
604 for col in 0..cols {
605 let idx = row + col * rows;
606 let value = tensor.data[idx];
607 fields.push(format_numeric(value, &options.precision)?);
608 }
609 let line = fields.join(&options.delimiter);
610 if !line.is_empty() {
611 file.write_all(line.as_bytes()).map_err(|err| {
612 dlmwrite_error_with_source(format!("dlmwrite: failed to write data ({err})"), err)
613 })?;
614 bytes += line.len();
615 }
616 file.write_all(newline.as_bytes()).map_err(|err| {
617 dlmwrite_error_with_source(format!("dlmwrite: failed to write newline ({err})"), err)
618 })?;
619 bytes += newline.len();
620 }
621
622 file.flush().map_err(|err| {
623 dlmwrite_error_with_source(format!("dlmwrite: failed to flush output ({err})"), err)
624 })?;
625 Ok(bytes)
626}
627
628fn write_blank_row(
629 file: &mut File,
630 cols: usize,
631 coffset: usize,
632 delimiter: &str,
633 newline: &str,
634) -> BuiltinResult<usize> {
635 let mut bytes = 0usize;
636 if coffset == 0 && cols == 0 {
637 file.write_all(newline.as_bytes()).map_err(|err| {
638 dlmwrite_error_with_source(
639 format!("dlmwrite: failed to write offset newline ({err})"),
640 err,
641 )
642 })?;
643 return Ok(newline.len());
644 }
645 let mut fields = Vec::with_capacity(coffset + cols);
646 for _ in 0..coffset {
647 fields.push(String::new());
648 }
649 for _ in 0..cols {
650 fields.push(String::new());
651 }
652 let line = fields.join(delimiter);
653 if !line.is_empty() {
654 file.write_all(line.as_bytes()).map_err(|err| {
655 dlmwrite_error_with_source(format!("dlmwrite: failed to write offset row ({err})"), err)
656 })?;
657 bytes += line.len();
658 }
659 file.write_all(newline.as_bytes()).map_err(|err| {
660 dlmwrite_error_with_source(
661 format!("dlmwrite: failed to write offset newline ({err})"),
662 err,
663 )
664 })?;
665 bytes += newline.len();
666 Ok(bytes)
667}
668
669async fn file_ends_with_newline(path: &Path) -> io::Result<bool> {
670 let metadata = vfs::metadata_async(path).await?;
671 let len = metadata.len();
672 if len == 0 {
673 return Ok(false);
674 }
675 let mut file = File::open(path)?;
676 let to_read = len.min(2) as usize;
677 file.seek(SeekFrom::End(-(to_read as i64)))?;
678 let mut buffer = vec![0u8; to_read];
679 file.read_exact(&mut buffer)?;
680 Ok(buffer.contains(&b'\n') || buffer.contains(&b'\r'))
681}
682
683fn format_numeric(value: f64, precision: &PrecisionSpec) -> BuiltinResult<String> {
684 if value.is_nan() {
685 return Ok("NaN".to_string());
686 }
687 if value.is_infinite() {
688 return Ok(if value.is_sign_negative() {
689 "-Inf".to_string()
690 } else {
691 "Inf".to_string()
692 });
693 }
694 match precision {
695 PrecisionSpec::Significant(digits) => {
696 if *digits == 0 {
697 return Err(dlmwrite_error("dlmwrite: precision must be positive"));
698 }
699 let fmt = format!("%.{digits}g");
700 let mut rendered = c_format(value, &fmt)?;
701 if value == 0.0 || rendered == "-0" {
702 rendered = "0".to_string();
703 }
704 Ok(rendered)
705 }
706 PrecisionSpec::Format(spec) => {
707 let rendered = c_format(value, spec)?;
708 if value == 0.0 && rendered.starts_with("-") {
709 Ok(rendered.trim_start_matches('-').to_string())
710 } else {
711 Ok(rendered)
712 }
713 }
714 }
715}
716
717#[cfg(not(target_arch = "wasm32"))]
718fn c_format(value: f64, spec: &str) -> BuiltinResult<String> {
719 let fmt = CString::new(spec.as_bytes()).map_err(|_| {
720 dlmwrite_error("dlmwrite: precision format must not contain embedded null bytes")
721 })?;
722 let mut size: usize = 128;
723 loop {
724 let mut buffer = vec![0u8; size];
725 let written = unsafe {
726 platform_snprintf(
727 buffer.as_mut_ptr() as *mut c_char,
728 size,
729 fmt.as_ptr(),
730 value,
731 )
732 };
733 if written < 0 {
734 return Err(dlmwrite_error(
735 "dlmwrite: failed to apply precision format string",
736 ));
737 }
738 let written = written as usize;
739 if written >= size {
740 size = written + 1;
741 continue;
742 }
743 buffer.truncate(written);
744 return String::from_utf8(buffer)
745 .map_err(|_| dlmwrite_error("dlmwrite: formatted output was not valid UTF-8"));
746 }
747}
748
749#[cfg(target_arch = "wasm32")]
750fn c_format(value: f64, spec: &str) -> BuiltinResult<String> {
751 wasm_format_float(value, spec)
752}
753
754#[cfg(all(not(target_arch = "wasm32"), not(windows)))]
755unsafe fn platform_snprintf(
756 buffer: *mut c_char,
757 size: usize,
758 fmt: *const c_char,
759 value: f64,
760) -> c_int {
761 libc::snprintf(buffer, size as libc::size_t, fmt, value)
762}
763
764#[cfg(all(windows, target_env = "msvc"))]
765extern "C" {
766 fn _snprintf(buffer: *mut c_char, size: usize, fmt: *const c_char, ...) -> c_int;
767}
768
769#[cfg(all(windows, target_env = "msvc"))]
770unsafe fn platform_snprintf(
771 buffer: *mut c_char,
772 size: usize,
773 fmt: *const c_char,
774 value: f64,
775) -> c_int {
776 _snprintf(buffer, size, fmt, value)
777}
778
779#[cfg(all(windows, target_env = "gnu"))]
780unsafe fn platform_snprintf(
781 buffer: *mut c_char,
782 size: usize,
783 fmt: *const c_char,
784 value: f64,
785) -> c_int {
786 libc::snprintf(buffer, size as libc::size_t, fmt, value)
787}
788
789#[cfg(any(target_arch = "wasm32", test))]
790fn wasm_format_float(value: f64, spec: &str) -> BuiltinResult<String> {
791 let parsed = ParsedFormat::parse(spec)?;
792 Ok(parsed.render(value))
793}
794
795#[cfg(any(target_arch = "wasm32", test))]
796#[derive(Clone, Copy, Debug, PartialEq, Eq)]
797enum FloatSpecifier {
798 Fixed,
799 Exponent,
800 General,
801}
802
803#[cfg(any(target_arch = "wasm32", test))]
804#[derive(Clone, Copy, Debug, PartialEq, Eq)]
805enum SignFlag {
806 None,
807 Plus,
808 Space,
809}
810
811#[cfg(any(target_arch = "wasm32", test))]
812#[derive(Clone, Copy, Debug)]
813struct ParsedFormat {
814 specifier: FloatSpecifier,
815 uppercase: bool,
816 alternate: bool,
817 sign: SignFlag,
818 left_adjust: bool,
819 zero_pad: bool,
820 width: Option<usize>,
821 precision: Option<usize>,
822}
823
824#[cfg(any(target_arch = "wasm32", test))]
825impl ParsedFormat {
826 fn parse(input: &str) -> BuiltinResult<Self> {
827 use std::iter::Peekable;
828 use std::str::Chars;
829
830 fn parse_number(
831 chars: &mut Peekable<Chars<'_>>,
832 label: &str,
833 ) -> BuiltinResult<Option<usize>> {
834 let mut value: usize = 0;
835 let mut saw_digit = false;
836 while let Some(&ch) = chars.peek() {
837 if ch.is_ascii_digit() {
838 saw_digit = true;
839 value = value
840 .checked_mul(10)
841 .and_then(|v| v.checked_add((ch as u8 - b'0') as usize))
842 .ok_or_else(|| {
843 dlmwrite_error(format!(
844 "dlmwrite: {label} too large in precision format"
845 ))
846 })?;
847 chars.next();
848 } else {
849 break;
850 }
851 }
852 Ok(if saw_digit { Some(value) } else { None })
853 }
854
855 let mut chars = input.chars().peekable();
856 match chars.next() {
857 Some('%') => {}
858 _ => {
859 return Err(dlmwrite_error(
860 "dlmwrite: precision format must start with '%'",
861 ));
862 }
863 }
864
865 let mut left_adjust = false;
866 let mut sign = SignFlag::None;
867 let mut zero_pad = false;
868 let mut alternate = false;
869 while let Some(&ch) = chars.peek() {
870 match ch {
871 '-' => {
872 left_adjust = true;
873 zero_pad = false;
874 chars.next();
875 }
876 '+' => {
877 sign = SignFlag::Plus;
878 chars.next();
879 }
880 ' ' => {
881 if sign != SignFlag::Plus {
882 sign = SignFlag::Space;
883 }
884 chars.next();
885 }
886 '0' => {
887 if !left_adjust {
888 zero_pad = true;
889 }
890 chars.next();
891 }
892 '#' => {
893 alternate = true;
894 chars.next();
895 }
896 _ => break,
897 }
898 }
899
900 let width = parse_number(&mut chars, "field width")?;
901 let precision = if matches!(chars.peek(), Some('.')) {
902 chars.next();
903 parse_number(&mut chars, "precision")?.or(Some(0))
904 } else {
905 None
906 };
907
908 if matches!(chars.peek(), Some('l' | 'L' | 'h')) {
909 return Err(dlmwrite_error(
910 "dlmwrite: length modifiers are not supported in precision formats",
911 ));
912 }
913
914 let spec_ch = chars
915 .next()
916 .ok_or_else(|| dlmwrite_error("dlmwrite: incomplete precision format"))?;
917 if chars.next().is_some() {
918 return Err(dlmwrite_error(
919 "dlmwrite: unexpected trailing characters in precision format",
920 ));
921 }
922
923 let (specifier, uppercase) = match spec_ch {
924 'f' => (FloatSpecifier::Fixed, false),
925 'F' => (FloatSpecifier::Fixed, true),
926 'e' => (FloatSpecifier::Exponent, false),
927 'E' => (FloatSpecifier::Exponent, true),
928 'g' => (FloatSpecifier::General, false),
929 'G' => (FloatSpecifier::General, true),
930 other => {
931 return Err(dlmwrite_error(format!(
932 "dlmwrite: unsupported precision format specifier '{other}'"
933 )));
934 }
935 };
936
937 Ok(Self {
938 specifier,
939 uppercase,
940 alternate,
941 sign,
942 left_adjust,
943 zero_pad: zero_pad && !left_adjust,
944 width,
945 precision,
946 })
947 }
948
949 fn render(&self, value: f64) -> String {
950 let negative = value.is_sign_negative();
951 let magnitude = if negative { -value } else { value };
952 let mut body = match self.specifier {
953 FloatSpecifier::Fixed => {
954 format_fixed_body(magnitude, self.precision.unwrap_or(6), self.alternate)
955 }
956 FloatSpecifier::Exponent => format_exponential_body(
957 magnitude,
958 self.precision.unwrap_or(6),
959 self.alternate,
960 self.uppercase,
961 ),
962 FloatSpecifier::General => format_general_body(
963 magnitude,
964 self.precision.unwrap_or(6),
965 self.alternate,
966 self.uppercase,
967 ),
968 };
969 if self.uppercase {
970 body.make_ascii_uppercase();
971 }
972
973 let mut prefix = String::new();
974 if negative {
975 prefix.push('-');
976 } else {
977 match self.sign {
978 SignFlag::Plus => prefix.push('+'),
979 SignFlag::Space => prefix.push(' '),
980 SignFlag::None => {}
981 }
982 }
983
984 let total_len = prefix.len() + body.len();
985 if let Some(width) = self.width {
986 if width > total_len {
987 let pad = width - total_len;
988 if self.left_adjust {
989 let mut result = prefix;
990 result.push_str(&body);
991 result.extend(std::iter::repeat_n(' ', pad));
992 return result;
993 } else if self.zero_pad {
994 let mut result = String::with_capacity(width);
995 result.push_str(&prefix);
996 result.extend(std::iter::repeat_n('0', pad));
997 result.push_str(&body);
998 return result;
999 } else {
1000 let mut result = String::with_capacity(width);
1001 result.extend(std::iter::repeat_n(' ', pad));
1002 result.push_str(&prefix);
1003 result.push_str(&body);
1004 return result;
1005 }
1006 }
1007 }
1008
1009 let mut result = prefix;
1010 result.push_str(&body);
1011 result
1012 }
1013}
1014
1015#[cfg(any(target_arch = "wasm32", test))]
1016fn format_fixed_body(value: f64, precision: usize, alternate: bool) -> String {
1017 let mut s = format!("{:.*}", precision, value);
1018 if precision == 0 && alternate && !s.contains('.') {
1019 s.push('.');
1020 }
1021 s
1022}
1023
1024#[cfg(any(target_arch = "wasm32", test))]
1025fn format_exponential_body(
1026 value: f64,
1027 precision: usize,
1028 alternate: bool,
1029 uppercase: bool,
1030) -> String {
1031 let mut s = format!("{:.*e}", precision, value);
1032 normalize_exponent_notation(&mut s);
1033 if uppercase {
1034 s.make_ascii_uppercase();
1035 }
1036 if precision == 0 && alternate {
1037 insert_decimal_point(&mut s);
1038 }
1039 s
1040}
1041
1042#[cfg(any(target_arch = "wasm32", test))]
1043fn format_general_body(value: f64, precision: usize, alternate: bool, uppercase: bool) -> String {
1044 let effective_precision = precision.max(1);
1045 let abs_val = value.abs();
1046 if abs_val == 0.0 {
1047 return if alternate {
1048 let mut s = "0.".to_string();
1049 s.extend(std::iter::repeat_n('0', effective_precision - 1));
1050 s
1051 } else {
1052 "0".to_string()
1053 };
1054 }
1055
1056 let exponent = abs_val.log10().floor() as i32;
1057 let force_exponent = uppercase && alternate;
1058 let use_exponent = force_exponent || exponent < -4 || exponent >= effective_precision as i32;
1059 let mut s = if use_exponent {
1060 let frac = effective_precision.saturating_sub(1);
1061 let mut out = format!("{:.*e}", frac, abs_val);
1062 normalize_exponent_notation(&mut out);
1063 if uppercase {
1064 out.make_ascii_uppercase();
1065 }
1066 out
1067 } else {
1068 let frac = {
1069 let diff = effective_precision as isize - (exponent + 1) as isize;
1070 if diff < 0 {
1071 0
1072 } else {
1073 diff as usize
1074 }
1075 };
1076 let mut out = format!("{:.*}", frac, abs_val);
1077 if uppercase {
1078 out.make_ascii_uppercase();
1079 }
1080 out
1081 };
1082
1083 if alternate {
1084 insert_decimal_point(&mut s);
1085 } else {
1086 trim_trailing_zeros(&mut s);
1087 }
1088 s
1089}
1090
1091#[cfg(any(target_arch = "wasm32", test))]
1092fn insert_decimal_point(s: &mut String) {
1093 if s.contains('.') {
1094 return;
1095 }
1096 if let Some(idx) = find_exponent_index(s) {
1097 s.insert(idx, '.');
1098 } else {
1099 s.push('.');
1100 }
1101}
1102
1103#[cfg(any(target_arch = "wasm32", test))]
1104fn trim_trailing_zeros(s: &mut String) {
1105 if let Some(idx) = find_exponent_index(s) {
1106 let exponent = s[idx..].to_string();
1107 let mut mantissa = s[..idx].to_string();
1108 trim_fraction(&mut mantissa);
1109 s.clear();
1110 s.push_str(&mantissa);
1111 s.push_str(&exponent);
1112 normalize_exponent_notation(s);
1113 } else {
1114 trim_fraction(s);
1115 }
1116}
1117
1118#[cfg(any(target_arch = "wasm32", test))]
1119fn trim_fraction(s: &mut String) {
1120 if let Some(dot_idx) = s.find('.') {
1121 let mut idx = s.len();
1122 while idx > dot_idx + 1 && matches!(s.as_bytes().get(idx - 1), Some(b'0')) {
1123 idx -= 1;
1124 }
1125 if idx == dot_idx + 1 {
1126 idx -= 1;
1127 }
1128 s.truncate(idx);
1129 }
1130}
1131
1132#[cfg(any(target_arch = "wasm32", test))]
1133fn find_exponent_index(s: &str) -> Option<usize> {
1134 s.find('e').or_else(|| s.find('E'))
1135}
1136
1137#[cfg(any(target_arch = "wasm32", test))]
1138fn normalize_exponent_notation(s: &mut String) {
1139 if let Some(idx) = find_exponent_index(s) {
1140 let marker = s.as_bytes()[idx] as char;
1141 let suffix = &s[idx + 1..];
1142 let (sign, digits) = if let Some(first) = suffix.chars().next() {
1143 if first == '+' || first == '-' {
1144 (first, suffix.get(1..).unwrap_or("").to_string())
1145 } else {
1146 ('+', suffix.to_string())
1147 }
1148 } else {
1149 ('+', String::from("0"))
1150 };
1151 let mut normalized_digits = if digits.is_empty() {
1152 String::from("0")
1153 } else {
1154 digits
1155 };
1156 if normalized_digits.is_empty() {
1157 normalized_digits.push('0');
1158 }
1159 if normalized_digits.len() < 2 {
1160 normalized_digits = format!("{:0>2}", normalized_digits);
1161 }
1162 let mut rebuilt = String::with_capacity(idx + 1 + 1 + normalized_digits.len());
1163 rebuilt.push_str(&s[..idx]);
1164 rebuilt.push(marker);
1165 rebuilt.push(sign);
1166 rebuilt.push_str(&normalized_digits);
1167 *s = rebuilt;
1168 }
1169}
1170
1171#[cfg(test)]
1172pub(crate) mod tests {
1173 use super::*;
1174 use crate::builtins::common::test_support;
1175 use runmat_time::unix_timestamp_ms;
1176 use std::fs;
1177 use std::sync::atomic::{AtomicU64, Ordering};
1178
1179 #[cfg(feature = "wgpu")]
1180 use runmat_accelerate::backend::wgpu::provider as wgpu_provider;
1181 use runmat_accelerate_api::HostTensorView;
1182 use runmat_builtins::IntValue;
1183
1184 use crate::builtins::common::fs as fs_helpers;
1185
1186 fn dlmwrite_builtin(filename: Value, data: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
1187 futures::executor::block_on(super::dlmwrite_builtin(filename, data, rest))
1188 }
1189
1190 static NEXT_ID: AtomicU64 = AtomicU64::new(0);
1191
1192 fn error_message(err: RuntimeError) -> String {
1193 err.message().to_string()
1194 }
1195
1196 fn temp_path(ext: &str) -> PathBuf {
1197 let millis = unix_timestamp_ms();
1198 let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
1199 let mut path = std::env::temp_dir();
1200 path.push(format!(
1201 "runmat_dlmwrite_{}_{}_{}.{}",
1202 std::process::id(),
1203 millis,
1204 unique,
1205 ext
1206 ));
1207 path
1208 }
1209
1210 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1211 #[test]
1212 fn wasm_precision_parser_handles_common_specs() {
1213 fn fmt(value: f64, spec: &str) -> String {
1214 super::wasm_format_float(value, spec).expect("formatting failed")
1215 }
1216
1217 assert_eq!(fmt(12.3456, "%.2f"), "12.35");
1218 assert_eq!(fmt(-12.3456, "%+08.1f"), "-00012.3");
1219 assert_eq!(fmt(0.001234, "%.4g"), "0.001234");
1220 assert_eq!(fmt(12345.0, "%.3g"), "1.23e+04");
1221 assert_eq!(fmt(1.5, "%#.0f"), "2.");
1222 assert_eq!(fmt(1.5, "%#.2e"), "1.50e+00");
1223 assert_eq!(fmt(1.5, "%#.2G"), "1.5E+00");
1224 }
1225
1226 fn platform_newline() -> &'static str {
1227 LineEnding::platform_default().as_str()
1228 }
1229
1230 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1231 #[test]
1232 fn dlmwrite_writes_default_comma() {
1233 let path = temp_path("csv");
1234 let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
1235 let filename = path.to_string_lossy().into_owned();
1236
1237 dlmwrite_builtin(Value::from(filename), Value::Tensor(tensor), Vec::new()).unwrap();
1238
1239 let contents = fs::read_to_string(&path).unwrap();
1240 let nl = platform_newline();
1241 assert_eq!(contents, format!("1,2,3{nl}4,5,6{nl}"));
1242 let _ = fs::remove_file(path);
1243 }
1244
1245 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1246 #[test]
1247 fn dlmwrite_accepts_positional_delimiter_and_offsets() {
1248 let path = temp_path("txt");
1249 let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
1250 let filename = path.to_string_lossy().into_owned();
1251 dlmwrite_builtin(
1252 Value::from(filename),
1253 Value::Tensor(tensor),
1254 vec![
1255 Value::from(";"),
1256 Value::Int(IntValue::I32(1)),
1257 Value::Int(IntValue::I32(2)),
1258 ],
1259 )
1260 .unwrap();
1261
1262 let contents = fs::read_to_string(&path).unwrap();
1263 let nl = platform_newline();
1264 assert_eq!(contents, format!(";;;;{nl};;1;2;3{nl};;4;5;6{nl}"));
1265 let _ = fs::remove_file(path);
1266 }
1267
1268 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1269 #[test]
1270 fn dlmwrite_supports_append_and_offsets() {
1271 let path = temp_path("csv");
1272 let tensor_a = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
1273 let tensor_b = Tensor::new(vec![10.0, 13.0, 11.0, 14.0, 12.0, 15.0], vec![3, 2]).unwrap();
1274 let filename = path.to_string_lossy().into_owned();
1275 dlmwrite_builtin(
1276 Value::from(filename.clone()),
1277 Value::Tensor(tensor_a),
1278 Vec::new(),
1279 )
1280 .unwrap();
1281 dlmwrite_builtin(
1282 Value::from(filename),
1283 Value::Tensor(tensor_b),
1284 vec![
1285 Value::from("-append"),
1286 Value::from("roffset"),
1287 Value::Int(IntValue::I32(1)),
1288 Value::from("coffset"),
1289 Value::Int(IntValue::I32(1)),
1290 ],
1291 )
1292 .unwrap();
1293 let contents = fs::read_to_string(&path).unwrap();
1294 let nl = platform_newline();
1295 assert_eq!(
1296 contents,
1297 format!("1,2,3{nl},,{nl},10,14{nl},13,12{nl},11,15{nl}")
1298 );
1299 let _ = fs::remove_file(path);
1300 }
1301
1302 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1303 #[test]
1304 fn dlmwrite_precision_digits() {
1305 let path = temp_path("csv");
1306 let tensor = Tensor::new(vec![12.34567, std::f64::consts::PI], vec![1, 2]).unwrap();
1307 let filename = path.to_string_lossy().into_owned();
1308 dlmwrite_builtin(
1309 Value::from(filename),
1310 Value::Tensor(tensor),
1311 vec![Value::from("precision"), Value::Int(IntValue::I32(3))],
1312 )
1313 .unwrap();
1314 let contents = fs::read_to_string(&path).unwrap();
1315 let nl = platform_newline();
1316 assert_eq!(contents, format!("12.3,3.14{nl}"));
1317 let _ = fs::remove_file(path);
1318 }
1319
1320 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1321 #[test]
1322 fn dlmwrite_precision_format_string() {
1323 let path = temp_path("txt");
1324 let tensor = Tensor::new(vec![0.25, 0.5, 0.75, 1.5], vec![2, 2]).unwrap();
1325 let filename = path.to_string_lossy().into_owned();
1326 dlmwrite_builtin(
1327 Value::from(filename),
1328 Value::Tensor(tensor),
1329 vec![
1330 Value::from("precision"),
1331 Value::from("%.4f"),
1332 Value::from("delimiter"),
1333 Value::from("\t"),
1334 ],
1335 )
1336 .unwrap();
1337 let contents = fs::read_to_string(&path).unwrap();
1338 let nl = platform_newline();
1339 assert_eq!(contents, format!("0.2500\t0.7500{nl}0.5000\t1.5000{nl}"));
1340 let _ = fs::remove_file(path);
1341 }
1342
1343 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1344 #[test]
1345 fn dlmwrite_newline_pc() {
1346 let path = temp_path("csv");
1347 let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1348 let filename = path.to_string_lossy().into_owned();
1349 dlmwrite_builtin(
1350 Value::from(filename),
1351 Value::Tensor(tensor),
1352 vec![Value::from("newline"), Value::from("pc")],
1353 )
1354 .unwrap();
1355 let contents = fs::read_to_string(&path).unwrap();
1356 let nl = LineEnding::Pc.as_str();
1357 assert_eq!(contents, format!("1{nl}2{nl}"));
1358 let _ = fs::remove_file(path);
1359 }
1360
1361 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1362 #[test]
1363 fn dlmwrite_coffset_inserts_empty_fields() {
1364 let path = temp_path("csv");
1365 let tensor = Tensor::new(vec![1.0, 3.0, 2.0, 4.0], vec![2, 2]).unwrap();
1366 let filename = path.to_string_lossy().into_owned();
1367 dlmwrite_builtin(
1368 Value::from(filename),
1369 Value::Tensor(tensor),
1370 vec![Value::from("coffset"), Value::Int(IntValue::I32(2))],
1371 )
1372 .unwrap();
1373 let contents = fs::read_to_string(&path).unwrap();
1374 let nl = platform_newline();
1375 assert_eq!(contents, format!(",,1,2{nl},,3,4{nl}"));
1376 let _ = fs::remove_file(path);
1377 }
1378
1379 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1380 #[test]
1381 fn dlmwrite_handles_gpu_tensors() {
1382 test_support::with_test_provider(|provider| {
1383 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
1384 let view = HostTensorView {
1385 data: &tensor.data,
1386 shape: &tensor.shape,
1387 };
1388 let handle = provider.upload(&view).unwrap();
1389 let path = temp_path("csv");
1390 let filename = path.to_string_lossy().into_owned();
1391 dlmwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new()).unwrap();
1392 let contents = fs::read_to_string(&path).unwrap();
1393 let nl = platform_newline();
1394 assert_eq!(contents, format!("1,2,3{nl}"));
1395 let _ = fs::remove_file(path);
1396 });
1397 }
1398
1399 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1400 #[test]
1401 #[cfg(feature = "wgpu")]
1402 fn dlmwrite_handles_wgpu_provider_gather() {
1403 let _ =
1404 wgpu_provider::register_wgpu_provider(wgpu_provider::WgpuProviderOptions::default());
1405 let provider = runmat_accelerate_api::provider().expect("wgpu provider");
1406 let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
1407 let view = HostTensorView {
1408 data: &tensor.data,
1409 shape: &tensor.shape,
1410 };
1411 let handle = provider.upload(&view).unwrap();
1412 let path = temp_path("csv");
1413 let filename = path.to_string_lossy().into_owned();
1414 dlmwrite_builtin(Value::from(filename), Value::GpuTensor(handle), Vec::new()).unwrap();
1415 let contents = fs::read_to_string(&path).unwrap();
1416 let nl = platform_newline();
1417 assert_eq!(contents, format!("1,2{nl}"));
1418 let _ = fs::remove_file(path);
1419 }
1420
1421 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1422 #[test]
1423 fn dlmwrite_interprets_control_sequence_delimiters() {
1424 let path = temp_path("txt");
1425 let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
1426 let filename = path.to_string_lossy().into_owned();
1427 dlmwrite_builtin(
1428 Value::from(filename),
1429 Value::Tensor(tensor),
1430 vec![Value::from("delimiter"), Value::from("\\t")],
1431 )
1432 .unwrap();
1433 let contents = fs::read_to_string(&path).unwrap();
1434 let nl = platform_newline();
1435 assert_eq!(contents, format!("1\t2{nl}"));
1436 let _ = fs::remove_file(path);
1437 }
1438
1439 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1440 #[test]
1441 fn dlmwrite_rejects_negative_offsets() {
1442 let path = temp_path("csv");
1443 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
1444 let filename = path.to_string_lossy().into_owned();
1445 let message = error_message(
1446 dlmwrite_builtin(
1447 Value::from(filename),
1448 Value::Tensor(tensor),
1449 vec![Value::from("roffset"), Value::Int(IntValue::I32(-1))],
1450 )
1451 .unwrap_err(),
1452 );
1453 assert!(message.to_ascii_lowercase().contains("row offset"));
1454 assert!(!path.exists());
1455 }
1456
1457 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1458 #[test]
1459 fn dlmwrite_rejects_fractional_offsets() {
1460 let path = temp_path("csv");
1461 let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
1462 let filename = path.to_string_lossy().into_owned();
1463 let message = error_message(
1464 dlmwrite_builtin(
1465 Value::from(filename),
1466 Value::Tensor(tensor),
1467 vec![Value::from("coffset"), Value::Num(1.5)],
1468 )
1469 .unwrap_err(),
1470 );
1471 assert!(message.to_ascii_lowercase().contains("integer"));
1472 assert!(!path.exists());
1473 }
1474
1475 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1476 #[test]
1477 fn dlmwrite_rejects_empty_delimiter() {
1478 let path = temp_path("csv");
1479 let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
1480 let filename = path.to_string_lossy().into_owned();
1481 let message = error_message(
1482 dlmwrite_builtin(
1483 Value::from(filename),
1484 Value::Tensor(tensor),
1485 vec![Value::from("")],
1486 )
1487 .unwrap_err(),
1488 );
1489 assert!(message.to_ascii_lowercase().contains("delimiter"));
1490 assert!(!path.exists());
1491 }
1492
1493 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1494 #[test]
1495 fn dlmwrite_precision_zero_error() {
1496 let path = temp_path("csv");
1497 let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
1498 let filename = path.to_string_lossy().into_owned();
1499 let message = error_message(
1500 dlmwrite_builtin(
1501 Value::from(filename),
1502 Value::Tensor(tensor),
1503 vec![Value::from("precision"), Value::Int(IntValue::I32(0))],
1504 )
1505 .unwrap_err(),
1506 );
1507 assert!(message.to_ascii_lowercase().contains("precision"));
1508 assert!(!path.exists());
1509 }
1510
1511 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1512 #[test]
1513 fn dlmwrite_requires_name_value_pairs() {
1514 let path = temp_path("csv");
1515 let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
1516 let filename = path.to_string_lossy().into_owned();
1517 let message = error_message(
1518 dlmwrite_builtin(
1519 Value::from(filename),
1520 Value::Tensor(tensor),
1521 vec![Value::from("delimiter")],
1522 )
1523 .unwrap_err(),
1524 );
1525 assert!(message
1526 .to_ascii_lowercase()
1527 .contains("name-value arguments must appear in pairs"));
1528 assert!(!path.exists());
1529 }
1530
1531 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1532 #[test]
1533 fn dlmwrite_expands_home_directory() {
1534 let Some(mut home) = fs_helpers::home_directory() else {
1535 return;
1536 };
1537 let filename = format!(
1538 "runmat_dlmwrite_home_{}_{}.csv",
1539 std::process::id(),
1540 NEXT_ID.fetch_add(1, Ordering::Relaxed)
1541 );
1542 home.push(&filename);
1543
1544 let tilde_path = format!("~/{}", filename);
1545 let tensor = Tensor::new(vec![42.0], vec![1, 1]).unwrap();
1546
1547 dlmwrite_builtin(Value::from(tilde_path), Value::Tensor(tensor), Vec::new()).unwrap();
1548
1549 let contents = fs::read_to_string(&home).unwrap();
1550 let nl = platform_newline();
1551 assert_eq!(contents, format!("42{nl}"));
1552 let _ = fs::remove_file(home);
1553 }
1554
1555 #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1556 #[test]
1557 fn dlmwrite_rejects_non_numeric_inputs() {
1558 let path = temp_path("csv");
1559 let filename = path.to_string_lossy().into_owned();
1560 let message = error_message(
1561 dlmwrite_builtin(
1562 Value::from(filename),
1563 Value::String("abc".into()),
1564 Vec::new(),
1565 )
1566 .unwrap_err(),
1567 );
1568 assert!(message.contains("dlmwrite"));
1569 }
1570}