Skip to main content

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::Write;
4
5use runmat_builtins::{
6    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor, Value,
8};
9use runmat_macros::runtime_builtin;
10
11use crate::builtins::common::format::{
12    decode_escape_sequences, flatten_arguments, format_variadic_with_cursor, ArgCursor,
13};
14use crate::builtins::common::spec::{
15    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
16    ReductionNaN, ResidencyPolicy, ShapeRequirements,
17};
18use crate::builtins::io::filetext::registry::{self, FileInfo, SharedFileHandle};
19use crate::console::{record_console_output, ConsoleStream};
20use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
21
22const BUILTIN_NAME: &str = "fprintf";
23
24const FPRINTF_OUTPUT_COUNT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
25    name: "count",
26    ty: BuiltinParamType::NumericScalar,
27    arity: BuiltinParamArity::Required,
28    default: None,
29    description: "Number of bytes written.",
30}];
31const FPRINTF_INPUTS_FORMAT_VARIADIC: [BuiltinParamDescriptor; 2] = [
32    BuiltinParamDescriptor {
33        name: "formatSpec",
34        ty: BuiltinParamType::Any,
35        arity: BuiltinParamArity::Required,
36        default: None,
37        description: "Format string or character row vector.",
38    },
39    BuiltinParamDescriptor {
40        name: "A",
41        ty: BuiltinParamType::Any,
42        arity: BuiltinParamArity::Variadic,
43        default: None,
44        description: "Values consumed by conversion specifiers.",
45    },
46];
47const FPRINTF_INPUTS_FID_FORMAT_VARIADIC: [BuiltinParamDescriptor; 3] = [
48    BuiltinParamDescriptor {
49        name: "fid_or_stream",
50        ty: BuiltinParamType::Any,
51        arity: BuiltinParamArity::Required,
52        default: Some("1"),
53        description: "Numeric file identifier, or stream label ('stdout'|'stderr').",
54    },
55    BuiltinParamDescriptor {
56        name: "formatSpec",
57        ty: BuiltinParamType::Any,
58        arity: BuiltinParamArity::Required,
59        default: None,
60        description: "Format string or character row vector.",
61    },
62    BuiltinParamDescriptor {
63        name: "A",
64        ty: BuiltinParamType::Any,
65        arity: BuiltinParamArity::Variadic,
66        default: None,
67        description: "Values consumed by conversion specifiers.",
68    },
69];
70const FPRINTF_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
71    BuiltinSignatureDescriptor {
72        label: "count = fprintf(formatSpec, A...)",
73        inputs: &FPRINTF_INPUTS_FORMAT_VARIADIC,
74        outputs: &FPRINTF_OUTPUT_COUNT,
75    },
76    BuiltinSignatureDescriptor {
77        label: "count = fprintf(fid_or_stream, formatSpec, A...)",
78        inputs: &FPRINTF_INPUTS_FID_FORMAT_VARIADIC,
79        outputs: &FPRINTF_OUTPUT_COUNT,
80    },
81];
82
83const FPRINTF_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
84    code: "RM.FPRINTF.INVALID_INPUT",
85    identifier: Some("RunMat:fprintf:InvalidInput"),
86    when: "Argument count/type does not satisfy fprintf requirements.",
87    message: "fprintf: invalid input arguments",
88};
89const FPRINTF_ERROR_INVALID_IDENTIFIER: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
90    code: "RM.FPRINTF.INVALID_IDENTIFIER",
91    identifier: Some("RunMat:fprintf:InvalidIdentifier"),
92    when: "File identifier is invalid or not writable.",
93    message: "fprintf: invalid file identifier. Use fopen to generate a valid file ID.",
94};
95const FPRINTF_ERROR_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
96    code: "RM.FPRINTF.FORMAT",
97    identifier: Some("RunMat:fprintf:InvalidFormat"),
98    when: "Format string parsing or placeholder consumption fails.",
99    message: "fprintf: invalid format specification",
100};
101const FPRINTF_ERROR_ENCODE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
102    code: "RM.FPRINTF.ENCODE",
103    identifier: Some("RunMat:fprintf:EncodeFailed"),
104    when: "Rendered text cannot be encoded for destination stream/file encoding.",
105    message: "fprintf: failed to encode output",
106};
107const FPRINTF_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
108    code: "RM.FPRINTF.IO",
109    identifier: Some("RunMat:fprintf:IoFailure"),
110    when: "Write to target stream/file fails.",
111    message: "fprintf: write failed",
112};
113const FPRINTF_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
114    code: "RM.FPRINTF.INTERNAL",
115    identifier: None,
116    when: "Internal runtime control-flow or conversion fails.",
117    message: "fprintf: internal error",
118};
119const FPRINTF_ERRORS: [BuiltinErrorDescriptor; 6] = [
120    FPRINTF_ERROR_INVALID_INPUT,
121    FPRINTF_ERROR_INVALID_IDENTIFIER,
122    FPRINTF_ERROR_FORMAT,
123    FPRINTF_ERROR_ENCODE,
124    FPRINTF_ERROR_IO,
125    FPRINTF_ERROR_INTERNAL,
126];
127pub const FPRINTF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
128    signatures: &FPRINTF_SIGNATURES,
129    output_mode: BuiltinOutputMode::Fixed,
130    completion_policy: BuiltinCompletionPolicy::Public,
131    errors: &FPRINTF_ERRORS,
132};
133
134#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::filetext::fprintf")]
135pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
136    name: "fprintf",
137    op_kind: GpuOpKind::Custom("io-file-write"),
138    supported_precisions: &[],
139    broadcast: BroadcastSemantics::None,
140    provider_hooks: &[],
141    constant_strategy: ConstantStrategy::InlineLiteral,
142    residency: ResidencyPolicy::GatherImmediately,
143    nan_mode: ReductionNaN::Include,
144    two_pass_threshold: None,
145    workgroup_size: None,
146    accepts_nan_mode: false,
147    notes: "Host-only text I/O. Arguments residing on the GPU are gathered before formatting.",
148};
149
150fn fprintf_error_with_detail(
151    error: &'static BuiltinErrorDescriptor,
152    detail: impl AsRef<str>,
153) -> RuntimeError {
154    fprintf_error_with_message(format!("{}: {}", error.message, detail.as_ref()), error)
155}
156
157fn fprintf_error_with_message(
158    message: impl Into<String>,
159    error: &'static BuiltinErrorDescriptor,
160) -> RuntimeError {
161    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
162    if let Some(identifier) = error.identifier {
163        builder = builder.with_identifier(identifier);
164    }
165    builder.build()
166}
167
168fn map_control_flow(err: RuntimeError) -> RuntimeError {
169    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
170        .with_builtin(BUILTIN_NAME)
171        .with_source(err);
172    if let Some(identifier) = FPRINTF_ERROR_INTERNAL.identifier {
173        builder = builder.with_identifier(identifier);
174    }
175    builder.build()
176}
177
178fn map_string_result<T>(
179    result: Result<T, String>,
180    error: &'static BuiltinErrorDescriptor,
181) -> BuiltinResult<T> {
182    result.map_err(|message| fprintf_error_with_detail(error, message))
183}
184
185#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::filetext::fprintf")]
186pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
187    name: "fprintf",
188    shape: ShapeRequirements::Any,
189    constant_strategy: ConstantStrategy::InlineLiteral,
190    elementwise: None,
191    reduction: None,
192    emits_nan: false,
193    notes: "Formatting is a side-effecting sink and never participates in fusion.",
194};
195
196/// Result of evaluating `fprintf`.
197#[derive(Debug)]
198pub struct FprintfEval {
199    bytes_written: usize,
200}
201
202impl FprintfEval {
203    /// Number of bytes emitted by the write.
204    pub fn bytes_written(&self) -> usize {
205        self.bytes_written
206    }
207}
208
209/// Evaluate the `fprintf` builtin without going through the dispatcher.
210pub async fn evaluate(args: &[Value]) -> BuiltinResult<FprintfEval> {
211    if args.is_empty() {
212        return Err(fprintf_error_with_detail(
213            &FPRINTF_ERROR_INVALID_INPUT,
214            "not enough input arguments",
215        ));
216    }
217
218    // Gather all arguments to host first
219    let mut all: Vec<Value> = Vec::with_capacity(args.len());
220    for v in args {
221        all.push(gather_value(v).await?);
222    }
223
224    // Locate the first valid formatSpec anywhere in the list
225    let mut fmt_idx: Option<usize> = None;
226    let mut format_string_val: Option<String> = None;
227    for (i, value) in all.iter().enumerate() {
228        // Never interpret a stream label ('stdout'/'stderr') as the format string
229        if match_stream_label(value).is_some() {
230            continue;
231        }
232        if let Some(Value::String(s)) =
233            map_string_result(coerce_to_format_string(value), &FPRINTF_ERROR_INVALID_INPUT)?
234        {
235            fmt_idx = Some(i);
236            format_string_val = Some(s);
237            break;
238        }
239    }
240    let fmt_idx = fmt_idx.ok_or_else(|| {
241        fprintf_error_with_detail(&FPRINTF_ERROR_INVALID_INPUT, "missing format string")
242    })?;
243    let raw_format = format_string_val.unwrap();
244
245    // Determine output target by scanning only arguments BEFORE the format
246    let mut target_idx: Option<usize> = None;
247    let mut target: OutputTarget = OutputTarget::Stdout;
248    // Prefer explicit stream labels over numeric fids if both appear
249    let mut first_stream: Option<(usize, SpecialStream)> = None;
250    for (i, value) in all.iter().enumerate().take(fmt_idx) {
251        if let Some(stream) = match_stream_label(value) {
252            first_stream = Some((i, stream));
253            break;
254        }
255    }
256    if let Some((idx, stream)) = first_stream {
257        target_idx = Some(idx);
258        target = match stream {
259            SpecialStream::Stdout => OutputTarget::Stdout,
260            SpecialStream::Stderr => OutputTarget::Stderr,
261        };
262    } else {
263        // Try to parse a numeric fid that appears before the format
264        for (i, value) in all.iter().enumerate().take(fmt_idx) {
265            if matches!(value, Value::Num(_) | Value::Int(_) | Value::Tensor(_)) {
266                if let Ok(fid) = parse_fid(value) {
267                    target_idx = Some(i);
268                    target = target_from_fid(fid)?;
269                    break;
270                }
271            }
272        }
273    }
274
275    // Remaining arguments are data, excluding the chosen target and the format
276    let mut data_args: Vec<Value> = Vec::with_capacity(all.len().saturating_sub(1));
277    for (i, v) in all.into_iter().enumerate() {
278        if i == fmt_idx {
279            continue;
280        }
281        if let Some(tidx) = target_idx {
282            if i == tidx {
283                continue;
284            }
285        }
286        data_args.push(v);
287    }
288
289    let format_string =
290        decode_escape_sequences("fprintf", &raw_format).map_err(map_control_flow)?;
291    let flattened_args = flatten_arguments(&data_args, "fprintf")
292        .await
293        .map_err(map_control_flow)?;
294    let rendered = format_with_repetition(&format_string, &flattened_args)?;
295    let bytes = map_string_result(
296        encode_output(&rendered, target.encoding_label()),
297        &FPRINTF_ERROR_ENCODE,
298    )?;
299    target.write(&bytes)?;
300    Ok(FprintfEval {
301        bytes_written: bytes.len(),
302    })
303}
304
305// kind_of was used for debugging logs; removed to avoid dead code in production builds.
306
307fn try_tensor_char_row_as_string(value: &Value) -> Option<Result<String, String>> {
308    match value {
309        Value::Tensor(t) => {
310            let is_row = (t.shape.len() == 2 && t.shape[0] == 1 && t.data.len() == t.shape[1])
311                || (t.shape.len() == 1 && t.data.len() == t.shape[0]);
312            if is_row {
313                let mut out = String::with_capacity(t.data.len());
314                for &code in &t.data {
315                    if !code.is_finite() {
316                        return Some(Err(
317                            "fprintf: formatSpec must be a character row vector or string scalar"
318                                .to_string(),
319                        ));
320                    }
321                    let v = code as u32;
322                    // Allow full Unicode range; MATLAB chars are UTF-16 but format strings are ASCII-compatible typically
323                    if let Some(ch) = char::from_u32(v) {
324                        out.push(ch);
325                    } else {
326                        return Some(Err(
327                            "fprintf: formatSpec contains invalid character code".to_string()
328                        ));
329                    }
330                }
331                return Some(Ok(out));
332            }
333            None
334        }
335        _ => None,
336    }
337}
338
339fn coerce_to_format_string(value: &Value) -> Result<Option<Value>, String> {
340    match value {
341        Value::String(s) => Ok(Some(Value::String(s.clone()))),
342        Value::StringArray(sa) if sa.data.len() == 1 => Ok(Some(Value::String(sa.data[0].clone()))),
343        Value::CharArray(ca) => {
344            let s: String = ca.data.iter().collect();
345            Ok(Some(Value::String(s)))
346        }
347        Value::Tensor(t) => {
348            // Only accept numeric codepoint vectors of length >= 2 as formatSpec.
349            // This avoids misinterpreting stray 1x1 numerics (e.g., accidental stack values)
350            // as a valid format string.
351            if t.data.len() >= 2 {
352                match try_tensor_char_row_as_string(value) {
353                    Some(Ok(s)) => Ok(Some(Value::String(s))),
354                    Some(Err(e)) => Err(e),
355                    None => Ok(None),
356                }
357            } else {
358                Ok(None)
359            }
360        }
361        _ => Ok(None),
362    }
363}
364
365#[runtime_builtin(
366    name = "fprintf",
367    category = "io/filetext",
368    summary = "Write formatted text to files or standard streams.",
369    keywords = "fprintf,format,printf,io",
370    accel = "cpu",
371    sink = true,
372    suppress_auto_output = true,
373    type_resolver(crate::builtins::io::type_resolvers::fprintf_type),
374    descriptor(crate::builtins::io::filetext::fprintf::FPRINTF_DESCRIPTOR),
375    builtin_path = "crate::builtins::io::filetext::fprintf"
376)]
377async fn fprintf_builtin(first: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
378    let mut args = Vec::with_capacity(rest.len() + 1);
379    args.push(first);
380    args.extend(rest);
381    let eval = evaluate(&args).await?;
382    Ok(Value::Num(eval.bytes_written() as f64))
383}
384
385#[derive(Clone, Copy)]
386enum SpecialStream {
387    Stdout,
388    Stderr,
389}
390
391enum OutputTarget {
392    Stdout,
393    Stderr,
394    File {
395        handle: SharedFileHandle,
396        encoding: String,
397    },
398}
399
400impl OutputTarget {
401    fn encoding_label(&self) -> Option<&str> {
402        match self {
403            OutputTarget::Stdout | OutputTarget::Stderr => None,
404            OutputTarget::File { encoding, .. } => Some(encoding.as_str()),
405        }
406    }
407
408    fn write(&self, bytes: &[u8]) -> BuiltinResult<()> {
409        match self {
410            OutputTarget::Stdout => {
411                record_console_chunk(ConsoleStream::Stdout, bytes);
412                Ok(())
413            }
414            OutputTarget::Stderr => {
415                record_console_chunk(ConsoleStream::Stderr, bytes);
416                Ok(())
417            }
418            OutputTarget::File { handle, .. } => {
419                let mut guard = handle.lock().map_err(|_| {
420                    fprintf_error_with_detail(
421                        &FPRINTF_ERROR_INTERNAL,
422                        "failed to lock file handle (poisoned mutex)",
423                    )
424                })?;
425                let file = guard.as_mut().ok_or_else(|| {
426                    fprintf_error_with_message(
427                        FPRINTF_ERROR_INVALID_IDENTIFIER.message,
428                        &FPRINTF_ERROR_INVALID_IDENTIFIER,
429                    )
430                })?;
431                file.write_all(bytes).map_err(|err| {
432                    fprintf_error_with_detail(
433                        &FPRINTF_ERROR_IO,
434                        format!("failed to write to file ({err})"),
435                    )
436                })
437            }
438        }
439    }
440}
441
442fn record_console_chunk(stream: ConsoleStream, bytes: &[u8]) {
443    if bytes.is_empty() {
444        return;
445    }
446    let text = String::from_utf8_lossy(bytes).to_string();
447    record_console_output(stream, text);
448}
449
450async fn gather_value(value: &Value) -> BuiltinResult<Value> {
451    gather_if_needed_async(value)
452        .await
453        .map_err(map_control_flow)
454}
455
456fn target_from_fid(fid: i32) -> BuiltinResult<OutputTarget> {
457    if fid < 0 {
458        return Err(fprintf_error_with_detail(
459            &FPRINTF_ERROR_INVALID_INPUT,
460            "file identifier must be non-negative",
461        ));
462    }
463    match fid {
464        0 => Err(fprintf_error_with_detail(
465            &FPRINTF_ERROR_INVALID_IDENTIFIER,
466            "file identifier 0 (stdin) is not writable",
467        )),
468        1 => Ok(OutputTarget::Stdout),
469        2 => Ok(OutputTarget::Stderr),
470        _ => {
471            let info = registry::info_for(fid).ok_or_else(|| {
472                fprintf_error_with_message(
473                    FPRINTF_ERROR_INVALID_IDENTIFIER.message,
474                    &FPRINTF_ERROR_INVALID_IDENTIFIER,
475                )
476            })?;
477            ensure_writable(&info)?;
478            let handle = registry::take_handle(fid).ok_or_else(|| {
479                fprintf_error_with_message(
480                    FPRINTF_ERROR_INVALID_IDENTIFIER.message,
481                    &FPRINTF_ERROR_INVALID_IDENTIFIER,
482                )
483            })?;
484            Ok(OutputTarget::File {
485                handle,
486                encoding: info.encoding.clone(),
487            })
488        }
489    }
490}
491
492fn parse_fid(value: &Value) -> Result<i32, String> {
493    let scalar = match value {
494        Value::Num(n) => *n,
495        Value::Int(int) => int.to_f64(),
496        Value::Tensor(t) => {
497            if t.shape == vec![1, 1] && t.data.len() == 1 {
498                t.data[0]
499            } else {
500                return Err("fprintf: file identifier must be numeric".to_string());
501            }
502        }
503        _ => return Err("fprintf: file identifier must be numeric".to_string()),
504    };
505    if !scalar.is_finite() {
506        return Err("fprintf: file identifier must be finite".to_string());
507    }
508    if (scalar.fract().abs()) > f64::EPSILON {
509        return Err("fprintf: file identifier must be an integer".to_string());
510    }
511    Ok(scalar as i32)
512}
513
514fn ensure_writable(info: &FileInfo) -> BuiltinResult<()> {
515    let permission = info.permission.to_ascii_lowercase();
516    if permission.contains('w') || permission.contains('a') || permission.contains('+') {
517        Ok(())
518    } else {
519        Err(fprintf_error_with_detail(
520            &FPRINTF_ERROR_INVALID_IDENTIFIER,
521            "file is not open for writing",
522        ))
523    }
524}
525
526fn match_stream_label(value: &Value) -> Option<SpecialStream> {
527    let candidate = match value {
528        Value::String(s) => s.trim().to_string(),
529        Value::CharArray(ca) if ca.rows == 1 => {
530            ca.data.iter().collect::<String>().trim().to_string()
531        }
532        Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].trim().to_string(),
533        _ => return None,
534    };
535    match candidate.to_ascii_lowercase().as_str() {
536        "stdout" => Some(SpecialStream::Stdout),
537        "stderr" => Some(SpecialStream::Stderr),
538        _ => None,
539    }
540}
541
542fn format_with_repetition(format: &str, args: &[Value]) -> BuiltinResult<String> {
543    let mut cursor = ArgCursor::new(args);
544    let mut out = String::new();
545    loop {
546        let step = format_variadic_with_cursor(format, &mut cursor).map_err(remap_format_error)?;
547        out.push_str(&step.output);
548        if step.consumed == 0 {
549            if cursor.remaining() > 0 {
550                return Err(fprintf_error_with_detail(
551                    &FPRINTF_ERROR_FORMAT,
552                    "formatSpec contains no conversion specifiers but additional arguments were supplied",
553                ));
554            }
555            break;
556        }
557        if cursor.remaining() == 0 {
558            break;
559        }
560    }
561    Ok(out)
562}
563
564fn remap_format_error(err: RuntimeError) -> RuntimeError {
565    let message = err.message().replace("sprintf", "fprintf");
566    let mut builder = build_runtime_error(message)
567        .with_builtin(BUILTIN_NAME)
568        .with_source(err);
569    if let Some(identifier) = FPRINTF_ERROR_FORMAT.identifier {
570        builder = builder.with_identifier(identifier);
571    }
572    builder.build()
573}
574
575fn encode_output(text: &str, encoding: Option<&str>) -> Result<Vec<u8>, String> {
576    let label = encoding
577        .map(|s| s.trim())
578        .filter(|s| !s.is_empty())
579        .unwrap_or("utf-8");
580    let lower = label.to_ascii_lowercase();
581    let collapsed: String = lower
582        .chars()
583        .filter(|ch| !matches!(ch, '-' | '_' | ' '))
584        .collect();
585    if matches!(
586        collapsed.as_str(),
587        "utf8" | "unicode" | "auto" | "default" | "system"
588    ) {
589        Ok(text.as_bytes().to_vec())
590    } else if matches!(collapsed.as_str(), "ascii" | "usascii" | "ansix341968") {
591        encode_ascii(text)
592    } else if matches!(
593        collapsed.as_str(),
594        "latin1" | "iso88591" | "cp819" | "ibm819"
595    ) {
596        encode_latin1(text, label)
597    } else if matches!(collapsed.as_str(), "windows1252" | "cp1252" | "ansi") {
598        encode_windows_1252(text, label)
599    } else {
600        Ok(text.as_bytes().to_vec())
601    }
602}
603
604fn encode_ascii(text: &str) -> Result<Vec<u8>, String> {
605    let mut bytes = Vec::with_capacity(text.len());
606    for ch in text.chars() {
607        if ch as u32 > 0x7F {
608            return Err(format!(
609                "fprintf: character '{}' (U+{:04X}) cannot be encoded as ASCII",
610                ch, ch as u32
611            ));
612        }
613        bytes.push(ch as u8);
614    }
615    Ok(bytes)
616}
617
618fn encode_latin1(text: &str, label: &str) -> Result<Vec<u8>, String> {
619    let mut bytes = Vec::with_capacity(text.len());
620    for ch in text.chars() {
621        if ch as u32 > 0xFF {
622            return Err(format!(
623                "fprintf: character '{}' (U+{:04X}) cannot be encoded as {}",
624                ch, ch as u32, label
625            ));
626        }
627        bytes.push(ch as u8);
628    }
629    Ok(bytes)
630}
631
632fn encode_windows_1252(text: &str, label: &str) -> Result<Vec<u8>, String> {
633    let mut bytes = Vec::with_capacity(text.len());
634    for ch in text.chars() {
635        if let Some(byte) = windows_1252_byte(ch) {
636            bytes.push(byte);
637        } else {
638            return Err(format!(
639                "fprintf: character '{}' (U+{:04X}) cannot be encoded as {}",
640                ch, ch as u32, label
641            ));
642        }
643    }
644    Ok(bytes)
645}
646
647fn windows_1252_byte(ch: char) -> Option<u8> {
648    let code = ch as u32;
649    if code <= 0x7F {
650        return Some(code as u8);
651    }
652    if (0xA0..=0xFF).contains(&code) {
653        return Some(code as u8);
654    }
655    match code {
656        0x20AC => Some(0x80),
657        0x201A => Some(0x82),
658        0x0192 => Some(0x83),
659        0x201E => Some(0x84),
660        0x2026 => Some(0x85),
661        0x2020 => Some(0x86),
662        0x2021 => Some(0x87),
663        0x02C6 => Some(0x88),
664        0x2030 => Some(0x89),
665        0x0160 => Some(0x8A),
666        0x2039 => Some(0x8B),
667        0x0152 => Some(0x8C),
668        0x017D => Some(0x8E),
669        0x2018 => Some(0x91),
670        0x2019 => Some(0x92),
671        0x201C => Some(0x93),
672        0x201D => Some(0x94),
673        0x2022 => Some(0x95),
674        0x2013 => Some(0x96),
675        0x2014 => Some(0x97),
676        0x02DC => Some(0x98),
677        0x2122 => Some(0x99),
678        0x0161 => Some(0x9A),
679        0x203A => Some(0x9B),
680        0x0153 => Some(0x9C),
681        0x017E => Some(0x9E),
682        0x0178 => Some(0x9F),
683        _ => None,
684    }
685}
686
687#[cfg(test)]
688pub(crate) mod tests {
689    use super::*;
690    use crate::builtins::common::test_support;
691    use crate::builtins::io::filetext::{fclose, fopen, registry};
692    use crate::RuntimeError;
693    use runmat_accelerate_api::HostTensorView;
694    use runmat_builtins::{IntValue, Tensor};
695    use runmat_filesystem::File;
696    use runmat_time::system_time_now;
697    use std::io::Read;
698    use std::path::PathBuf;
699    use std::time::UNIX_EPOCH;
700
701    fn unwrap_error_message(err: RuntimeError) -> String {
702        err.message().to_string()
703    }
704
705    fn run_evaluate(args: &[Value]) -> BuiltinResult<FprintfEval> {
706        futures::executor::block_on(evaluate(args))
707    }
708
709    fn run_fopen(args: &[Value]) -> BuiltinResult<fopen::FopenEval> {
710        futures::executor::block_on(fopen::evaluate(args))
711    }
712
713    fn run_fclose(args: &[Value]) -> BuiltinResult<fclose::FcloseEval> {
714        futures::executor::block_on(fclose::evaluate(args))
715    }
716
717    fn registry_guard() -> std::sync::MutexGuard<'static, ()> {
718        registry::test_guard()
719    }
720
721    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
722    #[test]
723    fn fprintf_descriptor_signatures_cover_core_forms() {
724        let labels: Vec<&str> = FPRINTF_DESCRIPTOR
725            .signatures
726            .iter()
727            .map(|sig| sig.label)
728            .collect();
729        assert!(labels.contains(&"count = fprintf(formatSpec, A...)"));
730        assert!(labels.contains(&"count = fprintf(fid_or_stream, formatSpec, A...)"));
731    }
732
733    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
734    #[test]
735    fn fprintf_matrix_column_major() {
736        let _guard = registry_guard();
737        registry::reset_for_tests();
738        let path = unique_path("fprintf_matrix");
739        let open = run_fopen(&[
740            Value::from(path.to_string_lossy().to_string()),
741            Value::from("w"),
742        ])
743        .expect("fopen");
744        let fid = open.as_open().unwrap().fid as i32;
745
746        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
747        let args = vec![
748            Value::Num(fid as f64),
749            Value::String("%d %d\n".to_string()),
750            Value::Tensor(tensor),
751        ];
752        let eval = run_evaluate(&args).expect("fprintf");
753        assert_eq!(eval.bytes_written(), 12);
754
755        run_fclose(&[Value::Num(fid as f64)]).unwrap();
756
757        let contents = test_support::fs::read_to_string(&path).expect("read");
758        assert_eq!(contents, "1 4\n2 5\n3 6\n");
759        test_support::fs::remove_file(path).unwrap();
760    }
761
762    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
763    #[test]
764    fn fprintf_ascii_encoding_errors() {
765        let _guard = registry_guard();
766        registry::reset_for_tests();
767        let path = unique_path("fprintf_ascii");
768        let open = run_fopen(&[
769            Value::from(path.to_string_lossy().to_string()),
770            Value::from("w"),
771            Value::from("native"),
772            Value::from("ascii"),
773        ])
774        .expect("fopen");
775        let fid = open.as_open().unwrap().fid as i32;
776
777        let args = vec![
778            Value::Num(fid as f64),
779            Value::String("%s".to_string()),
780            Value::String("café".to_string()),
781        ];
782        let err = unwrap_error_message(run_evaluate(&args).unwrap_err());
783        assert!(err.contains("cannot be encoded as ASCII"), "{err}");
784
785        run_fclose(&[Value::Num(fid as f64)]).unwrap();
786        test_support::fs::remove_file(path).unwrap();
787    }
788
789    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
790    #[test]
791    fn fprintf_gpu_gathers_values() {
792        let _guard = registry_guard();
793        registry::reset_for_tests();
794        let path = unique_path("fprintf_gpu");
795
796        test_support::with_test_provider(|provider| {
797            registry::reset_for_tests();
798            let open = run_fopen(&[
799                Value::from(path.to_string_lossy().to_string()),
800                Value::from("w"),
801            ])
802            .expect("fopen");
803            let fid = open.as_open().unwrap().fid as i32;
804
805            let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
806            let view = HostTensorView {
807                data: &tensor.data,
808                shape: &tensor.shape,
809            };
810            let handle = provider.upload(&view).expect("upload");
811            let args = vec![
812                Value::Num(fid as f64),
813                Value::String("%.1f,".to_string()),
814                Value::GpuTensor(handle),
815            ];
816            let eval = run_evaluate(&args).expect("fprintf");
817            assert_eq!(eval.bytes_written(), 12);
818
819            run_fclose(&[Value::Num(fid as f64)]).unwrap();
820        });
821
822        let mut file = File::open(&path).expect("open");
823        let mut contents = String::new();
824        file.read_to_string(&mut contents).expect("read");
825        assert_eq!(contents, "1.0,2.0,3.0,");
826        test_support::fs::remove_file(path).unwrap();
827    }
828
829    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
830    #[test]
831    fn fprintf_missing_format_errors() {
832        let err = unwrap_error_message(run_evaluate(&[Value::Num(1.0)]).unwrap_err());
833        assert!(err.contains("missing format string"), "{err}");
834    }
835
836    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
837    #[test]
838    fn fprintf_literal_with_extra_args_errors() {
839        let err = unwrap_error_message(
840            run_evaluate(&[
841                Value::String("literal text".to_string()),
842                Value::Int(IntValue::I32(1)),
843            ])
844            .unwrap_err(),
845        );
846        assert!(err.contains("contains no conversion specifiers"), "{err}");
847    }
848
849    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
850    #[test]
851    fn fprintf_invalid_identifier_errors() {
852        let err = unwrap_error_message(
853            run_evaluate(&[Value::Num(99.0), Value::String("value".to_string())]).unwrap_err(),
854        );
855        assert_eq!(err, FPRINTF_ERROR_INVALID_IDENTIFIER.message);
856    }
857
858    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
859    #[test]
860    fn fprintf_read_only_error() {
861        let _guard = registry_guard();
862        registry::reset_for_tests();
863        let path = unique_path("fprintf_read_only");
864        test_support::fs::write(&path, b"readonly").unwrap();
865        let open = run_fopen(&[
866            Value::from(path.to_string_lossy().to_string()),
867            Value::from("r"),
868        ])
869        .expect("fopen");
870        let fid = open.as_open().unwrap().fid as i32;
871        let err = unwrap_error_message(
872            run_evaluate(&[Value::Num(fid as f64), Value::String("text".to_string())]).unwrap_err(),
873        );
874        assert!(err.contains("not open for writing"), "{err}");
875
876        run_fclose(&[Value::Num(fid as f64)]).unwrap();
877    }
878
879    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
880    #[test]
881    fn fprintf_encoding_aliases_encode_expected_bytes() {
882        let utf = encode_output("é", Some("utf_8")).expect("utf_8 alias");
883        assert_eq!(utf, "é".as_bytes());
884
885        let latin = encode_output("é", Some("cp819")).expect("cp819 alias");
886        assert_eq!(latin, vec![0xE9]);
887
888        let win = encode_output("€’", Some("windows-1252")).expect("windows-1252 alias");
889        assert_eq!(win, vec![0x80, 0x92]);
890    }
891
892    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
893    #[test]
894    fn fprintf_windows1252_reports_unencodable_characters() {
895        let err = encode_output("Ā", Some("cp1252")).expect_err("cp1252 should reject U+0100");
896        assert!(err.contains("cannot be encoded"), "{err}");
897    }
898
899    fn unique_path(prefix: &str) -> PathBuf {
900        let nanos = system_time_now()
901            .duration_since(UNIX_EPOCH)
902            .unwrap()
903            .as_nanos();
904        let filename = format!("runmat_{prefix}_{nanos}.txt");
905        std::env::temp_dir().join(filename)
906    }
907}