Skip to main content

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
129fn adjacent_gap(tokens: &[Token], ix: usize) -> Option<std::ops::Range<usize>> {
130    let end =
131        (ix + 1..tokens.len()).find(|&j| matches!(tokens[j], Token::BlockEnd(Block::Section4)))?;
132    if matches!(tokens.get(end + 1), Some(Token::BlockStart(Block::Block)))
133        && matches!(tokens.get(end + 2), Some(Token::BlockEnd(Block::Block)))
134    {
135        Some(end + 1..end + 3)
136    } else {
137        None
138    }
139}
140
141impl Doc {
142    /// Render a monochrome version of the document
143    ///
144    /// `full` indicates if full message should be rendered, this makes
145    /// difference for rendered help message, otherwise you can pass `true`.
146    #[must_use]
147    pub fn monochrome(&self, full: bool) -> String {
148        self.render_console(full, Color::Monochrome, MAX_WIDTH)
149    }
150
151    #[allow(clippy::too_many_lines)] // it's a big ass match statement
152    pub(crate) fn render_console(&self, full: bool, color: Color, max_width: usize) -> String {
153        let mut res = String::new();
154        let mut tabstop = 0;
155        let mut byte_pos = 0;
156        {
157            let mut current = 0;
158            let mut in_term = false;
159            // looking for widest term below MAX_TAB
160            for token in self.tokens.iter().copied() {
161                match token {
162                    Token::Text { bytes, style: _ } => {
163                        if in_term {
164                            current += self.payload[byte_pos..byte_pos + bytes].chars().count();
165                        }
166                        byte_pos += bytes;
167                    }
168                    Token::BlockStart(Block::ItemTerm) => {
169                        in_term = true;
170                        current = 0;
171                    }
172                    Token::BlockEnd(Block::ItemTerm) => {
173                        in_term = false;
174                        if current > tabstop && current <= MAX_TAB {
175                            tabstop = current;
176                        }
177                    }
178                    _ => {}
179                }
180            }
181            byte_pos = 0;
182        }
183        let tabstop = tabstop + 4;
184
185        #[cfg(test)]
186        let mut stack = Vec::new();
187        let mut skip = Skip::default();
188        let mut char_pos = 0;
189
190        let mut margins: Vec<usize> = Vec::new();
191
192        // a single new line, unless one exists
193        let mut pending_newline = false;
194        // a double newline, unless one exists
195        let mut pending_blank_line = false;
196
197        let mut pending_margin = false;
198
199        let mut adjacent_skip: std::ops::Range<usize> = 0..0;
200
201        for (ix, token) in self.tokens.iter().copied().enumerate() {
202            if adjacent_skip.contains(&ix) {
203                continue;
204            }
205            match token {
206                Token::Text { bytes, style } => {
207                    let input = &self.payload[byte_pos..byte_pos + bytes];
208                    byte_pos += bytes;
209
210                    if skip.enabled() {
211                        continue;
212                    }
213
214                    for chunk in split(input) {
215                        match chunk {
216                            Chunk::Raw(s, w) => {
217                                let margin = margins.last().copied().unwrap_or(0usize);
218                                if !res.is_empty() {
219                                    if (pending_newline || pending_blank_line)
220                                        && !res.ends_with('\n')
221                                    {
222                                        char_pos = 0;
223                                        res.push('\n');
224                                    }
225                                    if pending_blank_line && !res.ends_with("\n\n") {
226                                        res.push('\n');
227                                    }
228                                    if char_pos + s.len() > max_width {
229                                        char_pos = 0;
230                                        res.truncate(res.trim_end().len());
231                                        res.push('\n');
232                                        if s == " " {
233                                            continue;
234                                        }
235                                    }
236                                }
237
238                                let mut pushed = 0;
239                                if let Some(missing) = margin.checked_sub(char_pos) {
240                                    res.push_str(&PADDING[..missing]);
241                                    char_pos = margin;
242                                    pushed = missing;
243                                }
244                                if pending_margin && char_pos >= MAX_TAB + 4 && pushed < 2 {
245                                    let missing = 2 - pushed;
246                                    res.push_str(&PADDING[..missing]);
247                                    char_pos += missing;
248                                }
249
250                                pending_newline = false;
251                                pending_blank_line = false;
252                                pending_margin = false;
253
254                                #[cfg(feature = "color")]
255                                {
256                                    color.push_str(style, &mut res, s);
257                                }
258                                #[cfg(not(feature = "color"))]
259                                {
260                                    let _ = style;
261                                    let _ = color;
262                                    res.push_str(s);
263                                }
264                                char_pos += w;
265                            }
266                            Chunk::Paragraph => {
267                                res.push('\n');
268                                char_pos = 0;
269                                if !full {
270                                    skip.enable();
271                                    break;
272                                }
273                            }
274                            Chunk::LineBreak => {
275                                res.push('\n');
276                                char_pos = 0;
277                            }
278                        }
279                    }
280                }
281                Token::BlockStart(block) => {
282                    #[cfg(test)]
283                    stack.push(block);
284                    let margin = margins.last().copied().unwrap_or(0usize);
285
286                    match block {
287                        Block::Header | Block::Section2 => {
288                            pending_newline = true;
289                            margins.push(margin);
290                        }
291                        Block::Section3 => {
292                            pending_newline = true;
293                            margins.push(margin + 2);
294                        }
295                        Block::Section4 => {
296                            pending_newline = true;
297                            let mut margin = margin + 2;
298                            if let Some(stop) = adjacent_gap(&self.tokens, ix) {
299                                margin += 2;
300                                adjacent_skip = stop;
301                            }
302                            margins.push(margin);
303                        }
304                        Block::ItemTerm => {
305                            pending_newline = true;
306                            margins.push(margin + 4);
307                        }
308                        Block::ItemBody => {
309                            margins.push(margin + tabstop + 2);
310                            pending_margin = true;
311                        }
312                        Block::InlineBlock => {
313                            skip.push();
314                        }
315                        Block::Block => {
316                            margins.push(margin);
317                        }
318                        Block::DefinitionList | Block::Meta | Block::Mono => {}
319                        Block::TermRef => {
320                            if color == Color::Monochrome {
321                                res.push('`');
322                                char_pos += 1;
323                            }
324                        }
325                    }
326                }
327                Token::BlockEnd(block) => {
328                    #[cfg(test)]
329                    assert_eq!(stack.pop(), Some(block));
330
331                    margins.pop();
332                    match block {
333                        Block::ItemBody => {
334                            pending_margin = false;
335                        }
336                        Block::Header
337                        | Block::Section2
338                        | Block::Section3
339                        | Block::Section4
340                        | Block::ItemTerm
341                        | Block::DefinitionList
342                        | Block::Meta
343                        | Block::Mono => {}
344                        Block::InlineBlock => {
345                            skip.pop();
346                        }
347                        Block::Block => {
348                            pending_blank_line = true;
349                        }
350                        Block::TermRef => {
351                            if color == Color::Monochrome {
352                                res.push('`');
353                                char_pos += 1;
354                            }
355                        }
356                    }
357                }
358            }
359        }
360        if pending_newline || pending_blank_line {
361            res.push('\n');
362        }
363        #[cfg(test)]
364        assert_eq!(stack, &[]);
365        res
366    }
367}
368
369/*
370#[cfg(test)]
371mod test {
372    use super::*;
373
374    #[test]
375    fn tabstop_works() {
376        // tabstop followed by newline
377        let mut m = Buffer::default();
378        m.token(Token::TermStart);
379        m.text("aa");
380        m.token(Token::TermStop);
381        m.token(Token::LineBreak);
382
383        m.token(Token::TermStart);
384        m.text("b");
385        m.token(Token::TermStop);
386        m.text("c");
387        m.token(Token::LineBreak);
388        assert_eq!(m.monochrome(true), "    aa\n    b   c\n");
389        m.clear();
390
391        // plain, narrow first
392        m.token(Token::TermStart);
393        m.text("1");
394        m.token(Token::TermStop);
395        m.text("22");
396        m.token(Token::LineBreak);
397
398        m.token(Token::TermStart);
399        m.text("33");
400        m.token(Token::TermStop);
401        m.text("4");
402        m.token(Token::LineBreak);
403        assert_eq!(m.monochrome(true), "    1   22\n    33  4\n");
404        m.clear();
405
406        // plain, wide first
407        m.token(Token::TermStart);
408        m.text("aa");
409        m.token(Token::TermStop);
410
411        m.text("b");
412        m.token(Token::LineBreak);
413
414        m.token(Token::TermStart);
415        m.text("c");
416        m.token(Token::TermStop);
417
418        m.text("dd");
419        m.token(Token::LineBreak);
420        assert_eq!(m.monochrome(true), "    aa  b\n    c   dd\n");
421        m.clear();
422
423        // two different styles first
424        m.token(Token::TermStart);
425        m.text("a");
426        m.literal("b");
427        m.token(Token::TermStop);
428
429        m.literal("c");
430        m.token(Token::LineBreak);
431        m.token(Token::TermStart);
432        m.text("d");
433        m.token(Token::TermStop);
434
435        m.literal("e");
436        m.token(Token::LineBreak);
437        assert_eq!(m.monochrome(true), "    ab  c\n    d   e\n");
438    }
439
440    #[test]
441    fn linewrap_works() {
442        let mut m = Buffer::default();
443        m.token(Token::TermStart);
444        m.write_str("--hello", Style::Literal);
445        m.token(Token::TermStop);
446        for i in 0..25 {
447            m.write_str(&format!("and word{i} "), Style::Text)
448        }
449        m.write_str("and last word", Style::Text);
450        m.token(Token::LineBreak);
451
452        let expected =
453"    --hello  and word0 and word1 and word2 and word3 and word4 and word5 and word6 and word7 and word8
454             and word9 and word10 and word11 and word12 and word13 and word14 and word15 and word16 and
455             word17 and word18 and word19 and word20 and word21 and word22 and word23 and word24 and last
456             word
457";
458
459        assert_eq!(m.monochrome(true), expected);
460    }
461
462    #[test]
463    fn very_long_tabstop() {
464        let mut m = Buffer::default();
465        m.token(Token::TermStart);
466        m.write_str(
467            "--this-is-a-very-long-option <DON'T DO THIS AT HOME>",
468            Style::Literal,
469        );
470        m.token(Token::TermStop);
471        for i in 0..15 {
472            m.write_str(&format!("and word{i} "), Style::Text)
473        }
474        m.write_str("and last word", Style::Text);
475        m.token(Token::LineBreak);
476
477        let expected =
478"    --this-is-a-very-long-option <DON'T DO THIS AT HOME>  and word0 and word1 and word2 and word3 and word4
479      and word5 and word6 and word7 and word8 and word9 and word10 and word11 and word12 and word13 and
480      word14 and last word
481";
482
483        assert_eq!(m.monochrome(true), expected);
484    }
485
486    #[test]
487    fn line_breaking_rules() {
488        let mut buffer = Buffer::default();
489        buffer.write_str("hello ", Style::Text);
490        assert_eq!(buffer.monochrome(true), "hello ");
491        buffer.clear();
492
493        buffer.write_str("hello\n world\n", Style::Text);
494        assert_eq!(buffer.monochrome(true), "hello\nworld ");
495        buffer.clear();
496
497        buffer.write_str("hello\nworld", Style::Text);
498        assert_eq!(buffer.monochrome(true), "hello world");
499        buffer.clear();
500
501        buffer.write_str("hello\nworld\n", Style::Text);
502        assert_eq!(buffer.monochrome(true), "hello world ");
503        buffer.clear();
504
505        buffer.write_str("hello\n\nworld", Style::Text);
506        assert_eq!(buffer.monochrome(false), "hello\n");
507        buffer.clear();
508
509        buffer.write_str("hello\n\nworld", Style::Text);
510        assert_eq!(buffer.monochrome(true), "hello\nworld");
511        buffer.clear();
512    }
513
514    #[test]
515    fn splitter_works() {
516        assert_eq!(
517            split("hello ").collect::<Vec<_>>(),
518            [Chunk::Raw("hello", 5), Chunk::Raw(" ", 1)]
519        );
520
521        assert_eq!(
522            split("hello\nworld").collect::<Vec<_>>(),
523            [
524                Chunk::Raw("hello", 5),
525                Chunk::Raw(" ", 1),
526                Chunk::Raw("world", 5)
527            ]
528        );
529
530        assert_eq!(
531            split("hello\n world").collect::<Vec<_>>(),
532            [
533                Chunk::Raw("hello", 5),
534                Chunk::HardLineBreak,
535                Chunk::Raw("world", 5)
536            ]
537        );
538
539        assert_eq!(
540            split("hello\n\nworld").collect::<Vec<_>>(),
541            [
542                Chunk::Raw("hello", 5),
543                Chunk::SoftLineBreak,
544                Chunk::Raw("world", 5)
545            ]
546        );
547    }
548}*/