Skip to main content

click/
formatting.rs

1//! Help text formatting utilities.
2//!
3//! This module provides the `HelpFormatter` struct for creating well-formatted
4//! help output for CLI commands, including proper text wrapping, indentation,
5//! and definition list formatting.
6
7/// Default terminal width when detection fails
8const DEFAULT_WIDTH: usize = 80;
9
10/// Minimum terminal width
11const MIN_WIDTH: usize = 40;
12
13/// Default indentation for help text
14#[allow(dead_code)]
15const DEFAULT_INDENT: usize = 2;
16
17/// Default indentation for definition lists
18const DEFINITION_INDENT: usize = 2;
19
20/// Column spacing between definition term and description
21const DEFINITION_SPACING: usize = 2;
22
23/// Maximum width for definition terms before wrapping description below
24const MAX_TERM_WIDTH: usize = 24;
25
26/// Help text formatter with terminal-aware wrapping.
27///
28/// This struct provides methods for formatting help text with proper
29/// indentation, text wrapping, and special formatting for definition
30/// lists (options/arguments).
31///
32/// # Example
33///
34/// ```rust
35/// use click::HelpFormatter;
36///
37/// let mut formatter = HelpFormatter::new(80);
38///
39/// formatter.write_usage("mycli", "[OPTIONS] COMMAND");
40/// formatter.write_paragraph("A helpful CLI tool.");
41/// formatter.write_heading("Options");
42/// formatter.write_definition_list(&[
43///     ("--help, -h", "Show this help message"),
44///     ("--verbose, -v", "Enable verbose output"),
45/// ]);
46///
47/// println!("{}", formatter.get_help());
48/// ```
49#[derive(Debug)]
50pub struct HelpFormatter {
51    /// Maximum width for output
52    width: usize,
53    /// Current indentation level
54    indent: usize,
55    /// Buffer for accumulated output
56    buffer: String,
57    /// Current column position
58    current_col: usize,
59}
60
61impl HelpFormatter {
62    /// Create a new HelpFormatter with the specified terminal width.
63    ///
64    /// If width is 0 or less than minimum, uses default width.
65    pub fn new(width: usize) -> Self {
66        let width = if width < MIN_WIDTH { DEFAULT_WIDTH } else { width };
67        Self {
68            width,
69            indent: 0,
70            buffer: String::new(),
71            current_col: 0,
72        }
73    }
74
75    /// Create a new HelpFormatter that detects terminal width.
76    pub fn detect_width() -> Self {
77        let width = detect_terminal_width().unwrap_or(DEFAULT_WIDTH);
78        Self::new(width)
79    }
80
81    /// Get the accumulated help text.
82    pub fn get_help(&self) -> &str {
83        &self.buffer
84    }
85
86    /// Consume the formatter and return the help text.
87    pub fn into_help(self) -> String {
88        self.buffer
89    }
90
91    /// Get the current terminal width.
92    pub fn width(&self) -> usize {
93        self.width
94    }
95
96    /// Set the current indentation level.
97    pub fn set_indent(&mut self, indent: usize) {
98        self.indent = indent;
99    }
100
101    /// Increase indentation by the given amount.
102    pub fn indent(&mut self, amount: usize) {
103        self.indent += amount;
104    }
105
106    /// Decrease indentation by the given amount.
107    pub fn dedent(&mut self, amount: usize) {
108        self.indent = self.indent.saturating_sub(amount);
109    }
110
111    /// Write a blank line.
112    pub fn write_blank(&mut self) {
113        self.buffer.push('\n');
114        self.current_col = 0;
115    }
116
117    /// Write raw text without any processing.
118    pub fn write_raw(&mut self, text: &str) {
119        self.buffer.push_str(text);
120        // Update column position based on last line
121        if let Some(last_newline) = text.rfind('\n') {
122            self.current_col = text.len() - last_newline - 1;
123        } else {
124            self.current_col += text.len();
125        }
126    }
127
128    /// Write text with the current indentation.
129    pub fn write(&mut self, text: &str) {
130        let indent_str = " ".repeat(self.indent);
131        for line in text.lines() {
132            self.buffer.push_str(&indent_str);
133            self.buffer.push_str(line);
134            self.buffer.push('\n');
135        }
136        self.current_col = 0;
137    }
138
139    /// Write a usage line.
140    ///
141    /// Format: `Usage: prog [OPTIONS] ARGS`
142    pub fn write_usage(&mut self, prog: &str, args: &str) {
143        self.buffer.push_str("Usage: ");
144        self.buffer.push_str(prog);
145        if !args.is_empty() {
146            self.buffer.push(' ');
147            self.buffer.push_str(args);
148        }
149        self.buffer.push('\n');
150        self.current_col = 0;
151    }
152
153    /// Write a section heading.
154    ///
155    /// The heading is followed by a blank line.
156    pub fn write_heading(&mut self, heading: &str) {
157        if !self.buffer.is_empty() && !self.buffer.ends_with("\n\n") {
158            self.buffer.push('\n');
159        }
160        self.buffer.push_str(heading);
161        self.buffer.push_str(":\n");
162        self.current_col = 0;
163    }
164
165    /// Write a paragraph of text with word wrapping.
166    ///
167    /// Preserves paragraph breaks (double newlines) and wraps text
168    /// to fit within the terminal width.
169    pub fn write_paragraph(&mut self, text: &str) {
170        if text.is_empty() {
171            return;
172        }
173
174        let max_width = self.width.saturating_sub(self.indent);
175        let indent_str = " ".repeat(self.indent);
176
177        // Split into paragraphs (separated by blank lines)
178        for (i, paragraph) in text.split("\n\n").enumerate() {
179            if i > 0 {
180                self.buffer.push('\n');
181            }
182
183            // Wrap the paragraph
184            let wrapped = wrap_text(paragraph, max_width);
185            for line in wrapped.lines() {
186                self.buffer.push_str(&indent_str);
187                self.buffer.push_str(line);
188                self.buffer.push('\n');
189            }
190        }
191
192        self.current_col = 0;
193    }
194
195    /// Write a definition list (term + description pairs).
196    ///
197    /// Used for formatting options and arguments. Each item is a tuple
198    /// of (term, description).
199    ///
200    /// Format depends on term length:
201    /// - Short terms: description on same line
202    /// - Long terms: description on next line, indented
203    pub fn write_definition_list(&mut self, items: &[(&str, &str)]) {
204        let base_indent = " ".repeat(DEFINITION_INDENT);
205        let desc_indent = " ".repeat(DEFINITION_INDENT + MAX_TERM_WIDTH + DEFINITION_SPACING);
206        let desc_width = self.width.saturating_sub(desc_indent.len());
207
208        for (term, description) in items {
209            // Write the term
210            self.buffer.push_str(&base_indent);
211            self.buffer.push_str(term);
212
213            if term.len() <= MAX_TERM_WIDTH && !description.is_empty() {
214                // Term fits - description on same line
215                let padding = MAX_TERM_WIDTH - term.len() + DEFINITION_SPACING;
216                self.buffer.push_str(&" ".repeat(padding));
217
218                // Wrap description
219                let wrapped = wrap_text(description, desc_width);
220                let mut lines = wrapped.lines();
221
222                // First line on same line as term
223                if let Some(first) = lines.next() {
224                    self.buffer.push_str(first);
225                    self.buffer.push('\n');
226                }
227
228                // Remaining lines indented
229                for line in lines {
230                    self.buffer.push_str(&desc_indent);
231                    self.buffer.push_str(line);
232                    self.buffer.push('\n');
233                }
234            } else if !description.is_empty() {
235                // Term too long - description on next line
236                self.buffer.push('\n');
237
238                let wrapped = wrap_text(description, desc_width);
239                for line in wrapped.lines() {
240                    self.buffer.push_str(&desc_indent);
241                    self.buffer.push_str(line);
242                    self.buffer.push('\n');
243                }
244            } else {
245                self.buffer.push('\n');
246            }
247        }
248
249        self.current_col = 0;
250    }
251
252    /// Write a definition list with string tuples.
253    pub fn write_definition_list_strings(&mut self, items: &[(String, String)]) {
254        let refs: Vec<(&str, &str)> = items
255            .iter()
256            .map(|(t, d)| (t.as_str(), d.as_str()))
257            .collect();
258        self.write_definition_list(&refs);
259    }
260}
261
262impl Default for HelpFormatter {
263    fn default() -> Self {
264        Self::detect_width()
265    }
266}
267
268/// Wrap text to fit within the specified width.
269///
270/// Preserves existing line breaks and word boundaries.
271pub fn wrap_text(text: &str, width: usize) -> String {
272    if width == 0 {
273        return text.to_string();
274    }
275
276    let mut result = String::new();
277    let mut current_line = String::new();
278    let mut current_width = 0;
279
280    for line in text.lines() {
281        // Handle explicit line breaks
282        if !result.is_empty() || !current_line.is_empty() {
283            if !current_line.is_empty() {
284                result.push_str(&current_line);
285                current_line.clear();
286                current_width = 0;
287            }
288            result.push('\n');
289        }
290
291        // Process words in this line
292        for word in line.split_whitespace() {
293            let word_width = word.len();
294
295            if current_width == 0 {
296                // First word on line
297                current_line.push_str(word);
298                current_width = word_width;
299            } else if current_width + 1 + word_width <= width {
300                // Word fits on current line
301                current_line.push(' ');
302                current_line.push_str(word);
303                current_width += 1 + word_width;
304            } else {
305                // Start new line
306                result.push_str(&current_line);
307                result.push('\n');
308                current_line.clear();
309                current_line.push_str(word);
310                current_width = word_width;
311            }
312        }
313    }
314
315    // Add remaining content
316    if !current_line.is_empty() {
317        result.push_str(&current_line);
318    }
319
320    result
321}
322
323/// Detect the terminal width.
324///
325/// Returns None if detection fails. Currently only checks the COLUMNS
326/// environment variable. For more robust terminal detection, consider
327/// using the `crossterm` or `terminal_size` crate.
328pub fn detect_terminal_width() -> Option<usize> {
329    // Try environment variable first
330    if let Ok(cols) = std::env::var("COLUMNS") {
331        if let Ok(width) = cols.parse::<usize>() {
332            if width >= MIN_WIDTH {
333                return Some(width);
334            }
335        }
336    }
337
338    // Try TERM_PROGRAM for common terminals
339    if let Ok(term) = std::env::var("TERM_PROGRAM") {
340        // Most modern terminals default to 80 or wider
341        match term.as_str() {
342            "vscode" | "iTerm.app" | "Apple_Terminal" | "Hyper" => {
343                return Some(DEFAULT_WIDTH);
344            }
345            _ => {}
346        }
347    }
348
349    None
350}
351
352/// Get terminal width with a fallback default.
353pub fn get_terminal_width() -> usize {
354    detect_terminal_width().unwrap_or(DEFAULT_WIDTH)
355}
356
357/// Create a horizontal rule of the specified character.
358pub fn make_rule(char: char, width: usize) -> String {
359    std::iter::repeat(char).take(width).collect()
360}
361
362/// Truncate text to fit within width, adding ellipsis if needed.
363pub fn truncate_text(text: &str, max_width: usize) -> String {
364    if text.len() <= max_width {
365        return text.to_string();
366    }
367
368    if max_width <= 3 {
369        return "...".to_string();
370    }
371
372    let mut result = text[..max_width - 3].to_string();
373    result.push_str("...");
374    result
375}
376
377/// Split text into lines that fit within the specified width.
378pub fn split_into_lines(text: &str, width: usize) -> Vec<String> {
379    wrap_text(text, width)
380        .lines()
381        .map(String::from)
382        .collect()
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_wrap_text() {
391        let text = "This is a test of the text wrapping functionality";
392        let wrapped = wrap_text(text, 20);
393        assert!(wrapped.lines().all(|l| l.len() <= 20));
394    }
395
396    #[test]
397    fn test_wrap_preserves_newlines() {
398        let text = "Line one\nLine two\nLine three";
399        let wrapped = wrap_text(text, 80);
400        assert_eq!(wrapped.lines().count(), 3);
401    }
402
403    #[test]
404    fn test_help_formatter_usage() {
405        let mut fmt = HelpFormatter::new(80);
406        fmt.write_usage("mycli", "[OPTIONS] COMMAND");
407        assert!(fmt.get_help().contains("Usage: mycli [OPTIONS] COMMAND"));
408    }
409
410    #[test]
411    fn test_help_formatter_heading() {
412        let mut fmt = HelpFormatter::new(80);
413        fmt.write_heading("Options");
414        assert!(fmt.get_help().contains("Options:\n"));
415    }
416
417    #[test]
418    fn test_help_formatter_definition_list() {
419        let mut fmt = HelpFormatter::new(80);
420        fmt.write_definition_list(&[
421            ("--help, -h", "Show help"),
422            ("--version", "Show version"),
423        ]);
424        let help = fmt.get_help();
425        assert!(help.contains("--help, -h"));
426        assert!(help.contains("Show help"));
427    }
428
429    #[test]
430    fn test_truncate_text() {
431        assert_eq!(truncate_text("hello", 10), "hello");
432        assert_eq!(truncate_text("hello world", 8), "hello...");
433        // Text that fits within max_width is not truncated
434        assert_eq!(truncate_text("hi", 3), "hi");
435        // Text that's too long for even "..." gets truncated to "..."
436        assert_eq!(truncate_text("hello", 3), "...");
437    }
438}