ansi_align/
lib.rs

1use string_width::string_width;
2
3/// Alignment options for text
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum Alignment {
6    Left,
7    Center,
8    Right,
9}
10
11/// A type-safe wrapper for display width values
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub struct Width(usize);
14
15impl Width {
16    /// Create a new Width value
17    pub fn new(value: usize) -> Self {
18        Self(value)
19    }
20
21    /// Get the underlying usize value
22    pub fn get(self) -> usize {
23        self.0
24    }
25}
26
27impl From<usize> for Width {
28    fn from(value: usize) -> Self {
29        Self(value)
30    }
31}
32
33/// Options for text alignment
34#[derive(Debug, Clone)]
35pub struct AlignOptions {
36    /// The alignment type (left, center, right)
37    pub align: Alignment,
38    /// The string to split lines on (default: "\n")
39    pub split: String,
40    /// The padding character to use (default: " ")
41    pub pad: char,
42}
43
44impl Default for AlignOptions {
45    fn default() -> Self {
46        Self {
47            align: Alignment::Center,
48            split: "\n".to_string(),
49            pad: ' ',
50        }
51    }
52}
53
54impl AlignOptions {
55    /// Create new options with specified alignment
56    pub fn new(align: Alignment) -> Self {
57        Self {
58            align,
59            ..Default::default()
60        }
61    }
62
63    /// Set the split string using builder pattern
64    pub fn with_split<S: Into<String>>(mut self, split: S) -> Self {
65        self.split = split.into();
66        self
67    }
68
69    /// Set the padding character using builder pattern
70    pub fn with_pad(mut self, pad: char) -> Self {
71        self.pad = pad;
72        self
73    }
74}
75
76/// Efficiently create padding string for alignment
77fn create_padding(pad_char: char, count: usize) -> String {
78    match count {
79        0 => String::new(),
80        1 => pad_char.to_string(),
81        2..=8 => pad_char.to_string().repeat(count),
82        _ => {
83            let mut padding = String::with_capacity(count);
84            for _ in 0..count {
85                padding.push(pad_char);
86            }
87            padding
88        }
89    }
90}
91
92/// Align text with center alignment (default behavior)
93#[must_use]
94pub fn ansi_align(text: &str) -> String {
95    ansi_align_with_options(text, AlignOptions::default())
96}
97
98/// Align text with support for ANSI escape sequences
99///
100/// This function handles text containing ANSI escape sequences (like color codes)
101/// by calculating display width correctly, ignoring the escape sequences.
102///
103/// # Examples
104///
105/// ```
106/// use ansi_align::{ansi_align, ansi_align_with_options, Alignment, AlignOptions};
107///
108/// // Basic center alignment
109/// let result = ansi_align("hello\nworld");
110///
111/// // Right alignment with custom padding
112/// let opts = AlignOptions::new(Alignment::Right).with_pad('.');
113/// let result = ansi_align_with_options("hi\nhello", opts);
114/// assert_eq!(result, "...hi\nhello");
115///
116/// // Works with ANSI escape sequences
117/// let colored = "\x1b[31mred\x1b[0m\n\x1b[32mgreen\x1b[0m";
118/// let result = ansi_align_with_options(colored, AlignOptions::new(Alignment::Center));
119/// // ANSI codes are preserved but don't affect alignment calculation
120/// ```
121///
122/// # Performance
123///
124/// This function makes a single pass through the input text for optimal performance.
125/// For left alignment, it returns the input unchanged as an optimization.
126#[must_use]
127pub fn ansi_align_with_options(text: &str, opts: AlignOptions) -> String {
128    if text.is_empty() {
129        return text.to_string();
130    }
131
132    // Short-circuit left alignment as no-op
133    if opts.align == Alignment::Left {
134        return text.to_string();
135    }
136
137    // Single pass: collect line data and find max width simultaneously
138    let line_data: Vec<(&str, Width)> = text
139        .split(&opts.split)
140        .map(|line| (line, Width::from(string_width(line))))
141        .collect();
142
143    let max_width = line_data
144        .iter()
145        .map(|(_, width)| width.get())
146        .max()
147        .unwrap_or(0);
148
149    let aligned_lines: Vec<String> = line_data
150        .into_iter()
151        .map(|(line, width)| {
152            let padding_needed = match opts.align {
153                Alignment::Left => 0, // Already handled above
154                Alignment::Center => (max_width - width.get()) / 2,
155                Alignment::Right => max_width - width.get(),
156            };
157
158            if padding_needed == 0 {
159                line.to_string()
160            } else {
161                let mut result = create_padding(opts.pad, padding_needed);
162                result.push_str(line);
163                result
164            }
165        })
166        .collect();
167
168    aligned_lines.join(&opts.split)
169}
170
171/// Align text to the left (no-op, returns original text)
172#[must_use]
173pub fn left(text: &str) -> String {
174    ansi_align_with_options(text, AlignOptions::new(Alignment::Left))
175}
176
177/// Align text to the center
178#[must_use]
179pub fn center(text: &str) -> String {
180    ansi_align_with_options(text, AlignOptions::new(Alignment::Center))
181}
182
183/// Align text to the right
184#[must_use]
185pub fn right(text: &str) -> String {
186    ansi_align_with_options(text, AlignOptions::new(Alignment::Right))
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    // Basic alignment tests
194    #[test]
195    fn test_left_alignment() {
196        let text = "hello\nworld";
197        let result = left(text);
198        assert_eq!(result, text); // Left alignment should be no-op
199    }
200
201    #[test]
202    fn test_center_alignment() {
203        let text = "hi\nhello";
204        let result = center(text);
205        let lines: Vec<&str> = result.split('\n').collect();
206        assert_eq!(lines[0], " hi"); // 1 space padding for "hi"
207        assert_eq!(lines[1], "hello"); // No padding for "hello"
208    }
209
210    #[test]
211    fn test_right_alignment() {
212        let text = "hi\nhello";
213        let result = right(text);
214        let lines: Vec<&str> = result.split('\n').collect();
215        assert_eq!(lines[0], "   hi"); // 3 spaces padding for "hi"
216        assert_eq!(lines[1], "hello"); // No padding for "hello"
217    }
218
219    // Unicode and ANSI tests
220    #[test]
221    fn test_unicode_characters() {
222        let text = "古\n古古古";
223        let result = center(text);
224        let lines: Vec<&str> = result.split('\n').collect();
225        assert_eq!(lines[0], "  古"); // 2 spaces padding (CJK char is width 2)
226        assert_eq!(lines[1], "古古古"); // No padding
227    }
228
229    #[test]
230    fn test_ansi_escape_sequences() {
231        let text = "hello\n\u{001B}[1mworld\u{001B}[0m";
232        let result = center(text);
233        let lines: Vec<&str> = result.split('\n').collect();
234        assert_eq!(lines[0], "hello");
235        assert_eq!(lines[1], "\u{001B}[1mworld\u{001B}[0m"); // ANSI codes preserved
236    }
237
238    #[test]
239    fn test_complex_ansi_sequences() {
240        // Test with multiple ANSI codes and colors
241        let text = "\x1b[31m\x1b[1mred\x1b[0m\n\x1b[32mgreen text\x1b[0m";
242        let result = right(text);
243        let lines: Vec<&str> = result.split('\n').collect();
244        // "red" has display width 3, "green text" has width 10
245        assert_eq!(lines[0], "       \x1b[31m\x1b[1mred\x1b[0m"); // 7 spaces padding
246        assert_eq!(lines[1], "\x1b[32mgreen text\x1b[0m"); // No padding
247    }
248
249    // Edge cases
250    #[test]
251    fn test_empty_string() {
252        assert_eq!(ansi_align_with_options("", AlignOptions::default()), "");
253        assert_eq!(left(""), "");
254        assert_eq!(center(""), "");
255        assert_eq!(right(""), "");
256    }
257
258    #[test]
259    fn test_single_line() {
260        let text = "hello";
261        assert_eq!(left(text), "hello");
262        assert_eq!(center(text), "hello");
263        assert_eq!(right(text), "hello");
264    }
265
266    #[test]
267    fn test_single_character() {
268        let text = "a\nb";
269        let result = center(text);
270        assert_eq!(result, "a\nb"); // Both lines same width, no padding needed
271    }
272
273    #[test]
274    fn test_whitespace_only() {
275        let text = "   \n ";
276        let result = center(text);
277        let lines: Vec<&str> = result.split('\n').collect();
278        assert_eq!(lines[0], "   "); // 3 spaces, no padding needed
279        assert_eq!(lines[1], "  "); // 1 space + 1 padding space
280    }
281
282    // Custom options tests
283    #[test]
284    fn test_custom_split_and_pad() {
285        let text = "a|bb";
286        let opts = AlignOptions::new(Alignment::Right)
287            .with_split("|")
288            .with_pad('.');
289        let result = ansi_align_with_options(text, opts);
290        assert_eq!(result, ".a|bb");
291    }
292
293    #[test]
294    fn test_custom_split_multichar() {
295        let text = "short<->very long line";
296        let opts = AlignOptions::new(Alignment::Center).with_split("<->");
297        let result = ansi_align_with_options(text, opts);
298        assert_eq!(result, "    short<->very long line");
299    }
300
301    #[test]
302    fn test_different_padding_chars() {
303        let text = "hi\nhello";
304
305        // Test dot padding
306        let opts = AlignOptions::new(Alignment::Right).with_pad('.');
307        let result = ansi_align_with_options(text, opts);
308        assert_eq!(result, "...hi\nhello");
309
310        // Test underscore padding
311        let opts = AlignOptions::new(Alignment::Center).with_pad('_');
312        let result = ansi_align_with_options(text, opts);
313        assert_eq!(result, "_hi\nhello");
314
315        // Test zero padding
316        let opts = AlignOptions::new(Alignment::Right).with_pad('0');
317        let result = ansi_align_with_options(text, opts);
318        assert_eq!(result, "000hi\nhello");
319    }
320
321    // Performance and memory optimization tests
322    #[test]
323    fn test_large_padding() {
324        let text = format!("a\n{}", "b".repeat(100));
325        let result = right(&text);
326        let lines: Vec<&str> = result.split('\n').collect();
327        assert_eq!(lines[0].len(), 100); // 99 spaces + "a"
328        assert!(lines[0].starts_with(&" ".repeat(99)));
329        assert!(lines[0].ends_with("a"));
330        assert_eq!(lines[1], "b".repeat(100));
331    }
332
333    #[test]
334    fn test_no_padding_optimization() {
335        // Test that lines requiring no padding are handled efficiently
336        let text = "same\nsame\nsame";
337        let result = center(text);
338        assert_eq!(result, text); // Should be unchanged
339    }
340
341    // Width type tests
342    #[test]
343    fn test_width_type() {
344        let width = Width::new(42);
345        assert_eq!(width.get(), 42);
346
347        let width_from_usize: Width = 24.into();
348        assert_eq!(width_from_usize.get(), 24);
349
350        // Test ordering
351        assert!(Width::new(10) < Width::new(20));
352        assert_eq!(Width::new(15), Width::new(15));
353    }
354
355    // Comprehensive alignment scenarios
356    #[test]
357    fn test_mixed_width_lines() {
358        let text = "a\nbb\nccc\ndddd\neeeee";
359
360        // Center alignment
361        let result = center(text);
362        let lines: Vec<&str> = result.split('\n').collect();
363
364        // The longest line is "eeeee" with 5 chars
365        // So padding for center should be: (5-1)/2=2, (5-2)/2=1, (5-3)/2=1, (5-4)/2=0, (5-5)/2=0
366        assert_eq!(lines[0], "  a"); // 2 spaces + "a"
367        assert_eq!(lines[1], " bb"); // 1 space + "bb"
368        assert_eq!(lines[2], " ccc"); // 1 space + "ccc" (corrected)
369        assert_eq!(lines[3], "dddd"); // no padding (corrected)
370        assert_eq!(lines[4], "eeeee"); // no padding
371
372        // Right alignment
373        let result = right(text);
374        let lines: Vec<&str> = result.split('\n').collect();
375        assert_eq!(lines[0], "    a"); // 4 spaces + "a"
376        assert_eq!(lines[1], "   bb"); // 3 spaces + "bb"
377        assert_eq!(lines[2], "  ccc"); // 2 spaces + "ccc"
378        assert_eq!(lines[3], " dddd"); // 1 space + "dddd"
379        assert_eq!(lines[4], "eeeee"); // no padding
380    }
381
382    #[test]
383    fn test_center_odd_padding() {
384        // Test center alignment with odd padding amounts
385        let text = "a\nbbbb";
386        let result = center(text);
387        let lines: Vec<&str> = result.split('\n').collect();
388        assert_eq!(lines[0], " a"); // (4-1)/2 = 1 space
389        assert_eq!(lines[1], "bbbb"); // no padding
390    }
391
392    #[test]
393    fn test_multiline_with_empty_lines() {
394        let text = "hello\n\nworld";
395        let result = center(text);
396        let lines: Vec<&str> = result.split('\n').collect();
397        assert_eq!(lines[0], "hello");
398        assert_eq!(lines[1], "  "); // 2 spaces for empty line (center of 5-char width)
399        assert_eq!(lines[2], "world");
400    }
401
402    // Regression tests for performance improvements
403    #[test]
404    fn test_no_unnecessary_allocations() {
405        // This test ensures we don't regress on the performance improvements
406        let text = "line1\nline2\nline3";
407        let result = left(text);
408        // Left alignment should return original string (no allocations for processing)
409        assert_eq!(result, text);
410    }
411
412    #[test]
413    fn test_padding_efficiency() {
414        // Test the efficient padding creation for different sizes
415        let text = format!("a\n{}", "b".repeat(20));
416
417        // Small padding (should use repeat)
418        let opts = AlignOptions::new(Alignment::Right);
419        let result = ansi_align_with_options("a\nbb", opts.clone());
420        assert_eq!(result, " a\nbb");
421
422        // Large padding (should use with_capacity)
423        let result = ansi_align_with_options(&text, opts);
424        let lines: Vec<&str> = result.split('\n').collect();
425        assert_eq!(lines[0].len(), 20); // 19 spaces + "a"
426        assert!(lines[0].ends_with("a"));
427    }
428
429    // Integration tests
430    #[test]
431    fn test_real_world_scenario() {
432        // Simulate aligning a simple table or menu
433        let menu = "Home\nAbout Us\nContact\nServices";
434        let result = center(menu);
435        let lines: Vec<&str> = result.split('\n').collect();
436
437        // "About Us" and "Services" are both 8 chars (longest)
438        assert_eq!(lines[0], "  Home"); // 2 spaces (8-4)/2 = 2
439        assert_eq!(lines[1], "About Us"); // no padding (8 chars)
440        assert_eq!(lines[2], "Contact"); // no padding - "Contact" is 7 chars, (8-7)/2 = 0
441        assert_eq!(lines[3], "Services"); // no padding (8 chars)
442    }
443
444    #[test]
445    fn test_code_alignment() {
446        // Test aligning code-like content
447        let code = "if x:\n    return y\nelse:\n    return z";
448        let result = right(code);
449        let lines: Vec<&str> = result.split('\n').collect();
450
451        // "    return y" and "    return z" are longest at 12 chars
452        assert_eq!(lines[0], "       if x:"); // 7 spaces + "if x:" (12-5=7)
453        assert_eq!(lines[1], "    return y"); // no padding (12 chars)
454        assert_eq!(lines[2], "       else:"); // 7 spaces + "else:" (12-5=7)
455        assert_eq!(lines[3], "    return z"); // no padding (12 chars)
456    }
457}