runmat_runtime/builtins/io/filetext/
fprintf.rs

1//! MATLAB-compatible `fprintf` builtin enabling formatted text output to files and standard streams.
2
3use std::io::{self, Write};
4use std::sync::{Arc, Mutex as StdMutex};
5
6use runmat_builtins::Value;
7use runmat_macros::runtime_builtin;
8
9use crate::builtins::common::format::{
10    decode_escape_sequences, flatten_arguments, format_variadic_with_cursor, ArgCursor,
11};
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::io::filetext::registry::{self, FileInfo};
17#[cfg(feature = "doc_export")]
18use crate::register_builtin_doc_text;
19use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
20
21const INVALID_IDENTIFIER_MESSAGE: &str =
22    "fprintf: Invalid file identifier. Use fopen to generate a valid file ID.";
23const MISSING_FORMAT_MESSAGE: &str = "fprintf: missing format string";
24
25#[cfg(feature = "doc_export")]
26pub const DOC_MD: &str = r#"---
27title: "fprintf"
28category: "io/filetext"
29keywords: ["fprintf", "format", "printf", "write file", "stdout", "stderr", "encoding"]
30summary: "Write formatted text to files or standard output/error using MATLAB-compatible semantics."
31references:
32  - https://www.mathworks.com/help/matlab/ref/fprintf.html
33gpu_support:
34  elementwise: false
35  reduction: false
36  precisions: []
37  broadcasting: "none"
38  notes: "Formatting and I/O execute on the CPU. GPU tensors are gathered automatically before substitution."
39fusion:
40  elementwise: false
41  reduction: false
42  max_inputs: 3
43  constants: "inline"
44requires_feature: null
45tested:
46  unit: "builtins::io::filetext::fprintf::tests"
47  integration:
48    - "builtins::io::filetext::fprintf::tests::fprintf_matrix_column_major"
49    - "builtins::io::filetext::fprintf::tests::fprintf_ascii_encoding_errors"
50    - "builtins::io::filetext::fprintf::tests::fprintf_gpu_gathers_values"
51---
52
53# What does the `fprintf` function do in MATLAB / RunMat?
54`fprintf` formats data according to a printf-style template and writes the result to a file,
55standard output (`stdout`), or standard error (`stderr`). The builtin mirrors MATLAB behaviour,
56including repetition of the format string, column-major traversal of matrix inputs, support for
57the special stream names `'stdout'`/`'stderr'`, and the same set of numeric and text conversions
58available to `sprintf`.
59
60## How does the `fprintf` function behave in MATLAB / RunMat?
61- `fprintf(formatSpec, A, ...)` writes to standard output. The format repeats automatically until
62  every element from the argument list has been consumed, traversing arrays in column-major order.
63- `fprintf(fid, formatSpec, A, ...)` writes to a file identifier returned by `fopen`. Identifiers
64  `1`/`"stdout"` and `2`/`"stderr"` refer to the process streams. Identifiers must be finite,
65  non-negative integers.
66- The return value is the number of bytes written as a double scalar. Omitting the output argument
67  discards it without affecting the write.
68- Text arguments (character vectors, string scalars, string arrays, cell arrays of text) are
69  expanded in column-major order, matching MATLAB's behaviour.
70- Numeric arrays (double, integer, logical, or gpuArray) are flattened column-first and substituted
71  element-by-element into the format string. Star (`*`) width and precision arguments are also
72  drawn from the flattened stream.
73- The text encoding recorded by `fopen` is honoured. ASCII and Latin-1 encodings raise descriptive
74  errors when characters cannot be represented. Binary/RAW encodings treat the output as UTF-8,
75  mirroring MATLAB's default on modern systems.
76- Arguments that reside on the GPU are gathered to the host before formatting. Formatting itself is
77  always executed on the CPU.
78
79## `fprintf` Function GPU Execution Behaviour
80`fprintf` is a residency sink. Any argument containing `gpuArray` data is gathered via the active
81acceleration provider before formatting. No GPU kernels are launched. When no provider is
82registered, the builtin raises the same descriptive error used by other sinks (`gather: no
83acceleration provider registered`).
84
85## Examples of using the `fprintf` function in MATLAB / RunMat
86
87### Write Formatted Text To A File
88```matlab
89[fid, msg] = fopen('report.txt', 'w');
90assert(fid ~= -1, msg);
91fprintf(fid, 'Total: %d (%.2f%%)\n', 42, 87.5);
92fclose(fid);
93```
94Expected contents of `report.txt`:
95```matlab
96Total: 42 (87.50%)
97```
98
99### Use Standard Output Without An Explicit File Identifier
100```matlab
101fprintf('Processing %s ...\n', datestr(now, 0));
102```
103Expected console output:
104```matlab
105Processing 07-Jan-2025 23:14:55 ...
106```
107
108### Write To Standard Error Using The Stream Name
109```matlab
110fprintf('stderr', 'Warning: iteration limit reached (%d steps)\n', iter);
111```
112Expected console output (sent to stderr):
113```matlab
114Warning: iteration limit reached (250 steps)
115```
116
117### Format A Matrix In Column-Major Order
118```matlab
119A = [1 2 3; 4 5 6];
120fprintf('%d %d\n', A);
121```
122Expected console output:
123```matlab
1241 4
1252 5
1263 6
127```
128
129### Respect File Encoding Constraints
130```matlab
131[fid, msg] = fopen('ascii.txt', 'w', 'native', 'ascii');
132if fid == -1, error(msg); end
133try
134    fprintf(fid, 'café\n');
135catch err
136    disp(err.message);
137end
138fclose(fid);
139```
140Expected console output:
141```matlab
142fprintf: character 'é' (U+00E9) cannot be encoded as ASCII
143```
144
145### Format GPU-Resident Data Transparently
146```matlab
147G = gpuArray([1.2 3.4 5.6]);
148[fid, msg] = fopen('gpu.txt', 'w');
149assert(fid ~= -1, msg);
150fprintf(fid, '%.1f,', G);
151fclose(fid);
152```
153Expected contents of `gpu.txt`:
154```matlab
1551.2,3.4,5.6,
156```
157
158## GPU residency in RunMat (Do I need `gpuArray`?)
159You can pass `gpuArray` inputs directly—`fprintf` gathers them back to host memory before formatting.
160No provider-specific hooks are required and outputs always reside on the CPU. This mirrors MATLAB,
161where explicit `gather` calls are unnecessary when writing to files or console streams.
162
163## FAQ
164
165### Does `fprintf` return the number of characters or bytes?
166It returns the number of bytes written. This may differ from the number of characters when using
167multi-byte encodings such as UTF-8.
168
169### Can I use `'stdout'` or `'stderr'` instead of numeric identifiers?
170Yes. The strings `'stdout'` and `'stderr'` (any case) map to identifiers `1` and `2` respectively,
171matching MATLAB.
172
173### What happens if the file was opened read-only?
174`fprintf` raises `fprintf: file is not open for writing`. Ensure the permission string passed to
175`fopen` includes `'w'`, `'a'`, or `'+'`.
176
177### Which encodings are supported?
178`fprintf` honours the encoding recorded by `fopen`. UTF-8 (default), ASCII, and Latin-1 are
179supported explicitly. Other labels fall back to UTF-8 behaviour.
180
181### How are multi-dimensional arrays handled?
182Arguments are flattened in column-major order. The format string repeats until every element has
183been consumed, just like MATLAB.
184
185### Does `fprintf` flush the stream?
186The builtin delegates to Rust's buffered writers. Files are flushed when closed; standard streams
187inherit the host buffering policy.
188
189### What if the format string contains no conversions?
190Literal format strings are written once. Supplying additional arguments raises
191`fprintf: formatSpec contains no conversion specifiers but additional arguments were supplied`.
192
193### Are cell arrays supported?
194Yes. Cell arrays containing supported scalar or text values are flattened in column-major order
195before formatting.
196
197### Can I mix numeric and text arguments?
198Absolutely. Numeric, logical, and text inputs can be interleaved. Star width/precision arguments
199use the same flattened stream.
200
201### How do I suppress the return value?
202Ignore it, just as in MATLAB. Omitting the output argument does not change the write behaviour.
203
204## See Also
205[sprintf](../../strings/core/sprintf), [compose](../../strings/core/compose),
206[fopen](./fopen), [fclose](./fclose), [fwrite](./fwrite), [fileread](./fileread)
207
208## Source & Feedback
209- Implementation: `crates/runmat-runtime/src/builtins/io/filetext/fprintf.rs`
210- Found a behavioural difference? [Open an issue](https://github.com/runmat-org/runmat/issues/new/choose)
211  with a minimal reproduction.
212"#;
213
214pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
215    name: "fprintf",
216    op_kind: GpuOpKind::Custom("io-file-write"),
217    supported_precisions: &[],
218    broadcast: BroadcastSemantics::None,
219    provider_hooks: &[],
220    constant_strategy: ConstantStrategy::InlineLiteral,
221    residency: ResidencyPolicy::GatherImmediately,
222    nan_mode: ReductionNaN::Include,
223    two_pass_threshold: None,
224    workgroup_size: None,
225    accepts_nan_mode: false,
226    notes: "Host-only text I/O. Arguments residing on the GPU are gathered before formatting.",
227};
228
229register_builtin_gpu_spec!(GPU_SPEC);
230
231pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
232    name: "fprintf",
233    shape: ShapeRequirements::Any,
234    constant_strategy: ConstantStrategy::InlineLiteral,
235    elementwise: None,
236    reduction: None,
237    emits_nan: false,
238    notes: "Formatting is a side-effecting sink and never participates in fusion.",
239};
240
241register_builtin_fusion_spec!(FUSION_SPEC);
242
243#[cfg(feature = "doc_export")]
244register_builtin_doc_text!("fprintf", DOC_MD);
245
246/// Result of evaluating `fprintf`.
247#[derive(Debug)]
248pub struct FprintfEval {
249    bytes_written: usize,
250}
251
252impl FprintfEval {
253    /// Number of bytes emitted by the write.
254    pub fn bytes_written(&self) -> usize {
255        self.bytes_written
256    }
257}
258
259/// Evaluate the `fprintf` builtin without going through the dispatcher.
260pub fn evaluate(args: &[Value]) -> Result<FprintfEval, String> {
261    if args.is_empty() {
262        return Err("fprintf: not enough input arguments".to_string());
263    }
264
265    // Gather all arguments to host first
266    let mut all: Vec<Value> = Vec::with_capacity(args.len());
267    for v in args {
268        all.push(gather_value(v)?);
269    }
270
271    // Locate the first valid formatSpec anywhere in the list
272    let mut fmt_idx: Option<usize> = None;
273    let mut format_string_val: Option<String> = None;
274    for (i, value) in all.iter().enumerate() {
275        // Never interpret a stream label ('stdout'/'stderr') as the format string
276        if match_stream_label(value).is_some() {
277            continue;
278        }
279        if let Some(Value::String(s)) = coerce_to_format_string(value)? {
280            fmt_idx = Some(i);
281            format_string_val = Some(s);
282            break;
283        }
284    }
285    let fmt_idx = fmt_idx.ok_or_else(|| MISSING_FORMAT_MESSAGE.to_string())?;
286    let raw_format = format_string_val.unwrap();
287
288    // Determine output target by scanning only arguments BEFORE the format
289    let mut target_idx: Option<usize> = None;
290    let mut target: OutputTarget = OutputTarget::Stdout;
291    // Prefer explicit stream labels over numeric fids if both appear
292    let mut first_stream: Option<(usize, SpecialStream)> = None;
293    for (i, value) in all.iter().enumerate().take(fmt_idx) {
294        if let Some(stream) = match_stream_label(value) {
295            first_stream = Some((i, stream));
296            break;
297        }
298    }
299    if let Some((idx, stream)) = first_stream {
300        target_idx = Some(idx);
301        target = match stream {
302            SpecialStream::Stdout => OutputTarget::Stdout,
303            SpecialStream::Stderr => OutputTarget::Stderr,
304        };
305    } else {
306        // Try to parse a numeric fid that appears before the format
307        for (i, value) in all.iter().enumerate().take(fmt_idx) {
308            if matches!(value, Value::Num(_) | Value::Int(_) | Value::Tensor(_)) {
309                if let Ok(fid) = parse_fid(value) {
310                    target_idx = Some(i);
311                    target = target_from_fid(fid)?;
312                    break;
313                }
314            }
315        }
316    }
317
318    // Remaining arguments are data, excluding the chosen target and the format
319    let mut data_args: Vec<Value> = Vec::with_capacity(all.len().saturating_sub(1));
320    for (i, v) in all.into_iter().enumerate() {
321        if i == fmt_idx {
322            continue;
323        }
324        if let Some(tidx) = target_idx {
325            if i == tidx {
326                continue;
327            }
328        }
329        data_args.push(v);
330    }
331
332    let format_string = decode_escape_sequences("fprintf", &raw_format)?;
333    let flattened_args = flatten_arguments(&data_args, "fprintf")?;
334    let rendered = format_with_repetition(&format_string, &flattened_args)?;
335    let bytes = encode_output(&rendered, target.encoding_label())?;
336    target.write(&bytes)?;
337    Ok(FprintfEval {
338        bytes_written: bytes.len(),
339    })
340}
341
342// kind_of was used for debugging logs; removed to avoid dead code in production builds.
343
344fn try_tensor_char_row_as_string(value: &Value) -> Option<Result<String, String>> {
345    match value {
346        Value::Tensor(t) => {
347            let is_row = (t.shape.len() == 2 && t.shape[0] == 1 && t.data.len() == t.shape[1])
348                || (t.shape.len() == 1 && t.data.len() == t.shape[0]);
349            if is_row {
350                let mut out = String::with_capacity(t.data.len());
351                for &code in &t.data {
352                    if !code.is_finite() {
353                        return Some(Err(
354                            "fprintf: formatSpec must be a character row vector or string scalar"
355                                .to_string(),
356                        ));
357                    }
358                    let v = code as u32;
359                    // Allow full Unicode range; MATLAB chars are UTF-16 but format strings are ASCII-compatible typically
360                    if let Some(ch) = char::from_u32(v) {
361                        out.push(ch);
362                    } else {
363                        return Some(Err(
364                            "fprintf: formatSpec contains invalid character code".to_string()
365                        ));
366                    }
367                }
368                return Some(Ok(out));
369            }
370            None
371        }
372        _ => None,
373    }
374}
375
376fn coerce_to_format_string(value: &Value) -> Result<Option<Value>, String> {
377    match value {
378        Value::String(s) => Ok(Some(Value::String(s.clone()))),
379        Value::StringArray(sa) if sa.data.len() == 1 => Ok(Some(Value::String(sa.data[0].clone()))),
380        Value::CharArray(ca) => {
381            let s: String = ca.data.iter().collect();
382            Ok(Some(Value::String(s)))
383        }
384        Value::Tensor(t) => {
385            // Only accept numeric codepoint vectors of length >= 2 as formatSpec.
386            // This avoids misinterpreting stray 1x1 numerics (e.g., accidental stack values)
387            // as a valid format string.
388            if t.data.len() >= 2 {
389                match try_tensor_char_row_as_string(value) {
390                    Some(Ok(s)) => Ok(Some(Value::String(s))),
391                    Some(Err(e)) => Err(e),
392                    None => Ok(None),
393                }
394            } else {
395                Ok(None)
396            }
397        }
398        _ => Ok(None),
399    }
400}
401
402#[runtime_builtin(
403    name = "fprintf",
404    category = "io/filetext",
405    summary = "Write formatted text to files or standard streams.",
406    keywords = "fprintf,format,printf,io",
407    accel = "cpu",
408    sink = true
409)]
410fn fprintf_builtin(first: Value, rest: Vec<Value>) -> Result<Value, String> {
411    let mut args = Vec::with_capacity(rest.len() + 1);
412    args.push(first);
413    args.extend(rest);
414    let eval = evaluate(&args)?;
415    Ok(Value::Num(eval.bytes_written() as f64))
416}
417
418#[derive(Clone, Copy)]
419enum SpecialStream {
420    Stdout,
421    Stderr,
422}
423
424enum OutputTarget {
425    Stdout,
426    Stderr,
427    File {
428        handle: Arc<StdMutex<std::fs::File>>,
429        encoding: String,
430    },
431}
432
433impl OutputTarget {
434    fn encoding_label(&self) -> Option<&str> {
435        match self {
436            OutputTarget::Stdout | OutputTarget::Stderr => None,
437            OutputTarget::File { encoding, .. } => Some(encoding.as_str()),
438        }
439    }
440
441    fn write(&self, bytes: &[u8]) -> Result<(), String> {
442        match self {
443            OutputTarget::Stdout => {
444                let mut stdout = io::stdout().lock();
445                stdout
446                    .write_all(bytes)
447                    .map_err(|err| format!("fprintf: failed to write to stdout ({err})"))
448            }
449            OutputTarget::Stderr => {
450                let mut stderr = io::stderr().lock();
451                stderr
452                    .write_all(bytes)
453                    .map_err(|err| format!("fprintf: failed to write to stderr ({err})"))
454            }
455            OutputTarget::File { handle, .. } => {
456                let mut guard = handle.lock().map_err(|_| {
457                    "fprintf: failed to lock file handle (poisoned mutex)".to_string()
458                })?;
459                guard
460                    .write_all(bytes)
461                    .map_err(|err| format!("fprintf: failed to write to file ({err})"))
462            }
463        }
464    }
465}
466
467fn gather_value(value: &Value) -> Result<Value, String> {
468    gather_if_needed(value).map_err(|e| format!("fprintf: {e}"))
469}
470
471#[allow(dead_code)]
472fn resolve_target<'a>(
473    first: &'a Value,
474    rest: &'a [Value],
475) -> Result<(OutputTarget, &'a Value, &'a [Value]), String> {
476    if let Some(stream) = match_stream_label(first) {
477        if rest.is_empty() {
478            return Err(MISSING_FORMAT_MESSAGE.to_string());
479        }
480        let target = match stream {
481            SpecialStream::Stdout => OutputTarget::Stdout,
482            SpecialStream::Stderr => OutputTarget::Stderr,
483        };
484        return Ok((target, &rest[0], &rest[1..]));
485    }
486
487    match first {
488        Value::Num(_) | Value::Int(_) => {
489            let fid = parse_fid(first)?;
490            resolve_fid_target(fid, rest)
491        }
492        Value::Tensor(t) => {
493            // If this looks like a 1xN row of character codes, treat it as a format string to stdout
494            if t.shape.len() == 2 && t.shape[0] == 1 && t.data.len() == t.shape[1] {
495                return Ok((OutputTarget::Stdout, first, rest));
496            }
497            // Otherwise only scalar numeric tensors are valid as fids
498            let fid = parse_fid(first)?;
499            resolve_fid_target(fid, rest)
500        }
501        Value::String(_) | Value::CharArray(_) | Value::StringArray(_) => {
502            let target = OutputTarget::Stdout;
503            Ok((target, first, rest))
504        }
505        // Be permissive: if it's not a numeric fid or stream label, interpret as format string to stdout
506        _ => Ok((OutputTarget::Stdout, first, rest)),
507    }
508}
509
510fn resolve_fid_target(
511    fid: i32,
512    rest: &[Value],
513) -> Result<(OutputTarget, &Value, &[Value]), String> {
514    if rest.is_empty() {
515        return Err(MISSING_FORMAT_MESSAGE.to_string());
516    }
517    if fid < 0 {
518        return Err("fprintf: file identifier must be non-negative".to_string());
519    }
520    match fid {
521        0 => Err("fprintf: file identifier 0 (stdin) is not writable".to_string()),
522        1 => Ok((OutputTarget::Stdout, &rest[0], &rest[1..])),
523        2 => Ok((OutputTarget::Stderr, &rest[0], &rest[1..])),
524        _ => {
525            let info =
526                registry::info_for(fid).ok_or_else(|| INVALID_IDENTIFIER_MESSAGE.to_string())?;
527            ensure_writable(&info)?;
528            let handle =
529                registry::take_handle(fid).ok_or_else(|| INVALID_IDENTIFIER_MESSAGE.to_string())?;
530            Ok((
531                OutputTarget::File {
532                    handle,
533                    encoding: info.encoding.clone(),
534                },
535                &rest[0],
536                &rest[1..],
537            ))
538        }
539    }
540}
541
542fn target_from_fid(fid: i32) -> Result<OutputTarget, String> {
543    if fid < 0 {
544        return Err("fprintf: file identifier must be non-negative".to_string());
545    }
546    match fid {
547        0 => Err("fprintf: file identifier 0 (stdin) is not writable".to_string()),
548        1 => Ok(OutputTarget::Stdout),
549        2 => Ok(OutputTarget::Stderr),
550        _ => {
551            let info =
552                registry::info_for(fid).ok_or_else(|| INVALID_IDENTIFIER_MESSAGE.to_string())?;
553            ensure_writable(&info)?;
554            let handle =
555                registry::take_handle(fid).ok_or_else(|| INVALID_IDENTIFIER_MESSAGE.to_string())?;
556            Ok(OutputTarget::File {
557                handle,
558                encoding: info.encoding.clone(),
559            })
560        }
561    }
562}
563
564fn parse_fid(value: &Value) -> Result<i32, String> {
565    let scalar = match value {
566        Value::Num(n) => *n,
567        Value::Int(int) => int.to_f64(),
568        Value::Tensor(t) => {
569            if t.shape == vec![1, 1] && t.data.len() == 1 {
570                t.data[0]
571            } else {
572                return Err("fprintf: file identifier must be numeric".to_string());
573            }
574        }
575        _ => return Err("fprintf: file identifier must be numeric".to_string()),
576    };
577    if !scalar.is_finite() {
578        return Err("fprintf: file identifier must be finite".to_string());
579    }
580    if (scalar.fract().abs()) > f64::EPSILON {
581        return Err("fprintf: file identifier must be an integer".to_string());
582    }
583    Ok(scalar as i32)
584}
585
586fn ensure_writable(info: &FileInfo) -> Result<(), String> {
587    let permission = info.permission.to_ascii_lowercase();
588    if permission.contains('w') || permission.contains('a') || permission.contains('+') {
589        Ok(())
590    } else {
591        Err("fprintf: file is not open for writing".to_string())
592    }
593}
594
595fn match_stream_label(value: &Value) -> Option<SpecialStream> {
596    let candidate = match value {
597        Value::String(s) => s.trim().to_string(),
598        Value::CharArray(ca) if ca.rows == 1 => {
599            ca.data.iter().collect::<String>().trim().to_string()
600        }
601        Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].trim().to_string(),
602        _ => return None,
603    };
604    match candidate.to_ascii_lowercase().as_str() {
605        "stdout" => Some(SpecialStream::Stdout),
606        "stderr" => Some(SpecialStream::Stderr),
607        _ => None,
608    }
609}
610
611fn format_with_repetition(format: &str, args: &[Value]) -> Result<String, String> {
612    let mut cursor = ArgCursor::new(args);
613    let mut out = String::new();
614    loop {
615        let step = format_variadic_with_cursor(format, &mut cursor).map_err(remap_format_error)?;
616        out.push_str(&step.output);
617        if step.consumed == 0 {
618            if cursor.remaining() > 0 {
619                return Err("fprintf: formatSpec contains no conversion specifiers but additional arguments were supplied".to_string());
620            }
621            break;
622        }
623        if cursor.remaining() == 0 {
624            break;
625        }
626    }
627    Ok(out)
628}
629
630fn remap_format_error(err: String) -> String {
631    if err.contains("sprintf") {
632        err.replace("sprintf", "fprintf")
633    } else {
634        err
635    }
636}
637
638fn encode_output(text: &str, encoding: Option<&str>) -> Result<Vec<u8>, String> {
639    let label = encoding
640        .map(|s| s.trim())
641        .filter(|s| !s.is_empty())
642        .unwrap_or("utf-8");
643    let lower = label.to_ascii_lowercase();
644    if matches!(
645        lower.as_str(),
646        "utf-8" | "utf8" | "unicode" | "auto" | "default" | "system"
647    ) {
648        Ok(text.as_bytes().to_vec())
649    } else if matches!(
650        lower.as_str(),
651        "ascii" | "us-ascii" | "us_ascii" | "usascii"
652    ) {
653        encode_ascii(text)
654    } else if matches!(
655        lower.as_str(),
656        "latin1" | "latin-1" | "latin_1" | "iso-8859-1" | "iso8859-1" | "iso88591"
657    ) {
658        encode_latin1(text, label)
659    } else {
660        Ok(text.as_bytes().to_vec())
661    }
662}
663
664fn encode_ascii(text: &str) -> Result<Vec<u8>, String> {
665    let mut bytes = Vec::with_capacity(text.len());
666    for ch in text.chars() {
667        if ch as u32 > 0x7F {
668            return Err(format!(
669                "fprintf: character '{}' (U+{:04X}) cannot be encoded as ASCII",
670                ch, ch as u32
671            ));
672        }
673        bytes.push(ch as u8);
674    }
675    Ok(bytes)
676}
677
678fn encode_latin1(text: &str, label: &str) -> Result<Vec<u8>, String> {
679    let mut bytes = Vec::with_capacity(text.len());
680    for ch in text.chars() {
681        if ch as u32 > 0xFF {
682            return Err(format!(
683                "fprintf: character '{}' (U+{:04X}) cannot be encoded as {}",
684                ch, ch as u32, label
685            ));
686        }
687        bytes.push(ch as u8);
688    }
689    Ok(bytes)
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use crate::builtins::common::test_support;
696    use crate::builtins::io::filetext::{fclose, fopen, registry};
697    use runmat_accelerate_api::HostTensorView;
698    use runmat_builtins::{IntValue, Tensor};
699    use std::fs::{self, File};
700    use std::io::Read;
701    use std::path::PathBuf;
702    use std::time::{SystemTime, UNIX_EPOCH};
703
704    #[test]
705    fn fprintf_matrix_column_major() {
706        registry::reset_for_tests();
707        let path = unique_path("fprintf_matrix");
708        let open = fopen::evaluate(&[
709            Value::from(path.to_string_lossy().to_string()),
710            Value::from("w"),
711        ])
712        .expect("fopen");
713        let fid = open.as_open().unwrap().fid as i32;
714
715        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
716        let args = vec![
717            Value::Num(fid as f64),
718            Value::String("%d %d\n".to_string()),
719            Value::Tensor(tensor),
720        ];
721        let eval = evaluate(&args).expect("fprintf");
722        assert_eq!(eval.bytes_written(), 12);
723
724        fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
725
726        let contents = fs::read_to_string(&path).expect("read");
727        assert_eq!(contents, "1 4\n2 5\n3 6\n");
728        fs::remove_file(path).unwrap();
729    }
730
731    #[test]
732    fn fprintf_ascii_encoding_errors() {
733        registry::reset_for_tests();
734        let path = unique_path("fprintf_ascii");
735        let open = fopen::evaluate(&[
736            Value::from(path.to_string_lossy().to_string()),
737            Value::from("w"),
738            Value::from("native"),
739            Value::from("ascii"),
740        ])
741        .expect("fopen");
742        let fid = open.as_open().unwrap().fid as i32;
743
744        let args = vec![
745            Value::Num(fid as f64),
746            Value::String("%s".to_string()),
747            Value::String("café".to_string()),
748        ];
749        let err = evaluate(&args).expect_err("fprintf should reject ASCII-incompatible text");
750        assert!(err.contains("cannot be encoded as ASCII"), "{err}");
751
752        fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
753        fs::remove_file(path).unwrap();
754    }
755
756    #[test]
757    fn fprintf_gpu_gathers_values() {
758        registry::reset_for_tests();
759        let path = unique_path("fprintf_gpu");
760
761        test_support::with_test_provider(|provider| {
762            registry::reset_for_tests();
763            let open = fopen::evaluate(&[
764                Value::from(path.to_string_lossy().to_string()),
765                Value::from("w"),
766            ])
767            .expect("fopen");
768            let fid = open.as_open().unwrap().fid as i32;
769
770            let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
771            let view = HostTensorView {
772                data: &tensor.data,
773                shape: &tensor.shape,
774            };
775            let handle = provider.upload(&view).expect("upload");
776            let args = vec![
777                Value::Num(fid as f64),
778                Value::String("%.1f,".to_string()),
779                Value::GpuTensor(handle),
780            ];
781            let eval = evaluate(&args).expect("fprintf");
782            assert_eq!(eval.bytes_written(), 12);
783
784            fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
785        });
786
787        let mut file = File::open(&path).expect("open");
788        let mut contents = String::new();
789        file.read_to_string(&mut contents).expect("read");
790        assert_eq!(contents, "1.0,2.0,3.0,");
791        fs::remove_file(path).unwrap();
792    }
793
794    #[test]
795    fn fprintf_missing_format_errors() {
796        let err = evaluate(&[Value::Num(1.0)]).expect_err("fprintf should require format");
797        assert!(err.contains("missing format string"), "{err}");
798    }
799
800    #[test]
801    fn fprintf_literal_with_extra_args_errors() {
802        let err = evaluate(&[
803            Value::String("literal text".to_string()),
804            Value::Int(IntValue::I32(1)),
805        ])
806        .expect_err("fprintf should reject extra args without conversions");
807        assert!(err.contains("contains no conversion specifiers"), "{err}");
808    }
809
810    #[test]
811    fn fprintf_invalid_identifier_errors() {
812        let err = evaluate(&[Value::Num(99.0), Value::String("value".to_string())])
813            .expect_err("fprintf should reject unknown fid");
814        assert!(err.contains("Invalid file identifier"), "{err}");
815    }
816
817    #[test]
818    fn fprintf_read_only_error() {
819        registry::reset_for_tests();
820        let path = unique_path("fprintf_read_only");
821        fs::write(&path, b"readonly").unwrap();
822        let open = fopen::evaluate(&[
823            Value::from(path.to_string_lossy().to_string()),
824            Value::from("r"),
825        ])
826        .expect("fopen");
827        let fid = open.as_open().unwrap().fid as i32;
828        let err = evaluate(&[Value::Num(fid as f64), Value::String("text".to_string())])
829            .expect_err("fprintf should reject read-only handles");
830        assert!(err.contains("not open for writing"), "{err}");
831
832        fclose::evaluate(&[Value::Num(fid as f64)]).unwrap();
833    }
834
835    #[test]
836    #[cfg(feature = "doc_export")]
837    fn fprintf_doc_examples_parse() {
838        let blocks = test_support::doc_examples(DOC_MD);
839        assert!(!blocks.is_empty());
840    }
841
842    fn unique_path(prefix: &str) -> PathBuf {
843        let nanos = SystemTime::now()
844            .duration_since(UNIX_EPOCH)
845            .unwrap()
846            .as_nanos();
847        let filename = format!("runmat_{prefix}_{nanos}.txt");
848        std::env::temp_dir().join(filename)
849    }
850}