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