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::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    CharArray, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::format::{
11    decode_escape_sequences, extract_format_string, flatten_arguments, format_variadic_with_cursor,
12    ArgCursor,
13};
14use crate::builtins::common::map_control_flow_with_builtin;
15use crate::builtins::common::spec::{
16    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
17    ReductionNaN, ResidencyPolicy, ShapeRequirements,
18};
19use crate::builtins::strings::type_resolvers::string_scalar_type;
20use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
21
22#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::sprintf")]
23pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
24    name: "sprintf",
25    op_kind: GpuOpKind::Custom("format"),
26    supported_precisions: &[],
27    broadcast: BroadcastSemantics::None,
28    provider_hooks: &[],
29    constant_strategy: ConstantStrategy::InlineLiteral,
30    residency: ResidencyPolicy::GatherImmediately,
31    nan_mode: ReductionNaN::Include,
32    two_pass_threshold: None,
33    workgroup_size: None,
34    accepts_nan_mode: false,
35    notes: "Formatting runs on the CPU; GPU tensors are gathered before substitution.",
36};
37
38#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::sprintf")]
39pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
40    name: "sprintf",
41    shape: ShapeRequirements::Any,
42    constant_strategy: ConstantStrategy::InlineLiteral,
43    elementwise: None,
44    reduction: None,
45    emits_nan: false,
46    notes: "Formatting is a residency sink and is not fused; callers should treat sprintf as a CPU-only builtin.",
47};
48
49const BUILTIN_NAME: &str = "sprintf";
50
51const SPRINTF_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
52    name: "txt",
53    ty: BuiltinParamType::Any,
54    arity: BuiltinParamArity::Required,
55    default: None,
56    description: "Formatted character row vector output.",
57}];
58
59const SPRINTF_INPUTS: [BuiltinParamDescriptor; 2] = [
60    BuiltinParamDescriptor {
61        name: "formatSpec",
62        ty: BuiltinParamType::Any,
63        arity: BuiltinParamArity::Required,
64        default: None,
65        description: "Format template text.",
66    },
67    BuiltinParamDescriptor {
68        name: "A...",
69        ty: BuiltinParamType::Any,
70        arity: BuiltinParamArity::Variadic,
71        default: None,
72        description: "Values substituted by conversion specifiers.",
73    },
74];
75
76const SPRINTF_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
77    label: "txt = sprintf(formatSpec, A...)",
78    inputs: &SPRINTF_INPUTS,
79    outputs: &SPRINTF_OUTPUT,
80}];
81
82const SPRINTF_ERROR_INVALID_FORMAT_SPEC: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
83    code: "RM.SPRINTF.INVALID_FORMAT_SPEC",
84    identifier: Some("RunMat:sprintf:InvalidFormatSpec"),
85    when: "formatSpec is invalid or unsupported.",
86    message: "sprintf: invalid formatSpec",
87};
88
89const SPRINTF_ERROR_ARGUMENT_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
90    code: "RM.SPRINTF.ARGUMENT_MISMATCH",
91    identifier: Some("RunMat:sprintf:ArgumentMismatch"),
92    when: "Conversion specifier count does not match provided arguments.",
93    message: "sprintf: format arguments do not match conversion specifiers",
94};
95
96const SPRINTF_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
97    code: "RM.SPRINTF.INTERNAL",
98    identifier: Some("RunMat:sprintf:InternalError"),
99    when: "Internal char-array construction failed.",
100    message: "sprintf: internal error",
101};
102
103const SPRINTF_ERRORS: [BuiltinErrorDescriptor; 3] = [
104    SPRINTF_ERROR_INVALID_FORMAT_SPEC,
105    SPRINTF_ERROR_ARGUMENT_MISMATCH,
106    SPRINTF_ERROR_INTERNAL,
107];
108
109pub const SPRINTF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
110    signatures: &SPRINTF_SIGNATURES,
111    output_mode: BuiltinOutputMode::Fixed,
112    completion_policy: BuiltinCompletionPolicy::Public,
113    errors: &SPRINTF_ERRORS,
114};
115
116fn sprintf_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
117    sprintf_error_with_message(error.message, error)
118}
119
120fn sprintf_error_with_message(
121    message: impl Into<String>,
122    error: &'static BuiltinErrorDescriptor,
123) -> RuntimeError {
124    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
125    if let Some(identifier) = error.identifier {
126        builder = builder.with_identifier(identifier);
127    }
128    builder.build()
129}
130
131fn remap_sprintf_flow(err: RuntimeError) -> RuntimeError {
132    map_control_flow_with_builtin(err, BUILTIN_NAME)
133}
134
135#[runtime_builtin(
136    name = "sprintf",
137    category = "strings/core",
138    summary = "Format data into a character vector using printf-style specifiers.",
139    keywords = "sprintf,format,printf,text",
140    accel = "format",
141    sink = true,
142    type_resolver(string_scalar_type),
143    descriptor(crate::builtins::strings::core::sprintf::SPRINTF_DESCRIPTOR),
144    builtin_path = "crate::builtins::strings::core::sprintf"
145)]
146async fn sprintf_builtin(format_spec: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
147    let gathered_spec = gather_if_needed_async(&format_spec)
148        .await
149        .map_err(remap_sprintf_flow)?;
150    let raw_format =
151        extract_format_string(&gathered_spec, "sprintf").map_err(remap_sprintf_flow)?;
152    let format_string =
153        decode_escape_sequences("sprintf", &raw_format).map_err(remap_sprintf_flow)?;
154    let flattened_args = flatten_arguments(&rest, "sprintf")
155        .await
156        .map_err(remap_sprintf_flow)?;
157    let mut cursor = ArgCursor::new(&flattened_args);
158    let mut output = String::new();
159
160    loop {
161        let step =
162            format_variadic_with_cursor(&format_string, &mut cursor).map_err(remap_sprintf_flow)?;
163        output.push_str(&step.output);
164
165        if step.consumed == 0 {
166            if cursor.remaining() > 0 {
167                return Err(sprintf_error_with_message(
168                    "sprintf: formatSpec contains no conversion specifiers but additional arguments were supplied",
169                    &SPRINTF_ERROR_ARGUMENT_MISMATCH,
170                ));
171            }
172            break;
173        }
174
175        if cursor.remaining() == 0 {
176            break;
177        }
178    }
179
180    char_row_value(&output)
181}
182
183fn char_row_value(text: &str) -> BuiltinResult<Value> {
184    let chars: Vec<char> = text.chars().collect();
185    let len = chars.len();
186    let array =
187        CharArray::new(chars, 1, len).map_err(|_| sprintf_error(&SPRINTF_ERROR_INTERNAL))?;
188    Ok(Value::CharArray(array))
189}
190
191#[cfg(test)]
192pub(crate) mod tests {
193    use super::*;
194    use crate::{builtins::common::test_support, make_cell};
195    use runmat_builtins::{CharArray, IntValue, ResolveContext, StringArray, Tensor, Type};
196
197    fn sprintf_builtin(format_spec: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
198        futures::executor::block_on(super::sprintf_builtin(format_spec, rest))
199    }
200
201    fn error_message(err: crate::RuntimeError) -> String {
202        err.message().to_string()
203    }
204
205    fn char_value_to_string(value: Value) -> String {
206        match value {
207            Value::CharArray(ca) => ca.data.into_iter().collect(),
208            other => panic!("expected char output, got {other:?}"),
209        }
210    }
211
212    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
213    #[test]
214    fn sprintf_basic_integer() {
215        let result = sprintf_builtin(
216            Value::String("Value: %d".to_string()),
217            vec![Value::Int(IntValue::I32(42))],
218        )
219        .expect("sprintf");
220        assert_eq!(char_value_to_string(result), "Value: 42");
221    }
222
223    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
224    #[test]
225    fn sprintf_float_precision() {
226        let result = sprintf_builtin(
227            Value::String("pi ~= %.3f".to_string()),
228            vec![Value::Num(std::f64::consts::PI)],
229        )
230        .expect("sprintf");
231        assert_eq!(char_value_to_string(result), "pi ~= 3.142");
232    }
233
234    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
235    #[test]
236    fn sprintf_array_repeat() {
237        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
238        let result = sprintf_builtin(
239            Value::String("%d ".to_string()),
240            vec![Value::Tensor(tensor)],
241        )
242        .expect("sprintf");
243        assert_eq!(char_value_to_string(result), "1 2 3 ");
244    }
245
246    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
247    #[test]
248    fn sprintf_star_width() {
249        let args = vec![
250            Value::Int(IntValue::I32(6)),
251            Value::Int(IntValue::I32(2)),
252            Value::Num(12.345),
253        ];
254        let result = sprintf_builtin(Value::String("%*.*f".to_string()), args).expect("sprintf");
255        assert_eq!(char_value_to_string(result), " 12.35");
256    }
257
258    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
259    #[test]
260    fn sprintf_literal_percent() {
261        let result =
262            sprintf_builtin(Value::String("%% complete".to_string()), Vec::new()).expect("sprintf");
263        assert_eq!(char_value_to_string(result), "% complete");
264    }
265
266    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
267    #[test]
268    fn sprintf_gpu_numeric() {
269        test_support::with_test_provider(|provider| {
270            let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
271            let view = runmat_accelerate_api::HostTensorView {
272                data: &tensor.data,
273                shape: &tensor.shape,
274            };
275            let handle = provider.upload(&view).expect("upload");
276            let value = Value::GpuTensor(handle);
277            let result =
278                sprintf_builtin(Value::String("%0.1f,".to_string()), vec![value]).expect("sprintf");
279            assert_eq!(char_value_to_string(result), "1.0,2.0,");
280        });
281    }
282
283    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
284    #[test]
285    fn sprintf_matrix_column_major() {
286        let tensor = Tensor::new(vec![1.0, 3.0, 2.0, 4.0], vec![2, 2]).unwrap();
287        let result = sprintf_builtin(
288            Value::String("%0.0f ".to_string()),
289            vec![Value::Tensor(tensor)],
290        )
291        .expect("sprintf");
292        assert_eq!(char_value_to_string(result), "1 3 2 4 ");
293    }
294
295    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
296    #[test]
297    fn sprintf_not_enough_arguments_error() {
298        let err = error_message(
299            sprintf_builtin(
300                Value::String("%d %d".to_string()),
301                vec![Value::Int(IntValue::I32(1))],
302            )
303            .expect_err("sprintf should error"),
304        );
305        assert!(
306            err.contains("not enough input arguments"),
307            "unexpected error: {err}"
308        );
309    }
310
311    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
312    #[test]
313    fn sprintf_extra_arguments_error() {
314        let err = error_message(
315            sprintf_builtin(
316                Value::String("literal text".to_string()),
317                vec![Value::Int(IntValue::I32(1))],
318            )
319            .expect_err("sprintf should error"),
320        );
321        assert!(
322            err.contains("contains no conversion specifiers"),
323            "unexpected error: {err}"
324        );
325    }
326
327    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
328    #[test]
329    fn sprintf_format_spec_multirow_error() {
330        let chars = CharArray::new("hi!".chars().collect(), 3, 1).unwrap();
331        let err = error_message(
332            sprintf_builtin(Value::CharArray(chars), Vec::new()).expect_err("sprintf"),
333        );
334        assert!(
335            err.contains("formatSpec must be a character row vector"),
336            "unexpected error: {err}"
337        );
338    }
339
340    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
341    #[test]
342    fn sprintf_unsupported_specifier_reports_stable_identifier() {
343        let err = sprintf_builtin(Value::String("%q".to_string()), vec![Value::Num(1.0)])
344            .expect_err("sprintf should error");
345        assert_eq!(
346            err.identifier(),
347            Some("RunMat:format:UnsupportedSpecifier"),
348            "unsupported formatter specifiers should expose a stable identifier"
349        );
350    }
351
352    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
353    #[test]
354    fn sprintf_percent_c_from_numeric() {
355        let result = sprintf_builtin(
356            Value::String("%c".to_string()),
357            vec![Value::Int(IntValue::I32(65))],
358        )
359        .expect("sprintf");
360        assert_eq!(char_value_to_string(result), "A");
361    }
362
363    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
364    #[test]
365    fn sprintf_cell_arguments() {
366        let cell = make_cell(
367            vec![
368                Value::Num(1.0),
369                Value::String("two".to_string()),
370                Value::Num(3.0),
371            ],
372            3,
373            1,
374        )
375        .expect("cell");
376        let result = sprintf_builtin(Value::String("%0.0f %s %0.0f".to_string()), vec![cell])
377            .expect("sprintf");
378        assert_eq!(char_value_to_string(result), "1 two 3");
379    }
380
381    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
382    #[test]
383    fn sprintf_string_array_column_major() {
384        let data = vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()];
385        let array =
386            StringArray::new(data, vec![3, 1]).expect("string array construction must succeed");
387        let result = sprintf_builtin(
388            Value::String("%s ".to_string()),
389            vec![Value::StringArray(array)],
390        )
391        .expect("sprintf");
392        assert_eq!(char_value_to_string(result), "alpha beta gamma ");
393    }
394
395    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
396    #[test]
397    fn sprintf_complex_s_conversion() {
398        let result = sprintf_builtin(
399            Value::String("%s".to_string()),
400            vec![Value::Complex(1.5, -2.0)],
401        )
402        .expect("sprintf");
403        assert_eq!(char_value_to_string(result), "1.5-2i");
404    }
405
406    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
407    #[test]
408    fn sprintf_escape_sequences() {
409        let result = sprintf_builtin(
410            Value::String("Line 1\\nLine 2\\t(tab)".to_string()),
411            Vec::new(),
412        )
413        .expect("sprintf");
414        assert_eq!(char_value_to_string(result), "Line 1\nLine 2\t(tab)");
415    }
416
417    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
418    #[test]
419    fn sprintf_hex_and_octal_escapes() {
420        let result =
421            sprintf_builtin(Value::String("\\x41\\101".to_string()), Vec::new()).expect("sprintf");
422        assert_eq!(char_value_to_string(result), "AA");
423    }
424
425    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
426    #[test]
427    fn sprintf_unknown_escape_preserved() {
428        let result =
429            sprintf_builtin(Value::String("Value\\q".to_string()), Vec::new()).expect("sprintf");
430        assert_eq!(char_value_to_string(result), "Value\\q");
431    }
432
433    #[test]
434    fn sprintf_type_is_string_scalar() {
435        assert_eq!(
436            string_scalar_type(&[Type::String], &ResolveContext::new(Vec::new())),
437            Type::String
438        );
439    }
440}