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