bpaf/buffer/
console.rs

1// help needs to support following features:
2// - linebreak - insert linebreak whenever
3// - newline - start text on a new line, don't start a new line if not already at one
4// - margin - start text at some offset at a new line
5// - tabstop - all tabstops are aligned within a section
6// - section title - "usage", "available options", "available positionals", etc. starts a new
7//         section - resets tabstops
8// - literal text user needs to type - flags, command names, etc.
9// - metavar - meta placehoder user needs to write something
10// - subsection title - two spaces + text, used for adjacent groups
11
12// help might want to render it:
13// - monochrome - default mode
14// - bright/dull/custom colors
15// - export to markdown and groff
16//
17// monochrome and colors are rendered with different widths so tabstops are out of buffer rendering
18
19// text formatting rules:
20//
21// want to be able to produce both brief and full versions of the documentation, it only makes
22// sense to look for that in the plain text...
23// - "\n " => hard line break, inserted always
24// - "\n\n" => paragraphs are separated by this, only the first one in inserted unless in "full" mode
25// // - "\n" => converted to spaces, text flows to the current margin value
26//
27// tabstops are aligned the same position within a section, tabstop sets a temporary margin for all
28// the soft linebreaks, tabstop
29//
30// margin sets the minimal offset for any new text and retained until new margin is set:
31// "hello" [margin 8] "world" is rendered as "hello   world"
32
33use super::{
34    splitter::{split, Chunk},
35    Block, Doc, Skip, Token,
36};
37
38#[cfg(feature = "color")]
39use super::Style;
40
41const MAX_TAB: usize = 24;
42pub(crate) const MAX_WIDTH: usize = 100;
43
44#[derive(Debug, Copy, Clone, Eq, PartialEq)]
45/// Default to dull color if colors are enabled,
46#[allow(dead_code)] // not fully used in without colors
47pub(crate) enum Color {
48    Monochrome,
49    #[cfg(feature = "color")]
50    Dull,
51    #[cfg(feature = "color")]
52    Bright,
53}
54
55impl Default for Color {
56    fn default() -> Self {
57        #![allow(clippy::let_and_return)]
58        #![allow(unused_mut)]
59        #![allow(unused_assignments)]
60        let mut res;
61        #[cfg(not(feature = "color"))]
62        {
63            res = Color::Monochrome;
64        }
65
66        #[cfg(feature = "color")]
67        {
68            res = Color::Dull;
69        }
70
71        #[cfg(feature = "bright-color")]
72        {
73            res = Color::Bright;
74        }
75
76        #[cfg(feature = "dull-color")]
77        {
78            res = Color::Dull;
79        }
80
81        #[cfg(feature = "color")]
82        {
83            use supports_color::{on, Stream};
84            if !(on(Stream::Stdout).is_some() && on(Stream::Stderr).is_some()) {
85                res = Color::Monochrome;
86            }
87        }
88        res
89    }
90}
91
92#[cfg(feature = "color")]
93impl Color {
94    pub(crate) fn push_str(self, style: Style, res: &mut String, item: &str) {
95        use owo_colors::OwoColorize;
96        use std::fmt::Write;
97        match self {
98            Color::Monochrome => {
99                res.push_str(item);
100                Ok(())
101            }
102            Color::Dull => match style {
103                Style::Text => {
104                    res.push_str(item);
105                    Ok(())
106                }
107                Style::Emphasis => write!(res, "{}", item.underline().bold()),
108                Style::Literal => write!(res, "{}", item.bold()),
109                Style::Metavar => write!(res, "{}", item.underline()),
110                Style::Invalid => write!(res, "{}", item.bold().red()),
111            },
112            Color::Bright => match style {
113                Style::Text => {
114                    res.push_str(item);
115                    Ok(())
116                }
117                Style::Emphasis => write!(res, "{}", item.yellow().bold()),
118                Style::Literal => write!(res, "{}", item.green().bold()),
119                Style::Metavar => write!(res, "{}", item.blue().bold()),
120                Style::Invalid => write!(res, "{}", item.red().bold()),
121            },
122        }
123        .unwrap();
124    }
125}
126
127const PADDING: &str = "                                                  ";
128
129impl Doc {
130    /// Render a monochrome version of the document
131    ///
132    /// `full` indicates if full message should be rendered, this makes
133    /// difference for rendered help message, otherwise you can pass `true`.
134    #[must_use]
135    pub fn monochrome(&self, full: bool) -> String {
136        self.render_console(full, Color::Monochrome, MAX_WIDTH)
137    }
138
139    #[allow(clippy::too_many_lines)] // it's a big ass match statement
140    pub(crate) fn render_console(&self, full: bool, color: Color, max_width: usize) -> String {
141        let mut res = String::new();
142        let mut tabstop = 0;
143        let mut byte_pos = 0;
144        {
145            let mut current = 0;
146            let mut in_term = false;
147            // looking for widest term below MAX_TAB
148            for token in self.tokens.iter().copied() {
149                match token {
150                    Token::Text { bytes, style: _ } => {
151                        if in_term {
152                            current += self.payload[byte_pos..byte_pos + bytes].chars().count();
153                        }
154                        byte_pos += bytes;
155                    }
156                    Token::BlockStart(Block::ItemTerm) => {
157                        in_term = true;
158                        current = 0;
159                    }
160                    Token::BlockEnd(Block::ItemTerm) => {
161                        in_term = false;
162                        if current > tabstop && current <= MAX_TAB {
163                            tabstop = current;
164                        }
165                    }
166                    _ => {}
167                }
168            }
169            byte_pos = 0;
170        }
171        let tabstop = tabstop + 4;
172
173        #[cfg(test)]
174        let mut stack = Vec::new();
175        let mut skip = Skip::default();
176        let mut char_pos = 0;
177
178        let mut margins: Vec<usize> = Vec::new();
179
180        // a single new line, unless one exists
181        let mut pending_newline = false;
182        // a double newline, unless one exists
183        let mut pending_blank_line = false;
184
185        let mut pending_margin = false;
186
187        for token in self.tokens.iter().copied() {
188            match token {
189                Token::Text { bytes, style } => {
190                    let input = &self.payload[byte_pos..byte_pos + bytes];
191                    byte_pos += bytes;
192
193                    if skip.enabled() {
194                        continue;
195                    }
196
197                    for chunk in split(input) {
198                        match chunk {
199                            Chunk::Raw(s, w) => {
200                                let margin = margins.last().copied().unwrap_or(0usize);
201                                if !res.is_empty() {
202                                    if (pending_newline || pending_blank_line)
203                                        && !res.ends_with('\n')
204                                    {
205                                        char_pos = 0;
206                                        res.push('\n');
207                                    }
208                                    if pending_blank_line && !res.ends_with("\n\n") {
209                                        res.push('\n');
210                                    }
211                                    if char_pos + s.len() > max_width {
212                                        char_pos = 0;
213                                        res.truncate(res.trim_end().len());
214                                        res.push('\n');
215                                        if s == " " {
216                                            continue;
217                                        }
218                                    }
219                                }
220
221                                let mut pushed = 0;
222                                if let Some(missing) = margin.checked_sub(char_pos) {
223                                    res.push_str(&PADDING[..missing]);
224                                    char_pos = margin;
225                                    pushed = missing;
226                                }
227                                if pending_margin && char_pos >= MAX_TAB + 4 && pushed < 2 {
228                                    let missing = 2 - pushed;
229                                    res.push_str(&PADDING[..missing]);
230                                    char_pos += missing;
231                                }
232
233                                pending_newline = false;
234                                pending_blank_line = false;
235                                pending_margin = false;
236
237                                #[cfg(feature = "color")]
238                                {
239                                    color.push_str(style, &mut res, s);
240                                }
241                                #[cfg(not(feature = "color"))]
242                                {
243                                    let _ = style;
244                                    let _ = color;
245                                    res.push_str(s);
246                                }
247                                char_pos += w;
248                            }
249                            Chunk::Paragraph => {
250                                res.push('\n');
251                                char_pos = 0;
252                                if !full {
253                                    skip.enable();
254                                    break;
255                                }
256                            }
257                            Chunk::LineBreak => {
258                                res.push('\n');
259                                char_pos = 0;
260                            }
261                        }
262                    }
263                }
264                Token::BlockStart(block) => {
265                    #[cfg(test)]
266                    stack.push(block);
267                    let margin = margins.last().copied().unwrap_or(0usize);
268
269                    match block {
270                        Block::Header | Block::Section2 => {
271                            pending_newline = true;
272                            margins.push(margin);
273                        }
274                        Block::Section3 => {
275                            pending_newline = true;
276                            margins.push(margin + 2);
277                        }
278                        Block::ItemTerm => {
279                            pending_newline = true;
280                            margins.push(margin + 4);
281                        }
282                        Block::ItemBody => {
283                            margins.push(margin + tabstop + 2);
284                            pending_margin = true;
285                        }
286                        Block::InlineBlock => {
287                            skip.push();
288                        }
289                        Block::Block => {
290                            margins.push(margin);
291                        }
292                        Block::DefinitionList | Block::Meta | Block::Mono => {}
293                        Block::TermRef => {
294                            if color == Color::Monochrome {
295                                res.push('`');
296                                char_pos += 1;
297                            }
298                        }
299                    }
300                }
301                Token::BlockEnd(block) => {
302                    #[cfg(test)]
303                    assert_eq!(stack.pop(), Some(block));
304
305                    margins.pop();
306                    match block {
307                        Block::ItemBody => {
308                            pending_margin = false;
309                        }
310                        Block::Header
311                        | Block::Section2
312                        | Block::Section3
313                        | Block::ItemTerm
314                        | Block::DefinitionList
315                        | Block::Meta
316                        | Block::Mono => {}
317                        Block::InlineBlock => {
318                            skip.pop();
319                        }
320                        Block::Block => {
321                            pending_blank_line = true;
322                        }
323                        Block::TermRef => {
324                            if color == Color::Monochrome {
325                                res.push('`');
326                                char_pos += 1;
327                            }
328                        }
329                    }
330                }
331            }
332        }
333        if pending_newline || pending_blank_line {
334            res.push('\n');
335        }
336        #[cfg(test)]
337        assert_eq!(stack, &[]);
338        res
339    }
340}
341
342/*
343#[cfg(test)]
344mod test {
345    use super::*;
346
347    #[test]
348    fn tabstop_works() {
349        // tabstop followed by newline
350        let mut m = Buffer::default();
351        m.token(Token::TermStart);
352        m.text("aa");
353        m.token(Token::TermStop);
354        m.token(Token::LineBreak);
355
356        m.token(Token::TermStart);
357        m.text("b");
358        m.token(Token::TermStop);
359        m.text("c");
360        m.token(Token::LineBreak);
361        assert_eq!(m.monochrome(true), "    aa\n    b   c\n");
362        m.clear();
363
364        // plain, narrow first
365        m.token(Token::TermStart);
366        m.text("1");
367        m.token(Token::TermStop);
368        m.text("22");
369        m.token(Token::LineBreak);
370
371        m.token(Token::TermStart);
372        m.text("33");
373        m.token(Token::TermStop);
374        m.text("4");
375        m.token(Token::LineBreak);
376        assert_eq!(m.monochrome(true), "    1   22\n    33  4\n");
377        m.clear();
378
379        // plain, wide first
380        m.token(Token::TermStart);
381        m.text("aa");
382        m.token(Token::TermStop);
383
384        m.text("b");
385        m.token(Token::LineBreak);
386
387        m.token(Token::TermStart);
388        m.text("c");
389        m.token(Token::TermStop);
390
391        m.text("dd");
392        m.token(Token::LineBreak);
393        assert_eq!(m.monochrome(true), "    aa  b\n    c   dd\n");
394        m.clear();
395
396        // two different styles first
397        m.token(Token::TermStart);
398        m.text("a");
399        m.literal("b");
400        m.token(Token::TermStop);
401
402        m.literal("c");
403        m.token(Token::LineBreak);
404        m.token(Token::TermStart);
405        m.text("d");
406        m.token(Token::TermStop);
407
408        m.literal("e");
409        m.token(Token::LineBreak);
410        assert_eq!(m.monochrome(true), "    ab  c\n    d   e\n");
411    }
412
413    #[test]
414    fn linewrap_works() {
415        let mut m = Buffer::default();
416        m.token(Token::TermStart);
417        m.write_str("--hello", Style::Literal);
418        m.token(Token::TermStop);
419        for i in 0..25 {
420            m.write_str(&format!("and word{i} "), Style::Text)
421        }
422        m.write_str("and last word", Style::Text);
423        m.token(Token::LineBreak);
424
425        let expected =
426"    --hello  and word0 and word1 and word2 and word3 and word4 and word5 and word6 and word7 and word8
427             and word9 and word10 and word11 and word12 and word13 and word14 and word15 and word16 and
428             word17 and word18 and word19 and word20 and word21 and word22 and word23 and word24 and last
429             word
430";
431
432        assert_eq!(m.monochrome(true), expected);
433    }
434
435    #[test]
436    fn very_long_tabstop() {
437        let mut m = Buffer::default();
438        m.token(Token::TermStart);
439        m.write_str(
440            "--this-is-a-very-long-option <DON'T DO THIS AT HOME>",
441            Style::Literal,
442        );
443        m.token(Token::TermStop);
444        for i in 0..15 {
445            m.write_str(&format!("and word{i} "), Style::Text)
446        }
447        m.write_str("and last word", Style::Text);
448        m.token(Token::LineBreak);
449
450        let expected =
451"    --this-is-a-very-long-option <DON'T DO THIS AT HOME>  and word0 and word1 and word2 and word3 and word4
452      and word5 and word6 and word7 and word8 and word9 and word10 and word11 and word12 and word13 and
453      word14 and last word
454";
455
456        assert_eq!(m.monochrome(true), expected);
457    }
458
459    #[test]
460    fn line_breaking_rules() {
461        let mut buffer = Buffer::default();
462        buffer.write_str("hello ", Style::Text);
463        assert_eq!(buffer.monochrome(true), "hello ");
464        buffer.clear();
465
466        buffer.write_str("hello\n world\n", Style::Text);
467        assert_eq!(buffer.monochrome(true), "hello\nworld ");
468        buffer.clear();
469
470        buffer.write_str("hello\nworld", Style::Text);
471        assert_eq!(buffer.monochrome(true), "hello world");
472        buffer.clear();
473
474        buffer.write_str("hello\nworld\n", Style::Text);
475        assert_eq!(buffer.monochrome(true), "hello world ");
476        buffer.clear();
477
478        buffer.write_str("hello\n\nworld", Style::Text);
479        assert_eq!(buffer.monochrome(false), "hello\n");
480        buffer.clear();
481
482        buffer.write_str("hello\n\nworld", Style::Text);
483        assert_eq!(buffer.monochrome(true), "hello\nworld");
484        buffer.clear();
485    }
486
487    #[test]
488    fn splitter_works() {
489        assert_eq!(
490            split("hello ").collect::<Vec<_>>(),
491            [Chunk::Raw("hello", 5), Chunk::Raw(" ", 1)]
492        );
493
494        assert_eq!(
495            split("hello\nworld").collect::<Vec<_>>(),
496            [
497                Chunk::Raw("hello", 5),
498                Chunk::Raw(" ", 1),
499                Chunk::Raw("world", 5)
500            ]
501        );
502
503        assert_eq!(
504            split("hello\n world").collect::<Vec<_>>(),
505            [
506                Chunk::Raw("hello", 5),
507                Chunk::HardLineBreak,
508                Chunk::Raw("world", 5)
509            ]
510        );
511
512        assert_eq!(
513            split("hello\n\nworld").collect::<Vec<_>>(),
514            [
515                Chunk::Raw("hello", 5),
516                Chunk::SoftLineBreak,
517                Chunk::Raw("world", 5)
518            ]
519        );
520    }
521}*/