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