1use 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#[derive(Debug)]
248pub struct FprintfEval {
249 bytes_written: usize,
250}
251
252impl FprintfEval {
253 pub fn bytes_written(&self) -> usize {
255 self.bytes_written
256 }
257}
258
259pub 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 let mut all: Vec<Value> = Vec::with_capacity(args.len());
267 for v in args {
268 all.push(gather_value(v)?);
269 }
270
271 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 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 let mut target_idx: Option<usize> = None;
290 let mut target: OutputTarget = OutputTarget::Stdout;
291 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 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 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
342fn 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 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 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 t.shape.len() == 2 && t.shape[0] == 1 && t.data.len() == t.shape[1] {
495 return Ok((OutputTarget::Stdout, first, rest));
496 }
497 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 _ => 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}