runmat_runtime/builtins/io/filetext/
fwrite.rs

1//! MATLAB-compatible `fwrite` builtin for RunMat.
2use std::io::{Seek, SeekFrom, Write};
3
4use runmat_builtins::{CharArray, Value};
5use runmat_macros::runtime_builtin;
6
7use crate::builtins::common::spec::{
8    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
9    ReductionNaN, ResidencyPolicy, ShapeRequirements,
10};
11use crate::builtins::io::filetext::registry;
12use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
13
14#[cfg(feature = "doc_export")]
15use crate::register_builtin_doc_text;
16
17#[cfg(feature = "doc_export")]
18pub const DOC_MD: &str = r#"---
19title: "fwrite"
20category: "io/filetext"
21keywords: ["fwrite", "binary write", "io", "precision", "machine format", "skip"]
22summary: "Write binary data to a file identifier with MATLAB-compatible precision, skip, and machine-format semantics."
23references:
24  - https://www.mathworks.com/help/matlab/ref/fwrite.html
25gpu_support:
26  elementwise: false
27  reduction: false
28  precisions: []
29  broadcasting: "none"
30  notes: "Runs entirely on the host CPU. When data or arguments live on the GPU, RunMat gathers them first; providers do not expose file-I/O hooks."
31fusion:
32  elementwise: false
33  reduction: false
34  max_inputs: 4
35  constants: "inline"
36requires_feature: null
37tested:
38  unit: "builtins::io::filetext::fwrite::tests"
39  integration:
40    - "builtins::io::filetext::fwrite::tests::fwrite_double_precision_writes_native_endian"
41    - "builtins::io::filetext::fwrite::tests::fwrite_gpu_tensor_gathers_before_write"
42    - "builtins::io::filetext::fwrite::tests::fwrite_wgpu_tensor_roundtrip"
43    - "builtins::io::filetext::fwrite::tests::fwrite_invalid_precision_errors"
44    - "builtins::io::filetext::fwrite::tests::fwrite_negative_skip_errors"
45---
46
47# What does the `fwrite` function do in MATLAB / RunMat?
48`fwrite` writes binary data to a file identifier obtained from `fopen`. It mirrors MATLAB's handling
49of precision strings, skip values, and machine-format overrides so existing MATLAB scripts can save data
50without modification. The builtin accepts numeric tensors, logical data, and character arrays; the bytes are
51emitted in column-major order to match MATLAB storage.
52
53## How does the `fwrite` function behave in MATLAB / RunMat?
54- `count = fwrite(fid, A)` converts `A` to unsigned 8-bit integers and writes one byte per element.
55- `count = fwrite(fid, A, precision)` converts `A` to the requested precision before writing. Supported
56  precisions are `double`, `single`, `uint8`, `int8`, `uint16`, `int16`, `uint32`, `int32`, `uint64`,
57  `int64`, and `char`. Shorthand aliases such as `uchar`, `byte`, and `real*8` are also recognised.
58- `count = fwrite(fid, A, precision, skip)` skips `skip` bytes after writing each element. RunMat applies the
59  skip with a file seek, which produces sparse regions when the target position moves beyond the current end.
60- `count = fwrite(fid, A, precision, skip, machinefmt)` overrides the byte ordering used for the conversion.
61  Supported machine formats are `'native'`, `'ieee-le'`, and `'ieee-be'`. When omitted, the builtin honours the
62  format recorded by `fopen`.
63- Column-major ordering matches MATLAB semantics: tensors and character arrays write their first column
64  completely before advancing to the next column. Scalars and vectors behave as 1-by-N matrices.
65- The return value `count` is the number of elements written, not the number of bytes. A zero-length input
66  produces `count == 0`.
67- RunMat executes `fwrite` entirely on the host. When the data resides on a GPU (`gpuArray`), RunMat gathers the
68  tensor to host memory before writing; providers do not currently implement device-side file I/O.
69
70## `fwrite` Function GPU Execution Behaviour
71`fwrite` never launches GPU kernels. If any input value (file identifier, data, or optional arguments) is backed
72by a GPU tensor, RunMat gathers the value to host memory before performing the write. This mirrors MATLAB's own
73behaviour when working with `gpuArray` objects: data is moved to the CPU for I/O. When a provider is available,
74the gather occurs via the provider's `download` path; otherwise the builtin emits an informative error.
75
76## GPU residency in RunMat (Do I need `gpuArray`?)
77You rarely need to move data with `gpuArray` purely for `fwrite`. RunMat keeps tensors on the GPU while compute
78stays in fused expressions, but explicit file I/O always happens on the host. If your data already lives on the
79device, `fwrite` performs an automatic gather, writes the bytes, and leaves residency unchanged for the rest of
80the program. You can still call `gpuArray` manually when porting MATLAB code verbatim—the builtin will gather it
81for you automatically.
82
83## Examples of using the `fwrite` function in MATLAB / RunMat
84
85### Write unsigned bytes with the default precision
86```matlab
87fid = fopen('bytes.bin', 'w+b');
88count = fwrite(fid, [1 2 3 255]);
89fclose(fid);
90```
91Expected output:
92```matlab
93count = 4
94```
95
96### Write double-precision values
97```matlab
98fid = fopen('values.bin', 'w+b');
99data = [1.5 -2.25 42.0];
100count = fwrite(fid, data, 'double');
101fclose(fid);
102```
103Expected output:
104```matlab
105count = 3
106```
107The file contains three IEEE 754 doubles in the machine format recorded by `fopen`.
108
109### Write 16-bit integers using big-endian byte ordering
110```matlab
111fid = fopen('sensor.be', 'w+b', 'ieee-be');
112fwrite(fid, [258 772], 'uint16');
113fclose(fid);
114```
115Expected output:
116```matlab
117count = 2
118```
119The bytes on disk follow big-endian ordering (`01 02 03 04` for the values above).
120
121### Insert padding bytes between samples
122```matlab
123fid = fopen('spaced.bin', 'w+b');
124fwrite(fid, [10 20 30], 'uint8', 1);   % skip one byte between elements
125fclose(fid);
126```
127Expected output:
128```matlab
129count = 3
130```
131The file layout is `0A 00 14 00 1E`, leaving a zero byte between each stored value.
132
133### Write character data without manual conversions
134```matlab
135fid = fopen('greeting.txt', 'w+b');
136fwrite(fid, 'RunMat!', 'char');
137fclose(fid);
138```
139Expected output:
140```matlab
141count = 7
142```
143Passing text uses the character codes (UTF-16 code units truncated to 8 bits) and writes them sequentially.
144
145### Gather GPU data before writing
146```matlab
147fid = fopen('gpu.bin', 'w+b');
148G = gpuArray([1 2 3 4]);
149count = fwrite(fid, G, 'uint16');
150fclose(fid);
151```
152Expected output when a GPU provider is active:
153```matlab
154count = 4
155```
156RunMat gathers `G` to the host before conversion. When no provider is registered, `fwrite` raises an error
157stating that GPU values cannot be gathered.
158
159## FAQ
160
161### What precisions does `fwrite` support?
162RunMat recognises the commonly used MATLAB precisions: `double`, `single`, `uint8`, `int8`, `uint16`, `int16`,
163`uint32`, `int32`, `uint64`, `int64`, and `char`, along with their documented aliases (`real*8`, `uchar`, etc.).
164The `precision => output` forms are accepted when both sides match; differing output classes are not implemented yet.
165
166### How are values converted before writing?
167Numeric inputs are converted to the requested precision using MATLAB-style rounding (to the nearest integer) with
168saturation to the target range. Logical inputs map `true` to 1 and `false` to 0. Character inputs use their Unicode
169scalar values.
170
171### What does the return value represent?
172`fwrite` returns the number of elements successfully written, not the total number of bytes. Multiply by the element
173size when you need to know the byte count.
174
175### Does `skip` insert bytes into the file?
176`skip` seeks forward after each element is written. When the seek lands beyond the current end of file, the OS
177creates a sparse region (holes are zero-filled on most platforms). Use `skip = 0` (the default) to write densely.
178
179### How do machine formats affect the output?
180The machine format controls byte ordering for multi-byte precisions. `'native'` uses the host endianness, `'ieee-le'`
181forces little-endian ordering, and `'ieee-be'` forces big-endian ordering regardless of the host.
182
183### Can I write directly to standard output?
184Not yet. File identifiers 0, 1, and 2 (stdin, stdout, stderr) are reserved and raise a descriptive error. Use
185`fopen` to create a file handle before calling `fwrite`.
186
187### Are GPU tensors supported?
188Yes. RunMat gathers GPU tensors to host memory before writing. The gather relies on the active provider; if no
189provider is registered, an informative error is raised.
190
191### Do string arrays insert newline characters?
192RunMat joins string-array elements using newline (`'\n'`) separators before writing. This mirrors how MATLAB flattens
193string arrays to character data for binary I/O.
194
195### What happens with `NaN` or infinite values?
196`NaN` values map to zero for integer precisions and remain `NaN` for floating-point precisions. Infinite values
197saturate to the min/max integer representable by the target precision.
198
199## See Also
200[fopen](./fopen), [fclose](./fclose), [fread](./fread), [fileread](./fileread), [filewrite](./filewrite)
201
202## Source & Feedback
203- Implementation: [`crates/runmat-runtime/src/builtins/io/filetext/fwrite.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/io/filetext/fwrite.rs)
204- Found a behavioural mismatch? [Open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
205"#;
206
207pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
208    name: "fwrite",
209    op_kind: GpuOpKind::Custom("file-io-write"),
210    supported_precisions: &[],
211    broadcast: BroadcastSemantics::None,
212    provider_hooks: &[],
213    constant_strategy: ConstantStrategy::InlineLiteral,
214    residency: ResidencyPolicy::GatherImmediately,
215    nan_mode: ReductionNaN::Include,
216    two_pass_threshold: None,
217    workgroup_size: None,
218    accepts_nan_mode: false,
219    notes: "Host-only binary file I/O; GPU arguments are gathered to the CPU prior to writing.",
220};
221
222register_builtin_gpu_spec!(GPU_SPEC);
223
224pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
225    name: "fwrite",
226    shape: ShapeRequirements::Any,
227    constant_strategy: ConstantStrategy::InlineLiteral,
228    elementwise: None,
229    reduction: None,
230    emits_nan: false,
231    notes: "File I/O is never fused; metadata recorded for completeness.",
232};
233
234register_builtin_fusion_spec!(FUSION_SPEC);
235
236#[cfg(feature = "doc_export")]
237register_builtin_doc_text!("fwrite", DOC_MD);
238
239#[runtime_builtin(
240    name = "fwrite",
241    category = "io/filetext",
242    summary = "Write binary data to a file identifier.",
243    keywords = "fwrite,file,io,binary,precision",
244    accel = "cpu"
245)]
246fn fwrite_builtin(fid: Value, data: Value, rest: Vec<Value>) -> Result<Value, String> {
247    let eval = evaluate(&fid, &data, &rest)?;
248    Ok(Value::Num(eval.count as f64))
249}
250
251/// Result of an `fwrite` evaluation.
252#[derive(Debug, Clone)]
253pub struct FwriteEval {
254    count: usize,
255}
256
257impl FwriteEval {
258    fn new(count: usize) -> Self {
259        Self { count }
260    }
261
262    /// Number of elements successfully written.
263    pub fn count(&self) -> usize {
264        self.count
265    }
266}
267
268/// Evaluate the `fwrite` builtin without invoking the runtime dispatcher.
269pub fn evaluate(
270    fid_value: &Value,
271    data_value: &Value,
272    rest: &[Value],
273) -> Result<FwriteEval, String> {
274    let fid_host = gather_value(fid_value)?;
275    let fid = parse_fid(&fid_host)?;
276    if fid < 0 {
277        return Err("fwrite: file identifier must be non-negative".to_string());
278    }
279    if fid < 3 {
280        return Err("fwrite: standard input/output identifiers are not supported yet".to_string());
281    }
282
283    let info = registry::info_for(fid).ok_or_else(|| {
284        "fwrite: Invalid file identifier. Use fopen to generate a valid file ID.".to_string()
285    })?;
286    let handle = registry::take_handle(fid).ok_or_else(|| {
287        "fwrite: Invalid file identifier. Use fopen to generate a valid file ID.".to_string()
288    })?;
289
290    let mut file = handle
291        .lock()
292        .map_err(|_| "fwrite: failed to lock file handle (poisoned mutex)".to_string())?;
293
294    let data_host = gather_value(data_value)?;
295    let rest_host = gather_args(rest)?;
296    let (precision_arg, skip_arg, machine_arg) = classify_arguments(&rest_host)?;
297
298    let precision_spec = parse_precision(precision_arg)?;
299    let skip_bytes = parse_skip(skip_arg)?;
300    let machine_format = parse_machine_format(machine_arg, &info.machinefmt)?;
301
302    let elements = flatten_elements(&data_host)?;
303    let count = write_elements(
304        &mut file,
305        &elements,
306        precision_spec,
307        skip_bytes,
308        machine_format,
309    )?;
310    Ok(FwriteEval::new(count))
311}
312
313fn gather_value(value: &Value) -> Result<Value, String> {
314    gather_if_needed(value).map_err(|e| format!("fwrite: {e}"))
315}
316
317fn gather_args(args: &[Value]) -> Result<Vec<Value>, String> {
318    let mut gathered = Vec::with_capacity(args.len());
319    for value in args {
320        gathered.push(gather_if_needed(value).map_err(|e| format!("fwrite: {e}"))?);
321    }
322    Ok(gathered)
323}
324
325fn parse_fid(value: &Value) -> Result<i32, String> {
326    let scalar = match value {
327        Value::Num(n) => *n,
328        Value::Int(int) => int.to_f64(),
329        _ => return Err("fwrite: file identifier must be numeric".to_string()),
330    };
331    if !scalar.is_finite() {
332        return Err("fwrite: file identifier must be finite".to_string());
333    }
334    if scalar.fract().abs() > f64::EPSILON {
335        return Err("fwrite: file identifier must be an integer".to_string());
336    }
337    Ok(scalar as i32)
338}
339
340type FwriteArgs<'a> = (Option<&'a Value>, Option<&'a Value>, Option<&'a Value>);
341
342fn classify_arguments(args: &[Value]) -> Result<FwriteArgs<'_>, String> {
343    match args.len() {
344        0 => Ok((None, None, None)),
345        1 => {
346            if is_string_like(&args[0]) {
347                Ok((Some(&args[0]), None, None))
348            } else {
349                Err(
350                    "fwrite: precision argument must be a string scalar or character vector"
351                        .to_string(),
352                )
353            }
354        }
355        2 => {
356            if !is_string_like(&args[0]) {
357                return Err(
358                    "fwrite: precision argument must be a string scalar or character vector"
359                        .to_string(),
360                );
361            }
362            if is_numeric_like(&args[1]) {
363                Ok((Some(&args[0]), Some(&args[1]), None))
364            } else if is_string_like(&args[1]) {
365                Ok((Some(&args[0]), None, Some(&args[1])))
366            } else {
367                Err("fwrite: invalid argument combination (expected numeric skip or machine format string)".to_string())
368            }
369        }
370        3 => {
371            if !is_string_like(&args[0]) || !is_numeric_like(&args[1]) || !is_string_like(&args[2])
372            {
373                return Err("fwrite: expected arguments (precision, skip, machinefmt)".to_string());
374            }
375            Ok((Some(&args[0]), Some(&args[1]), Some(&args[2])))
376        }
377        _ => Err("fwrite: too many input arguments".to_string()),
378    }
379}
380
381fn is_string_like(value: &Value) -> bool {
382    match value {
383        Value::String(_) => true,
384        Value::CharArray(ca) => ca.rows == 1,
385        Value::StringArray(sa) => sa.data.len() == 1,
386        _ => false,
387    }
388}
389
390fn is_numeric_like(value: &Value) -> bool {
391    match value {
392        Value::Num(_) | Value::Int(_) | Value::Bool(_) => true,
393        Value::Tensor(t) => t.data.len() == 1,
394        Value::LogicalArray(la) => la.data.len() == 1,
395        _ => false,
396    }
397}
398
399#[derive(Clone, Copy, Debug)]
400struct WriteSpec {
401    input: InputType,
402}
403
404impl WriteSpec {
405    fn default() -> Self {
406        Self {
407            input: InputType::UInt8,
408        }
409    }
410}
411
412fn parse_precision(arg: Option<&Value>) -> Result<WriteSpec, String> {
413    match arg {
414        None => Ok(WriteSpec::default()),
415        Some(value) => {
416            let text = scalar_string(
417                value,
418                "fwrite: precision argument must be a string scalar or character vector",
419            )?;
420            parse_precision_string(&text)
421        }
422    }
423}
424
425fn parse_precision_string(raw: &str) -> Result<WriteSpec, String> {
426    let trimmed = raw.trim();
427    if trimmed.is_empty() {
428        return Err("fwrite: precision argument must not be empty".to_string());
429    }
430    let lower = trimmed.to_ascii_lowercase();
431    if let Some((lhs, rhs)) = lower.split_once("=>") {
432        let lhs = lhs.trim();
433        let rhs = rhs.trim();
434        let input = parse_input_label(lhs)?;
435        let output = parse_input_label(rhs)?;
436        if input != output {
437            return Err(
438                "fwrite: differing input/output precisions are not implemented yet".to_string(),
439            );
440        }
441        Ok(WriteSpec { input })
442    } else {
443        parse_input_label(lower.trim()).map(|input| WriteSpec { input })
444    }
445}
446
447fn parse_skip(arg: Option<&Value>) -> Result<usize, String> {
448    match arg {
449        None => Ok(0),
450        Some(value) => {
451            let scalar = numeric_scalar(value, "fwrite: skip must be numeric")?;
452            if !scalar.is_finite() {
453                return Err("fwrite: skip value must be finite".to_string());
454            }
455            if scalar < 0.0 {
456                return Err("fwrite: skip value must be non-negative".to_string());
457            }
458            let rounded = scalar.round();
459            if (rounded - scalar).abs() > f64::EPSILON {
460                return Err("fwrite: skip value must be an integer".to_string());
461            }
462            if rounded > i64::MAX as f64 {
463                return Err("fwrite: skip value is too large".to_string());
464            }
465            Ok(rounded as usize)
466        }
467    }
468}
469
470#[derive(Clone, Copy, Debug)]
471enum MachineFormat {
472    Native,
473    LittleEndian,
474    BigEndian,
475}
476
477impl MachineFormat {
478    fn to_endianness(self) -> Endianness {
479        match self {
480            MachineFormat::Native => {
481                if cfg!(target_endian = "little") {
482                    Endianness::Little
483                } else {
484                    Endianness::Big
485                }
486            }
487            MachineFormat::LittleEndian => Endianness::Little,
488            MachineFormat::BigEndian => Endianness::Big,
489        }
490    }
491}
492
493#[derive(Clone, Copy, Debug)]
494enum Endianness {
495    Little,
496    Big,
497}
498
499fn parse_machine_format(arg: Option<&Value>, default_label: &str) -> Result<MachineFormat, String> {
500    match arg {
501        Some(value) => {
502            let text = scalar_string(
503                value,
504                "fwrite: machine format must be a string scalar or character vector",
505            )?;
506            machine_format_from_label(&text)
507        }
508        None => machine_format_from_label(default_label),
509    }
510}
511
512fn machine_format_from_label(label: &str) -> Result<MachineFormat, String> {
513    let trimmed = label.trim();
514    if trimmed.is_empty() {
515        return Err("fwrite: machine format must not be empty".to_string());
516    }
517    let lower = trimmed.to_ascii_lowercase();
518    let collapsed: String = lower
519        .chars()
520        .filter(|c| !matches!(c, '-' | '_' | ' '))
521        .collect();
522    if matches!(collapsed.as_str(), "native" | "n" | "system" | "default") {
523        return Ok(MachineFormat::Native);
524    }
525    if matches!(
526        collapsed.as_str(),
527        "l" | "le" | "littleendian" | "pc" | "intel"
528    ) {
529        return Ok(MachineFormat::LittleEndian);
530    }
531    if matches!(
532        collapsed.as_str(),
533        "b" | "be" | "bigendian" | "mac" | "motorola"
534    ) {
535        return Ok(MachineFormat::BigEndian);
536    }
537    if lower.starts_with("ieee-le") {
538        return Ok(MachineFormat::LittleEndian);
539    }
540    if lower.starts_with("ieee-be") {
541        return Ok(MachineFormat::BigEndian);
542    }
543    Err(format!("fwrite: unsupported machine format '{trimmed}'"))
544}
545
546fn scalar_string(value: &Value, err: &str) -> Result<String, String> {
547    match value {
548        Value::String(s) => Ok(s.clone()),
549        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
550        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
551        _ => Err(err.to_string()),
552    }
553}
554
555fn numeric_scalar(value: &Value, err: &str) -> Result<f64, String> {
556    match value {
557        Value::Num(n) => Ok(*n),
558        Value::Int(int) => Ok(int.to_f64()),
559        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
560        Value::Tensor(t) if t.data.len() == 1 => Ok(t.data[0]),
561        Value::LogicalArray(la) if la.data.len() == 1 => {
562            Ok(if la.data[0] != 0 { 1.0 } else { 0.0 })
563        }
564        _ => Err(err.to_string()),
565    }
566}
567
568fn flatten_elements(value: &Value) -> Result<Vec<f64>, String> {
569    match value {
570        Value::Tensor(tensor) => Ok(tensor.data.clone()),
571        Value::Num(n) => Ok(vec![*n]),
572        Value::Int(int) => Ok(vec![int.to_f64()]),
573        Value::Bool(b) => Ok(vec![if *b { 1.0 } else { 0.0 }]),
574        Value::LogicalArray(array) => Ok(array
575            .data
576            .iter()
577            .map(|bit| if *bit != 0 { 1.0 } else { 0.0 })
578            .collect()),
579        Value::CharArray(ca) => Ok(flatten_char_array(ca)),
580        Value::String(text) => Ok(text.chars().map(|ch| ch as u32 as f64).collect()),
581        Value::StringArray(sa) => Ok(flatten_string_array(sa)),
582        Value::GpuTensor(_) => Err("fwrite: expected host tensor data after gathering".to_string()),
583        Value::Complex(_, _) | Value::ComplexTensor(_) => {
584            Err("fwrite: complex values are not supported yet".to_string())
585        }
586        _ => Err(format!("fwrite: unsupported data type {:?}", value)),
587    }
588}
589
590fn flatten_char_array(ca: &CharArray) -> Vec<f64> {
591    let mut values = Vec::with_capacity(ca.rows.saturating_mul(ca.cols));
592    for c in 0..ca.cols {
593        for r in 0..ca.rows {
594            let idx = r * ca.cols + c;
595            values.push(ca.data[idx] as u32 as f64);
596        }
597    }
598    values
599}
600
601fn flatten_string_array(sa: &runmat_builtins::StringArray) -> Vec<f64> {
602    if sa.data.is_empty() {
603        return Vec::new();
604    }
605    let mut values = Vec::new();
606    for (idx, text) in sa.data.iter().enumerate() {
607        if idx > 0 {
608            values.push('\n' as u32 as f64);
609        }
610        values.extend(text.chars().map(|ch| ch as u32 as f64));
611    }
612    values
613}
614
615fn write_elements(
616    file: &mut std::sync::MutexGuard<'_, std::fs::File>,
617    values: &[f64],
618    spec: WriteSpec,
619    skip: usize,
620    machine: MachineFormat,
621) -> Result<usize, String> {
622    let endianness = machine.to_endianness();
623    let skip_offset = skip as i64;
624    for &value in values {
625        match spec.input {
626            InputType::UInt8 => {
627                let byte = to_u8(value);
628                write_bytes(file, &[byte])?;
629            }
630            InputType::Int8 => {
631                let byte = to_i8(value) as u8;
632                write_bytes(file, &[byte])?;
633            }
634            InputType::UInt16 => {
635                let bytes = encode_u16(value, endianness);
636                write_bytes(file, &bytes)?;
637            }
638            InputType::Int16 => {
639                let bytes = encode_i16(value, endianness);
640                write_bytes(file, &bytes)?;
641            }
642            InputType::UInt32 => {
643                let bytes = encode_u32(value, endianness);
644                write_bytes(file, &bytes)?;
645            }
646            InputType::Int32 => {
647                let bytes = encode_i32(value, endianness);
648                write_bytes(file, &bytes)?;
649            }
650            InputType::UInt64 => {
651                let bytes = encode_u64(value, endianness);
652                write_bytes(file, &bytes)?;
653            }
654            InputType::Int64 => {
655                let bytes = encode_i64(value, endianness);
656                write_bytes(file, &bytes)?;
657            }
658            InputType::Float32 => {
659                let bytes = encode_f32(value, endianness);
660                write_bytes(file, &bytes)?;
661            }
662            InputType::Float64 => {
663                let bytes = encode_f64(value, endianness);
664                write_bytes(file, &bytes)?;
665            }
666        }
667
668        if skip > 0 {
669            file.seek(SeekFrom::Current(skip_offset))
670                .map_err(|err| format!("fwrite: failed to seek while applying skip ({err})"))?;
671        }
672    }
673    Ok(values.len())
674}
675
676fn write_bytes(file: &mut std::fs::File, bytes: &[u8]) -> Result<(), String> {
677    file.write_all(bytes)
678        .map_err(|err| format!("fwrite: failed to write to file ({err})"))
679}
680
681fn to_u8(value: f64) -> u8 {
682    if !value.is_finite() {
683        return if value.is_sign_negative() { 0 } else { u8::MAX };
684    }
685    let mut rounded = value.round();
686    if rounded.is_nan() {
687        return 0;
688    }
689    if rounded < 0.0 {
690        rounded = 0.0;
691    }
692    if rounded > u8::MAX as f64 {
693        rounded = u8::MAX as f64;
694    }
695    rounded as u8
696}
697
698fn to_i8(value: f64) -> i8 {
699    saturating_round(value, i8::MIN as f64, i8::MAX as f64) as i8
700}
701
702fn encode_u16(value: f64, endianness: Endianness) -> [u8; 2] {
703    let rounded = saturating_round(value, 0.0, u16::MAX as f64) as u16;
704    match endianness {
705        Endianness::Little => rounded.to_le_bytes(),
706        Endianness::Big => rounded.to_be_bytes(),
707    }
708}
709
710fn encode_i16(value: f64, endianness: Endianness) -> [u8; 2] {
711    let rounded = saturating_round(value, i16::MIN as f64, i16::MAX as f64) as i16;
712    match endianness {
713        Endianness::Little => rounded.to_le_bytes(),
714        Endianness::Big => rounded.to_be_bytes(),
715    }
716}
717
718fn encode_u32(value: f64, endianness: Endianness) -> [u8; 4] {
719    let rounded = saturating_round(value, 0.0, u32::MAX as f64) as u32;
720    match endianness {
721        Endianness::Little => rounded.to_le_bytes(),
722        Endianness::Big => rounded.to_be_bytes(),
723    }
724}
725
726fn encode_i32(value: f64, endianness: Endianness) -> [u8; 4] {
727    let rounded = saturating_round(value, i32::MIN as f64, i32::MAX as f64) as i32;
728    match endianness {
729        Endianness::Little => rounded.to_le_bytes(),
730        Endianness::Big => rounded.to_be_bytes(),
731    }
732}
733
734fn encode_u64(value: f64, endianness: Endianness) -> [u8; 8] {
735    let rounded = saturating_round(value, 0.0, u64::MAX as f64);
736    let as_u64 = if rounded.is_finite() {
737        rounded as u64
738    } else if rounded.is_sign_negative() {
739        0
740    } else {
741        u64::MAX
742    };
743    match endianness {
744        Endianness::Little => as_u64.to_le_bytes(),
745        Endianness::Big => as_u64.to_be_bytes(),
746    }
747}
748
749fn encode_i64(value: f64, endianness: Endianness) -> [u8; 8] {
750    let rounded = saturating_round(value, i64::MIN as f64, i64::MAX as f64);
751    let as_i64 = if rounded.is_finite() {
752        rounded as i64
753    } else if rounded.is_sign_negative() {
754        i64::MIN
755    } else {
756        i64::MAX
757    };
758    match endianness {
759        Endianness::Little => as_i64.to_le_bytes(),
760        Endianness::Big => as_i64.to_be_bytes(),
761    }
762}
763
764fn encode_f32(value: f64, endianness: Endianness) -> [u8; 4] {
765    let as_f32 = value as f32;
766    let bits = as_f32.to_bits();
767    match endianness {
768        Endianness::Little => bits.to_le_bytes(),
769        Endianness::Big => bits.to_be_bytes(),
770    }
771}
772
773fn encode_f64(value: f64, endianness: Endianness) -> [u8; 8] {
774    let bits = value.to_bits();
775    match endianness {
776        Endianness::Little => bits.to_le_bytes(),
777        Endianness::Big => bits.to_be_bytes(),
778    }
779}
780
781fn saturating_round(value: f64, min: f64, max: f64) -> f64 {
782    if !value.is_finite() {
783        return if value.is_sign_negative() { min } else { max };
784    }
785    let mut rounded = value.round();
786    if rounded.is_nan() {
787        return 0.0;
788    }
789    if rounded < min {
790        rounded = min;
791    }
792    if rounded > max {
793        rounded = max;
794    }
795    rounded
796}
797
798#[derive(Clone, Copy, Debug, PartialEq, Eq)]
799enum InputType {
800    UInt8,
801    Int8,
802    UInt16,
803    Int16,
804    UInt32,
805    Int32,
806    UInt64,
807    Int64,
808    Float32,
809    Float64,
810}
811
812fn parse_input_label(label: &str) -> Result<InputType, String> {
813    match label {
814        "double" | "float64" | "real*8" => Ok(InputType::Float64),
815        "single" | "float32" | "real*4" => Ok(InputType::Float32),
816        "int8" | "schar" | "integer*1" => Ok(InputType::Int8),
817        "uint8" | "uchar" | "unsignedchar" | "char" | "byte" => Ok(InputType::UInt8),
818        "int16" | "short" | "integer*2" => Ok(InputType::Int16),
819        "uint16" | "ushort" | "unsignedshort" => Ok(InputType::UInt16),
820        "int32" | "integer*4" | "long" => Ok(InputType::Int32),
821        "uint32" | "unsignedint" | "unsignedlong" => Ok(InputType::UInt32),
822        "int64" | "integer*8" | "longlong" => Ok(InputType::Int64),
823        "uint64" | "unsignedlonglong" => Ok(InputType::UInt64),
824        other => Err(format!("fwrite: unsupported precision '{other}'")),
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use crate::builtins::common::test_support;
832    use crate::builtins::io::filetext::registry;
833    use crate::builtins::io::filetext::{fclose, fopen};
834    #[cfg(feature = "wgpu")]
835    use runmat_accelerate::backend::wgpu::provider;
836    #[cfg(feature = "wgpu")]
837    use runmat_accelerate_api::AccelProvider;
838    use runmat_accelerate_api::HostTensorView;
839    use runmat_builtins::Tensor;
840    use std::fs::{self, File};
841    use std::io::Read;
842    use std::path::PathBuf;
843    use std::time::{SystemTime, UNIX_EPOCH};
844
845    #[test]
846    fn fwrite_default_uint8_bytes() {
847        registry::reset_for_tests();
848        let path = unique_path("fwrite_uint8");
849        let open = fopen::evaluate(&[
850            Value::from(path.to_string_lossy().to_string()),
851            Value::from("w+b"),
852        ])
853        .expect("fopen");
854        let fid = open.as_open().unwrap().fid as i32;
855
856        let tensor = Tensor::new(vec![1.0, 2.0, 255.0], vec![3, 1]).unwrap();
857        let eval =
858            evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &Vec::new()).expect("fwrite");
859        assert_eq!(eval.count(), 3);
860
861        fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
862
863        let bytes = fs::read(&path).expect("read");
864        assert_eq!(bytes, vec![1u8, 2, 255]);
865        fs::remove_file(path).unwrap();
866    }
867
868    #[test]
869    fn fwrite_double_precision_writes_native_endian() {
870        registry::reset_for_tests();
871        let path = unique_path("fwrite_double");
872        let open = fopen::evaluate(&[
873            Value::from(path.to_string_lossy().to_string()),
874            Value::from("w+b"),
875        ])
876        .expect("fopen");
877        let fid = open.as_open().unwrap().fid as i32;
878
879        let tensor = Tensor::new(vec![1.5, -2.25], vec![2, 1]).unwrap();
880        let args = vec![Value::from("double")];
881        let eval =
882            evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).expect("fwrite");
883        assert_eq!(eval.count(), 2);
884
885        fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
886
887        let bytes = fs::read(&path).expect("read");
888        let expected: Vec<u8> = if cfg!(target_endian = "little") {
889            [1.5f64.to_le_bytes(), (-2.25f64).to_le_bytes()].concat()
890        } else {
891            [1.5f64.to_be_bytes(), (-2.25f64).to_be_bytes()].concat()
892        };
893        assert_eq!(bytes, expected);
894        fs::remove_file(path).unwrap();
895    }
896
897    #[test]
898    fn fwrite_big_endian_uint16() {
899        registry::reset_for_tests();
900        let path = unique_path("fwrite_be");
901        let open = fopen::evaluate(&[
902            Value::from(path.to_string_lossy().to_string()),
903            Value::from("w+b"),
904            Value::from("ieee-be"),
905        ])
906        .expect("fopen");
907        let fid = open.as_open().unwrap().fid as i32;
908
909        let tensor = Tensor::new(vec![258.0, 772.0], vec![2, 1]).unwrap();
910        let args = vec![Value::from("uint16")];
911        let eval =
912            evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).expect("fwrite");
913        assert_eq!(eval.count(), 2);
914
915        fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
916
917        let bytes = fs::read(&path).expect("read");
918        assert_eq!(bytes, vec![0x01, 0x02, 0x03, 0x04]);
919        fs::remove_file(path).unwrap();
920    }
921
922    #[test]
923    fn fwrite_skip_inserts_padding() {
924        registry::reset_for_tests();
925        let path = unique_path("fwrite_skip");
926        let open = fopen::evaluate(&[
927            Value::from(path.to_string_lossy().to_string()),
928            Value::from("w+b"),
929        ])
930        .expect("fopen");
931        let fid = open.as_open().unwrap().fid as i32;
932
933        let tensor = Tensor::new(vec![10.0, 20.0, 30.0], vec![3, 1]).unwrap();
934        let args = vec![Value::from("uint8"), Value::Num(1.0)];
935        let eval =
936            evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).expect("fwrite");
937        assert_eq!(eval.count(), 3);
938
939        fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
940
941        let bytes = fs::read(&path).expect("read");
942        assert_eq!(bytes, vec![10u8, 0, 20, 0, 30]);
943        fs::remove_file(path).unwrap();
944    }
945
946    #[test]
947    fn fwrite_gpu_tensor_gathers_before_write() {
948        registry::reset_for_tests();
949        let path = unique_path("fwrite_gpu");
950
951        test_support::with_test_provider(|provider| {
952            registry::reset_for_tests();
953            let open = fopen::evaluate(&[
954                Value::from(path.to_string_lossy().to_string()),
955                Value::from("w+b"),
956            ])
957            .expect("fopen");
958            let fid = open.as_open().unwrap().fid as i32;
959
960            let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![4, 1]).unwrap();
961            let view = HostTensorView {
962                data: &tensor.data,
963                shape: &tensor.shape,
964            };
965            let handle = provider.upload(&view).expect("upload");
966            let args = vec![Value::from("uint16")];
967            let eval = evaluate(&Value::Num(fid as f64), &Value::GpuTensor(handle), &args)
968                .expect("fwrite");
969            assert_eq!(eval.count(), 4);
970
971            fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
972        });
973
974        let mut file = File::open(&path).expect("open");
975        let mut bytes = Vec::new();
976        file.read_to_end(&mut bytes).expect("read");
977        assert_eq!(bytes.len(), 8);
978        let mut decoded = Vec::new();
979        for chunk in bytes.chunks_exact(2) {
980            let value = if cfg!(target_endian = "little") {
981                u16::from_le_bytes([chunk[0], chunk[1]])
982            } else {
983                u16::from_be_bytes([chunk[0], chunk[1]])
984            };
985            decoded.push(value);
986        }
987        assert_eq!(decoded, vec![1u16, 2, 3, 4]);
988        fs::remove_file(path).unwrap();
989    }
990
991    #[test]
992    fn fwrite_invalid_precision_errors() {
993        registry::reset_for_tests();
994        let path = unique_path("fwrite_invalid_precision");
995        let open = fopen::evaluate(&[
996            Value::from(path.to_string_lossy().to_string()),
997            Value::from("w+b"),
998        ])
999        .expect("fopen");
1000        let fid = open.as_open().unwrap().fid as i32;
1001
1002        let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
1003        let args = vec![Value::from("bogus-class")];
1004        let err = evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).unwrap_err();
1005        assert!(err.contains("unsupported precision"));
1006        let _ = fclose::evaluate(&[Value::Num(fid as f64)]);
1007        fs::remove_file(path).unwrap();
1008    }
1009
1010    #[test]
1011    fn fwrite_negative_skip_errors() {
1012        registry::reset_for_tests();
1013        let path = unique_path("fwrite_negative_skip");
1014        let open = fopen::evaluate(&[
1015            Value::from(path.to_string_lossy().to_string()),
1016            Value::from("w+b"),
1017        ])
1018        .expect("fopen");
1019        let fid = open.as_open().unwrap().fid as i32;
1020
1021        let tensor = Tensor::new(vec![10.0], vec![1, 1]).unwrap();
1022        let args = vec![Value::from("uint8"), Value::Num(-1.0)];
1023        let err = evaluate(&Value::Num(fid as f64), &Value::Tensor(tensor), &args).unwrap_err();
1024        assert!(err.contains("skip value must be non-negative"));
1025        let _ = fclose::evaluate(&[Value::Num(fid as f64)]);
1026        fs::remove_file(path).unwrap();
1027    }
1028
1029    #[test]
1030    #[cfg(feature = "wgpu")]
1031    fn fwrite_wgpu_tensor_roundtrip() {
1032        registry::reset_for_tests();
1033        let path = unique_path("fwrite_wgpu_roundtrip");
1034        let open = fopen::evaluate(&[
1035            Value::from(path.to_string_lossy().to_string()),
1036            Value::from("w+b"),
1037        ])
1038        .expect("fopen");
1039        let fid = open.as_open().unwrap().fid as i32;
1040
1041        let provider = provider::register_wgpu_provider(provider::WgpuProviderOptions::default())
1042            .expect("wgpu provider");
1043
1044        let tensor = Tensor::new(vec![0.5, -1.25, 3.75], vec![3, 1]).unwrap();
1045        let expected = tensor.data.clone();
1046        let view = HostTensorView {
1047            data: &tensor.data,
1048            shape: &tensor.shape,
1049        };
1050        let handle = provider.upload(&view).expect("upload to gpu");
1051        let args = vec![Value::from("double")];
1052        let eval =
1053            evaluate(&Value::Num(fid as f64), &Value::GpuTensor(handle), &args).expect("fwrite");
1054        assert_eq!(eval.count(), 3);
1055
1056        fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
1057
1058        let mut file = File::open(&path).expect("open");
1059        let mut bytes = Vec::new();
1060        file.read_to_end(&mut bytes).expect("read");
1061        assert_eq!(bytes.len(), 24);
1062        for (chunk, expected_value) in bytes.chunks_exact(8).zip(expected.iter()) {
1063            let mut buf = [0u8; 8];
1064            buf.copy_from_slice(chunk);
1065            let value = if cfg!(target_endian = "little") {
1066                f64::from_le_bytes(buf)
1067            } else {
1068                f64::from_be_bytes(buf)
1069            };
1070            assert!(
1071                (value - expected_value).abs() < 1e-12,
1072                "mismatch: {} vs {}",
1073                value,
1074                expected_value
1075            );
1076        }
1077        fs::remove_file(path).unwrap();
1078    }
1079
1080    #[test]
1081    fn fwrite_invalid_identifier_errors() {
1082        registry::reset_for_tests();
1083        let err = evaluate(&Value::Num(-1.0), &Value::Num(1.0), &Vec::new()).unwrap_err();
1084        assert!(err.contains("file identifier must be non-negative"));
1085    }
1086
1087    #[test]
1088    #[cfg(feature = "doc_export")]
1089    fn doc_examples_present() {
1090        let blocks = test_support::doc_examples(DOC_MD);
1091        assert!(!blocks.is_empty());
1092    }
1093
1094    fn unique_path(prefix: &str) -> PathBuf {
1095        let now = SystemTime::now()
1096            .duration_since(UNIX_EPOCH)
1097            .expect("time went backwards");
1098        let filename = format!(
1099            "runmat_{prefix}_{}_{}.tmp",
1100            now.as_secs(),
1101            now.subsec_nanos()
1102        );
1103        std::env::temp_dir().join(filename)
1104    }
1105}