1use runmat_builtins::{StringArray, Value};
3use runmat_macros::runtime_builtin;
4
5use crate::builtins::common::spec::{
6 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
7 ReductionNaN, ResidencyPolicy, ShapeRequirements,
8};
9use crate::builtins::strings::core::string::{
10 extract_format_spec, format_from_spec, FormatSpecData,
11};
12#[cfg(feature = "doc_export")]
13use crate::register_builtin_doc_text;
14use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
15
16#[cfg(feature = "doc_export")]
17pub const DOC_MD: &str = r#"---
18title: "compose"
19category: "strings/core"
20keywords: ["compose", "format", "string array", "sprintf", "gpu"]
21summary: "Format numeric, logical, and text data into MATLAB string arrays using printf-style placeholders."
22references:
23 - https://www.mathworks.com/help/matlab/ref/compose.html
24gpu_support:
25 elementwise: false
26 reduction: false
27 precisions: []
28 broadcasting: "none"
29 notes: "Formatting runs on the CPU. GPU inputs are gathered to host memory before substitution."
30fusion:
31 elementwise: false
32 reduction: false
33 max_inputs: 1
34 constants: "inline"
35requires_feature: null
36tested:
37 unit: "builtins::strings::core::compose::tests"
38 integration: "builtins::strings::core::compose::tests::compose_gpu_argument"
39---
40
41# What does the `compose` function do in MATLAB / RunMat?
42`compose(formatSpec, A1, ..., An)` substitutes data into MATLAB-compatible `%` placeholders and
43returns the result as a string array. It combines `sprintf`-style formatting with string-array
44broadcasting so you can generate multiple strings in one call.
45
46## How does the `compose` function behave in MATLAB / RunMat?
47- `formatSpec` must be text: a string scalar, string array, character vector, character array, or
48 cell array of character vectors.
49- If `formatSpec` is scalar and any argument array has more than one element, RunMat broadcasts the
50 scalar specification over the array dimensions.
51- When `formatSpec` is a string or character array with multiple elements, the output has the same
52 shape as the specification. Each element uses the corresponding row or cell during formatting.
53- Arguments can be numeric, logical, string, or text-like cell arrays. Non-text arguments are
54 converted using MATLAB-compatible rules (logical values become `1` or `0`, complex numbers use the
55 `a + bi` form).
56- When you omit additional arguments, `compose(formatSpec)` simply converts the specification into a
57 string array, preserving the original structure.
58- Errors are raised if argument shapes are incompatible with the specification or if format specifiers
59 are incomplete.
60
61## `compose` Function GPU Execution Behaviour
62`compose` is a residency sink. When inputs include GPU-resident tensors, RunMat gathers the data
63back to host memory using the active acceleration provider before performing the formatting logic.
64All formatted strings live in host memory, so acceleration providers do not need compose-specific
65kernels.
66
67## Examples of using the `compose` function in MATLAB / RunMat
68
69### Formatting A Scalar Value Into A Sentence
70```matlab
71msg = compose("The answer is %d.", 42);
72```
73Expected output:
74```matlab
75msg = "The answer is 42."
76```
77
78### Broadcasting A Scalar Format Spec Over A Vector
79```matlab
80result = compose("Trial %d", 1:4);
81```
82Expected output:
83```matlab
84result = 1×4 string
85 "Trial 1" "Trial 2" "Trial 3" "Trial 4"
86```
87
88### Using A String Array Of Formats
89```matlab
90spec = ["max: %0.2f", "min: %0.2f"];
91values = compose(spec, [3.14159, 0.125]);
92```
93Expected output:
94```matlab
95values = 1×2 string
96 "max: 3.14" "min: 0.12"
97```
98
99### Formatting Each Row Of A Character Array
100```matlab
101C = ['Row %02d'; 'Row %02d'; 'Row %02d'];
102idx = compose(C, (1:3).');
103```
104Expected output:
105```matlab
106idx = 3×1 string
107 "Row 01"
108 "Row 02"
109 "Row 03"
110```
111
112### Combining Real And Imaginary Parts
113```matlab
114Z = [1+2i, 3-4i];
115txt = compose("z = %s", Z);
116```
117Expected output:
118```matlab
119txt = 1×2 string
120 "z = 1+2i" "z = 3-4i"
121```
122
123### Using A Cell Array Of Format Specs
124```matlab
125specs = {'%0.1f volts', '%0.1f amps'};
126readings = compose(specs, {12.6, 3.4});
127```
128Expected output:
129```matlab
130readings = 2×1 string
131 "12.6 volts"
132 "3.4 amps"
133```
134
135### Formatting GPU-Resident Data
136```matlab
137G = gpuArray([10 20 30]);
138labels = compose("Value %d", G);
139```
140Expected output:
141```matlab
142labels = 1×3 string
143 "Value 10" "Value 20" "Value 30"
144```
145RunMat gathers `G` from the GPU before formatting, so the behaviour matches CPU inputs.
146
147## FAQ
148
149### What happens if the number of format arguments does not match the placeholders?
150RunMat raises `compose: format data arguments must be scalars or match formatSpec size`. Ensure that
151each placeholder has a corresponding value or broadcast the specification appropriately.
152
153### Can `compose` handle complex numbers?
154Yes. Complex numbers use MATLAB's canonical `a + bi` formatting, so `%s` specifiers receive the
155string form of the complex scalar.
156
157### How does `compose` treat logical inputs?
158Logical values are converted to numeric `1` or `0` before formatting so they work with `%d`, `%i`,
159or `%f` placeholders.
160
161### Does `compose` modify the shape of the output?
162No. The output matches the broadcasted size between `formatSpec` and the input arguments. Scalar
163specifications broadcast across non-scalar arguments.
164
165### What if I pass GPU arrays?
166Inputs that reside on the GPU are automatically gathered to host memory before formatting. The
167resulting string array always lives on the CPU.
168
169### How do I emit literal percent signs?
170Use `%%` inside `formatSpec` just like `sprintf`. The formatter converts `%%` into a single `%`.
171
172### Can I mix scalars and arrays in the arguments list?
173Yes, as long as non-scalar arguments all share the same number of elements or match the size of
174`formatSpec`. Scalars broadcast across the target shape.
175
176### What happens when `formatSpec` is empty?
177`compose(formatSpec)` returns an empty string array with the same shape as `formatSpec`. When
178`formatSpec` and arguments have zero elements, the output is `0×0`.
179
180## See Also
181`string`, `sprintf`, `strcat`, `join`
182"#;
183
184pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
185 name: "compose",
186 op_kind: GpuOpKind::Custom("format"),
187 supported_precisions: &[],
188 broadcast: BroadcastSemantics::None,
189 provider_hooks: &[],
190 constant_strategy: ConstantStrategy::InlineLiteral,
191 residency: ResidencyPolicy::GatherImmediately,
192 nan_mode: ReductionNaN::Include,
193 two_pass_threshold: None,
194 workgroup_size: None,
195 accepts_nan_mode: false,
196 notes: "Formatting always executes on the CPU; GPU tensors are gathered before substitution.",
197};
198
199register_builtin_gpu_spec!(GPU_SPEC);
200
201pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
202 name: "compose",
203 shape: ShapeRequirements::Any,
204 constant_strategy: ConstantStrategy::InlineLiteral,
205 elementwise: None,
206 reduction: None,
207 emits_nan: false,
208 notes: "Formatting builtin; not eligible for fusion and materialises host string arrays.",
209};
210
211register_builtin_fusion_spec!(FUSION_SPEC);
212
213#[cfg(feature = "doc_export")]
214register_builtin_doc_text!("compose", DOC_MD);
215
216#[runtime_builtin(
217 name = "compose",
218 category = "strings/core",
219 summary = "Format values into MATLAB string arrays using printf-style placeholders.",
220 keywords = "compose,format,string array,gpu",
221 accel = "sink"
222)]
223fn compose_builtin(format_spec: Value, rest: Vec<Value>) -> Result<Value, String> {
224 let format_value = gather_if_needed(&format_spec).map_err(|e| format!("compose: {e}"))?;
225 let mut gathered_args = Vec::with_capacity(rest.len());
226 for arg in rest {
227 let gathered = gather_if_needed(&arg).map_err(|e| format!("compose: {e}"))?;
228 gathered_args.push(gathered);
229 }
230
231 if gathered_args.is_empty() {
232 let spec = extract_format_spec(format_value).map_err(compose_error)?;
233 let array = format_spec_data_to_string_array(spec)?;
234 return Ok(Value::StringArray(array));
235 }
236
237 let formatted = format_from_spec(format_value, gathered_args).map_err(compose_error)?;
238 Ok(Value::StringArray(formatted))
239}
240
241fn compose_error(err: String) -> String {
242 if let Some(rest) = err.strip_prefix("string:") {
243 format!("compose:{rest}")
244 } else if err.starts_with("compose:") {
245 err
246 } else {
247 format!("compose: {err}")
248 }
249}
250
251fn format_spec_data_to_string_array(spec: FormatSpecData) -> Result<StringArray, String> {
252 let shape = if spec.shape.is_empty() {
253 match spec.specs.len() {
254 0 => vec![0, 0],
255 1 => vec![1, 1],
256 len => vec![len, 1],
257 }
258 } else {
259 spec.shape
260 };
261 StringArray::new(spec.specs, shape).map_err(|e| format!("compose: {e}"))
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::builtins::common::test_support;
268 use runmat_builtins::{IntValue, Tensor};
269
270 #[test]
271 fn compose_scalar_numeric() {
272 let result = compose_builtin(Value::from("Count %d"), vec![Value::Int(IntValue::I32(7))])
273 .expect("compose");
274 match result {
275 Value::StringArray(sa) => {
276 assert_eq!(sa.shape, vec![1, 1]);
277 assert_eq!(sa.data, vec!["Count 7".to_string()]);
278 }
279 other => panic!("expected string array, got {other:?}"),
280 }
281 }
282
283 #[test]
284 fn compose_broadcasts_scalar_spec() {
285 let tensor = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
286 let result = compose_builtin(Value::from("Item %0.0f"), vec![Value::Tensor(tensor)])
287 .expect("compose");
288 match result {
289 Value::StringArray(sa) => {
290 assert_eq!(sa.shape, vec![1, 2]);
291 assert_eq!(sa.data, vec!["Item 1".to_string(), "Item 2".to_string()]);
292 }
293 other => panic!("expected string array, got {other:?}"),
294 }
295 }
296
297 #[test]
298 fn compose_zero_arguments_returns_spec() {
299 let spec = Value::StringArray(
300 StringArray::new(vec!["alpha".into(), "beta".into()], vec![1, 2]).unwrap(),
301 );
302 let result = compose_builtin(spec, Vec::new()).expect("compose");
303 match result {
304 Value::StringArray(sa) => {
305 assert_eq!(sa.shape, vec![1, 2]);
306 assert_eq!(sa.data, vec!["alpha".to_string(), "beta".to_string()]);
307 }
308 other => panic!("expected string array, got {other:?}"),
309 }
310 }
311
312 #[test]
313 fn compose_mismatched_lengths_errors() {
314 let spec = Value::StringArray(
315 StringArray::new(vec!["%d".into(), "%d".into()], vec![1, 2]).unwrap(),
316 );
317 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
318 let err = compose_builtin(spec, vec![Value::Tensor(tensor)]).unwrap_err();
319 assert!(
320 err.starts_with("compose: "),
321 "expected compose prefix, got {err}"
322 );
323 assert!(
324 err.contains("format data arguments must be scalars or match formatSpec size"),
325 "unexpected error text: {err}"
326 );
327 }
328
329 #[test]
330 fn compose_gpu_argument() {
331 test_support::with_test_provider(|provider| {
332 let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
333 let view = runmat_accelerate_api::HostTensorView {
334 data: &tensor.data,
335 shape: &tensor.shape,
336 };
337 let handle = provider.upload(&view).expect("upload");
338 let result =
339 compose_builtin(Value::from("Value %0.0f"), vec![Value::GpuTensor(handle)])
340 .expect("compose");
341 match result {
342 Value::StringArray(sa) => {
343 assert_eq!(sa.shape, vec![1, 3]);
344 assert_eq!(
345 sa.data,
346 vec![
347 "Value 1".to_string(),
348 "Value 2".to_string(),
349 "Value 3".to_string()
350 ]
351 );
352 }
353 other => panic!("expected string array, got {other:?}"),
354 }
355 });
356 }
357
358 #[test]
359 #[cfg(feature = "doc_export")]
360 fn doc_examples_parse() {
361 let blocks = test_support::doc_examples(DOC_MD);
362 assert!(!blocks.is_empty());
363 }
364
365 #[test]
366 #[cfg(feature = "wgpu")]
367 fn compose_wgpu_numeric_tensor_matches_cpu() {
368 let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
369 runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
370 );
371 let tensor = Tensor::new(vec![1.25, 2.5, 3.75], vec![1, 3]).unwrap();
372 let cpu = compose_builtin(
373 Value::from("Value %0.2f"),
374 vec![Value::Tensor(tensor.clone())],
375 )
376 .expect("cpu compose");
377 let view = runmat_accelerate_api::HostTensorView {
378 data: &tensor.data,
379 shape: &tensor.shape,
380 };
381 let provider = runmat_accelerate_api::provider().expect("wgpu provider");
382 let handle = provider.upload(&view).expect("gpu upload");
383 let gpu = compose_builtin(Value::from("Value %0.2f"), vec![Value::GpuTensor(handle)])
384 .expect("gpu compose");
385 match (cpu, gpu) {
386 (Value::StringArray(expect), Value::StringArray(actual)) => {
387 assert_eq!(actual.shape, expect.shape);
388 assert_eq!(actual.data, expect.data);
389 }
390 other => panic!("unexpected results {other:?}"),
391 }
392 }
393}