runmat_runtime/builtins/strings/core/
sprintf.rs

1//! MATLAB-compatible `sprintf` builtin that mirrors printf-style formatting semantics.
2
3use 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}