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}