Skip to main content

asciidoc_parser/document/
toc.rs

1//! Describes where (and whether) a document's table of contents is rendered,
2//! together with the resolved depth, title, and CSS class.
3
4use crate::{Parser, document::InterpretedValue};
5
6/// Where (and whether) a document's table of contents (TOC) is generated,
7/// resolved from the [`toc` attribute].
8///
9/// The `toc` attribute is header-only, so this value is fixed once a document's
10/// header has been processed. A nested [AsciiDoc table cell] behaves as its own
11/// standalone document and resolves its own [`TocMode`] independently — it does
12/// **not** inherit the parent document's setting.
13///
14/// The `auto`, `left`, and `right` placements all render the TOC automatically
15/// near the top of the document; `left` and `right` additionally request a
16/// fixed side column when converting to standalone HTML (a presentation detail
17/// outside this crate's scope). `preamble` places the TOC immediately below the
18/// preamble, and `macro` defers placement to a `toc::[]` block macro.
19///
20/// [`toc` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/
21/// [AsciiDoc table cell]: crate::blocks::TableCellContent::AsciiDoc
22#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
23pub enum TocMode {
24    /// The `toc` attribute is unset: no table of contents is generated.
25    Disabled,
26
27    /// The `toc` attribute is empty (the value an empty `:toc:` resolves to) or
28    /// set to `auto`. The TOC is generated automatically near the top of the
29    /// document.
30    Auto,
31
32    /// The `toc` attribute is set to `left`: an automatically placed TOC that,
33    /// in standalone HTML, is rendered as a fixed left-hand side column.
34    Left,
35
36    /// The `toc` attribute is set to `right`: an automatically placed TOC that,
37    /// in standalone HTML, is rendered as a fixed right-hand side column.
38    Right,
39
40    /// The `toc` attribute is set to `preamble`: the TOC is generated
41    /// immediately below the document's preamble.
42    Preamble,
43
44    /// The `toc` attribute is set to `macro`: the table of contents is
45    /// generated only where a `toc::[]` block macro appears.
46    Macro,
47}
48
49impl TocMode {
50    /// Resolves the table-of-contents placement from a parser's current `toc`
51    /// attribute state.
52    pub(crate) fn from_parser(parser: &Parser) -> Self {
53        let value = parser.attribute_value("toc");
54        if value == InterpretedValue::Unset {
55            return Self::Disabled;
56        }
57
58        // `toc` has a built-in default of `auto`, so a bare `:toc:` resolves to
59        // `Value("auto")` (never `Set`). An explicit `auto`, or any other
60        // (unrecognized) value, is treated as an automatic placement, matching
61        // Asciidoctor (which renders an auto-placed TOC for any non-positional
62        // value).
63        match value.as_maybe_str().map(str::trim) {
64            Some("macro") => Self::Macro,
65            Some("left") => Self::Left,
66            Some("right") => Self::Right,
67            Some("preamble") => Self::Preamble,
68            _ => Self::Auto,
69        }
70    }
71
72    /// Returns `true` unless the `toc` attribute is unset (i.e. a table of
73    /// contents is generated somewhere in the document).
74    pub fn is_enabled(self) -> bool {
75        self != Self::Disabled
76    }
77}
78
79/// The depth of section levels included in a table of contents when the
80/// `toclevels` attribute is not set. Matches Asciidoctor's default of 2
81/// (sections up to and including `===`).
82pub(crate) const DEFAULT_TOCLEVELS: usize = 2;
83
84/// The title of the table of contents when the `toc-title` attribute is not
85/// set. Matches Asciidoctor's default.
86pub(crate) const DEFAULT_TOC_TITLE: &str = "Table of Contents";
87
88/// The CSS class applied to the table of contents container when the
89/// `toc-class` attribute is not set. Matches Asciidoctor's default.
90pub(crate) const DEFAULT_TOC_CLASS: &str = "toc";
91
92/// The resolved table-of-contents configuration for a document (or a nested
93/// AsciiDoc table cell, which resolves its own configuration independently).
94///
95/// Like [`TocMode`], the underlying attributes (`toc`, `toclevels`,
96/// `toc-title`, `toc-class`) are header-only, so this value is captured once
97/// the header has been processed and the parser still holds the document's
98/// resolved attribute state.
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub(crate) struct TocConfig {
101    /// Where (and whether) the TOC is placed.
102    pub(crate) mode: TocMode,
103
104    /// The depth of section levels included in the TOC, from the `toclevels`
105    /// attribute (default [`DEFAULT_TOCLEVELS`]).
106    pub(crate) levels: usize,
107
108    /// The TOC title, from the `toc-title` attribute (default
109    /// [`DEFAULT_TOC_TITLE`]).
110    pub(crate) title: String,
111
112    /// The CSS class applied to the TOC container, from the `toc-class`
113    /// attribute (default [`DEFAULT_TOC_CLASS`]).
114    pub(crate) class: String,
115}
116
117impl TocConfig {
118    /// Resolves the full table-of-contents configuration from a parser's
119    /// current attribute state.
120    pub(crate) fn from_parser(parser: &Parser) -> Self {
121        Self {
122            mode: TocMode::from_parser(parser),
123            levels: resolve_levels(parser),
124            title: resolve_title(parser),
125            class: resolve_class(parser),
126        }
127    }
128
129    /// Returns a configuration with no table of contents, used as the default
130    /// for a structure that has not enabled a TOC.
131    #[cfg(test)]
132    pub(crate) fn disabled() -> Self {
133        Self {
134            mode: TocMode::Disabled,
135            levels: DEFAULT_TOCLEVELS,
136            title: DEFAULT_TOC_TITLE.to_string(),
137            class: DEFAULT_TOC_CLASS.to_string(),
138        }
139    }
140}
141
142/// Resolves the `toclevels` depth. Accepted values are the integers 0 through
143/// 5: the value `0` is coerced to `1` (this crate has no multipart-book parts,
144/// so level 0 sections never appear) and values above `5` are clamped to `5`,
145/// matching the documented range. Any unparseable value falls back to the
146/// default of 2.
147fn resolve_levels(parser: &Parser) -> usize {
148    parser
149        .attribute_value("toclevels")
150        .as_maybe_str()
151        .and_then(|s| s.trim().parse::<usize>().ok())
152        .map(|n| n.clamp(1, 5))
153        .unwrap_or(DEFAULT_TOCLEVELS)
154}
155
156/// Resolves the `toc-title`. An empty `:toc-title:` (set but with no value)
157/// yields an empty title, matching Asciidoctor; an unset attribute falls back
158/// to the default.
159fn resolve_title(parser: &Parser) -> String {
160    match parser.attribute_value("toc-title") {
161        InterpretedValue::Value(v) => v,
162        InterpretedValue::Set => String::new(),
163        InterpretedValue::Unset => DEFAULT_TOC_TITLE.to_string(),
164    }
165}
166
167/// Resolves the `toc-class`, falling back to the default when unset or empty.
168fn resolve_class(parser: &Parser) -> String {
169    match parser.attribute_value("toc-class") {
170        InterpretedValue::Value(v) if !v.trim().is_empty() => v,
171        _ => DEFAULT_TOC_CLASS.to_string(),
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use crate::{Parser, document::TocMode};
178
179    /// Parses a minimal document with the given header attribute lines and
180    /// returns the parsed [`Document`](crate::Document) for inspection.
181    fn doc_with(header: &str) -> crate::Document<'static> {
182        let src = format!("= Title\n{header}\n\n== Section\n\ncontent");
183        Parser::default().parse(&src)
184    }
185
186    #[test]
187    fn mode_is_disabled_when_unset() {
188        assert_eq!(doc_with("").toc_mode(), TocMode::Disabled);
189        assert!(!TocMode::Disabled.is_enabled());
190    }
191
192    #[test]
193    fn mode_resolves_each_placement() {
194        assert_eq!(doc_with(":toc:").toc_mode(), TocMode::Auto);
195        assert_eq!(doc_with(":toc: auto").toc_mode(), TocMode::Auto);
196        assert_eq!(doc_with(":toc: left").toc_mode(), TocMode::Left);
197        assert_eq!(doc_with(":toc: right").toc_mode(), TocMode::Right);
198        assert_eq!(doc_with(":toc: preamble").toc_mode(), TocMode::Preamble);
199        assert_eq!(doc_with(":toc: macro").toc_mode(), TocMode::Macro);
200
201        for mode in [
202            TocMode::Auto,
203            TocMode::Left,
204            TocMode::Right,
205            TocMode::Preamble,
206            TocMode::Macro,
207        ] {
208            assert!(mode.is_enabled());
209        }
210    }
211
212    #[test]
213    fn unrecognized_mode_is_treated_as_auto() {
214        assert_eq!(doc_with(":toc: bogus").toc_mode(), TocMode::Auto);
215    }
216
217    #[test]
218    fn levels_default_and_overrides() {
219        assert_eq!(doc_with(":toc:").toc_levels(), 2);
220        assert_eq!(doc_with(":toc:\n:toclevels: 5").toc_levels(), 5);
221        // `0` is coerced to `1`, values above `5` are clamped to `5`, and an
222        // unparseable value falls back to the default.
223        assert_eq!(doc_with(":toc:\n:toclevels: 0").toc_levels(), 1);
224        assert_eq!(doc_with(":toc:\n:toclevels: 6").toc_levels(), 5);
225        assert_eq!(doc_with(":toc:\n:toclevels: nope").toc_levels(), 2);
226    }
227
228    #[test]
229    fn title_default_value_and_empty() {
230        assert_eq!(doc_with(":toc:").toc_title(), "Table of Contents");
231        assert_eq!(doc_with(":toc:\n:toc-title: My TOC").toc_title(), "My TOC");
232        assert_eq!(doc_with(":toc:\n:toc-title:").toc_title(), "");
233    }
234
235    #[test]
236    fn class_default_value_and_empty() {
237        assert_eq!(doc_with(":toc:").toc_class(), "toc");
238        assert_eq!(doc_with(":toc:\n:toc-class: floaty").toc_class(), "floaty");
239        // An empty `:toc-class:` falls back to the default.
240        assert_eq!(doc_with(":toc:\n:toc-class:").toc_class(), "toc");
241    }
242}