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}*/