ferray-strings 0.4.9

String operations on character arrays for ferray
Documentation
// ferray-strings: Alignment and padding operations (REQ-6)
//
// Implements center, ljust, rjust, zfill — elementwise on StringArray.
//
// ## REQ status
//
// SHIPPED:
//   - REQ-6 alignment/padding — `center`, `ljust`, `ljust_with`, `rjust`,
//     `rjust_with`, `zfill` (all `pub fn` in this file). Padding is measured
//     in Unicode code points (`s.chars().count()`), matching what
//     `numpy.strings`/`numpy.char` delegate to via CPython `str` methods.
//     `center` reproduces CPython's left-bias rule exactly
//     (`unicode_center_impl`: extra pad column goes LEFT iff both the margin
//     and the target width are odd) — the #1086 left-bias fix. `zfill`
//     preserves a leading sign before the zero-fill, like `str.zfill`.
//
// Consumers (non-test): re-exported from the crate root
// (`ferray-strings/src/lib.rs` `pub use align::{center, ljust, ljust_with,
// rjust, rjust_with, zfill}`) and bound at the Python surface by the
// `#[pyfunction]` shims `center`, `ljust`, `rjust`, `zfill` in
// `ferray-python/src/char.rs` (calling `fs::center`, `fs::ljust_with`,
// `fs::rjust_with`, `fs::zfill`), which back `numpy.char`/`numpy.strings`.

use ferray_core::dimension::Dimension;
use ferray_core::error::FerrayResult;

use crate::string_array::StringArray;

/// Center each string in a field of the given width, padded with `fillchar`.
///
/// If the string is already longer than `width`, it is returned unchanged.
///
/// # Errors
/// Returns an error if the internal array construction fails.
pub fn center<D: Dimension>(
    a: &StringArray<D>,
    width: usize,
    fillchar: char,
) -> FerrayResult<StringArray<D>> {
    a.map(|s| {
        let char_count = s.chars().count();
        if char_count >= width {
            return s.to_string();
        }
        let total_pad = width - char_count;
        // CPython `str.center` (Objects/unicodeobject.c `unicode_center_impl`):
        // `left = marg / 2 + (marg & width & 1)`. The extra pad column goes
        // LEFT iff both the margin and the target width are odd; otherwise the
        // left side gets `floor(marg/2)`. numpy.strings/char.center mirror this.
        let left_pad = total_pad / 2 + (total_pad & width & 1);
        let right_pad = total_pad - left_pad;
        let mut result = String::with_capacity(s.len() + total_pad);
        for _ in 0..left_pad {
            result.push(fillchar);
        }
        result.push_str(s);
        for _ in 0..right_pad {
            result.push(fillchar);
        }
        result
    })
}

/// Left-justify each string in a field of the given width, padded with spaces.
///
/// If the string is already longer than `width`, it is returned unchanged.
///
/// # Errors
/// Returns an error if the internal array construction fails.
pub fn ljust<D: Dimension>(a: &StringArray<D>, width: usize) -> FerrayResult<StringArray<D>> {
    ljust_with(a, width, ' ')
}

/// Left-justify each string in a field of the given width, padded with `fillchar`.
///
/// If the string is already longer than `width`, it is returned unchanged.
///
/// # Errors
/// Returns an error if the internal array construction fails.
pub fn ljust_with<D: Dimension>(
    a: &StringArray<D>,
    width: usize,
    fillchar: char,
) -> FerrayResult<StringArray<D>> {
    a.map(|s| {
        let char_count = s.chars().count();
        if char_count >= width {
            return s.to_string();
        }
        let pad = width - char_count;
        let mut result = String::with_capacity(s.len() + pad * fillchar.len_utf8());
        result.push_str(s);
        for _ in 0..pad {
            result.push(fillchar);
        }
        result
    })
}

/// Right-justify each string in a field of the given width, padded with spaces.
///
/// If the string is already longer than `width`, it is returned unchanged.
///
/// # Errors
/// Returns an error if the internal array construction fails.
pub fn rjust<D: Dimension>(a: &StringArray<D>, width: usize) -> FerrayResult<StringArray<D>> {
    rjust_with(a, width, ' ')
}

/// Right-justify each string in a field of the given width, padded with `fillchar`.
///
/// If the string is already longer than `width`, it is returned unchanged.
///
/// # Errors
/// Returns an error if the internal array construction fails.
pub fn rjust_with<D: Dimension>(
    a: &StringArray<D>,
    width: usize,
    fillchar: char,
) -> FerrayResult<StringArray<D>> {
    a.map(|s| {
        let char_count = s.chars().count();
        if char_count >= width {
            return s.to_string();
        }
        let pad = width - char_count;
        let mut result = String::with_capacity(s.len() + pad * fillchar.len_utf8());
        for _ in 0..pad {
            result.push(fillchar);
        }
        result.push_str(s);
        result
    })
}

/// Pad each string on the left with zeros to fill the given width.
///
/// If the string starts with a sign (`+` or `-`), the sign is placed
/// before the zeros. If the string is already longer than `width`,
/// it is returned unchanged.
///
/// # Errors
/// Returns an error if the internal array construction fails.
pub fn zfill<D: Dimension>(a: &StringArray<D>, width: usize) -> FerrayResult<StringArray<D>> {
    a.map(|s| {
        let char_count = s.chars().count();
        if char_count >= width {
            return s.to_string();
        }
        let pad = width - char_count;
        let (sign, rest) = if s.starts_with('+') || s.starts_with('-') {
            (&s[..1], &s[1..])
        } else {
            ("", s)
        };
        let mut result = String::with_capacity(s.len() + pad);
        result.push_str(sign);
        for _ in 0..pad {
            result.push('0');
        }
        result.push_str(rest);
        result
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::string_array::array;

    #[test]
    fn test_center() {
        let a = array(&["hi", "x"]).unwrap();
        let b = center(&a, 6, '*').unwrap();
        assert_eq!(b.as_slice(), &["**hi**", "**x***"]);
    }

    #[test]
    fn test_center_no_pad_needed() {
        let a = array(&["hello"]).unwrap();
        let b = center(&a, 3, ' ').unwrap();
        assert_eq!(b.as_slice(), &["hello"]);
    }

    #[test]
    fn test_ljust() {
        let a = array(&["hi", "hello"]).unwrap();
        let b = ljust(&a, 6).unwrap();
        assert_eq!(b.as_slice(), &["hi    ", "hello "]);
    }

    #[test]
    fn test_ljust_no_pad_needed() {
        let a = array(&["hello"]).unwrap();
        let b = ljust(&a, 3).unwrap();
        assert_eq!(b.as_slice(), &["hello"]);
    }

    #[test]
    fn test_rjust() {
        let a = array(&["hi", "hello"]).unwrap();
        let b = rjust(&a, 6).unwrap();
        assert_eq!(b.as_slice(), &["    hi", " hello"]);
    }

    #[test]
    fn test_ljust_with_fillchar() {
        let a = array(&["hi"]).unwrap();
        let b = ljust_with(&a, 6, '-').unwrap();
        assert_eq!(b.as_slice(), &["hi----"]);
    }

    #[test]
    fn test_rjust_with_fillchar() {
        let a = array(&["hi"]).unwrap();
        let b = rjust_with(&a, 6, '.').unwrap();
        assert_eq!(b.as_slice(), &["....hi"]);
    }

    #[test]
    fn test_ljust_with_unicode_fillchar() {
        let a = array(&["ab"]).unwrap();
        let b = ljust_with(&a, 5, '').unwrap();
        assert_eq!(b.as_slice(), &["ab★★★"]);
    }

    #[test]
    fn test_zfill() {
        let a = array(&["42", "-17", "+5", "abc"]).unwrap();
        let b = zfill(&a, 5).unwrap();
        assert_eq!(b.as_slice(), &["00042", "-0017", "+0005", "00abc"]);
    }

    #[test]
    fn test_zfill_no_pad_needed() {
        let a = array(&["12345"]).unwrap();
        let b = zfill(&a, 3).unwrap();
        assert_eq!(b.as_slice(), &["12345"]);
    }
}