1use 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#[derive(Debug)]
313pub struct FprintfEval {
314 bytes_written: usize,
315}
316
317impl FprintfEval {
318 pub fn bytes_written(&self) -> usize {
320 self.bytes_written
321 }
322}
323
324pub 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 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 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 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 let mut target_idx: Option<usize> = None;
363 let mut target: OutputTarget = OutputTarget::Stdout;
364 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 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 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
422fn 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 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 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}