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