ctl_core/document.rs
1//! The semantic document a command's human output is built from.
2//!
3//! A [`Document`] is an ordered list of [`Block`]s: headings, paragraphs,
4//! [`Fields`], [`Table`]s, [`Section`]s, [`Notice`]s, rules, and verbatim
5//! text. Text inside a block is a [`Text`] of [`Span`]s, and each span has a
6//! [`Role`] that says what it means, not how it looks.
7//!
8//! The types hold no borders, widths, colours, or streams. The renderer lays
9//! a document out and a `Theme` turns each role into a style, so one document
10//! renders the same way in every CLI. Enable the `document` feature to build
11//! documents without any rendering engine.
12//!
13//! ```
14//! use ctl_core::document::{Document, Fields, Notice, NoticeLevel, Table};
15//!
16//! let document = Document::new()
17//! .heading("Queue")
18//! .fields(Fields::new().row("pending", "2"))
19//! .table(
20//! Table::new(["id", "title"])
21//! .id_column(0)
22//! .row(["A-1", "Ship"]),
23//! )
24//! .notice(Notice::new(NoticeLevel::Warning, "one row is stale"));
25//! assert_eq!(document.blocks().len(), 4);
26//! ```
27
28/// An ordered list of blocks that make up a command's human output.
29///
30/// Every builder method takes `self` and returns it, so a document reads as
31/// one chain. A command's `Present` implementation returns one, and a
32/// [`Section`] nests one as its body.
33#[derive(Clone, Debug, Default, Eq, PartialEq)]
34pub struct Document {
35 blocks: Vec<Block>,
36}
37
38impl Document {
39 /// Start an empty document.
40 #[must_use]
41 pub const fn new() -> Self {
42 Self { blocks: Vec::new() }
43 }
44
45 /// Append any value that converts into a [`Block`], such as a [`Fields`],
46 /// [`Table`], [`Section`], [`Notice`], or [`Rule`].
47 #[must_use]
48 pub fn block(mut self, block: impl Into<Block>) -> Self {
49 self.blocks.push(block.into());
50 self
51 }
52
53 /// Append a heading. It is styled with [`Role::Heading`] and wraps to the
54 /// width.
55 #[must_use]
56 pub fn heading(self, value: impl Into<Text>) -> Self {
57 self.block(Block::Heading(value.into()))
58 }
59
60 /// Append prose that wraps to the width. Spans keep their roles.
61 #[must_use]
62 pub fn paragraph(self, value: impl Into<Text>) -> Self {
63 self.block(Block::Paragraph(value.into()))
64 }
65
66 /// Append preformatted text that is never wrapped or styled.
67 ///
68 /// See [`Block::Verbatim`] for the characters the renderer removes.
69 #[must_use]
70 pub fn verbatim(self, value: impl Into<String>) -> Self {
71 self.block(Block::Verbatim(value.into()))
72 }
73
74 /// Append a record of label and value rows.
75 #[must_use]
76 pub fn fields(self, fields: Fields) -> Self {
77 self.block(fields)
78 }
79
80 /// Append a table. Its frame follows the table frame of the render options.
81 #[must_use]
82 pub fn table(self, table: Table) -> Self {
83 self.block(table)
84 }
85
86 /// Append a section: a heading directly over a nested document.
87 #[must_use]
88 pub fn section(self, section: Section) -> Self {
89 self.block(section)
90 }
91
92 /// Append a notice: a level label, an optional code, and a message.
93 #[must_use]
94 pub fn notice(self, notice: Notice) -> Self {
95 self.block(notice)
96 }
97
98 /// Append a horizontal rule across the width. A `title` is drawn in the
99 /// rule with [`Role::Heading`].
100 #[must_use]
101 pub fn rule(self, title: Option<Text>) -> Self {
102 self.block(Rule { title })
103 }
104
105 /// Whether the document has no blocks. A document of blocks that draw
106 /// nothing, such as an empty paragraph, is not empty but renders no lines.
107 #[must_use]
108 pub fn is_empty(&self) -> bool {
109 self.blocks.is_empty()
110 }
111
112 /// The blocks, in the order they were appended.
113 #[must_use]
114 pub fn blocks(&self) -> &[Block] {
115 &self.blocks
116 }
117}
118
119/// One block of a [`Document`].
120///
121/// Each variant has a builder method on [`Document`] that wraps it. The enum
122/// is the read side: a custom renderer matches on it.
123///
124/// A new block, such as a panel, can join the set in a minor release. So a
125/// match on it outside ctl-core ends with a wildcard arm.
126#[derive(Clone, Debug, Eq, PartialEq)]
127#[non_exhaustive]
128pub enum Block {
129 /// A heading, drawn in [`Role::Heading`].
130 Heading(Text),
131 /// Prose that wraps to the width.
132 Paragraph(Text),
133 /// Preformatted text whose line breaks survive rendering.
134 ///
135 /// The renderer removes terminal control characters, bidirectional
136 /// controls, and the Unicode line separators U+2028 and U+2029, but keeps
137 /// newlines and tabs. It strips newlines from the end, and expands a tab
138 /// to the next multiple of 8 cells. Other Unicode formatting characters
139 /// stay.
140 Verbatim(String),
141 /// A record of label and value rows.
142 Fields(Fields),
143 /// A table, with or without a header row.
144 Table(Table),
145 /// A heading over a nested document.
146 Section(Section),
147 /// A level label with an optional code and a message.
148 Notice(Notice),
149 /// A horizontal rule, optionally titled.
150 Rule(Rule),
151}
152
153impl From<Fields> for Block {
154 fn from(value: Fields) -> Self {
155 Self::Fields(value)
156 }
157}
158
159impl From<Table> for Block {
160 fn from(value: Table) -> Self {
161 Self::Table(value)
162 }
163}
164
165impl From<Section> for Block {
166 fn from(value: Section) -> Self {
167 Self::Section(value)
168 }
169}
170
171impl From<Notice> for Block {
172 fn from(value: Notice) -> Self {
173 Self::Notice(value)
174 }
175}
176
177impl From<Rule> for Block {
178 fn from(value: Rule) -> Self {
179 Self::Rule(value)
180 }
181}
182
183/// Text made of [`Span`]s, each carrying a [`Role`].
184///
185/// A `&str` or `String` converts into plain text, so most methods that take
186/// `impl Into<Text>` accept either.
187///
188/// ```
189/// use ctl_core::document::{Role, Text};
190///
191/// let text = Text::new().token("--force").then(" overwrites ").id("A-1");
192/// assert_eq!(text.spans().len(), 3);
193/// assert_eq!(text.spans()[0].role(), Role::Token);
194/// ```
195#[derive(Clone, Debug, Default, Eq, PartialEq)]
196pub struct Text {
197 spans: Vec<Span>,
198}
199
200impl Text {
201 /// Start text with no spans.
202 #[must_use]
203 pub const fn new() -> Self {
204 Self { spans: Vec::new() }
205 }
206
207 /// Start text with one [`Role::Plain`] span holding `value`.
208 #[must_use]
209 pub fn plain(value: impl Into<String>) -> Self {
210 Self::new().span(Role::Plain, value)
211 }
212
213 /// Append a span holding `value` in `role`.
214 #[must_use]
215 pub fn span(mut self, role: Role, value: impl Into<String>) -> Self {
216 self.spans.push(Span {
217 role,
218 value: value.into(),
219 });
220 self
221 }
222
223 /// Append `value` in [`Role::Plain`]. A chain of `then` and role methods
224 /// reads left to right.
225 #[must_use]
226 pub fn then(self, value: impl Into<String>) -> Self {
227 self.span(Role::Plain, value)
228 }
229
230 /// Append `value` in [`Role::Token`]: a flag, command, or field name.
231 #[must_use]
232 pub fn token(self, value: impl Into<String>) -> Self {
233 self.span(Role::Token, value)
234 }
235
236 /// Append `value` in [`Role::Id`]: the id of a row, patch, or package.
237 #[must_use]
238 pub fn id(self, value: impl Into<String>) -> Self {
239 self.span(Role::Id, value)
240 }
241
242 /// Append `value` in [`Role::Value`]: a value or a metavar.
243 #[must_use]
244 pub fn value(self, value: impl Into<String>) -> Self {
245 self.span(Role::Value, value)
246 }
247
248 /// Append `value` in [`Role::Muted`]: secondary information.
249 #[must_use]
250 pub fn muted(self, value: impl Into<String>) -> Self {
251 self.span(Role::Muted, value)
252 }
253
254 /// Append `value` in [`Role::Success`].
255 #[must_use]
256 pub fn success(self, value: impl Into<String>) -> Self {
257 self.span(Role::Success, value)
258 }
259
260 /// Append `value` in [`Role::Warning`].
261 #[must_use]
262 pub fn warning(self, value: impl Into<String>) -> Self {
263 self.span(Role::Warning, value)
264 }
265
266 /// Append `value` in [`Role::Error`].
267 #[must_use]
268 pub fn error(self, value: impl Into<String>) -> Self {
269 self.span(Role::Error, value)
270 }
271
272 /// The spans, in the order they were appended.
273 #[must_use]
274 pub fn spans(&self) -> &[Span] {
275 &self.spans
276 }
277
278 /// Whether every span is empty, or there are none. A span of spaces is not
279 /// empty.
280 #[must_use]
281 pub fn is_empty(&self) -> bool {
282 self.spans.iter().all(|span| span.value.is_empty())
283 }
284}
285
286impl From<&str> for Text {
287 fn from(value: &str) -> Self {
288 Self::plain(value)
289 }
290}
291
292impl From<String> for Text {
293 fn from(value: String) -> Self {
294 Self::plain(value)
295 }
296}
297
298/// A run of text in one [`Role`]. A [`Text`] holds spans, and a span is read
299/// through [`Self::role`] and [`Self::value`].
300#[derive(Clone, Debug, Eq, PartialEq)]
301pub struct Span {
302 role: Role,
303 value: String,
304}
305
306impl Span {
307 /// The role that tells the renderer how to style the span.
308 #[must_use]
309 pub const fn role(&self) -> Role {
310 self.role
311 }
312
313 /// The span's text.
314 #[must_use]
315 pub fn value(&self) -> &str {
316 &self.value
317 }
318}
319
320/// What a [`Span`] means. A `Theme` chooses the style that draws each role.
321///
322/// The enum is `#[non_exhaustive]`: a `match` needs a wildcard arm.
323#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
324#[non_exhaustive]
325pub enum Role {
326 /// Content with no style of its own. A heading or a token column gives a
327 /// plain span its own role.
328 #[default]
329 Plain,
330 /// The text of a heading, a section title, or a table header.
331 Heading,
332 /// A successful outcome.
333 Success,
334 /// A warning.
335 Warning,
336 /// An error.
337 Error,
338 /// A value or a metavar, such as `<FILE>`.
339 Value,
340 /// Secondary information that should not draw the eye.
341 Muted,
342 /// A flag, command, field name, or other literal the user types or reads.
343 Token,
344 /// The identifier of a row, patch, package, or other domain record.
345 Id,
346}
347
348/// A record: label and value rows, aligned by the renderer.
349///
350/// The record style (boxed, keys right, or keys left) comes from the render
351/// options.
352#[derive(Clone, Debug, Default, Eq, PartialEq)]
353pub struct Fields {
354 rows: Vec<(Text, Text)>,
355}
356
357impl Fields {
358 /// Start a record with no rows.
359 #[must_use]
360 pub const fn new() -> Self {
361 Self { rows: Vec::new() }
362 }
363
364 /// Append a row. The `label` is a [`Role::Token`] span; the `value` keeps
365 /// the roles it has.
366 #[must_use]
367 pub fn row(mut self, label: impl Into<String>, value: impl Into<Text>) -> Self {
368 self.rows.push((Text::new().token(label), value.into()));
369 self
370 }
371
372 /// Append a row whose `label` is composed text, for a label that is not a
373 /// single token.
374 #[must_use]
375 pub fn text_row(mut self, label: impl Into<Text>, value: impl Into<Text>) -> Self {
376 self.rows.push((label.into(), value.into()));
377 self
378 }
379
380 /// The label and value of each row, in the order appended.
381 #[must_use]
382 pub fn rows(&self) -> &[(Text, Text)] {
383 &self.rows
384 }
385
386 /// Whether the record has no rows.
387 #[must_use]
388 pub fn is_empty(&self) -> bool {
389 self.rows.is_empty()
390 }
391}
392
393/// Rows of cells under an optional header row.
394///
395/// The table has as many columns as its longest row or its header row. A
396/// shorter row gets empty cells. Column marks, such as [`Self::id_column`],
397/// choose the role of a column's plain cells and how it is cut or aligned.
398///
399/// ```
400/// use ctl_core::document::Table;
401///
402/// let table = Table::new(["id", "title", "age"])
403/// .id_column(0)
404/// .ellipsis_column(1)
405/// .numeric_column(2)
406/// .row(["A-1", "Ship the release", "3"]);
407/// assert_eq!(table.rows().len(), 1);
408/// assert_eq!(table.id_column_index(), Some(0));
409/// ```
410#[derive(Clone, Debug, Default, Eq, PartialEq)]
411pub struct Table {
412 headers: Vec<Text>,
413 rows: Vec<Vec<Text>>,
414 token_column: Option<usize>,
415 id_column: Option<usize>,
416 ellipsis_columns: Vec<usize>,
417 numeric_columns: Vec<usize>,
418 stacked_below: Option<Stacked>,
419}
420
421impl Table {
422 /// Start a table with no header row.
423 #[must_use]
424 pub fn plain() -> Self {
425 Self::default()
426 }
427
428 /// Start a table with one header cell per item of `headers`.
429 #[must_use]
430 pub fn new(headers: impl IntoIterator<Item = impl Into<Text>>) -> Self {
431 Self {
432 headers: headers.into_iter().map(Into::into).collect(),
433 ..Self::default()
434 }
435 }
436
437 /// Draw the plain cells of column `index` (counted from 0) as
438 /// [`Role::Token`].
439 #[must_use]
440 pub const fn token_column(mut self, index: usize) -> Self {
441 self.token_column = Some(index);
442 self
443 }
444
445 /// Draw the plain cells of column `index` (counted from 0) as
446 /// [`Role::Id`].
447 #[must_use]
448 pub const fn id_column(mut self, index: usize) -> Self {
449 self.id_column = Some(index);
450 self
451 }
452
453 /// Mark column `index` (counted from 0) as text that may be cut short.
454 ///
455 /// A cell too wide for its column keeps one line and ends in `…`. The
456 /// column gives up its width before a column of whole words does.
457 /// Use it for a title or a summary, never for a value a reader copies
458 /// out, such as a URL or a path. Call it once per column.
459 #[must_use]
460 pub fn ellipsis_column(mut self, index: usize) -> Self {
461 self.ellipsis_columns.push(index);
462 self
463 }
464
465 /// Mark column `index` (counted from 0) as numbers.
466 ///
467 /// Every cell of the column, header included, aligns right, so numbers
468 /// line up on their last digit. Call it once per column.
469 #[must_use]
470 pub fn numeric_column(mut self, index: usize) -> Self {
471 self.numeric_columns.push(index);
472 self
473 }
474
475 /// Stack each row when the available width is below `width` cells.
476 ///
477 /// The first `label_columns` cells of a row form its label line. The
478 /// remaining cells form the indented description under it. The label cells
479 /// are drawn as [`Role::Token`].
480 #[must_use]
481 pub const fn stacked_below(mut self, width: u16, label_columns: usize) -> Self {
482 self.stacked_below = Some(Stacked {
483 width,
484 label_columns,
485 });
486 self
487 }
488
489 /// Append a row of cells, one per item.
490 #[must_use]
491 pub fn row(mut self, cells: impl IntoIterator<Item = impl Into<Text>>) -> Self {
492 self.rows.push(cells.into_iter().map(Into::into).collect());
493 self
494 }
495
496 /// The header cells, empty for a table with no header row.
497 #[must_use]
498 pub fn headers(&self) -> &[Text] {
499 &self.headers
500 }
501
502 /// The body rows, in the order appended.
503 #[must_use]
504 pub fn rows(&self) -> &[Vec<Text>] {
505 &self.rows
506 }
507
508 /// The column set by [`Self::token_column`], if any.
509 #[must_use]
510 pub const fn token_column_index(&self) -> Option<usize> {
511 self.token_column
512 }
513
514 /// The column set by [`Self::id_column`], if any.
515 #[must_use]
516 pub const fn id_column_index(&self) -> Option<usize> {
517 self.id_column
518 }
519
520 /// Columns marked with [`Self::ellipsis_column`], in the order marked.
521 #[must_use]
522 pub fn ellipsis_columns(&self) -> &[usize] {
523 &self.ellipsis_columns
524 }
525
526 /// Columns marked with [`Self::numeric_column`], in the order marked.
527 #[must_use]
528 pub fn numeric_columns(&self) -> &[usize] {
529 &self.numeric_columns
530 }
531
532 /// The narrow-layout policy set by [`Self::stacked_below`], if any.
533 #[must_use]
534 pub const fn stacked(&self) -> Option<Stacked> {
535 self.stacked_below
536 }
537
538 /// Whether the table has no body rows. A header row does not count.
539 #[must_use]
540 pub fn is_empty(&self) -> bool {
541 self.rows.is_empty()
542 }
543}
544
545/// When a [`Table`] stacks its rows, and how many cells form each label.
546///
547/// Read it from [`Table::stacked`]; [`Table::stacked_below`] sets it.
548#[derive(Clone, Copy, Debug, Eq, PartialEq)]
549pub struct Stacked {
550 width: u16,
551 label_columns: usize,
552}
553
554impl Stacked {
555 /// The width, in cells, below which rows stack.
556 #[must_use]
557 pub const fn width(self) -> u16 {
558 self.width
559 }
560
561 /// The number of leading cells that form a stacked row's label.
562 #[must_use]
563 pub const fn label_columns(self) -> usize {
564 self.label_columns
565 }
566}
567
568/// A heading over a nested [`Document`].
569///
570/// The heading sits directly over the body with no blank line and no indent.
571/// A table in the body then lines up with the tables of the outer document.
572#[derive(Clone, Debug, Eq, PartialEq)]
573pub struct Section {
574 title: Text,
575 body: Document,
576}
577
578impl Section {
579 /// Build a section from its `title` and `body`.
580 #[must_use]
581 pub fn new(title: impl Into<Text>, body: Document) -> Self {
582 Self {
583 title: title.into(),
584 body,
585 }
586 }
587
588 /// The heading text.
589 #[must_use]
590 pub const fn title(&self) -> &Text {
591 &self.title
592 }
593
594 /// The nested document under the heading.
595 #[must_use]
596 pub const fn body(&self) -> &Document {
597 &self.body
598 }
599}
600
601/// A one-line message with a level label, such as `warning`, and an optional
602/// code.
603///
604/// The renderer draws the label, then the code if there is one, then the
605/// message, separated by ` · `.
606///
607/// ```
608/// use ctl_core::document::{Notice, NoticeLevel};
609///
610/// let notice = Notice::new(NoticeLevel::Error, "no such row").code("not-found");
611/// assert_eq!(notice.code_value(), Some("not-found"));
612/// ```
613#[derive(Clone, Debug, Eq, PartialEq)]
614pub struct Notice {
615 level: NoticeLevel,
616 code: Option<String>,
617 message: Text,
618}
619
620impl Notice {
621 /// Build a notice of `level` with no code.
622 #[must_use]
623 pub fn new(level: NoticeLevel, message: impl Into<Text>) -> Self {
624 Self {
625 level,
626 code: None,
627 message: message.into(),
628 }
629 }
630
631 /// Attach a short code that stays the same for the same kind of notice, so
632 /// a reader can search for it.
633 #[must_use]
634 pub fn code(mut self, code: impl Into<String>) -> Self {
635 self.code = Some(code.into());
636 self
637 }
638
639 /// The severity, which picks the label and its style.
640 #[must_use]
641 pub const fn level(&self) -> NoticeLevel {
642 self.level
643 }
644
645 /// The code set by [`Self::code`], if any.
646 #[must_use]
647 pub fn code_value(&self) -> Option<&str> {
648 self.code.as_deref()
649 }
650
651 /// The message text.
652 #[must_use]
653 pub const fn message(&self) -> &Text {
654 &self.message
655 }
656}
657
658/// The severity of a [`Notice`]. It picks the label (`success`, `warning`, or
659/// `error`) and the style of the label.
660///
661/// A level can join the set in a minor release, so a match on it outside
662/// ctl-core ends with a wildcard arm.
663#[derive(Clone, Copy, Debug, Eq, PartialEq)]
664#[non_exhaustive]
665pub enum NoticeLevel {
666 /// A successful outcome.
667 Success,
668 /// A warning that does not fail the command.
669 Warning,
670 /// A failed outcome.
671 Error,
672}
673
674/// A horizontal rule across the width, optionally titled.
675///
676/// Build one with [`Document::rule`]. This type is the read side.
677#[derive(Clone, Debug, Eq, PartialEq)]
678pub struct Rule {
679 title: Option<Text>,
680}
681
682impl Rule {
683 /// The title drawn in the rule, if any.
684 #[must_use]
685 pub const fn title(&self) -> Option<&Text> {
686 self.title.as_ref()
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::{Document, Fields, Notice, NoticeLevel, Role, Table, Text};
693
694 #[test]
695 fn fluent_document_preserves_semantics() {
696 let document = Document::new()
697 .heading("status")
698 .fields(Fields::new().row("pending", "2"))
699 .table(
700 Table::new(["id", "title"])
701 .token_column(0)
702 .row(["A-1", "Ship"]),
703 )
704 .notice(Notice::new(NoticeLevel::Warning, "stale"));
705
706 assert_eq!(document.blocks().len(), 4);
707 let token = Text::new().token("--force").then(" writes");
708 assert_eq!(token.spans()[0].role(), Role::Token);
709 assert_eq!(token.spans()[1].role(), Role::Plain);
710 }
711
712 #[test]
713 fn column_marks_accumulate() {
714 let table = Table::new(["id", "title", "age", "size"])
715 .ellipsis_column(1)
716 .numeric_column(2)
717 .numeric_column(3);
718 assert_eq!(table.ellipsis_columns(), [1]);
719 assert_eq!(table.numeric_columns(), [2, 3]);
720 }
721}