1use runmat_builtins::{CharArray, Value};
4use runmat_macros::runtime_builtin;
5
6use crate::builtins::common::format::{
7 decode_escape_sequences, extract_format_string, flatten_arguments, format_variadic_with_cursor,
8 ArgCursor,
9};
10use crate::builtins::common::spec::{
11 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
12 ReductionNaN, ResidencyPolicy, ShapeRequirements,
13};
14#[cfg(feature = "doc_export")]
15use crate::register_builtin_doc_text;
16use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
17
18#[cfg(feature = "doc_export")]
19pub const DOC_MD: &str = r#"---
20title: "sprintf"
21category: "strings/core"
22keywords: ["sprintf", "format", "printf", "text", "gpu"]
23summary: "Format data into a MATLAB-compatible character vector using printf-style placeholders."
24references:
25 - https://www.mathworks.com/help/matlab/ref/sprintf.html
26gpu_support:
27 elementwise: false
28 reduction: false
29 precisions: []
30 broadcasting: "none"
31 notes: "Formatting executes on the CPU. GPU tensors are gathered via the active provider before substitution."
32fusion:
33 elementwise: false
34 reduction: false
35 max_inputs: 1
36 constants: "inline"
37requires_feature: null
38tested:
39 unit: "builtins::strings::core::sprintf::tests"
40 integration: "builtins::strings::core::sprintf::tests::sprintf_gpu_numeric"
41---
42
43# What does the `sprintf` function do in MATLAB / RunMat?
44`sprintf(formatSpec, A1, ..., An)` formats data into a character row vector. It honours
45MATLAB's printf-style placeholders, including flags (`-`, `+`, space, `0`, `#`), field width,
46precision, and star (`*`) width/precision arguments. Arrays are processed column-major and the
47format string repeats automatically until every element has been consumed.
48
49## How does the `sprintf` function behave in MATLAB / RunMat?
50- `formatSpec` must be text: a character row vector or string scalar. Cell arrays and multi-row
51 character arrays are rejected.
52- Arguments can be numeric, logical, strings, character arrays, or cell arrays containing supported
53 scalar types. Numeric inputs accept scalar, vector, or matrix shapes; elements are flattened in
54 column-major order.
55- Escape sequences such as `\n`, `\t`, `\r`, `\f`, `\b`, `\a`, `\v`, octal (`\123`), and hexadecimal
56 (`\x7F`) are interpreted before formatting so that `sprintf` can build multi-line text easily.
57- Width and precision may be literal digits or drawn from the input list using `*` or `.*`. Star
58 arguments must be scalar numerics and are consumed in order.
59- Flags match MATLAB behaviour: `-` left-aligns, `+` forces a sign, space reserves a leading space
60 for positive numbers, `0` enables zero padding, and `#` produces alternate forms for octal,
61 hexadecimal, and binary outputs or preserves the decimal point in fixed-point conversions.
62- `%%` emits a literal percent character without consuming arguments.
63- Complex inputs are formatted as scalar text (`a+bi`) when used with `%s`; numeric conversions
64 expect real scalars.
65- Additional arguments beyond those required by the repeating format string raise an error to match
66 MATLAB diagnostics.
67
68## `sprintf` Function GPU Execution Behaviour
69`sprintf` is a residency sink. When inputs include GPU tensors, RunMat gathers them back to host
70memory via the active acceleration provider before executing the formatter. Formatting itself runs
71entirely on the CPU, ensuring consistent behaviour regardless of device availability.
72
73## Examples of using the `sprintf` function in MATLAB / RunMat
74
75### Formatting A Scalar Value
76```matlab
77txt = sprintf('Value: %d', 42);
78```
79Expected output:
80```matlab
81txt =
82 'Value: 42'
83```
84
85### Formatting With Precision And Width
86```matlab
87txt = sprintf('pi ~= %8.4f', pi);
88```
89Expected output:
90```matlab
91txt =
92 'pi ~= 3.1416'
93```
94
95### Combining Strings And Numbers
96```matlab
97label = sprintf('Trial %02d: %.1f%% complete', 7, 84.95);
98```
99Expected output:
100```matlab
101label =
102 'Trial 07: 85.0% complete'
103```
104
105### Formatting Vector Inputs
106```matlab
107data = [10 20 30];
108txt = sprintf('%d ', data);
109```
110Expected output:
111```matlab
112txt =
113 '10 20 30 '
114```
115
116### Using Star Width/Precision Arguments
117```matlab
118txt = sprintf('%*.*f %*.*f', 4, 2, 15.2, 6, 4, 0.001);
119```
120Expected output:
121```matlab
122txt =
123 '15.20 0.0010'
124```
125
126### Quoting Text With `%s`
127```matlab
128names = ["Ada", "Grace"];
129txt = sprintf('Hello, %s! ', names);
130```
131Expected output:
132```matlab
133txt =
134 'Hello, Ada! Hello, Grace! '
135```
136
137### Formatting GPU-Resident Data
138```matlab
139G = gpuArray([1.5 2.5 3.5]);
140txt = sprintf('%.1f;', G);
141```
142Expected output:
143```matlab
144txt =
145 '1.5;2.5;3.5;'
146```
147RunMat gathers `G` to host memory automatically before formatting so the behaviour matches CPU inputs.
148
149### Creating Multi-line Text
150```matlab
151message = sprintf('First line\\nSecond line\\t(indented)');
152```
153Expected output (showing control characters):
154```matlab
155message =
156 'First line
157Second line (indented)'
158```
159
160## FAQ
161
162### What happens if there are not enough input values for the format specifiers?
163RunMat raises `sprintf: not enough input arguments for format specifier` as soon as a placeholder
164cannot be satisfied. This matches MATLAB's error message.
165
166### How are additional values treated when the format string contains fewer specifiers?
167The format string repeats until all array elements are consumed. If the format string consumes no
168arguments (for example, it contains only literal text) and extra values remain, RunMat raises an
169error because MATLAB would also treat this as a mismatch.
170
171### Can I use star (`*`) width or precision arguments with arrays?
172Yes. Each `*` consumes the next scalar value from the input stream. When you provide arrays for
173width or precision, elements are read in column-major order the same way data arguments are.
174
175### Does `sprintf` support complex numbers?
176Complex values can be formatted with `%s`, which renders MATLAB's canonical `a+bi` text. Numeric
177conversions require real-valued inputs and raise an error for complex scalars.
178
179### Are logical values supported?
180Yes. Logical inputs are promoted to numeric (`1` or `0`) before formatting, so you can combine them
181with integer or floating-point conversions.
182
183### Does `sprintf` allocate string scalars?
184No. `sprintf` always returns a character row vector for MATLAB compatibility. Use `string` or
185`compose` if you need string scalars or string arrays.
186
187### Does GPU hardware change formatting behaviour?
188No. Formatting occurs on the CPU. When GPU tensors appear in the input list, RunMat gathers them to
189host memory before substitution so the results match MATLAB exactly.
190
191### How do I emit a literal percent sign?
192Use `%%` inside the format specification. The formatter converts `%%` into a single `%` without
193consuming an argument.
194
195### How are empty inputs handled?
196If all argument arrays are empty, `sprintf` returns an empty character array (`1×0`). If the format
197string itself is empty, the result is also empty.
198
199### What happens with multi-row character arrays?
200MATLAB requires `formatSpec` to be a row vector. RunMat follows the same rule: multi-row character
201arrays raise `sprintf: formatSpec must be a character row vector or string scalar`.
202
203## See Also
204[compose](./compose), [string](./string), [num2str](./num2str), [strlength](./strlength)
205"#;
206
207pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
208 name: "sprintf",
209 op_kind: GpuOpKind::Custom("format"),
210 supported_precisions: &[],
211 broadcast: BroadcastSemantics::None,
212 provider_hooks: &[],
213 constant_strategy: ConstantStrategy::InlineLiteral,
214 residency: ResidencyPolicy::GatherImmediately,
215 nan_mode: ReductionNaN::Include,
216 two_pass_threshold: None,
217 workgroup_size: None,
218 accepts_nan_mode: false,
219 notes: "Formatting runs on the CPU; GPU tensors are gathered before substitution.",
220};
221
222register_builtin_gpu_spec!(GPU_SPEC);
223
224pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
225 name: "sprintf",
226 shape: ShapeRequirements::Any,
227 constant_strategy: ConstantStrategy::InlineLiteral,
228 elementwise: None,
229 reduction: None,
230 emits_nan: false,
231 notes: "Formatting is a residency sink and is not fused; callers should treat sprintf as a CPU-only builtin.",
232};
233
234register_builtin_fusion_spec!(FUSION_SPEC);
235
236#[cfg(feature = "doc_export")]
237register_builtin_doc_text!("sprintf", DOC_MD);
238
239#[runtime_builtin(
240 name = "sprintf",
241 category = "strings/core",
242 summary = "Format data into a character vector using printf-style placeholders.",
243 keywords = "sprintf,format,printf,text",
244 accel = "format",
245 sink = true
246)]
247fn sprintf_builtin(format_spec: Value, rest: Vec<Value>) -> Result<Value, String> {
248 let gathered_spec = gather_if_needed(&format_spec).map_err(|e| format!("sprintf: {e}"))?;
249 let raw_format = extract_format_string(&gathered_spec, "sprintf")?;
250 let format_string = decode_escape_sequences("sprintf", &raw_format)?;
251 let flattened_args = flatten_arguments(&rest, "sprintf")?;
252 let mut cursor = ArgCursor::new(&flattened_args);
253 let mut output = String::new();
254
255 loop {
256 let step = format_variadic_with_cursor(&format_string, &mut cursor)?;
257 output.push_str(&step.output);
258
259 if step.consumed == 0 {
260 if cursor.remaining() > 0 {
261 return Err(
262 "sprintf: formatSpec contains no conversion specifiers but additional arguments were supplied"
263 .to_string(),
264 );
265 }
266 break;
267 }
268
269 if cursor.remaining() == 0 {
270 break;
271 }
272 }
273
274 char_row_value(&output)
275}
276
277fn char_row_value(text: &str) -> Result<Value, String> {
278 let chars: Vec<char> = text.chars().collect();
279 let len = chars.len();
280 let array = CharArray::new(chars, 1, len).map_err(|e| format!("sprintf: {e}"))?;
281 Ok(Value::CharArray(array))
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::{builtins::common::test_support, make_cell};
288 use runmat_builtins::{CharArray, IntValue, StringArray, Tensor};
289
290 fn char_value_to_string(value: Value) -> String {
291 match value {
292 Value::CharArray(ca) => ca.data.into_iter().collect(),
293 other => panic!("expected char output, got {other:?}"),
294 }
295 }
296
297 #[test]
298 fn sprintf_basic_integer() {
299 let result = sprintf_builtin(
300 Value::String("Value: %d".to_string()),
301 vec![Value::Int(IntValue::I32(42))],
302 )
303 .expect("sprintf");
304 assert_eq!(char_value_to_string(result), "Value: 42");
305 }
306
307 #[test]
308 fn sprintf_float_precision() {
309 let result = sprintf_builtin(
310 Value::String("pi ~= %.3f".to_string()),
311 vec![Value::Num(std::f64::consts::PI)],
312 )
313 .expect("sprintf");
314 assert_eq!(char_value_to_string(result), "pi ~= 3.142");
315 }
316
317 #[test]
318 fn sprintf_array_repeat() {
319 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
320 let result = sprintf_builtin(
321 Value::String("%d ".to_string()),
322 vec![Value::Tensor(tensor)],
323 )
324 .expect("sprintf");
325 assert_eq!(char_value_to_string(result), "1 2 3 ");
326 }
327
328 #[test]
329 fn sprintf_star_width() {
330 let args = vec![
331 Value::Int(IntValue::I32(6)),
332 Value::Int(IntValue::I32(2)),
333 Value::Num(12.345),
334 ];
335 let result = sprintf_builtin(Value::String("%*.*f".to_string()), args).expect("sprintf");
336 assert_eq!(char_value_to_string(result), " 12.35");
337 }
338
339 #[test]
340 fn sprintf_literal_percent() {
341 let result =
342 sprintf_builtin(Value::String("%% complete".to_string()), Vec::new()).expect("sprintf");
343 assert_eq!(char_value_to_string(result), "% complete");
344 }
345
346 #[test]
347 fn sprintf_gpu_numeric() {
348 test_support::with_test_provider(|provider| {
349 let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
350 let view = runmat_accelerate_api::HostTensorView {
351 data: &tensor.data,
352 shape: &tensor.shape,
353 };
354 let handle = provider.upload(&view).expect("upload");
355 let value = Value::GpuTensor(handle);
356 let result =
357 sprintf_builtin(Value::String("%0.1f,".to_string()), vec![value]).expect("sprintf");
358 assert_eq!(char_value_to_string(result), "1.0,2.0,");
359 });
360 }
361
362 #[test]
363 fn sprintf_matrix_column_major() {
364 let tensor = Tensor::new(vec![1.0, 3.0, 2.0, 4.0], vec![2, 2]).unwrap();
365 let result = sprintf_builtin(
366 Value::String("%0.0f ".to_string()),
367 vec![Value::Tensor(tensor)],
368 )
369 .expect("sprintf");
370 assert_eq!(char_value_to_string(result), "1 3 2 4 ");
371 }
372
373 #[test]
374 fn sprintf_not_enough_arguments_error() {
375 let err = sprintf_builtin(
376 Value::String("%d %d".to_string()),
377 vec![Value::Int(IntValue::I32(1))],
378 )
379 .expect_err("sprintf should error");
380 assert!(
381 err.contains("not enough input arguments"),
382 "unexpected error: {err}"
383 );
384 }
385
386 #[test]
387 fn sprintf_extra_arguments_error() {
388 let err = sprintf_builtin(
389 Value::String("literal text".to_string()),
390 vec![Value::Int(IntValue::I32(1))],
391 )
392 .expect_err("sprintf should error");
393 assert!(
394 err.contains("contains no conversion specifiers"),
395 "unexpected error: {err}"
396 );
397 }
398
399 #[test]
400 fn sprintf_format_spec_multirow_error() {
401 let chars = CharArray::new("hi!".chars().collect(), 3, 1).unwrap();
402 let err = sprintf_builtin(Value::CharArray(chars), Vec::new()).expect_err("sprintf");
403 assert!(
404 err.contains("formatSpec must be a character row vector"),
405 "unexpected error: {err}"
406 );
407 }
408
409 #[test]
410 fn sprintf_percent_c_from_numeric() {
411 let result = sprintf_builtin(
412 Value::String("%c".to_string()),
413 vec![Value::Int(IntValue::I32(65))],
414 )
415 .expect("sprintf");
416 assert_eq!(char_value_to_string(result), "A");
417 }
418
419 #[test]
420 fn sprintf_cell_arguments() {
421 let cell = make_cell(
422 vec![
423 Value::Num(1.0),
424 Value::String("two".to_string()),
425 Value::Num(3.0),
426 ],
427 3,
428 1,
429 )
430 .expect("cell");
431 let result = sprintf_builtin(Value::String("%0.0f %s %0.0f".to_string()), vec![cell])
432 .expect("sprintf");
433 assert_eq!(char_value_to_string(result), "1 two 3");
434 }
435
436 #[test]
437 fn sprintf_string_array_column_major() {
438 let data = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
439 let array =
440 StringArray::new(data, vec![3, 1]).expect("string array construction must succeed");
441 let result = sprintf_builtin(
442 Value::String("%s ".to_string()),
443 vec![Value::StringArray(array)],
444 )
445 .expect("sprintf");
446 assert_eq!(char_value_to_string(result), "alpha beta gamma ");
447 }
448
449 #[test]
450 fn sprintf_complex_s_conversion() {
451 let result = sprintf_builtin(
452 Value::String("%s".to_string()),
453 vec![Value::Complex(1.5, -2.0)],
454 )
455 .expect("sprintf");
456 assert_eq!(char_value_to_string(result), "1.5-2i");
457 }
458
459 #[test]
460 fn sprintf_escape_sequences() {
461 let result = sprintf_builtin(
462 Value::String("Line 1\\nLine 2\\t(tab)".to_string()),
463 Vec::new(),
464 )
465 .expect("sprintf");
466 assert_eq!(char_value_to_string(result), "Line 1\nLine 2\t(tab)");
467 }
468
469 #[test]
470 fn sprintf_hex_and_octal_escapes() {
471 let result =
472 sprintf_builtin(Value::String("\\x41\\101".to_string()), Vec::new()).expect("sprintf");
473 assert_eq!(char_value_to_string(result), "AA");
474 }
475
476 #[test]
477 fn sprintf_unknown_escape_preserved() {
478 let result =
479 sprintf_builtin(Value::String("Value\\q".to_string()), Vec::new()).expect("sprintf");
480 assert_eq!(char_value_to_string(result), "Value\\q");
481 }
482
483 #[test]
484 #[cfg(feature = "doc_export")]
485 fn doc_examples_present() {
486 let blocks = test_support::doc_examples(DOC_MD);
487 assert!(!blocks.is_empty());
488 }
489}