ansi_align/
lib.rs

1//! # ansi-align
2//!
3//! A Rust library for aligning text with proper support for ANSI escape sequences and Unicode characters.
4//!
5//! This crate provides functions to align text in various ways (left, center, right) while correctly
6//! handling ANSI escape sequences (like color codes) and Unicode characters with varying display widths.
7//!
8//! ## Features
9//!
10//! - **ANSI-aware alignment**: Correctly handles text containing ANSI escape sequences
11//! - **Unicode support**: Properly calculates display width for Unicode characters including CJK
12//! - **Multiple alignment options**: Left, center, and right alignment
13//! - **Customizable**: Configure split strings and padding characters
14//! - **Performance optimized**: Single-pass processing with efficient memory usage
15//! - **Type-safe**: Uses a [`Width`] type for display width values
16//!
17//! ## Quick Start
18//!
19//! ```rust
20//! use ansi_align::{ansi_align, center, left, right};
21//!
22//! // Basic alignment (defaults to center)
23//! let text = "hello\nworld";
24//! let centered = ansi_align(text);
25//!
26//! // Specific alignment functions
27//! let left_aligned = left("short\nlonger line");
28//! let centered = center("short\nlonger line");
29//! let right_aligned = right("short\nlonger line");
30//! ```
31//!
32//! ## Advanced Usage
33//!
34//! ```rust
35//! use ansi_align::{ansi_align_with_options, Alignment, AlignOptions};
36//!
37//! let text = "line1|line2|line3";
38//! let options = AlignOptions::new(Alignment::Right)
39//!     .with_split("|")
40//!     .with_pad('.');
41//!
42//! let result = ansi_align_with_options(text, &options);
43//! ```
44
45#![deny(missing_docs)]
46#![warn(clippy::all)]
47#![warn(clippy::pedantic)]
48#![warn(clippy::nursery)]
49
50use string_width::string_width;
51
52/// Specifies the alignment direction for text.
53///
54/// This enum defines the three possible alignment options that can be used
55/// with the alignment functions.
56///
57/// # Examples
58///
59/// ```rust
60/// use ansi_align::{Alignment, AlignOptions, ansi_align_with_options};
61///
62/// let text = "hello\nworld";
63///
64/// // Center alignment
65/// let opts = AlignOptions::new(Alignment::Center);
66/// let result = ansi_align_with_options(text, &opts);
67///
68/// // Right alignment
69/// let opts = AlignOptions::new(Alignment::Right);
70/// let result = ansi_align_with_options(text, &opts);
71/// ```
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Alignment {
74    /// Align text to the left (no padding added to the left side)
75    Left,
76    /// Align text to the center (padding distributed evenly on both sides)
77    Center,
78    /// Align text to the right (padding added to the left side)
79    Right,
80}
81
82/// A type-safe wrapper for display width values.
83///
84/// This type represents the visual width of text as it would appear on screen,
85/// taking into account ANSI escape sequences and Unicode character widths.
86/// It provides type safety to prevent confusion between byte lengths and display widths.
87///
88/// # Examples
89///
90/// ```rust
91/// use ansi_align::Width;
92///
93/// let width = Width::new(42);
94/// assert_eq!(width.get(), 42);
95///
96/// // Convert from usize
97/// let width: Width = 24.into();
98/// assert_eq!(width.get(), 24);
99///
100/// // Ordering works as expected
101/// assert!(Width::new(10) < Width::new(20));
102/// ```
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
104pub struct Width(usize);
105
106impl Width {
107    /// Creates a new `Width` value from a `usize`.
108    ///
109    /// # Arguments
110    ///
111    /// * `value` - The display width value
112    ///
113    /// # Examples
114    ///
115    /// ```rust
116    /// use ansi_align::Width;
117    ///
118    /// let width = Width::new(10);
119    /// assert_eq!(width.get(), 10);
120    /// ```
121    #[must_use]
122    pub const fn new(value: usize) -> Self {
123        Self(value)
124    }
125
126    /// Returns the underlying `usize` value.
127    ///
128    /// # Examples
129    ///
130    /// ```rust
131    /// use ansi_align::Width;
132    ///
133    /// let width = Width::new(42);
134    /// assert_eq!(width.get(), 42);
135    /// ```
136    #[must_use]
137    pub const fn get(self) -> usize {
138        self.0
139    }
140}
141
142impl From<usize> for Width {
143    fn from(value: usize) -> Self {
144        Self(value)
145    }
146}
147
148/// Configuration options for text alignment operations.
149///
150/// This struct allows you to customize how text alignment is performed,
151/// including the alignment direction, line separator, and padding character.
152///
153/// # Examples
154///
155/// ```rust
156/// use ansi_align::{AlignOptions, Alignment, ansi_align_with_options};
157///
158/// // Basic usage with default settings
159/// let opts = AlignOptions::new(Alignment::Center);
160///
161/// // Customized options
162/// let opts = AlignOptions::new(Alignment::Right)
163///     .with_split("|")
164///     .with_pad('.');
165///
166/// let text = "short|longer text";
167/// let result = ansi_align_with_options(text, &opts);
168/// ```
169#[derive(Debug, Clone)]
170pub struct AlignOptions {
171    /// The alignment type (left, center, right)
172    pub align: Alignment,
173    /// The string to split lines on (default: "\n")
174    pub split: String,
175    /// The padding character to use (default: " ")
176    pub pad: char,
177}
178
179impl Default for AlignOptions {
180    fn default() -> Self {
181        Self {
182            align: Alignment::Center,
183            split: "\n".to_string(),
184            pad: ' ',
185        }
186    }
187}
188
189impl AlignOptions {
190    /// Creates new alignment options with the specified alignment direction.
191    ///
192    /// Uses default values for split string (`"\n"`) and padding character (`' '`).
193    ///
194    /// # Arguments
195    ///
196    /// * `align` - The alignment direction to use
197    ///
198    /// # Examples
199    ///
200    /// ```rust
201    /// use ansi_align::{AlignOptions, Alignment};
202    ///
203    /// let opts = AlignOptions::new(Alignment::Center);
204    /// ```
205    #[must_use]
206    pub fn new(align: Alignment) -> Self {
207        Self {
208            align,
209            ..Default::default()
210        }
211    }
212
213    /// Sets the string used to split lines using the builder pattern.
214    ///
215    /// By default, lines are split on `"\n"`, but you can specify any string
216    /// as a line separator.
217    ///
218    /// # Arguments
219    ///
220    /// * `split` - The string to use as a line separator
221    ///
222    /// # Examples
223    ///
224    /// ```rust
225    /// use ansi_align::{AlignOptions, Alignment};
226    ///
227    /// let opts = AlignOptions::new(Alignment::Center)
228    ///     .with_split("|")
229    ///     .with_split("<->"); // Multi-character separators work too
230    /// ```
231    #[must_use]
232    pub fn with_split<S: Into<String>>(mut self, split: S) -> Self {
233        self.split = split.into();
234        self
235    }
236
237    /// Sets the character used for padding using the builder pattern.
238    ///
239    /// By default, spaces (`' '`) are used for padding, but you can specify
240    /// any character.
241    ///
242    /// # Arguments
243    ///
244    /// * `pad` - The character to use for padding
245    ///
246    /// # Examples
247    ///
248    /// ```rust
249    /// use ansi_align::{AlignOptions, Alignment};
250    ///
251    /// let opts = AlignOptions::new(Alignment::Right)
252    ///     .with_pad('.');
253    /// ```
254    #[must_use]
255    pub const fn with_pad(mut self, pad: char) -> Self {
256        self.pad = pad;
257        self
258    }
259}
260
261/// Efficiently create padding string for alignment
262fn create_padding(pad_char: char, count: usize) -> String {
263    match count {
264        0 => String::new(),
265        1 => pad_char.to_string(),
266        2..=8 => pad_char.to_string().repeat(count),
267        _ => {
268            let mut padding = String::with_capacity(count);
269            for _ in 0..count {
270                padding.push(pad_char);
271            }
272            padding
273        }
274    }
275}
276
277/// Align text with center alignment (default behavior)
278#[must_use]
279pub fn ansi_align(text: &str) -> String {
280    ansi_align_with_options(text, &AlignOptions::default())
281}
282
283/// Align text with support for ANSI escape sequences
284///
285/// This function handles text containing ANSI escape sequences (like color codes)
286/// by calculating display width correctly, ignoring the escape sequences.
287///
288/// # Examples
289///
290/// ```
291/// use ansi_align::{ansi_align, ansi_align_with_options, Alignment, AlignOptions};
292///
293/// // Basic center alignment
294/// let result = ansi_align("hello\nworld");
295///
296/// // Right alignment with custom padding
297/// let opts = AlignOptions::new(Alignment::Right).with_pad('.');
298/// let result = ansi_align_with_options("hi\nhello", &opts);
299///
300/// // Works with ANSI escape sequences
301/// let colored = "\x1b[31mred\x1b[0m\n\x1b[32mgreen\x1b[0m";
302/// let result = ansi_align_with_options(colored, &AlignOptions::new(Alignment::Center));
303/// ```
304///
305/// # Performance
306///
307/// This function makes a single pass through the input text for optimal performance.
308/// For left alignment, it returns the input unchanged as an optimization.
309#[must_use]
310pub fn ansi_align_with_options(text: &str, opts: &AlignOptions) -> String {
311    if text.is_empty() {
312        return text.to_string();
313    }
314
315    // Short-circuit left alignment as no-op
316    if opts.align == Alignment::Left {
317        return text.to_string();
318    }
319
320    // Single pass: collect line data and find max width simultaneously
321    let line_data: Vec<(&str, Width)> = text
322        .split(&opts.split)
323        .map(|line| (line, Width::from(string_width(line))))
324        .collect();
325
326    let max_width = line_data
327        .iter()
328        .map(|(_, width)| width.get())
329        .max()
330        .unwrap_or(0);
331
332    let aligned_lines: Vec<String> = line_data
333        .into_iter()
334        .map(|(line, width)| {
335            let padding_needed = match opts.align {
336                Alignment::Left => 0, // Already handled above
337                Alignment::Center => (max_width - width.get()) / 2,
338                Alignment::Right => max_width - width.get(),
339            };
340
341            if padding_needed == 0 {
342                line.to_string()
343            } else {
344                let mut result = create_padding(opts.pad, padding_needed);
345                result.push_str(line);
346                result
347            }
348        })
349        .collect();
350
351    aligned_lines.join(&opts.split)
352}
353
354/// Align text to the left (no-op, returns original text)
355#[must_use]
356pub fn left(text: &str) -> String {
357    ansi_align_with_options(text, &AlignOptions::new(Alignment::Left))
358}
359
360/// Align text to the center
361#[must_use]
362pub fn center(text: &str) -> String {
363    ansi_align_with_options(text, &AlignOptions::new(Alignment::Center))
364}
365
366/// Align text to the right
367#[must_use]
368pub fn right(text: &str) -> String {
369    ansi_align_with_options(text, &AlignOptions::new(Alignment::Right))
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    // Basic alignment tests
377    #[test]
378    fn test_left_alignment() {
379        let text = "hello\nworld";
380        let result = left(text);
381        assert_eq!(result, text); // Left alignment should be no-op
382    }
383
384    #[test]
385    fn test_center_alignment() {
386        let text = "hi\nhello";
387        let result = center(text);
388        let lines: Vec<&str> = result.split('\n').collect();
389        assert_eq!(lines[0], " hi"); // 1 space padding for "hi"
390        assert_eq!(lines[1], "hello"); // No padding for "hello"
391    }
392
393    #[test]
394    fn test_right_alignment() {
395        let text = "hi\nhello";
396        let result = right(text);
397        let lines: Vec<&str> = result.split('\n').collect();
398        assert_eq!(lines[0], "   hi"); // 3 spaces padding for "hi"
399        assert_eq!(lines[1], "hello"); // No padding for "hello"
400    }
401
402    // Unicode and ANSI tests
403    #[test]
404    fn test_unicode_characters() {
405        let text = "古\n古古古";
406        let result = center(text);
407        let lines: Vec<&str> = result.split('\n').collect();
408        assert_eq!(lines[0], "  古"); // 2 spaces padding (CJK char is width 2)
409        assert_eq!(lines[1], "古古古"); // No padding
410    }
411
412    #[test]
413    fn test_ansi_escape_sequences() {
414        let text = "hello\n\u{001B}[1mworld\u{001B}[0m";
415        let result = center(text);
416        let lines: Vec<&str> = result.split('\n').collect();
417        assert_eq!(lines[0], "hello");
418        assert_eq!(lines[1], "\u{001B}[1mworld\u{001B}[0m"); // ANSI codes preserved
419    }
420
421    #[test]
422    fn test_complex_ansi_sequences() {
423        // Test with multiple ANSI codes and colors
424        let text = "\x1b[31m\x1b[1mred\x1b[0m\n\x1b[32mgreen text\x1b[0m";
425        let result = right(text);
426        let lines: Vec<&str> = result.split('\n').collect();
427        // "red" has display width 3, "green text" has width 10
428        assert_eq!(lines[0], "       \x1b[31m\x1b[1mred\x1b[0m"); // 7 spaces padding
429        assert_eq!(lines[1], "\x1b[32mgreen text\x1b[0m"); // No padding
430    }
431
432    // Edge cases
433    #[test]
434    fn test_empty_string() {
435        assert_eq!(ansi_align_with_options("", &AlignOptions::default()), "");
436        assert_eq!(left(""), "");
437        assert_eq!(center(""), "");
438        assert_eq!(right(""), "");
439    }
440
441    #[test]
442    fn test_single_line() {
443        let text = "hello";
444        assert_eq!(left(text), "hello");
445        assert_eq!(center(text), "hello");
446        assert_eq!(right(text), "hello");
447    }
448
449    #[test]
450    fn test_single_character() {
451        let text = "a\nb";
452        let result = center(text);
453        assert_eq!(result, "a\nb"); // Both lines same width, no padding needed
454    }
455
456    #[test]
457    fn test_whitespace_only() {
458        let text = "   \n ";
459        let result = center(text);
460        let lines: Vec<&str> = result.split('\n').collect();
461        assert_eq!(lines[0], "   "); // 3 spaces, no padding needed
462        assert_eq!(lines[1], "  "); // 1 space + 1 padding space
463    }
464
465    // Custom options tests
466    #[test]
467    fn test_custom_split_and_pad() {
468        let text = "a|bb";
469        let opts = AlignOptions::new(Alignment::Right)
470            .with_split("|")
471            .with_pad('.');
472        let result = ansi_align_with_options(text, &opts);
473        assert_eq!(result, ".a|bb");
474    }
475
476    #[test]
477    fn test_custom_split_multichar() {
478        let text = "short<->very long line";
479        let opts = AlignOptions::new(Alignment::Center).with_split("<->");
480        let result = ansi_align_with_options(text, &opts);
481        assert_eq!(result, "    short<->very long line");
482    }
483
484    #[test]
485    fn test_different_padding_chars() {
486        let text = "hi\nhello";
487
488        // Test dot padding
489        let opts = AlignOptions::new(Alignment::Right).with_pad('.');
490        let result = ansi_align_with_options(text, &opts);
491        assert_eq!(result, "...hi\nhello");
492
493        // Test underscore padding
494        let opts = AlignOptions::new(Alignment::Center).with_pad('_');
495        let result = ansi_align_with_options(text, &opts);
496        assert_eq!(result, "_hi\nhello");
497
498        // Test zero padding
499        let opts = AlignOptions::new(Alignment::Right).with_pad('0');
500        let result = ansi_align_with_options(text, &opts);
501        assert_eq!(result, "000hi\nhello");
502    }
503
504    // Performance and memory optimization tests
505    #[test]
506    fn test_large_padding() {
507        let text = format!("a\n{}", "b".repeat(100));
508        let result = right(&text);
509        let lines: Vec<&str> = result.split('\n').collect();
510        assert_eq!(lines[0].len(), 100); // 99 spaces + "a"
511        assert!(lines[0].starts_with(&" ".repeat(99)));
512        assert!(lines[0].ends_with('a'));
513        assert_eq!(lines[1], "b".repeat(100));
514    }
515
516    #[test]
517    fn test_no_padding_optimization() {
518        // Test that lines requiring no padding are handled efficiently
519        let text = "same\nsame\nsame";
520        let result = center(text);
521        assert_eq!(result, text); // Should be unchanged
522    }
523
524    // Width type tests
525    #[test]
526    fn test_width_type() {
527        let width = Width::new(42);
528        assert_eq!(width.get(), 42);
529
530        let width_from_usize: Width = 24.into();
531        assert_eq!(width_from_usize.get(), 24);
532
533        // Test ordering
534        assert!(Width::new(10) < Width::new(20));
535        assert_eq!(Width::new(15), Width::new(15));
536    }
537
538    // Comprehensive alignment scenarios
539    #[test]
540    fn test_mixed_width_lines() {
541        let text = "a\nbb\nccc\ndddd\neeeee";
542
543        // Center alignment
544        let result = center(text);
545        let lines: Vec<&str> = result.split('\n').collect();
546
547        // The longest line is "eeeee" with 5 chars
548        // 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
549        assert_eq!(lines[0], "  a"); // 2 spaces + "a"
550        assert_eq!(lines[1], " bb"); // 1 space + "bb"
551        assert_eq!(lines[2], " ccc"); // 1 space + "ccc" (corrected)
552        assert_eq!(lines[3], "dddd"); // no padding (corrected)
553        assert_eq!(lines[4], "eeeee"); // no padding
554
555        // Right alignment
556        let result = right(text);
557        let lines: Vec<&str> = result.split('\n').collect();
558        assert_eq!(lines[0], "    a"); // 4 spaces + "a"
559        assert_eq!(lines[1], "   bb"); // 3 spaces + "bb"
560        assert_eq!(lines[2], "  ccc"); // 2 spaces + "ccc"
561        assert_eq!(lines[3], " dddd"); // 1 space + "dddd"
562        assert_eq!(lines[4], "eeeee"); // no padding
563    }
564
565    #[test]
566    fn test_center_odd_padding() {
567        // Test center alignment with odd padding amounts
568        let text = "a\nbbbb";
569        let result = center(text);
570        let lines: Vec<&str> = result.split('\n').collect();
571        assert_eq!(lines[0], " a"); // (4-1)/2 = 1 space
572        assert_eq!(lines[1], "bbbb"); // no padding
573    }
574
575    #[test]
576    fn test_multiline_with_empty_lines() {
577        let text = "hello\n\nworld";
578        let result = center(text);
579        let lines: Vec<&str> = result.split('\n').collect();
580        assert_eq!(lines[0], "hello");
581        assert_eq!(lines[1], "  "); // 2 spaces for empty line (center of 5-char width)
582        assert_eq!(lines[2], "world");
583    }
584
585    // Regression tests for performance improvements
586    #[test]
587    fn test_no_unnecessary_allocations() {
588        // This test ensures we don't regress on the performance improvements
589        let text = "line1\nline2\nline3";
590        let result = left(text);
591        // Left alignment should return original string (no allocations for processing)
592        assert_eq!(result, text);
593    }
594
595    #[test]
596    fn test_padding_efficiency() {
597        // Test the efficient padding creation for different sizes
598        let text = format!("a\n{}", "b".repeat(20));
599
600        // Small padding (should use repeat)
601        let opts = AlignOptions::new(Alignment::Right);
602        let result = ansi_align_with_options("a\nbb", &opts);
603        assert_eq!(result, " a\nbb");
604
605        // Large padding (should use with_capacity)
606        let result = ansi_align_with_options(&text, &opts);
607        let lines: Vec<&str> = result.split('\n').collect();
608        assert_eq!(lines[0].len(), 20); // 19 spaces + "a"
609        assert!(lines[0].ends_with('a'));
610    }
611
612    // Integration tests
613    #[test]
614    fn test_real_world_scenario() {
615        // Simulate aligning a simple table or menu
616        let menu = "Home\nAbout Us\nContact\nServices";
617        let result = center(menu);
618        let lines: Vec<&str> = result.split('\n').collect();
619
620        // "About Us" and "Services" are both 8 chars (longest)
621        assert_eq!(lines[0], "  Home"); // 2 spaces (8-4)/2 = 2
622        assert_eq!(lines[1], "About Us"); // no padding (8 chars)
623        assert_eq!(lines[2], "Contact"); // no padding - "Contact" is 7 chars, (8-7)/2 = 0
624        assert_eq!(lines[3], "Services"); // no padding (8 chars)
625    }
626
627    #[test]
628    fn test_code_alignment() {
629        // Test aligning code-like content
630        let code = "if x:\n    return y\nelse:\n    return z";
631        let result = right(code);
632        let lines: Vec<&str> = result.split('\n').collect();
633
634        // "    return y" and "    return z" are longest at 12 chars
635        assert_eq!(lines[0], "       if x:"); // 7 spaces + "if x:" (12-5=7)
636        assert_eq!(lines[1], "    return y"); // no padding (12 chars)
637        assert_eq!(lines[2], "       else:"); // 7 spaces + "else:" (12-5=7)
638        assert_eq!(lines[3], "    return z"); // no padding (12 chars)
639    }
640}