Skip to main content

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::map_control_flow_with_builtin;
11use crate::builtins::common::spec::{
12    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
13    ReductionNaN, ResidencyPolicy, ShapeRequirements,
14};
15use crate::builtins::strings::type_resolvers::string_scalar_type;
16use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
17
18#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::sprintf")]
19pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
20    name: "sprintf",
21    op_kind: GpuOpKind::Custom("format"),
22    supported_precisions: &[],
23    broadcast: BroadcastSemantics::None,
24    provider_hooks: &[],
25    constant_strategy: ConstantStrategy::InlineLiteral,
26    residency: ResidencyPolicy::GatherImmediately,
27    nan_mode: ReductionNaN::Include,
28    two_pass_threshold: None,
29    workgroup_size: None,
30    accepts_nan_mode: false,
31    notes: "Formatting runs on the CPU; GPU tensors are gathered before substitution.",
32};
33
34#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::sprintf")]
35pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
36    name: "sprintf",
37    shape: ShapeRequirements::Any,
38    constant_strategy: ConstantStrategy::InlineLiteral,
39    elementwise: None,
40    reduction: None,
41    emits_nan: false,
42    notes: "Formatting is a residency sink and is not fused; callers should treat sprintf as a CPU-only builtin.",
43};
44
45fn sprintf_flow(message: impl Into<String>) -> RuntimeError {
46    build_runtime_error(message).with_builtin("sprintf").build()
47}
48
49fn remap_sprintf_flow(err: RuntimeError) -> RuntimeError {
50    map_control_flow_with_builtin(err, "sprintf")
51}
52
53#[runtime_builtin(
54    name = "sprintf",
55    category = "strings/core",
56    summary = "Format data into a character vector using printf-style placeholders.",
57    keywords = "sprintf,format,printf,text",
58    accel = "format",
59    sink = true,
60    type_resolver(string_scalar_type),
61    builtin_path = "crate::builtins::strings::core::sprintf"
62)]
63async fn sprintf_builtin(format_spec: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
64    let gathered_spec = gather_if_needed_async(&format_spec)
65        .await
66        .map_err(remap_sprintf_flow)?;
67    let raw_format =
68        extract_format_string(&gathered_spec, "sprintf").map_err(remap_sprintf_flow)?;
69    let format_string =
70        decode_escape_sequences("sprintf", &raw_format).map_err(remap_sprintf_flow)?;
71    let flattened_args = flatten_arguments(&rest, "sprintf")
72        .await
73        .map_err(remap_sprintf_flow)?;
74    let mut cursor = ArgCursor::new(&flattened_args);
75    let mut output = String::new();
76
77    loop {
78        let step =
79            format_variadic_with_cursor(&format_string, &mut cursor).map_err(remap_sprintf_flow)?;
80        output.push_str(&step.output);
81
82        if step.consumed == 0 {
83            if cursor.remaining() > 0 {
84                return Err(sprintf_flow(
85                    "sprintf: formatSpec contains no conversion specifiers but additional arguments were supplied",
86                ));
87            }
88            break;
89        }
90
91        if cursor.remaining() == 0 {
92            break;
93        }
94    }
95
96    char_row_value(&output)
97}
98
99fn char_row_value(text: &str) -> BuiltinResult<Value> {
100    let chars: Vec<char> = text.chars().collect();
101    let len = chars.len();
102    let array = CharArray::new(chars, 1, len).map_err(|e| sprintf_flow(format!("sprintf: {e}")))?;
103    Ok(Value::CharArray(array))
104}
105
106#[cfg(test)]
107pub(crate) mod tests {
108    use super::*;
109    use crate::{builtins::common::test_support, make_cell};
110    use runmat_builtins::{CharArray, IntValue, ResolveContext, StringArray, Tensor, Type};
111
112    fn sprintf_builtin(format_spec: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
113        futures::executor::block_on(super::sprintf_builtin(format_spec, rest))
114    }
115
116    fn error_message(err: crate::RuntimeError) -> String {
117        err.message().to_string()
118    }
119
120    fn char_value_to_string(value: Value) -> String {
121        match value {
122            Value::CharArray(ca) => ca.data.into_iter().collect(),
123            other => panic!("expected char output, got {other:?}"),
124        }
125    }
126
127    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
128    #[test]
129    fn sprintf_basic_integer() {
130        let result = sprintf_builtin(
131            Value::String("Value: %d".to_string()),
132            vec![Value::Int(IntValue::I32(42))],
133        )
134        .expect("sprintf");
135        assert_eq!(char_value_to_string(result), "Value: 42");
136    }
137
138    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
139    #[test]
140    fn sprintf_float_precision() {
141        let result = sprintf_builtin(
142            Value::String("pi ~= %.3f".to_string()),
143            vec![Value::Num(std::f64::consts::PI)],
144        )
145        .expect("sprintf");
146        assert_eq!(char_value_to_string(result), "pi ~= 3.142");
147    }
148
149    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
150    #[test]
151    fn sprintf_array_repeat() {
152        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
153        let result = sprintf_builtin(
154            Value::String("%d ".to_string()),
155            vec![Value::Tensor(tensor)],
156        )
157        .expect("sprintf");
158        assert_eq!(char_value_to_string(result), "1 2 3 ");
159    }
160
161    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
162    #[test]
163    fn sprintf_star_width() {
164        let args = vec![
165            Value::Int(IntValue::I32(6)),
166            Value::Int(IntValue::I32(2)),
167            Value::Num(12.345),
168        ];
169        let result = sprintf_builtin(Value::String("%*.*f".to_string()), args).expect("sprintf");
170        assert_eq!(char_value_to_string(result), " 12.35");
171    }
172
173    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
174    #[test]
175    fn sprintf_literal_percent() {
176        let result =
177            sprintf_builtin(Value::String("%% complete".to_string()), Vec::new()).expect("sprintf");
178        assert_eq!(char_value_to_string(result), "% complete");
179    }
180
181    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
182    #[test]
183    fn sprintf_gpu_numeric() {
184        test_support::with_test_provider(|provider| {
185            let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
186            let view = runmat_accelerate_api::HostTensorView {
187                data: &tensor.data,
188                shape: &tensor.shape,
189            };
190            let handle = provider.upload(&view).expect("upload");
191            let value = Value::GpuTensor(handle);
192            let result =
193                sprintf_builtin(Value::String("%0.1f,".to_string()), vec![value]).expect("sprintf");
194            assert_eq!(char_value_to_string(result), "1.0,2.0,");
195        });
196    }
197
198    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
199    #[test]
200    fn sprintf_matrix_column_major() {
201        let tensor = Tensor::new(vec![1.0, 3.0, 2.0, 4.0], vec![2, 2]).unwrap();
202        let result = sprintf_builtin(
203            Value::String("%0.0f ".to_string()),
204            vec![Value::Tensor(tensor)],
205        )
206        .expect("sprintf");
207        assert_eq!(char_value_to_string(result), "1 3 2 4 ");
208    }
209
210    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
211    #[test]
212    fn sprintf_not_enough_arguments_error() {
213        let err = error_message(
214            sprintf_builtin(
215                Value::String("%d %d".to_string()),
216                vec![Value::Int(IntValue::I32(1))],
217            )
218            .expect_err("sprintf should error"),
219        );
220        assert!(
221            err.contains("not enough input arguments"),
222            "unexpected error: {err}"
223        );
224    }
225
226    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
227    #[test]
228    fn sprintf_extra_arguments_error() {
229        let err = error_message(
230            sprintf_builtin(
231                Value::String("literal text".to_string()),
232                vec![Value::Int(IntValue::I32(1))],
233            )
234            .expect_err("sprintf should error"),
235        );
236        assert!(
237            err.contains("contains no conversion specifiers"),
238            "unexpected error: {err}"
239        );
240    }
241
242    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
243    #[test]
244    fn sprintf_format_spec_multirow_error() {
245        let chars = CharArray::new("hi!".chars().collect(), 3, 1).unwrap();
246        let err = error_message(
247            sprintf_builtin(Value::CharArray(chars), Vec::new()).expect_err("sprintf"),
248        );
249        assert!(
250            err.contains("formatSpec must be a character row vector"),
251            "unexpected error: {err}"
252        );
253    }
254
255    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
256    #[test]
257    fn sprintf_percent_c_from_numeric() {
258        let result = sprintf_builtin(
259            Value::String("%c".to_string()),
260            vec![Value::Int(IntValue::I32(65))],
261        )
262        .expect("sprintf");
263        assert_eq!(char_value_to_string(result), "A");
264    }
265
266    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
267    #[test]
268    fn sprintf_cell_arguments() {
269        let cell = make_cell(
270            vec![
271                Value::Num(1.0),
272                Value::String("two".to_string()),
273                Value::Num(3.0),
274            ],
275            3,
276            1,
277        )
278        .expect("cell");
279        let result = sprintf_builtin(Value::String("%0.0f %s %0.0f".to_string()), vec![cell])
280            .expect("sprintf");
281        assert_eq!(char_value_to_string(result), "1 two 3");
282    }
283
284    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
285    #[test]
286    fn sprintf_string_array_column_major() {
287        let data = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
288        let array =
289            StringArray::new(data, vec![3, 1]).expect("string array construction must succeed");
290        let result = sprintf_builtin(
291            Value::String("%s ".to_string()),
292            vec![Value::StringArray(array)],
293        )
294        .expect("sprintf");
295        assert_eq!(char_value_to_string(result), "alpha beta gamma ");
296    }
297
298    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
299    #[test]
300    fn sprintf_complex_s_conversion() {
301        let result = sprintf_builtin(
302            Value::String("%s".to_string()),
303            vec![Value::Complex(1.5, -2.0)],
304        )
305        .expect("sprintf");
306        assert_eq!(char_value_to_string(result), "1.5-2i");
307    }
308
309    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
310    #[test]
311    fn sprintf_escape_sequences() {
312        let result = sprintf_builtin(
313            Value::String("Line 1\\nLine 2\\t(tab)".to_string()),
314            Vec::new(),
315        )
316        .expect("sprintf");
317        assert_eq!(char_value_to_string(result), "Line 1\nLine 2\t(tab)");
318    }
319
320    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
321    #[test]
322    fn sprintf_hex_and_octal_escapes() {
323        let result =
324            sprintf_builtin(Value::String("\\x41\\101".to_string()), Vec::new()).expect("sprintf");
325        assert_eq!(char_value_to_string(result), "AA");
326    }
327
328    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
329    #[test]
330    fn sprintf_unknown_escape_preserved() {
331        let result =
332            sprintf_builtin(Value::String("Value\\q".to_string()), Vec::new()).expect("sprintf");
333        assert_eq!(char_value_to_string(result), "Value\\q");
334    }
335
336    #[test]
337    fn sprintf_type_is_string_scalar() {
338        assert_eq!(
339            string_scalar_type(&[Type::String], &ResolveContext::new(Vec::new())),
340            Type::String
341        );
342    }
343}