runmat_runtime/builtins/strings/transform/
pad.rs

1//! MATLAB-compatible `pad` builtin with GPU-aware semantics for RunMat.
2
3use runmat_builtins::{CellArray, CharArray, StringArray, Value};
4use runmat_macros::runtime_builtin;
5
6use crate::builtins::common::spec::{
7    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
8    ReductionNaN, ResidencyPolicy, ShapeRequirements,
9};
10use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
11#[cfg(feature = "doc_export")]
12use crate::register_builtin_doc_text;
13use crate::{gather_if_needed, make_cell, register_builtin_fusion_spec, register_builtin_gpu_spec};
14
15#[cfg(feature = "doc_export")]
16pub const DOC_MD: &str = r#"---
17title: "pad"
18category: "strings/transform"
19keywords: ["pad", "pad string", "left pad", "right pad", "center text", "character arrays"]
20summary: "Pad strings, character arrays, and cell arrays to a target length using MATLAB-compatible options."
21references:
22  - https://www.mathworks.com/help/matlab/ref/pad.html
23gpu_support:
24  elementwise: false
25  reduction: false
26  precisions: []
27  broadcasting: "none"
28  notes: "Executes on the CPU; GPU-resident inputs are gathered before padding so behaviour matches MATLAB."
29fusion:
30  elementwise: false
31  reduction: false
32  max_inputs: 1
33  constants: "inline"
34requires_feature: null
35tested:
36  unit: "builtins::strings::transform::pad::tests"
37  integration: "builtins::strings::transform::pad::tests::pad_cell_array_mixed_content"
38---
39
40# What does the `pad` function do in MATLAB / RunMat?
41`pad` adds characters to the beginning, end, or both sides of strings so that each element reaches a
42specified length. It mirrors MATLAB semantics for string arrays, character arrays, and cell arrays of
43character vectors, including direction keywords, default space padding, and optional custom characters.
44
45## How does the `pad` function behave in MATLAB / RunMat?
46- Without a target length, `pad` extends each element to match the longest text in the input.
47- Providing a numeric target guarantees a minimum length; existing text that already meets or exceeds
48  the target is returned unchanged.
49- Direction keywords (`'left'`, `'right'`, `'both'`) are case-insensitive; `'right'` is the default.
50  When an odd number of pad characters is required for `'both'`, the extra character is appended to the end.
51- `padChar` must be a single character (string scalar or 1×1 char array). The default is a space.
52- Character arrays remain rectangular. Each row is padded independently and then widened with spaces so
53  the array keeps MATLAB’s column-major layout.
54- Cell arrays preserve their structure. Elements must be string scalars or 1×N character vectors and are
55  padded while keeping their original type.
56- Missing strings (`string(missing)`) and empty character vectors pass through unchanged, preserving metadata.
57
58## `pad` Function GPU Execution Behaviour
59`pad` always executes on the CPU. When an argument (or a value nested inside a cell array) lives on the GPU,
60RunMat gathers it, performs the padding step, and produces a host result or re-wraps the padded value inside
61the cell. No provider hooks exist yet for string padding, so providers and fusion planners treat `pad` as a
62sink that terminates device residency.
63
64## GPU residency in RunMat (Do I need `gpuArray`?)
65No. Text data in RunMat lives on the host today. If text happens to originate from a GPU computation,
66`pad` automatically gathers it before padding, so you never have to manage residency manually for this
67builtin.
68
69## Examples of using the `pad` function in MATLAB / RunMat
70
71### Pad Strings To A Common Width
72```matlab
73labels = ["GPU"; "Accelerate"; "RunMat"];
74aligned = pad(labels);
75```
76Expected output:
77```matlab
78aligned =
79  3×1 string
80    "GPU       "
81    "Accelerate"
82    "RunMat    "
83```
84
85### Pad Strings On The Left With Zeros
86```matlab
87ids = ["42"; "7"; "512"];
88zero_padded = pad(ids, 4, 'left', '0');
89```
90Expected output:
91```matlab
92zero_padded =
93  3×1 string
94    "0042"
95    "0007"
96    "0512"
97```
98
99### Center Text With Both-Sided Padding
100```matlab
101titles = ["core"; "planner"];
102centered = pad(titles, 10, 'both', '*');
103```
104Expected output:
105```matlab
106centered =
107  2×1 string
108    "***core***"
109    "*planner**"
110```
111
112### Pad Character Array Rows
113```matlab
114chars = char("GPU", "RunMat");
115out = pad(chars, 8);
116```
117Expected output:
118```matlab
119out =
120
121  2×8 char array
122
123    'GPU     '
124    'RunMat  '
125```
126
127### Pad A Cell Array Of Character Vectors
128```matlab
129C = {'solver', "planner", 'jit'};
130cell_out = pad(C, 'right', '.');
131```
132Expected output:
133```matlab
134cell_out = 1×3 cell array
135    {'solver.'}    {"planner"}    {'jit....'}
136```
137
138### Leave Missing Strings Unchanged
139```matlab
140values = ["RunMat", "<missing>", "GPU"];
141kept = pad(values, 8);
142```
143Expected output:
144```matlab
145kept =
146  1×3 string
147    "RunMat  "    <missing>    "GPU     "
148```
149
150## FAQ
151
152### What inputs does `pad` accept?
153String scalars, string arrays, character arrays, and cell arrays containing string scalars or character
154vectors. Other types raise MATLAB-compatible errors.
155
156### How are direction keywords interpreted?
157`'left'`, `'right'`, and `'both'` are supported (case-insensitive). `'right'` is the default. With `'both'`,
158extra characters are added to the end when an odd number of padding characters is required.
159
160### Can I shorten text with `pad`?
161No. When the existing text is already longer than the requested target length, it is returned unchanged.
162
163### What happens when I supply a custom padding character?
164The character must be length one. RunMat repeats it as many times as needed in the specified direction.
165
166### Do missing strings get padded?
167Missing strings (`<missing>`) are passed through untouched so downstream code that checks for missing
168values continues to work.
169
170### How are cell array elements returned?
171Each cell retains its type: string scalars remain strings and character vectors remain 1×N character
172arrays after padding.
173
174### Does `pad` change the orientation of row or column string arrays?
175No. The shape of the input array is preserved exactly; only element lengths change.
176
177### Will `pad` run on the GPU in the future?
178Possibly, but today it always gathers to the CPU. Providers may add device-side implementations later,
179and the behaviour documented here will remain the reference.
180
181## See Also
182[strip](./strip), [strcat](./strcat), [lower](./lower), [upper](./upper), [compose](../core/compose)
183
184## Source & Feedback
185- Implementation: [`crates/runmat-runtime/src/builtins/strings/transform/pad.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/strings/transform/pad.rs)
186- Found an issue? Please [open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
187"#;
188
189pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
190    name: "pad",
191    op_kind: GpuOpKind::Custom("string-transform"),
192    supported_precisions: &[],
193    broadcast: BroadcastSemantics::None,
194    provider_hooks: &[],
195    constant_strategy: ConstantStrategy::InlineLiteral,
196    residency: ResidencyPolicy::GatherImmediately,
197    nan_mode: ReductionNaN::Include,
198    two_pass_threshold: None,
199    workgroup_size: None,
200    accepts_nan_mode: false,
201    notes: "Executes on the CPU; GPU-resident inputs are gathered before padding to preserve MATLAB semantics.",
202};
203
204register_builtin_gpu_spec!(GPU_SPEC);
205
206pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
207    name: "pad",
208    shape: ShapeRequirements::Any,
209    constant_strategy: ConstantStrategy::InlineLiteral,
210    elementwise: None,
211    reduction: None,
212    emits_nan: false,
213    notes: "String transformation builtin; always gathers inputs and is not eligible for fusion.",
214};
215
216register_builtin_fusion_spec!(FUSION_SPEC);
217
218#[cfg(feature = "doc_export")]
219register_builtin_doc_text!("pad", DOC_MD);
220
221const ARG_TYPE_ERROR: &str =
222    "pad: first argument must be a string array, character array, or cell array of character vectors";
223const LENGTH_ERROR: &str = "pad: target length must be a non-negative integer scalar";
224const DIRECTION_ERROR: &str = "pad: direction must be 'left', 'right', or 'both'";
225const PAD_CHAR_ERROR: &str =
226    "pad: padding character must be a string scalar or character vector containing one character";
227const CELL_ELEMENT_ERROR: &str =
228    "pad: cell array elements must be string scalars or character vectors";
229const ARGUMENT_CONFIG_ERROR: &str = "pad: unable to interpret input arguments";
230
231#[derive(Clone, Copy, Eq, PartialEq)]
232enum PadDirection {
233    Left,
234    Right,
235    Both,
236}
237
238#[derive(Clone, Copy)]
239enum PadTarget {
240    Auto,
241    Length(usize),
242}
243
244#[derive(Clone, Copy)]
245struct PadOptions {
246    target: PadTarget,
247    direction: PadDirection,
248    pad_char: char,
249}
250
251impl Default for PadOptions {
252    fn default() -> Self {
253        Self {
254            target: PadTarget::Auto,
255            direction: PadDirection::Right,
256            pad_char: ' ',
257        }
258    }
259}
260
261impl PadOptions {
262    fn base_target(&self, auto_target: usize) -> usize {
263        match self.target {
264            PadTarget::Auto => auto_target,
265            PadTarget::Length(len) => len,
266        }
267    }
268}
269
270#[runtime_builtin(
271    name = "pad",
272    category = "strings/transform",
273    summary = "Pad strings, character arrays, and cell arrays to a target length.",
274    keywords = "pad,align,strings,character array",
275    accel = "sink"
276)]
277fn pad_builtin(value: Value, rest: Vec<Value>) -> Result<Value, String> {
278    let options = parse_arguments(&rest)?;
279    let gathered = gather_if_needed(&value).map_err(|e| format!("pad: {e}"))?;
280    match gathered {
281        Value::String(text) => pad_string(text, options),
282        Value::StringArray(array) => pad_string_array(array, options),
283        Value::CharArray(array) => pad_char_array(array, options),
284        Value::Cell(cell) => pad_cell_array(cell, options),
285        _ => Err(ARG_TYPE_ERROR.to_string()),
286    }
287}
288
289fn pad_string(text: String, options: PadOptions) -> Result<Value, String> {
290    if is_missing_string(&text) {
291        return Ok(Value::String(text));
292    }
293    let char_count = string_length(&text);
294    let base_target = options.base_target(char_count);
295    let target_len = element_target_length(&options, base_target, char_count);
296    let padded = apply_padding_owned(text, char_count, target_len, &options);
297    Ok(Value::String(padded))
298}
299
300fn pad_string_array(array: StringArray, options: PadOptions) -> Result<Value, String> {
301    let StringArray { data, shape, .. } = array;
302    let mut auto_len: usize = 0;
303    if matches!(options.target, PadTarget::Auto) {
304        for text in &data {
305            if !is_missing_string(text) {
306                auto_len = auto_len.max(string_length(text));
307            }
308        }
309    }
310    let base_target = options.base_target(auto_len);
311    let mut padded: Vec<String> = Vec::with_capacity(data.len());
312    for text in data.into_iter() {
313        if is_missing_string(&text) {
314            padded.push(text);
315            continue;
316        }
317        let char_count = string_length(&text);
318        let target_len = element_target_length(&options, base_target, char_count);
319        let new_text = apply_padding_owned(text, char_count, target_len, &options);
320        padded.push(new_text);
321    }
322    let result = StringArray::new(padded, shape).map_err(|e| format!("pad: {e}"))?;
323    Ok(Value::StringArray(result))
324}
325
326fn pad_char_array(array: CharArray, options: PadOptions) -> Result<Value, String> {
327    let CharArray { data, rows, cols } = array;
328    if rows == 0 {
329        return Ok(Value::CharArray(CharArray { data, rows, cols }));
330    }
331
332    let mut rows_text: Vec<String> = Vec::with_capacity(rows);
333    let mut auto_len = 0usize;
334    for row in 0..rows {
335        let text = char_row_to_string_slice(&data, cols, row);
336        auto_len = auto_len.max(string_length(&text));
337        rows_text.push(text);
338    }
339
340    let base_target = options.base_target(auto_len);
341    let mut padded_rows: Vec<String> = Vec::with_capacity(rows);
342    let mut final_cols: usize = 0;
343    for row_text in rows_text.into_iter() {
344        let char_count = string_length(&row_text);
345        let target_len = element_target_length(&options, base_target, char_count);
346        let padded = apply_padding_owned(row_text, char_count, target_len, &options);
347        final_cols = final_cols.max(string_length(&padded));
348        padded_rows.push(padded);
349    }
350
351    let mut new_data: Vec<char> = Vec::with_capacity(rows * final_cols);
352    for row_text in padded_rows.into_iter() {
353        let mut chars: Vec<char> = row_text.chars().collect();
354        if chars.len() < final_cols {
355            chars.resize(final_cols, ' ');
356        }
357        new_data.extend(chars.into_iter());
358    }
359
360    CharArray::new(new_data, rows, final_cols)
361        .map(Value::CharArray)
362        .map_err(|e| format!("pad: {e}"))
363}
364
365fn pad_cell_array(cell: CellArray, options: PadOptions) -> Result<Value, String> {
366    let rows = cell.rows;
367    let cols = cell.cols;
368    let total = rows * cols;
369    let mut items: Vec<CellItem> = Vec::with_capacity(total);
370    let mut auto_len = 0usize;
371
372    for idx in 0..total {
373        let value = &cell.data[idx];
374        let gathered = gather_if_needed(value).map_err(|e| format!("pad: {e}"))?;
375        let item = match gathered {
376            Value::String(text) => {
377                let is_missing = is_missing_string(&text);
378                let len = if is_missing { 0 } else { string_length(&text) };
379                if !is_missing {
380                    auto_len = auto_len.max(len);
381                }
382                CellItem {
383                    kind: CellKind::String,
384                    text,
385                    char_count: len,
386                    is_missing,
387                }
388            }
389            Value::StringArray(sa) if sa.data.len() == 1 => {
390                let text = sa.data.into_iter().next().unwrap_or_default();
391                let is_missing = is_missing_string(&text);
392                let len = if is_missing { 0 } else { string_length(&text) };
393                if !is_missing {
394                    auto_len = auto_len.max(len);
395                }
396                CellItem {
397                    kind: CellKind::String,
398                    text,
399                    char_count: len,
400                    is_missing,
401                }
402            }
403            Value::CharArray(ca) if ca.rows <= 1 => {
404                let text = if ca.rows == 0 {
405                    String::new()
406                } else {
407                    char_row_to_string_slice(&ca.data, ca.cols, 0)
408                };
409                let len = string_length(&text);
410                auto_len = auto_len.max(len);
411                CellItem {
412                    kind: CellKind::Char { rows: ca.rows },
413                    text,
414                    char_count: len,
415                    is_missing: false,
416                }
417            }
418            Value::CharArray(_) => return Err(CELL_ELEMENT_ERROR.to_string()),
419            _ => return Err(CELL_ELEMENT_ERROR.to_string()),
420        };
421        items.push(item);
422    }
423
424    let base_target = options.base_target(auto_len);
425    let mut results: Vec<Value> = Vec::with_capacity(total);
426    for item in items.into_iter() {
427        if item.is_missing {
428            results.push(Value::String(item.text));
429            continue;
430        }
431        let target_len = element_target_length(&options, base_target, item.char_count);
432        let padded = apply_padding_owned(item.text, item.char_count, target_len, &options);
433        match item.kind {
434            CellKind::String => results.push(Value::String(padded)),
435            CellKind::Char { rows } => {
436                let chars: Vec<char> = padded.chars().collect();
437                let cols = chars.len();
438                let array = CharArray::new(chars, rows, cols).map_err(|e| format!("pad: {e}"))?;
439                results.push(Value::CharArray(array));
440            }
441        }
442    }
443
444    make_cell(results, rows, cols).map_err(|e| format!("pad: {e}"))
445}
446
447#[derive(Clone)]
448struct CellItem {
449    kind: CellKind,
450    text: String,
451    char_count: usize,
452    is_missing: bool,
453}
454
455#[derive(Clone)]
456enum CellKind {
457    String,
458    Char { rows: usize },
459}
460
461fn parse_arguments(args: &[Value]) -> Result<PadOptions, String> {
462    let mut options = PadOptions::default();
463    match args.len() {
464        0 => Ok(options),
465        1 => {
466            if let Some(length) = parse_length(&args[0])? {
467                options.target = PadTarget::Length(length);
468                return Ok(options);
469            }
470            if let Some(direction) = try_parse_direction(&args[0], false)? {
471                options.direction = direction;
472                return Ok(options);
473            }
474            let pad_char = parse_pad_char(&args[0])?;
475            options.pad_char = pad_char;
476            Ok(options)
477        }
478        2 => {
479            if let Some(length) = parse_length(&args[0])? {
480                options.target = PadTarget::Length(length);
481                if let Some(direction) = try_parse_direction(&args[1], false)? {
482                    options.direction = direction;
483                } else {
484                    match parse_pad_char(&args[1]) {
485                        Ok(pad_char) => options.pad_char = pad_char,
486                        Err(_) => return Err(DIRECTION_ERROR.to_string()),
487                    }
488                }
489                Ok(options)
490            } else if let Some(direction) = try_parse_direction(&args[0], false)? {
491                options.direction = direction;
492                let pad_char = parse_pad_char(&args[1])?;
493                options.pad_char = pad_char;
494                Ok(options)
495            } else {
496                Err(ARGUMENT_CONFIG_ERROR.to_string())
497            }
498        }
499        3 => {
500            let length = parse_length(&args[0])?.ok_or_else(|| LENGTH_ERROR.to_string())?;
501            let direction =
502                try_parse_direction(&args[1], true)?.ok_or_else(|| DIRECTION_ERROR.to_string())?;
503            let pad_char = parse_pad_char(&args[2])?;
504            options.target = PadTarget::Length(length);
505            options.direction = direction;
506            options.pad_char = pad_char;
507            Ok(options)
508        }
509        _ => Err("pad: too many input arguments".to_string()),
510    }
511}
512
513fn parse_length(value: &Value) -> Result<Option<usize>, String> {
514    match value {
515        Value::Num(n) => {
516            if !n.is_finite() || *n < 0.0 {
517                return Err(LENGTH_ERROR.to_string());
518            }
519            if (n.fract()).abs() > f64::EPSILON {
520                return Err(LENGTH_ERROR.to_string());
521            }
522            Ok(Some(*n as usize))
523        }
524        Value::Int(i) => {
525            let val = i.to_i64();
526            if val < 0 {
527                return Err(LENGTH_ERROR.to_string());
528            }
529            Ok(Some(val as usize))
530        }
531        _ => Ok(None),
532    }
533}
534
535fn try_parse_direction(value: &Value, strict: bool) -> Result<Option<PadDirection>, String> {
536    let Some(text) = value_to_single_string(value) else {
537        return if strict {
538            Err(DIRECTION_ERROR.to_string())
539        } else {
540            Ok(None)
541        };
542    };
543    let lowered = text.trim().to_ascii_lowercase();
544    if lowered.is_empty() {
545        return if strict {
546            Err(DIRECTION_ERROR.to_string())
547        } else {
548            Ok(None)
549        };
550    }
551    let direction = match lowered.as_str() {
552        "left" => PadDirection::Left,
553        "right" => PadDirection::Right,
554        "both" => PadDirection::Both,
555        _ => {
556            return if strict {
557                Err(DIRECTION_ERROR.to_string())
558            } else {
559                Ok(None)
560            };
561        }
562    };
563    Ok(Some(direction))
564}
565
566fn parse_pad_char(value: &Value) -> Result<char, String> {
567    let text = value_to_single_string(value).ok_or_else(|| PAD_CHAR_ERROR.to_string())?;
568    let mut chars = text.chars();
569    let Some(first) = chars.next() else {
570        return Err(PAD_CHAR_ERROR.to_string());
571    };
572    if chars.next().is_some() {
573        return Err(PAD_CHAR_ERROR.to_string());
574    }
575    Ok(first)
576}
577
578fn value_to_single_string(value: &Value) -> Option<String> {
579    match value {
580        Value::String(text) => Some(text.clone()),
581        Value::StringArray(sa) => {
582            if sa.data.len() == 1 {
583                Some(sa.data[0].clone())
584            } else {
585                None
586            }
587        }
588        Value::CharArray(ca) if ca.rows <= 1 => {
589            if ca.rows == 0 {
590                Some(String::new())
591            } else {
592                Some(char_row_to_string_slice(&ca.data, ca.cols, 0))
593            }
594        }
595        _ => None,
596    }
597}
598
599fn string_length(text: &str) -> usize {
600    text.chars().count()
601}
602
603fn element_target_length(options: &PadOptions, base_target: usize, current_len: usize) -> usize {
604    match options.target {
605        PadTarget::Auto => base_target.max(current_len),
606        PadTarget::Length(_) => base_target.max(current_len),
607    }
608}
609
610fn apply_padding_owned(
611    text: String,
612    current_len: usize,
613    target_len: usize,
614    options: &PadOptions,
615) -> String {
616    if current_len >= target_len {
617        return text;
618    }
619    let delta = target_len - current_len;
620    let (left_pad, right_pad) = match options.direction {
621        PadDirection::Left => (delta, 0),
622        PadDirection::Right => (0, delta),
623        PadDirection::Both => {
624            let left = delta / 2;
625            (left, delta - left)
626        }
627    };
628    let mut result = String::with_capacity(text.len() + delta * options.pad_char.len_utf8());
629    for _ in 0..left_pad {
630        result.push(options.pad_char);
631    }
632    result.push_str(&text);
633    for _ in 0..right_pad {
634        result.push(options.pad_char);
635    }
636    result
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    #[cfg(any(feature = "doc_export", feature = "wgpu"))]
644    use crate::builtins::common::test_support;
645
646    #[test]
647    fn pad_string_length_right() {
648        let result = pad_builtin(Value::String("GPU".into()), vec![Value::Num(5.0)]).expect("pad");
649        assert_eq!(result, Value::String("GPU  ".into()));
650    }
651
652    #[test]
653    fn pad_string_left_with_custom_char() {
654        let result = pad_builtin(
655            Value::String("42".into()),
656            vec![
657                Value::Num(4.0),
658                Value::String("left".into()),
659                Value::String("0".into()),
660            ],
661        )
662        .expect("pad");
663        assert_eq!(result, Value::String("0042".into()));
664    }
665
666    #[test]
667    fn pad_string_both_with_odd_count() {
668        let result = pad_builtin(
669            Value::String("core".into()),
670            vec![
671                Value::Num(9.0),
672                Value::String("both".into()),
673                Value::String("*".into()),
674            ],
675        )
676        .expect("pad");
677        assert_eq!(result, Value::String("**core***".into()));
678    }
679
680    #[test]
681    fn pad_string_array_auto_uses_longest_element() {
682        let strings =
683            StringArray::new(vec!["GPU".into(), "Accelerate".into()], vec![2, 1]).unwrap();
684        let result = pad_builtin(Value::StringArray(strings), Vec::new()).expect("pad");
685        match result {
686            Value::StringArray(sa) => {
687                assert_eq!(sa.data[0], "GPU       ");
688                assert_eq!(sa.data[1], "Accelerate");
689            }
690            other => panic!("expected string array, got {other:?}"),
691        }
692    }
693
694    #[test]
695    fn pad_string_array_pad_character_only() {
696        let strings = StringArray::new(vec!["A".into(), "Run".into()], vec![2, 1]).unwrap();
697        let result =
698            pad_builtin(Value::StringArray(strings), vec![Value::String("*".into())]).expect("pad");
699        match result {
700            Value::StringArray(sa) => {
701                assert_eq!(sa.data[0], "A**");
702                assert_eq!(sa.data[1], "Run");
703            }
704            other => panic!("expected string array, got {other:?}"),
705        }
706    }
707
708    #[test]
709    fn pad_string_array_length_with_pad_character() {
710        let strings = StringArray::new(vec!["7".into(), "512".into()], vec![2, 1]).unwrap();
711        let result = pad_builtin(
712            Value::StringArray(strings),
713            vec![Value::Num(4.0), Value::String("0".into())],
714        )
715        .expect("pad");
716        match result {
717            Value::StringArray(sa) => {
718                assert_eq!(sa.data[0], "7000");
719                assert_eq!(sa.data[1], "5120");
720            }
721            other => panic!("expected string array, got {other:?}"),
722        }
723    }
724
725    #[test]
726    fn pad_string_array_direction_only() {
727        let strings =
728            StringArray::new(vec!["Mary".into(), "Elizabeth".into()], vec![2, 1]).unwrap();
729        let result = pad_builtin(
730            Value::StringArray(strings),
731            vec![Value::String("left".into())],
732        )
733        .expect("pad");
734        match result {
735            Value::StringArray(sa) => {
736                assert_eq!(sa.data[0], "     Mary");
737                assert_eq!(sa.data[1], "Elizabeth");
738            }
739            other => panic!("expected string array, got {other:?}"),
740        }
741    }
742
743    #[test]
744    fn pad_single_string_pad_character_only_leaves_length() {
745        let result =
746            pad_builtin(Value::String("GPU".into()), vec![Value::String("-".into())]).expect("pad");
747        assert_eq!(result, Value::String("GPU".into()));
748    }
749
750    #[test]
751    fn pad_char_array_resizes_columns() {
752        let chars: Vec<char> = "GPUrun".chars().collect();
753        let array = CharArray::new(chars, 2, 3).unwrap();
754        let result = pad_builtin(Value::CharArray(array), vec![Value::Num(5.0)]).expect("pad");
755        match result {
756            Value::CharArray(ca) => {
757                assert_eq!(ca.rows, 2);
758                assert_eq!(ca.cols, 5);
759                let expected: Vec<char> = "GPU  run  ".chars().collect();
760                assert_eq!(ca.data, expected);
761            }
762            other => panic!("expected char array, got {other:?}"),
763        }
764    }
765
766    #[test]
767    fn pad_cell_array_mixed_content() {
768        let cell = CellArray::new(
769            vec![
770                Value::String("solver".into()),
771                Value::CharArray(CharArray::new_row("jit")),
772                Value::String("planner".into()),
773            ],
774            1,
775            3,
776        )
777        .unwrap();
778        let result = pad_builtin(
779            Value::Cell(cell),
780            vec![Value::String("right".into()), Value::String(".".into())],
781        )
782        .expect("pad");
783        match result {
784            Value::Cell(out) => {
785                assert_eq!(out.rows, 1);
786                assert_eq!(out.cols, 3);
787                assert_eq!(out.get(0, 0).unwrap(), Value::String("solver.".into()));
788                assert_eq!(
789                    out.get(0, 1).unwrap(),
790                    Value::CharArray(CharArray::new_row("jit...."))
791                );
792                assert_eq!(out.get(0, 2).unwrap(), Value::String("planner".into()));
793            }
794            other => panic!("expected cell array, got {other:?}"),
795        }
796    }
797
798    #[test]
799    fn pad_preserves_missing_string() {
800        let result =
801            pad_builtin(Value::String("<missing>".into()), vec![Value::Num(8.0)]).expect("pad");
802        assert_eq!(result, Value::String("<missing>".into()));
803    }
804
805    #[test]
806    fn pad_errors_on_invalid_input_type() {
807        let err = pad_builtin(Value::Num(1.0), Vec::new()).unwrap_err();
808        assert_eq!(err, ARG_TYPE_ERROR);
809    }
810
811    #[test]
812    fn pad_errors_on_negative_length() {
813        let err = pad_builtin(Value::String("data".into()), vec![Value::Num(-1.0)]).unwrap_err();
814        assert_eq!(err, LENGTH_ERROR);
815    }
816
817    #[test]
818    fn pad_errors_on_invalid_direction() {
819        let err = pad_builtin(
820            Value::String("data".into()),
821            vec![Value::Num(6.0), Value::String("around".into())],
822        )
823        .unwrap_err();
824        assert_eq!(err, DIRECTION_ERROR);
825    }
826
827    #[test]
828    fn pad_errors_on_invalid_pad_character() {
829        let err = pad_builtin(
830            Value::String("data".into()),
831            vec![Value::String("left".into()), Value::String("##".into())],
832        )
833        .unwrap_err();
834        assert_eq!(err, PAD_CHAR_ERROR);
835    }
836
837    #[test]
838    #[cfg(feature = "wgpu")]
839    fn pad_works_with_wgpu_provider_active() {
840        test_support::with_test_provider(|_| {
841            let result =
842                pad_builtin(Value::String("GPU".into()), vec![Value::Num(6.0)]).expect("pad");
843            assert_eq!(result, Value::String("GPU   ".into()));
844        });
845    }
846
847    #[test]
848    #[cfg(feature = "doc_export")]
849    fn doc_examples_present() {
850        let blocks = test_support::doc_examples(DOC_MD);
851        assert!(!blocks.is_empty());
852    }
853}