Skip to main content

runmat_runtime/builtins/io/tabular/
dlmwrite.rs

1//! MATLAB-compatible `dlmwrite` builtin for delimiter-separated exports.
2
3#[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    // Consume any leading '-append' flags.
185    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    // Optional positional delimiter.
195    if idx < args.len() && is_positional_delimiter_candidate(&args[idx]) {
196        options.delimiter = parse_delimiter_value(&args[idx])?;
197        idx += 1;
198        // Optional positional row/col offsets if both numeric scalars follow.
199        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        // Consume any subsequent '-append'.
208        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    // Remaining arguments should be name-value pairs (allowing additional '-append').
219    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}