Skip to main content

ferray_strings/
align.rs

1// ferray-strings: Alignment and padding operations (REQ-6)
2//
3// Implements center, ljust, rjust, zfill — elementwise on StringArray.
4//
5// ## REQ status
6//
7// SHIPPED:
8//   - REQ-6 alignment/padding — `center`, `ljust`, `ljust_with`, `rjust`,
9//     `rjust_with`, `zfill` (all `pub fn` in this file). Padding is measured
10//     in Unicode code points (`s.chars().count()`), matching what
11//     `numpy.strings`/`numpy.char` delegate to via CPython `str` methods.
12//     `center` reproduces CPython's left-bias rule exactly
13//     (`unicode_center_impl`: extra pad column goes LEFT iff both the margin
14//     and the target width are odd) — the #1086 left-bias fix. `zfill`
15//     preserves a leading sign before the zero-fill, like `str.zfill`.
16//
17// Consumers (non-test): re-exported from the crate root
18// (`ferray-strings/src/lib.rs` `pub use align::{center, ljust, ljust_with,
19// rjust, rjust_with, zfill}`) and bound at the Python surface by the
20// `#[pyfunction]` shims `center`, `ljust`, `rjust`, `zfill` in
21// `ferray-python/src/char.rs` (calling `fs::center`, `fs::ljust_with`,
22// `fs::rjust_with`, `fs::zfill`), which back `numpy.char`/`numpy.strings`.
23
24use ferray_core::dimension::Dimension;
25use ferray_core::error::FerrayResult;
26
27use crate::string_array::StringArray;
28
29/// Center each string in a field of the given width, padded with `fillchar`.
30///
31/// If the string is already longer than `width`, it is returned unchanged.
32///
33/// # Errors
34/// Returns an error if the internal array construction fails.
35pub fn center<D: Dimension>(
36    a: &StringArray<D>,
37    width: usize,
38    fillchar: char,
39) -> FerrayResult<StringArray<D>> {
40    a.map(|s| {
41        let char_count = s.chars().count();
42        if char_count >= width {
43            return s.to_string();
44        }
45        let total_pad = width - char_count;
46        // CPython `str.center` (Objects/unicodeobject.c `unicode_center_impl`):
47        // `left = marg / 2 + (marg & width & 1)`. The extra pad column goes
48        // LEFT iff both the margin and the target width are odd; otherwise the
49        // left side gets `floor(marg/2)`. numpy.strings/char.center mirror this.
50        let left_pad = total_pad / 2 + (total_pad & width & 1);
51        let right_pad = total_pad - left_pad;
52        let mut result = String::with_capacity(s.len() + total_pad);
53        for _ in 0..left_pad {
54            result.push(fillchar);
55        }
56        result.push_str(s);
57        for _ in 0..right_pad {
58            result.push(fillchar);
59        }
60        result
61    })
62}
63
64/// Left-justify each string in a field of the given width, padded with spaces.
65///
66/// If the string is already longer than `width`, it is returned unchanged.
67///
68/// # Errors
69/// Returns an error if the internal array construction fails.
70pub fn ljust<D: Dimension>(a: &StringArray<D>, width: usize) -> FerrayResult<StringArray<D>> {
71    ljust_with(a, width, ' ')
72}
73
74/// Left-justify each string in a field of the given width, padded with `fillchar`.
75///
76/// If the string is already longer than `width`, it is returned unchanged.
77///
78/// # Errors
79/// Returns an error if the internal array construction fails.
80pub fn ljust_with<D: Dimension>(
81    a: &StringArray<D>,
82    width: usize,
83    fillchar: char,
84) -> FerrayResult<StringArray<D>> {
85    a.map(|s| {
86        let char_count = s.chars().count();
87        if char_count >= width {
88            return s.to_string();
89        }
90        let pad = width - char_count;
91        let mut result = String::with_capacity(s.len() + pad * fillchar.len_utf8());
92        result.push_str(s);
93        for _ in 0..pad {
94            result.push(fillchar);
95        }
96        result
97    })
98}
99
100/// Right-justify each string in a field of the given width, padded with spaces.
101///
102/// If the string is already longer than `width`, it is returned unchanged.
103///
104/// # Errors
105/// Returns an error if the internal array construction fails.
106pub fn rjust<D: Dimension>(a: &StringArray<D>, width: usize) -> FerrayResult<StringArray<D>> {
107    rjust_with(a, width, ' ')
108}
109
110/// Right-justify each string in a field of the given width, padded with `fillchar`.
111///
112/// If the string is already longer than `width`, it is returned unchanged.
113///
114/// # Errors
115/// Returns an error if the internal array construction fails.
116pub fn rjust_with<D: Dimension>(
117    a: &StringArray<D>,
118    width: usize,
119    fillchar: char,
120) -> FerrayResult<StringArray<D>> {
121    a.map(|s| {
122        let char_count = s.chars().count();
123        if char_count >= width {
124            return s.to_string();
125        }
126        let pad = width - char_count;
127        let mut result = String::with_capacity(s.len() + pad * fillchar.len_utf8());
128        for _ in 0..pad {
129            result.push(fillchar);
130        }
131        result.push_str(s);
132        result
133    })
134}
135
136/// Pad each string on the left with zeros to fill the given width.
137///
138/// If the string starts with a sign (`+` or `-`), the sign is placed
139/// before the zeros. If the string is already longer than `width`,
140/// it is returned unchanged.
141///
142/// # Errors
143/// Returns an error if the internal array construction fails.
144pub fn zfill<D: Dimension>(a: &StringArray<D>, width: usize) -> FerrayResult<StringArray<D>> {
145    a.map(|s| {
146        let char_count = s.chars().count();
147        if char_count >= width {
148            return s.to_string();
149        }
150        let pad = width - char_count;
151        let (sign, rest) = if s.starts_with('+') || s.starts_with('-') {
152            (&s[..1], &s[1..])
153        } else {
154            ("", s)
155        };
156        let mut result = String::with_capacity(s.len() + pad);
157        result.push_str(sign);
158        for _ in 0..pad {
159            result.push('0');
160        }
161        result.push_str(rest);
162        result
163    })
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use crate::string_array::array;
170
171    #[test]
172    fn test_center() {
173        let a = array(&["hi", "x"]).unwrap();
174        let b = center(&a, 6, '*').unwrap();
175        assert_eq!(b.as_slice(), &["**hi**", "**x***"]);
176    }
177
178    #[test]
179    fn test_center_no_pad_needed() {
180        let a = array(&["hello"]).unwrap();
181        let b = center(&a, 3, ' ').unwrap();
182        assert_eq!(b.as_slice(), &["hello"]);
183    }
184
185    #[test]
186    fn test_ljust() {
187        let a = array(&["hi", "hello"]).unwrap();
188        let b = ljust(&a, 6).unwrap();
189        assert_eq!(b.as_slice(), &["hi    ", "hello "]);
190    }
191
192    #[test]
193    fn test_ljust_no_pad_needed() {
194        let a = array(&["hello"]).unwrap();
195        let b = ljust(&a, 3).unwrap();
196        assert_eq!(b.as_slice(), &["hello"]);
197    }
198
199    #[test]
200    fn test_rjust() {
201        let a = array(&["hi", "hello"]).unwrap();
202        let b = rjust(&a, 6).unwrap();
203        assert_eq!(b.as_slice(), &["    hi", " hello"]);
204    }
205
206    #[test]
207    fn test_ljust_with_fillchar() {
208        let a = array(&["hi"]).unwrap();
209        let b = ljust_with(&a, 6, '-').unwrap();
210        assert_eq!(b.as_slice(), &["hi----"]);
211    }
212
213    #[test]
214    fn test_rjust_with_fillchar() {
215        let a = array(&["hi"]).unwrap();
216        let b = rjust_with(&a, 6, '.').unwrap();
217        assert_eq!(b.as_slice(), &["....hi"]);
218    }
219
220    #[test]
221    fn test_ljust_with_unicode_fillchar() {
222        let a = array(&["ab"]).unwrap();
223        let b = ljust_with(&a, 5, '★').unwrap();
224        assert_eq!(b.as_slice(), &["ab★★★"]);
225    }
226
227    #[test]
228    fn test_zfill() {
229        let a = array(&["42", "-17", "+5", "abc"]).unwrap();
230        let b = zfill(&a, 5).unwrap();
231        assert_eq!(b.as_slice(), &["00042", "-0017", "+0005", "00abc"]);
232    }
233
234    #[test]
235    fn test_zfill_no_pad_needed() {
236        let a = array(&["12345"]).unwrap();
237        let b = zfill(&a, 3).unwrap();
238        assert_eq!(b.as_slice(), &["12345"]);
239    }
240}