1use 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}