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