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, BuiltinExtensionDescriptor,
7    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
8    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
9    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
10    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
11    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
12};
13use runmat_macros::runtime_builtin;
14use runmat_value::{IntValue, NumericDType, Value};
15
16use crate::builtins::common::format::{
17    decode_escape_sequences, flatten_arguments, format_variadic_with_cursor, ArgCursor,
18};
19use crate::builtins::common::spec::{
20    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
21    ReductionNaN, ResidencyPolicy, ShapeRequirements,
22};
23use crate::builtins::common::tensor;
24use crate::builtins::io::filetext::registry::{self, FileInfo, SharedFileHandle};
25use crate::console::{record_console_output, ConsoleStream};
26use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
27
28const BUILTIN_NAME: &str = "fprintf";
29
30const FPRINTF_INTEGER_ID_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
31    id: "fprintf-integer-fileid",
32    mode: BuiltinExtensionMode::RunMatOnly,
33    description: "integer-class fprintf file identifiers are a RunMat extension",
34    error_identifier: Some("RunMat:compatibility:FprintfIntegerIdExtension"),
35};
36const FPRINTF_SINGLE_ID_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
37    id: "fprintf-single-fileid",
38    mode: BuiltinExtensionMode::RunMatOnly,
39    description: "single-precision fprintf file identifiers are a RunMat extension",
40    error_identifier: Some("RunMat:compatibility:FprintfSingleIdExtension"),
41};
42const FPRINTF_RESIDENT_ID_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
43    id: "fprintf-resident-fileid",
44    mode: BuiltinExtensionMode::RunMatOnly,
45    description: "provider-resident fprintf file identifiers are a RunMat extension",
46    error_identifier: Some("RunMat:compatibility:FprintfResidentIdExtension"),
47};
48const FPRINTF_INTEGER_FORMAT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
49    id: "fprintf-integer-format",
50    mode: BuiltinExtensionMode::RunMatOnly,
51    description: "numeric integer-code fprintf format specifications are a RunMat extension",
52    error_identifier: Some("RunMat:compatibility:FprintfIntegerFormatExtension"),
53};
54const FPRINTF_NUMERIC_FORMAT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
55    id: "fprintf-numeric-format",
56    mode: BuiltinExtensionMode::RunMatOnly,
57    description: "numeric-code fprintf format tensors are a RunMat extension",
58    error_identifier: Some("RunMat:compatibility:FprintfNumericFormatExtension"),
59};
60const FPRINTF_RESIDENT_FORMAT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
61    id: "fprintf-resident-format",
62    mode: BuiltinExtensionMode::RunMatOnly,
63    description: "provider-resident fprintf format specifications are a RunMat extension",
64    error_identifier: Some("RunMat:compatibility:FprintfResidentFormatExtension"),
65};
66const FPRINTF_STREAM_LABEL_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
67    id: "fprintf-stream-label",
68    mode: BuiltinExtensionMode::RunMatOnly,
69    description: "textual stdout and stderr fprintf targets are a RunMat extension",
70    error_identifier: Some("RunMat:compatibility:FprintfStreamLabelExtension"),
71};
72pub const FPRINTF_EXTENSIONS: [BuiltinExtensionDescriptor; 7] = [
73    FPRINTF_INTEGER_ID_EXTENSION,
74    FPRINTF_SINGLE_ID_EXTENSION,
75    FPRINTF_RESIDENT_ID_EXTENSION,
76    FPRINTF_INTEGER_FORMAT_EXTENSION,
77    FPRINTF_NUMERIC_FORMAT_EXTENSION,
78    FPRINTF_RESIDENT_FORMAT_EXTENSION,
79    FPRINTF_STREAM_LABEL_EXTENSION,
80];
81
82const FPRINTF_INTEGER_DATA_INPUTS: [BuiltinIntegerInputCapability; 1] =
83    [BuiltinIntegerInputCapability {
84        name: "A",
85        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
86        availability: BuiltinIntegerInputAvailability::Documented,
87        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
88        notes: "The compatibility target documents all eight integer classes; integer conversions format authoritative values exactly and arrays traverse in column-major order.",
89    }];
90const FPRINTF_INTEGER_ID_INPUTS: [BuiltinIntegerInputCapability; 1] =
91    [BuiltinIntegerInputCapability {
92        name: "fileID",
93        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
94        availability: BuiltinIntegerInputAvailability::RunMatOnly,
95        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
96        notes: "The compatibility target documents double identifiers; typed integer identifiers are an independently gated extension.",
97    }];
98const FPRINTF_INTEGER_FORMAT_INPUTS: [BuiltinIntegerInputCapability; 1] =
99    [BuiltinIntegerInputCapability {
100        name: "formatSpec",
101        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
102        availability: BuiltinIntegerInputAvailability::RunMatOnly,
103        scalar_double: BuiltinIntegerScalarDoubleRule::Rejected,
104        notes: "The compatibility target documents character or string format specifications; numeric code vectors are a gated RunMat extension.",
105    }];
106pub const INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 3] = [
107    BuiltinIntegerCapabilityDescriptor {
108        form: "count = fprintf(formatSpec, integer_data...)",
109        inputs: &FPRINTF_INTEGER_DATA_INPUTS,
110        computation_domain: BuiltinIntegerComputationDomain::Structural,
111        output_class: BuiltinIntegerOutputClassRule::Double,
112        overflow: BuiltinIntegerOverflowRule::NotApplicable,
113        backend: BuiltinIntegerBackendRule::GatherFallback,
114        overload: BuiltinIntegerOverloadKind::FunctionSpecific,
115        notes: "The host sink returns a double byte count; documented resident data gathers without changing the caller-owned value.",
116    },
117    BuiltinIntegerCapabilityDescriptor {
118        form: "count = fprintf(integer_fileID, formatSpec, A...)",
119        inputs: &FPRINTF_INTEGER_ID_INPUTS,
120        computation_domain: BuiltinIntegerComputationDomain::Structural,
121        output_class: BuiltinIntegerOutputClassRule::Double,
122        overflow: BuiltinIntegerOverflowRule::Error,
123        backend: BuiltinIntegerBackendRule::GatherFallback,
124        overload: BuiltinIntegerOverloadKind::ScalarOnly,
125        notes: "The identifier is range-checked exactly before registry access.",
126    },
127    BuiltinIntegerCapabilityDescriptor {
128        form: "count = fprintf(integer_formatSpec, A...)",
129        inputs: &FPRINTF_INTEGER_FORMAT_INPUTS,
130        computation_domain: BuiltinIntegerComputationDomain::Structural,
131        output_class: BuiltinIntegerOutputClassRule::Double,
132        overflow: BuiltinIntegerOverflowRule::Error,
133        backend: BuiltinIntegerBackendRule::HostOnly,
134        overload: BuiltinIntegerOverloadKind::StructuralParameter,
135        notes: "Integer code points are validated exactly before conversion to a host format string.",
136    },
137];
138
139const FPRINTF_OUTPUT_COUNT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
140    name: "count",
141    ty: BuiltinParamType::NumericScalar,
142    arity: BuiltinParamArity::Required,
143    default: None,
144    description: "Number of bytes written.",
145}];
146const FPRINTF_INPUTS_FORMAT_VARIADIC: [BuiltinParamDescriptor; 2] = [
147    BuiltinParamDescriptor {
148        name: "formatSpec",
149        ty: BuiltinParamType::Any,
150        arity: BuiltinParamArity::Required,
151        default: None,
152        description: "Format string or character row vector.",
153    },
154    BuiltinParamDescriptor {
155        name: "A",
156        ty: BuiltinParamType::Any,
157        arity: BuiltinParamArity::Variadic,
158        default: None,
159        description: "Values consumed by conversion specifiers.",
160    },
161];
162const FPRINTF_INPUTS_FID_FORMAT_VARIADIC: [BuiltinParamDescriptor; 3] = [
163    BuiltinParamDescriptor {
164        name: "fid_or_stream",
165        ty: BuiltinParamType::Any,
166        arity: BuiltinParamArity::Required,
167        default: Some("1"),
168        description: "Numeric file identifier, or stream label ('stdout'|'stderr').",
169    },
170    BuiltinParamDescriptor {
171        name: "formatSpec",
172        ty: BuiltinParamType::Any,
173        arity: BuiltinParamArity::Required,
174        default: None,
175        description: "Format string or character row vector.",
176    },
177    BuiltinParamDescriptor {
178        name: "A",
179        ty: BuiltinParamType::Any,
180        arity: BuiltinParamArity::Variadic,
181        default: None,
182        description: "Values consumed by conversion specifiers.",
183    },
184];
185const FPRINTF_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
186    BuiltinSignatureDescriptor {
187        label: "count = fprintf(formatSpec, A...)",
188        inputs: &FPRINTF_INPUTS_FORMAT_VARIADIC,
189        outputs: &FPRINTF_OUTPUT_COUNT,
190    },
191    BuiltinSignatureDescriptor {
192        label: "count = fprintf(fid_or_stream, formatSpec, A...)",
193        inputs: &FPRINTF_INPUTS_FID_FORMAT_VARIADIC,
194        outputs: &FPRINTF_OUTPUT_COUNT,
195    },
196];
197
198const FPRINTF_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
199    code: "RM.FPRINTF.INVALID_INPUT",
200    identifier: Some("RunMat:fprintf:InvalidInput"),
201    when: "Argument count/type does not satisfy fprintf requirements.",
202    message: "fprintf: invalid input arguments",
203};
204const FPRINTF_ERROR_INVALID_IDENTIFIER: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
205    code: "RM.FPRINTF.INVALID_IDENTIFIER",
206    identifier: Some("RunMat:fprintf:InvalidIdentifier"),
207    when: "File identifier is invalid or not writable.",
208    message: "fprintf: invalid file identifier. Use fopen to generate a valid file ID.",
209};
210const FPRINTF_ERROR_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
211    code: "RM.FPRINTF.FORMAT",
212    identifier: Some("RunMat:fprintf:InvalidFormat"),
213    when: "Format string parsing or placeholder consumption fails.",
214    message: "fprintf: invalid format specification",
215};
216const FPRINTF_ERROR_ENCODE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
217    code: "RM.FPRINTF.ENCODE",
218    identifier: Some("RunMat:fprintf:EncodeFailed"),
219    when: "Rendered text cannot be encoded for destination stream/file encoding.",
220    message: "fprintf: failed to encode output",
221};
222const FPRINTF_ERROR_IO: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
223    code: "RM.FPRINTF.IO",
224    identifier: Some("RunMat:fprintf:IoFailure"),
225    when: "Write to target stream/file fails.",
226    message: "fprintf: write failed",
227};
228const FPRINTF_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
229    code: "RM.FPRINTF.INTERNAL",
230    identifier: None,
231    when: "Internal runtime control-flow or conversion fails.",
232    message: "fprintf: internal error",
233};
234const FPRINTF_ERRORS: [BuiltinErrorDescriptor; 6] = [
235    FPRINTF_ERROR_INVALID_INPUT,
236    FPRINTF_ERROR_INVALID_IDENTIFIER,
237    FPRINTF_ERROR_FORMAT,
238    FPRINTF_ERROR_ENCODE,
239    FPRINTF_ERROR_IO,
240    FPRINTF_ERROR_INTERNAL,
241];
242pub const FPRINTF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
243    signatures: &FPRINTF_SIGNATURES,
244    output_mode: BuiltinOutputMode::Fixed,
245    completion_policy: BuiltinCompletionPolicy::Public,
246    errors: &FPRINTF_ERRORS,
247};
248
249#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::filetext::fprintf")]
250pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
251    name: "fprintf",
252    op_kind: GpuOpKind::Custom("io-file-write"),
253    supported_precisions: &[],
254    broadcast: BroadcastSemantics::None,
255    provider_hooks: &[],
256    constant_strategy: ConstantStrategy::InlineLiteral,
257    residency: ResidencyPolicy::GatherImmediately,
258    nan_mode: ReductionNaN::Include,
259    two_pass_threshold: None,
260    workgroup_size: None,
261    accepts_nan_mode: false,
262    notes: "Host-only text I/O. Arguments residing on the GPU are gathered before formatting.",
263};
264
265fn fprintf_error_with_detail(
266    error: &'static BuiltinErrorDescriptor,
267    detail: impl AsRef<str>,
268) -> RuntimeError {
269    fprintf_error_with_message(format!("{}: {}", error.message, detail.as_ref()), error)
270}
271
272fn fprintf_error_with_message(
273    message: impl Into<String>,
274    error: &'static BuiltinErrorDescriptor,
275) -> RuntimeError {
276    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
277    if let Some(identifier) = error.identifier {
278        builder = builder.with_identifier(identifier);
279    }
280    builder.build()
281}
282
283fn map_control_flow(err: RuntimeError) -> RuntimeError {
284    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
285        .with_builtin(BUILTIN_NAME)
286        .with_source(err);
287    if let Some(identifier) = FPRINTF_ERROR_INTERNAL.identifier {
288        builder = builder.with_identifier(identifier);
289    }
290    builder.build()
291}
292
293fn map_string_result<T>(
294    result: Result<T, String>,
295    error: &'static BuiltinErrorDescriptor,
296) -> BuiltinResult<T> {
297    result.map_err(|message| fprintf_error_with_detail(error, message))
298}
299
300#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::filetext::fprintf")]
301pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
302    name: "fprintf",
303    shape: ShapeRequirements::Any,
304    constant_strategy: ConstantStrategy::InlineLiteral,
305    elementwise: None,
306    reduction: None,
307    emits_nan: false,
308    notes: "Formatting is a side-effecting sink and never participates in fusion.",
309};
310
311/// Result of evaluating `fprintf`.
312#[derive(Debug)]
313pub struct FprintfEval {
314    bytes_written: usize,
315}
316
317impl FprintfEval {
318    /// Number of bytes emitted by the write.
319    pub fn bytes_written(&self) -> usize {
320        self.bytes_written
321    }
322}
323
324/// Evaluate the `fprintf` builtin without going through the dispatcher.
325pub async fn evaluate(args: &[Value]) -> BuiltinResult<FprintfEval> {
326    if args.is_empty() {
327        return Err(fprintf_error_with_detail(
328            &FPRINTF_ERROR_INVALID_INPUT,
329            "not enough input arguments",
330        ));
331    }
332
333    preflight_special_roles(args)?;
334    // Gather all arguments to host first
335    let mut all: Vec<Value> = Vec::with_capacity(args.len());
336    for v in args {
337        all.push(gather_value(v).await?);
338    }
339
340    // Locate the first valid formatSpec anywhere in the list
341    let mut fmt_idx: Option<usize> = None;
342    let mut format_string_val: Option<String> = None;
343    for (i, value) in all.iter().enumerate() {
344        // Never interpret a stream label ('stdout'/'stderr') as the format string
345        if match_stream_label(value).is_some() {
346            continue;
347        }
348        if let Some(Value::String(s)) =
349            map_string_result(coerce_to_format_string(value), &FPRINTF_ERROR_INVALID_INPUT)?
350        {
351            fmt_idx = Some(i);
352            format_string_val = Some(s);
353            break;
354        }
355    }
356    let fmt_idx = fmt_idx.ok_or_else(|| {
357        fprintf_error_with_detail(&FPRINTF_ERROR_INVALID_INPUT, "missing format string")
358    })?;
359    let raw_format = format_string_val.unwrap();
360
361    // Determine output target by scanning only arguments BEFORE the format
362    let mut target_idx: Option<usize> = None;
363    let mut target: OutputTarget = OutputTarget::Stdout;
364    // Prefer explicit stream labels over numeric fids if both appear
365    let mut first_stream: Option<(usize, SpecialStream)> = None;
366    for (i, value) in all.iter().enumerate().take(fmt_idx) {
367        if let Some(stream) = match_stream_label(value) {
368            first_stream = Some((i, stream));
369            break;
370        }
371    }
372    if let Some((idx, stream)) = first_stream {
373        target_idx = Some(idx);
374        target = match stream {
375            SpecialStream::Stdout => OutputTarget::Stdout,
376            SpecialStream::Stderr => OutputTarget::Stderr,
377        };
378    } else {
379        // Try to parse a numeric fid that appears before the format
380        for (i, value) in all.iter().enumerate().take(fmt_idx) {
381            if matches!(value, Value::Num(_) | Value::Int(_) | Value::Tensor(_)) {
382                if let Ok(fid) = parse_fid(value) {
383                    target_idx = Some(i);
384                    target = target_from_fid(fid)?;
385                    break;
386                }
387            }
388        }
389    }
390
391    // Remaining arguments are data, excluding the chosen target and the format
392    let mut data_args: Vec<Value> = Vec::with_capacity(all.len().saturating_sub(1));
393    for (i, v) in all.into_iter().enumerate() {
394        if i == fmt_idx {
395            continue;
396        }
397        if let Some(tidx) = target_idx {
398            if i == tidx {
399                continue;
400            }
401        }
402        data_args.push(v);
403    }
404
405    let format_string =
406        decode_escape_sequences("fprintf", &raw_format).map_err(map_control_flow)?;
407    let data_args = coerce_single_integer_string_argument(&format_string, data_args)?;
408    let flattened_args = flatten_arguments(&data_args, "fprintf")
409        .await
410        .map_err(map_control_flow)?;
411    let rendered = format_with_repetition(&format_string, &flattened_args)?;
412    let bytes = map_string_result(
413        encode_output(&rendered, target.encoding_label()),
414        &FPRINTF_ERROR_ENCODE,
415    )?;
416    target.write(&bytes)?;
417    Ok(FprintfEval {
418        bytes_written: bytes.len(),
419    })
420}
421
422// kind_of was used for debugging logs; removed to avoid dead code in release builds.
423
424fn try_tensor_char_row_as_string(value: &Value) -> Option<Result<String, String>> {
425    match value {
426        Value::Tensor(t) => {
427            let len = tensor::tensor_element_len(t);
428            let is_row = (t.shape.len() == 2 && t.shape[0] == 1 && len == t.shape[1])
429                || (t.shape.len() == 1 && len == t.shape[0]);
430            if is_row {
431                let mut out = String::with_capacity(len);
432                for index in 0..len {
433                    let code = t
434                        .numeric_value_at(index)
435                        .expect("index within authoritative numeric storage");
436                    if let Some(code) = code.into_int_value() {
437                        if let Some(ch) = char_from_int_value(&code) {
438                            out.push(ch);
439                        } else {
440                            return Some(Err(
441                                "fprintf: formatSpec contains invalid character code".to_string(),
442                            ));
443                        }
444                    } else {
445                        let code = code.materialize_f64();
446                        if !code.is_finite() || code.fract().abs() > f64::EPSILON || code < 0.0 {
447                            return Some(Err(
448                                "fprintf: formatSpec must be a character row vector or string scalar"
449                                    .to_string(),
450                            ));
451                        }
452                        let v = code as u32;
453                        // Allow full Unicode range; MATLAB chars are UTF-16 but format strings are ASCII-compatible typically
454                        if let Some(ch) = char::from_u32(v) {
455                            out.push(ch);
456                        } else {
457                            return Some(Err(
458                                "fprintf: formatSpec contains invalid character code".to_string(),
459                            ));
460                        }
461                    }
462                }
463                return Some(Ok(out));
464            }
465            None
466        }
467        _ => None,
468    }
469}
470
471fn preflight_special_roles(args: &[Value]) -> BuiltinResult<()> {
472    if args
473        .first()
474        .is_some_and(|value| match_stream_label(value).is_some())
475    {
476        crate::compatibility::ensure_builtin_extension_enabled(
477            &FPRINTF_STREAM_LABEL_EXTENSION,
478            BUILTIN_NAME,
479        )?;
480    }
481    let (fid_index, format_index) =
482        if args.len() >= 2 && is_fid_candidate(&args[0]) && is_format_value(&args[1]) {
483            (Some(0usize), 1usize)
484        } else {
485            (None, 0usize)
486        };
487    if let Some(index) = fid_index {
488        preflight_fid(&args[index])?;
489    }
490    if let Some(value) = args.get(format_index) {
491        preflight_format(value)?;
492    }
493    Ok(())
494}
495
496fn is_fid_candidate(value: &Value) -> bool {
497    matches!(value, Value::Num(_) | Value::Int(_) | Value::GpuTensor(_))
498        || matches!(value, Value::Tensor(tensor) if tensor.len() == 1)
499}
500
501fn is_format_value(value: &Value) -> bool {
502    matches!(
503        value,
504        Value::String(_) | Value::CharArray(_) | Value::StringArray(_)
505    ) || matches!(value, Value::Tensor(tensor) if tensor.len() >= 2)
506        || matches!(value, Value::GpuTensor(handle) if handle.shape.iter().product::<usize>() >= 2)
507}
508
509fn preflight_fid(value: &Value) -> BuiltinResult<()> {
510    match value {
511        Value::Int(_) => crate::compatibility::ensure_builtin_extension_enabled(
512            &FPRINTF_INTEGER_ID_EXTENSION,
513            BUILTIN_NAME,
514        ),
515        Value::Tensor(tensor) if tensor.integer_storage().is_some() => {
516            crate::compatibility::ensure_builtin_extension_enabled(
517                &FPRINTF_INTEGER_ID_EXTENSION,
518                BUILTIN_NAME,
519            )
520        }
521        Value::Tensor(tensor) if tensor.numeric_dtype() == NumericDType::F32 => {
522            crate::compatibility::ensure_builtin_extension_enabled(
523                &FPRINTF_SINGLE_ID_EXTENSION,
524                BUILTIN_NAME,
525            )
526        }
527        Value::GpuTensor(handle) => {
528            crate::compatibility::ensure_builtin_extension_enabled(
529                &FPRINTF_RESIDENT_ID_EXTENSION,
530                BUILTIN_NAME,
531            )?;
532            if runmat_accelerate_api::handle_integer_type(handle).is_some() {
533                crate::compatibility::ensure_builtin_extension_enabled(
534                    &FPRINTF_INTEGER_ID_EXTENSION,
535                    BUILTIN_NAME,
536                )?;
537            } else if runmat_accelerate_api::handle_precision(handle)
538                == Some(runmat_accelerate_api::ProviderPrecision::F32)
539            {
540                crate::compatibility::ensure_builtin_extension_enabled(
541                    &FPRINTF_SINGLE_ID_EXTENSION,
542                    BUILTIN_NAME,
543                )?;
544            }
545            Ok(())
546        }
547        _ => Ok(()),
548    }
549}
550
551fn preflight_format(value: &Value) -> BuiltinResult<()> {
552    match value {
553        Value::Int(_) => crate::compatibility::ensure_builtin_extension_enabled(
554            &FPRINTF_INTEGER_FORMAT_EXTENSION,
555            BUILTIN_NAME,
556        ),
557        Value::Tensor(tensor) if tensor.integer_storage().is_some() => {
558            crate::compatibility::ensure_builtin_extension_enabled(
559                &FPRINTF_INTEGER_FORMAT_EXTENSION,
560                BUILTIN_NAME,
561            )?;
562            crate::compatibility::ensure_builtin_extension_enabled(
563                &FPRINTF_NUMERIC_FORMAT_EXTENSION,
564                BUILTIN_NAME,
565            )
566        }
567        Value::Tensor(_) => crate::compatibility::ensure_builtin_extension_enabled(
568            &FPRINTF_NUMERIC_FORMAT_EXTENSION,
569            BUILTIN_NAME,
570        ),
571        Value::GpuTensor(handle) => {
572            crate::compatibility::ensure_builtin_extension_enabled(
573                &FPRINTF_RESIDENT_FORMAT_EXTENSION,
574                BUILTIN_NAME,
575            )?;
576            if runmat_accelerate_api::handle_integer_type(handle).is_none() {
577                return Ok(());
578            }
579            crate::compatibility::ensure_builtin_extension_enabled(
580                &FPRINTF_INTEGER_FORMAT_EXTENSION,
581                BUILTIN_NAME,
582            )
583        }
584        _ => Ok(()),
585    }
586}
587
588fn coerce_single_integer_string_argument(
589    format: &str,
590    mut args: Vec<Value>,
591) -> BuiltinResult<Vec<Value>> {
592    if args.len() != 1 || !has_only_string_conversion(format) {
593        return Ok(args);
594    }
595    let Some(value) = args.pop() else {
596        return Ok(args);
597    };
598    let converted = match value {
599        Value::Int(value) => Value::String(integer_codes_to_string([value])?),
600        Value::Tensor(tensor) if tensor.integer_storage().is_some() => {
601            let storage = tensor.integer_storage().expect("checked integer storage");
602            let values = (0..storage.len()).map(|index| {
603                storage
604                    .value_at(index)
605                    .expect("index within integer format storage")
606            });
607            Value::String(integer_codes_to_string(values)?)
608        }
609        other => other,
610    };
611    Ok(vec![converted])
612}
613
614fn integer_codes_to_string(values: impl IntoIterator<Item = IntValue>) -> BuiltinResult<String> {
615    let mut output = String::new();
616    for value in values {
617        let ch = char_from_int_value(&value).ok_or_else(|| {
618            fprintf_error_with_detail(
619                &FPRINTF_ERROR_FORMAT,
620                "integer %s data contains an invalid character code",
621            )
622        })?;
623        output.push(ch);
624    }
625    Ok(output)
626}
627
628fn has_only_string_conversion(format: &str) -> bool {
629    let mut chars = format.chars().peekable();
630    let mut conversions = Vec::new();
631    while let Some(ch) = chars.next() {
632        if ch != '%' {
633            continue;
634        }
635        if chars.peek() == Some(&'%') {
636            chars.next();
637            continue;
638        }
639        for next in chars.by_ref() {
640            if next.is_ascii_alphabetic() {
641                conversions.push(next);
642                break;
643            }
644        }
645    }
646    conversions == ['s']
647}
648
649fn char_from_int_value(value: &IntValue) -> Option<char> {
650    value
651        .try_to_u64()
652        .and_then(|code| u32::try_from(code).ok())
653        .and_then(char::from_u32)
654}
655
656fn coerce_to_format_string(value: &Value) -> Result<Option<Value>, String> {
657    match value {
658        Value::String(s) => Ok(Some(Value::String(s.clone()))),
659        Value::StringArray(sa) if sa.data.len() == 1 => Ok(Some(Value::String(sa.data[0].clone()))),
660        Value::CharArray(ca) => {
661            let s: String = ca.data.iter().collect();
662            Ok(Some(Value::String(s)))
663        }
664        Value::Tensor(t) => {
665            // Only accept numeric codepoint vectors of length >= 2 as formatSpec.
666            // This avoids misinterpreting stray 1x1 numerics (e.g., accidental stack values)
667            // as a valid format string.
668            if tensor::tensor_element_len(t) >= 2 {
669                match try_tensor_char_row_as_string(value) {
670                    Some(Ok(s)) => Ok(Some(Value::String(s))),
671                    Some(Err(e)) => Err(e),
672                    None => Ok(None),
673                }
674            } else {
675                Ok(None)
676            }
677        }
678        _ => Ok(None),
679    }
680}
681
682#[runtime_builtin(
683    name = "fprintf",
684    category = "io/filetext",
685    summary = "Write formatted text to files or standard streams.",
686    keywords = "fprintf,format,printf,io",
687    accel = "cpu",
688    sink = true,
689    suppress_auto_output = true,
690    type_resolver(crate::builtins::io::type_resolvers::fprintf_type),
691    descriptor(crate::builtins::io::filetext::fprintf::FPRINTF_DESCRIPTOR),
692    extensions(crate::builtins::io::filetext::fprintf::FPRINTF_EXTENSIONS),
693    integer_capabilities(crate::builtins::io::filetext::fprintf::INTEGER_CAPABILITIES),
694    builtin_path = "crate::builtins::io::filetext::fprintf"
695)]
696async fn fprintf_builtin(first: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
697    let mut args = Vec::with_capacity(rest.len() + 1);
698    args.push(first);
699    args.extend(rest);
700    let eval = evaluate(&args).await?;
701    Ok(Value::Num(eval.bytes_written() as f64))
702}
703
704#[derive(Clone, Copy)]
705enum SpecialStream {
706    Stdout,
707    Stderr,
708}
709
710enum OutputTarget {
711    Stdout,
712    Stderr,
713    File {
714        handle: SharedFileHandle,
715        encoding: String,
716    },
717}
718
719impl OutputTarget {
720    fn encoding_label(&self) -> Option<&str> {
721        match self {
722            OutputTarget::Stdout | OutputTarget::Stderr => None,
723            OutputTarget::File { encoding, .. } => Some(encoding.as_str()),
724        }
725    }
726
727    fn write(&self, bytes: &[u8]) -> BuiltinResult<()> {
728        match self {
729            OutputTarget::Stdout => {
730                record_console_chunk(ConsoleStream::Stdout, bytes);
731                Ok(())
732            }
733            OutputTarget::Stderr => {
734                record_console_chunk(ConsoleStream::Stderr, bytes);
735                Ok(())
736            }
737            OutputTarget::File { handle, .. } => {
738                let mut guard = handle.lock().map_err(|_| {
739                    fprintf_error_with_detail(
740                        &FPRINTF_ERROR_INTERNAL,
741                        "failed to lock file handle (poisoned mutex)",
742                    )
743                })?;
744                let file = guard.as_mut().ok_or_else(|| {
745                    fprintf_error_with_message(
746                        FPRINTF_ERROR_INVALID_IDENTIFIER.message,
747                        &FPRINTF_ERROR_INVALID_IDENTIFIER,
748                    )
749                })?;
750                file.write_all(bytes).map_err(|err| {
751                    fprintf_error_with_detail(
752                        &FPRINTF_ERROR_IO,
753                        format!("failed to write to file ({err})"),
754                    )
755                })
756            }
757        }
758    }
759}
760
761fn record_console_chunk(stream: ConsoleStream, bytes: &[u8]) {
762    if bytes.is_empty() {
763        return;
764    }
765    let text = String::from_utf8_lossy(bytes).to_string();
766    record_console_output(stream, text);
767}
768
769async fn gather_value(value: &Value) -> BuiltinResult<Value> {
770    gather_if_needed_async(value)
771        .await
772        .map_err(map_control_flow)
773}
774
775fn target_from_fid(fid: i32) -> BuiltinResult<OutputTarget> {
776    if fid < 0 {
777        return Err(fprintf_error_with_detail(
778            &FPRINTF_ERROR_INVALID_INPUT,
779            "file identifier must be non-negative",
780        ));
781    }
782    match fid {
783        0 => Err(fprintf_error_with_detail(
784            &FPRINTF_ERROR_INVALID_IDENTIFIER,
785            "file identifier 0 (stdin) is not writable",
786        )),
787        1 => Ok(OutputTarget::Stdout),
788        2 => Ok(OutputTarget::Stderr),
789        _ => {
790            let info = registry::info_for(fid).ok_or_else(|| {
791                fprintf_error_with_message(
792                    FPRINTF_ERROR_INVALID_IDENTIFIER.message,
793                    &FPRINTF_ERROR_INVALID_IDENTIFIER,
794                )
795            })?;
796            ensure_writable(&info)?;
797            let handle = registry::shared_handle(fid).ok_or_else(|| {
798                fprintf_error_with_message(
799                    FPRINTF_ERROR_INVALID_IDENTIFIER.message,
800                    &FPRINTF_ERROR_INVALID_IDENTIFIER,
801                )
802            })?;
803            Ok(OutputTarget::File {
804                handle,
805                encoding: info.encoding.clone(),
806            })
807        }
808    }
809}
810
811fn parse_fid(value: &Value) -> Result<i32, String> {
812    let scalar = match value {
813        Value::Num(n) => *n,
814        Value::Int(int) => {
815            return int
816                .try_to_i32()
817                .ok_or_else(|| "fprintf: file identifier is out of range".to_string());
818        }
819        Value::Tensor(t) => {
820            if t.shape == vec![1, 1] && tensor::is_scalar_tensor(t) {
821                if let Some(int) = t.integer_storage().and_then(|storage| storage.value_at(0)) {
822                    return int
823                        .try_to_i32()
824                        .ok_or_else(|| "fprintf: file identifier is out of range".to_string());
825                }
826                tensor::tensor_value_f64(t, 0)
827            } else {
828                return Err("fprintf: file identifier must be numeric".to_string());
829            }
830        }
831        _ => return Err("fprintf: file identifier must be numeric".to_string()),
832    };
833    if !scalar.is_finite() {
834        return Err("fprintf: file identifier must be finite".to_string());
835    }
836    if (scalar.fract().abs()) > f64::EPSILON {
837        return Err("fprintf: file identifier must be an integer".to_string());
838    }
839    if scalar < i32::MIN as f64 || scalar > i32::MAX as f64 {
840        return Err("fprintf: file identifier is out of range".to_string());
841    }
842    Ok(scalar as i32)
843}
844
845fn ensure_writable(info: &FileInfo) -> BuiltinResult<()> {
846    let permission = info.permission.to_ascii_lowercase();
847    if permission.contains('w') || permission.contains('a') || permission.contains('+') {
848        Ok(())
849    } else {
850        Err(fprintf_error_with_detail(
851            &FPRINTF_ERROR_INVALID_IDENTIFIER,
852            "file is not open for writing",
853        ))
854    }
855}
856
857fn match_stream_label(value: &Value) -> Option<SpecialStream> {
858    let candidate = match value {
859        Value::String(s) => s.trim().to_string(),
860        Value::CharArray(ca) if ca.rows == 1 => {
861            ca.data.iter().collect::<String>().trim().to_string()
862        }
863        Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].trim().to_string(),
864        _ => return None,
865    };
866    match candidate.to_ascii_lowercase().as_str() {
867        "stdout" => Some(SpecialStream::Stdout),
868        "stderr" => Some(SpecialStream::Stderr),
869        _ => None,
870    }
871}
872
873fn format_with_repetition(format: &str, args: &[Value]) -> BuiltinResult<String> {
874    let mut cursor = ArgCursor::new(args);
875    let mut out = String::new();
876    loop {
877        let step = format_variadic_with_cursor(format, &mut cursor).map_err(remap_format_error)?;
878        out.push_str(&step.output);
879        if step.consumed == 0 {
880            if cursor.remaining() > 0 {
881                return Err(fprintf_error_with_detail(
882                    &FPRINTF_ERROR_FORMAT,
883                    "formatSpec contains no conversion specifiers but additional arguments were supplied",
884                ));
885            }
886            break;
887        }
888        if cursor.remaining() == 0 {
889            break;
890        }
891    }
892    Ok(out)
893}
894
895fn remap_format_error(err: RuntimeError) -> RuntimeError {
896    let message = err.message().replace("sprintf", "fprintf");
897    let mut builder = build_runtime_error(message)
898        .with_builtin(BUILTIN_NAME)
899        .with_source(err);
900    if let Some(identifier) = FPRINTF_ERROR_FORMAT.identifier {
901        builder = builder.with_identifier(identifier);
902    }
903    builder.build()
904}
905
906fn encode_output(text: &str, encoding: Option<&str>) -> Result<Vec<u8>, String> {
907    let label = encoding
908        .map(|s| s.trim())
909        .filter(|s| !s.is_empty())
910        .unwrap_or("utf-8");
911    let lower = label.to_ascii_lowercase();
912    let collapsed: String = lower
913        .chars()
914        .filter(|ch| !matches!(ch, '-' | '_' | ' '))
915        .collect();
916    if matches!(
917        collapsed.as_str(),
918        "utf8" | "unicode" | "auto" | "default" | "system"
919    ) {
920        Ok(text.as_bytes().to_vec())
921    } else if matches!(collapsed.as_str(), "ascii" | "usascii" | "ansix341968") {
922        encode_ascii(text)
923    } else if matches!(
924        collapsed.as_str(),
925        "latin1" | "iso88591" | "cp819" | "ibm819"
926    ) {
927        encode_latin1(text, label)
928    } else if matches!(collapsed.as_str(), "windows1252" | "cp1252" | "ansi") {
929        encode_windows_1252(text, label)
930    } else {
931        Ok(text.as_bytes().to_vec())
932    }
933}
934
935fn encode_ascii(text: &str) -> Result<Vec<u8>, String> {
936    let mut bytes = Vec::with_capacity(text.len());
937    for ch in text.chars() {
938        if ch as u32 > 0x7F {
939            return Err(format!(
940                "fprintf: character '{}' (U+{:04X}) cannot be encoded as ASCII",
941                ch, ch as u32
942            ));
943        }
944        bytes.push(ch as u8);
945    }
946    Ok(bytes)
947}
948
949fn encode_latin1(text: &str, label: &str) -> Result<Vec<u8>, String> {
950    let mut bytes = Vec::with_capacity(text.len());
951    for ch in text.chars() {
952        if ch as u32 > 0xFF {
953            return Err(format!(
954                "fprintf: character '{}' (U+{:04X}) cannot be encoded as {}",
955                ch, ch as u32, label
956            ));
957        }
958        bytes.push(ch as u8);
959    }
960    Ok(bytes)
961}
962
963fn encode_windows_1252(text: &str, label: &str) -> Result<Vec<u8>, String> {
964    let mut bytes = Vec::with_capacity(text.len());
965    for ch in text.chars() {
966        if let Some(byte) = windows_1252_byte(ch) {
967            bytes.push(byte);
968        } else {
969            return Err(format!(
970                "fprintf: character '{}' (U+{:04X}) cannot be encoded as {}",
971                ch, ch as u32, label
972            ));
973        }
974    }
975    Ok(bytes)
976}
977
978fn windows_1252_byte(ch: char) -> Option<u8> {
979    let code = ch as u32;
980    if code <= 0x7F {
981        return Some(code as u8);
982    }
983    if (0xA0..=0xFF).contains(&code) {
984        return Some(code as u8);
985    }
986    match code {
987        0x20AC => Some(0x80),
988        0x201A => Some(0x82),
989        0x0192 => Some(0x83),
990        0x201E => Some(0x84),
991        0x2026 => Some(0x85),
992        0x2020 => Some(0x86),
993        0x2021 => Some(0x87),
994        0x02C6 => Some(0x88),
995        0x2030 => Some(0x89),
996        0x0160 => Some(0x8A),
997        0x2039 => Some(0x8B),
998        0x0152 => Some(0x8C),
999        0x017D => Some(0x8E),
1000        0x2018 => Some(0x91),
1001        0x2019 => Some(0x92),
1002        0x201C => Some(0x93),
1003        0x201D => Some(0x94),
1004        0x2022 => Some(0x95),
1005        0x2013 => Some(0x96),
1006        0x2014 => Some(0x97),
1007        0x02DC => Some(0x98),
1008        0x2122 => Some(0x99),
1009        0x0161 => Some(0x9A),
1010        0x203A => Some(0x9B),
1011        0x0153 => Some(0x9C),
1012        0x017E => Some(0x9E),
1013        0x0178 => Some(0x9F),
1014        _ => None,
1015    }
1016}
1017
1018#[cfg(test)]
1019pub(crate) mod tests {
1020    use super::*;
1021    use crate::builtins::common::test_support;
1022    use crate::builtins::io::filetext::{fclose, fopen, registry};
1023    use crate::RuntimeError;
1024    use runmat_accelerate_api::HostTensorView;
1025    use runmat_filesystem::File;
1026    use runmat_time::system_time_now;
1027    use runmat_value::{IntValue, IntegerStorage, Tensor};
1028    use std::io::Read;
1029    use std::path::PathBuf;
1030    use std::time::UNIX_EPOCH;
1031
1032    fn unwrap_error_message(err: RuntimeError) -> String {
1033        err.message().to_string()
1034    }
1035
1036    fn run_evaluate(args: &[Value]) -> BuiltinResult<FprintfEval> {
1037        futures::executor::block_on(evaluate(args))
1038    }
1039
1040    fn run_fopen(args: &[Value]) -> BuiltinResult<fopen::FopenEval> {
1041        futures::executor::block_on(fopen::evaluate(args))
1042    }
1043
1044    fn run_fclose(args: &[Value]) -> BuiltinResult<fclose::FcloseEval> {
1045        futures::executor::block_on(fclose::evaluate(args))
1046    }
1047
1048    fn registry_guard() -> std::sync::MutexGuard<'static, ()> {
1049        registry::test_guard()
1050    }
1051
1052    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1053    #[test]
1054    fn fprintf_descriptor_signatures_cover_core_forms() {
1055        let labels: Vec<&str> = FPRINTF_DESCRIPTOR
1056            .signatures
1057            .iter()
1058            .map(|sig| sig.label)
1059            .collect();
1060        assert!(labels.contains(&"count = fprintf(formatSpec, A...)"));
1061        assert!(labels.contains(&"count = fprintf(fid_or_stream, formatSpec, A...)"));
1062    }
1063
1064    #[test]
1065    fn fprintf_integer_capabilities_and_special_roles_are_independently_gated() {
1066        assert_eq!(INTEGER_CAPABILITIES.len(), 3);
1067        assert_eq!(INTEGER_CAPABILITIES[0].inputs[0].classes.len(), 8);
1068        let _matlab = crate::compatibility::push_runmat_extensions_enabled(false);
1069        let fid = preflight_fid(&Value::Int(IntValue::I32(1))).unwrap_err();
1070        assert_eq!(
1071            fid.identifier(),
1072            Some("RunMat:compatibility:FprintfIntegerIdExtension")
1073        );
1074        let format = Tensor::new_integer(
1075            IntegerStorage::U16(vec![b'%' as u16, b'd' as u16]),
1076            vec![1, 2],
1077        )
1078        .expect("format");
1079        let format = preflight_format(&Value::Tensor(format)).unwrap_err();
1080        assert_eq!(
1081            format.identifier(),
1082            Some("RunMat:compatibility:FprintfIntegerFormatExtension")
1083        );
1084    }
1085
1086    #[test]
1087    fn fprintf_resident_integer_format_is_gated_before_gathering() {
1088        let handle = runmat_accelerate_api::GpuTensorHandle {
1089            shape: vec![1, 2],
1090            device_id: 904,
1091            buffer_id: 904,
1092            descriptor: Default::default(),
1093        }
1094        .with_numeric_descriptor(
1095            runmat_accelerate_api::NumericElementType::U16,
1096            runmat_accelerate_api::GpuTensorStorage::Real,
1097        );
1098        let args = [Value::Num(1.0), Value::GpuTensor(handle.clone())];
1099        let _matlab = crate::compatibility::push_runmat_extensions_enabled(false);
1100        let error = preflight_special_roles(&args).unwrap_err();
1101        assert_eq!(
1102            error.identifier(),
1103            Some("RunMat:compatibility:FprintfResidentFormatExtension")
1104        );
1105    }
1106
1107    #[test]
1108    fn fprintf_all_host_numeric_format_tensors_are_gated_before_gathering() {
1109        let double = Tensor::new(vec![b'%' as f64, b'd' as f64], vec![1, 2]).expect("double");
1110        let single = Tensor::from_numeric_storage(
1111            runmat_value::NumericStorage::F32(vec![b'%' as f32, b'd' as f32]),
1112            vec![1, 2],
1113        )
1114        .expect("single");
1115        let _matlab = crate::compatibility::push_runmat_extensions_enabled(false);
1116        for format in [double, single] {
1117            let error = preflight_format(&Value::Tensor(format)).unwrap_err();
1118            assert_eq!(
1119                error.identifier(),
1120                Some("RunMat:compatibility:FprintfNumericFormatExtension")
1121            );
1122        }
1123    }
1124
1125    #[test]
1126    fn fprintf_textual_stream_labels_have_an_independent_gate() {
1127        let _matlab = crate::compatibility::push_runmat_extensions_enabled(false);
1128        let error = preflight_special_roles(&[
1129            Value::from("stderr"),
1130            Value::from("failure: %d"),
1131            Value::Num(1.0),
1132        ])
1133        .unwrap_err();
1134        assert_eq!(
1135            error.identifier(),
1136            Some("RunMat:compatibility:FprintfStreamLabelExtension")
1137        );
1138    }
1139
1140    #[test]
1141    fn fprintf_integer_string_conversion_preserves_character_codes() {
1142        let tensor = Tensor::new_integer(IntegerStorage::U64(vec![65, 66, 67]), vec![1, 3])
1143            .expect("character codes");
1144        let args = coerce_single_integer_string_argument("[%s]", vec![Value::Tensor(tensor)])
1145            .expect("integer string conversion");
1146        assert_eq!(args, vec![Value::String("ABC".to_string())]);
1147        assert_eq!(format_with_repetition("[%s]", &args).unwrap(), "[ABC]");
1148    }
1149
1150    #[test]
1151    fn fprintf_formats_wide_uint64_without_binary64_rounding() {
1152        let value = Value::Int(IntValue::U64(9_007_199_254_740_993));
1153        assert_eq!(
1154            format_with_repetition("%u", &[value]).unwrap(),
1155            "9007199254740993"
1156        );
1157    }
1158
1159    #[test]
1160    fn fprintf_format_tensor_reads_typed_integer_storage_exactly() {
1161        let format = Tensor::new_integer(
1162            IntegerStorage::U16(vec![b'%' as u16, b'd' as u16]),
1163            vec![1, 2],
1164        )
1165        .expect("format tensor");
1166
1167        assert_eq!(
1168            coerce_to_format_string(&Value::Tensor(format)).unwrap(),
1169            Some(Value::String("%d".to_string()))
1170        );
1171    }
1172
1173    #[test]
1174    fn fprintf_fid_parser_reads_typed_integer_storage_exactly() {
1175        let fid =
1176            Tensor::new_integer(IntegerStorage::U16(vec![7]), vec![1, 1]).expect("fid tensor");
1177        assert_eq!(parse_fid(&Value::Tensor(fid)).unwrap(), 7);
1178
1179        let too_large =
1180            Tensor::new_integer(IntegerStorage::U64(vec![u64::MAX]), vec![1, 1]).expect("fid");
1181        assert!(parse_fid(&Value::Tensor(too_large)).is_err());
1182    }
1183
1184    #[test]
1185    fn fprintf_fid_parser_ignores_poisoned_mirrors_for_every_integer_class() {
1186        let classes = [
1187            IntegerStorage::I8(vec![7]),
1188            IntegerStorage::I16(vec![7]),
1189            IntegerStorage::I32(vec![7]),
1190            IntegerStorage::I64(vec![7]),
1191            IntegerStorage::U8(vec![7]),
1192            IntegerStorage::U16(vec![7]),
1193            IntegerStorage::U32(vec![7]),
1194            IntegerStorage::U64(vec![7]),
1195        ];
1196
1197        for storage in classes {
1198            let fid = Tensor::new_integer(storage, vec![1, 1]).expect("typed fid");
1199            assert_eq!(parse_fid(&Value::Tensor(fid)).unwrap(), 7);
1200        }
1201    }
1202
1203    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1204    #[test]
1205    fn fprintf_matrix_column_major() {
1206        let _guard = registry_guard();
1207        registry::reset_for_tests();
1208        let path = unique_path("fprintf_matrix");
1209        let open = run_fopen(&[
1210            Value::from(path.to_string_lossy().to_string()),
1211            Value::from("w"),
1212        ])
1213        .expect("fopen");
1214        let fid = open.as_open().unwrap().fid as i32;
1215
1216        let tensor = Tensor::new(vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0], vec![2, 3]).unwrap();
1217        let args = vec![
1218            Value::Num(fid as f64),
1219            Value::String("%d %d\n".to_string()),
1220            Value::Tensor(tensor),
1221        ];
1222        let eval = run_evaluate(&args).expect("fprintf");
1223        assert_eq!(eval.bytes_written(), 12);
1224
1225        run_fclose(&[Value::Num(fid as f64)]).unwrap();
1226
1227        let contents = test_support::fs::read_to_string(&path).expect("read");
1228        assert_eq!(contents, "1 4\n2 5\n3 6\n");
1229        test_support::fs::remove_file(path).unwrap();
1230    }
1231
1232    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1233    #[test]
1234    fn fprintf_ascii_encoding_errors() {
1235        let _guard = registry_guard();
1236        registry::reset_for_tests();
1237        let path = unique_path("fprintf_ascii");
1238        let open = run_fopen(&[
1239            Value::from(path.to_string_lossy().to_string()),
1240            Value::from("w"),
1241            Value::from("native"),
1242            Value::from("ascii"),
1243        ])
1244        .expect("fopen");
1245        let fid = open.as_open().unwrap().fid as i32;
1246
1247        let args = vec![
1248            Value::Num(fid as f64),
1249            Value::String("%s".to_string()),
1250            Value::String("café".to_string()),
1251        ];
1252        let err = unwrap_error_message(run_evaluate(&args).unwrap_err());
1253        assert!(err.contains("cannot be encoded as ASCII"), "{err}");
1254
1255        run_fclose(&[Value::Num(fid as f64)]).unwrap();
1256        test_support::fs::remove_file(path).unwrap();
1257    }
1258
1259    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1260    #[test]
1261    fn fprintf_gpu_gathers_values() {
1262        let _guard = registry_guard();
1263        registry::reset_for_tests();
1264        let path = unique_path("fprintf_gpu");
1265
1266        test_support::with_test_provider(|provider| {
1267            registry::reset_for_tests();
1268            let open = run_fopen(&[
1269                Value::from(path.to_string_lossy().to_string()),
1270                Value::from("w"),
1271            ])
1272            .expect("fopen");
1273            let fid = open.as_open().unwrap().fid as i32;
1274
1275            let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1276            let view = HostTensorView {
1277                data: &tensor.materialize_f64(),
1278                shape: &tensor.shape,
1279            };
1280            let handle = provider.upload(&view).expect("upload");
1281            let args = vec![
1282                Value::Num(fid as f64),
1283                Value::String("%.1f,".to_string()),
1284                Value::GpuTensor(handle),
1285            ];
1286            let eval = run_evaluate(&args).expect("fprintf");
1287            assert_eq!(eval.bytes_written(), 12);
1288
1289            run_fclose(&[Value::Num(fid as f64)]).unwrap();
1290        });
1291
1292        let mut file = File::open(&path).expect("open");
1293        let mut contents = String::new();
1294        file.read_to_string(&mut contents).expect("read");
1295        assert_eq!(contents, "1.0,2.0,3.0,");
1296        test_support::fs::remove_file(path).unwrap();
1297    }
1298
1299    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1300    #[test]
1301    fn fprintf_missing_format_errors() {
1302        let err = unwrap_error_message(run_evaluate(&[Value::Num(1.0)]).unwrap_err());
1303        assert!(err.contains("missing format string"), "{err}");
1304    }
1305
1306    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1307    #[test]
1308    fn fprintf_literal_with_extra_args_errors() {
1309        let err = unwrap_error_message(
1310            run_evaluate(&[
1311                Value::String("literal text".to_string()),
1312                Value::Int(IntValue::I32(1)),
1313            ])
1314            .unwrap_err(),
1315        );
1316        assert!(err.contains("contains no conversion specifiers"), "{err}");
1317    }
1318
1319    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1320    #[test]
1321    fn fprintf_invalid_identifier_errors() {
1322        let err = unwrap_error_message(
1323            run_evaluate(&[Value::Num(99.0), Value::String("value".to_string())]).unwrap_err(),
1324        );
1325        assert_eq!(err, FPRINTF_ERROR_INVALID_IDENTIFIER.message);
1326    }
1327
1328    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1329    #[test]
1330    fn fprintf_read_only_error() {
1331        let _guard = registry_guard();
1332        registry::reset_for_tests();
1333        let path = unique_path("fprintf_read_only");
1334        test_support::fs::write(&path, b"readonly").unwrap();
1335        let open = run_fopen(&[
1336            Value::from(path.to_string_lossy().to_string()),
1337            Value::from("r"),
1338        ])
1339        .expect("fopen");
1340        let fid = open.as_open().unwrap().fid as i32;
1341        let err = unwrap_error_message(
1342            run_evaluate(&[Value::Num(fid as f64), Value::String("text".to_string())]).unwrap_err(),
1343        );
1344        assert!(err.contains("not open for writing"), "{err}");
1345
1346        run_fclose(&[Value::Num(fid as f64)]).unwrap();
1347    }
1348
1349    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1350    #[test]
1351    fn fprintf_encoding_aliases_encode_expected_bytes() {
1352        let utf = encode_output("é", Some("utf_8")).expect("utf_8 alias");
1353        assert_eq!(utf, "é".as_bytes());
1354
1355        let latin = encode_output("é", Some("cp819")).expect("cp819 alias");
1356        assert_eq!(latin, vec![0xE9]);
1357
1358        let win = encode_output("€’", Some("windows-1252")).expect("windows-1252 alias");
1359        assert_eq!(win, vec![0x80, 0x92]);
1360    }
1361
1362    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1363    #[test]
1364    fn fprintf_windows1252_reports_unencodable_characters() {
1365        let err = encode_output("Ā", Some("cp1252")).expect_err("cp1252 should reject U+0100");
1366        assert!(err.contains("cannot be encoded"), "{err}");
1367    }
1368
1369    fn unique_path(prefix: &str) -> PathBuf {
1370        let nanos = system_time_now()
1371            .duration_since(UNIX_EPOCH)
1372            .unwrap()
1373            .as_nanos();
1374        let filename = format!("runmat_{prefix}_{nanos}.txt");
1375        std::env::temp_dir().join(filename)
1376    }
1377}