Skip to main content

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}