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}