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`, `right`, `top`, and `bottom` placements all render the
16/// TOC automatically near the top of the document;
17/// `left`/`right`/`top`/`bottom` additionally request a fixed side (or
18/// top/bottom) column when converting to standalone HTML (a presentation detail
19/// outside this crate's scope). `preamble` places the TOC immediately below the
20/// preamble, and `macro` defers placement to a `toc::[]` block macro.
21///
22/// The positional placement can be selected by a keyword or a direction
23/// shorthand in the `toc` value (`<`/`>`/`^`/`v` for `left`/`right`/`top`/
24/// `bottom`), by the separate `toc-position` attribute, or by the legacy `toc2`
25/// alias, mirroring Asciidoctor's header normalization.
26///
27/// [`toc` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/
28/// [AsciiDoc table cell]: crate::blocks::TableCellContent::AsciiDoc
29#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
30pub enum TocMode {
31    /// The `toc` attribute is unset: no table of contents is generated.
32    Disabled,
33
34    /// The `toc` attribute is empty (the value an empty `:toc:` resolves to) or
35    /// set to `auto`. The TOC is generated automatically near the top of the
36    /// document.
37    Auto,
38
39    /// The `toc` attribute is set to `left` (or the legacy `toc2` alias is
40    /// set): an automatically placed TOC that, in standalone HTML, is rendered
41    /// as a fixed left-hand side column.
42    Left,
43
44    /// The `toc` attribute is set to `right`: an automatically placed TOC that,
45    /// in standalone HTML, is rendered as a fixed right-hand side column.
46    Right,
47
48    /// The placement resolves to `top` (via `:toc: top` / `:toc: ^`, or a
49    /// `toc-position` of `top`): an automatically placed TOC rendered as a
50    /// fixed top column in standalone HTML.
51    Top,
52
53    /// The placement resolves to `bottom` (via `:toc: bottom` / `:toc: v`, or a
54    /// `toc-position` of `bottom`): an automatically placed TOC rendered as a
55    /// fixed bottom column in standalone HTML.
56    Bottom,
57
58    /// The placement resolves to `preamble` (via `:toc: preamble` or
59    /// `:toc-placement: preamble`): the TOC is generated immediately below the
60    /// document's preamble.
61    Preamble,
62
63    /// The placement resolves to `macro` (via `:toc: macro` or
64    /// `:toc-placement: macro`): the table of contents is generated only where
65    /// a `toc::[]` block macro appears.
66    Macro,
67}
68
69impl TocMode {
70    /// Resolves the table-of-contents placement from a parser's current `toc`
71    /// attribute state, mirroring Asciidoctor's header normalization
72    /// (`lib/asciidoctor/document.rb`, the `toc_val = (attrs.delete 'toc2') …`
73    /// block).
74    ///
75    /// This reads the raw stored `toc`, `toc2`, `toc-placement`, and
76    /// `toc-position` attributes, distinguishing a never-set `toc-placement`
77    /// from an explicit unset tombstone. The derived `toc-position` /
78    /// `toc-placement` / `toc-class` document attributes are materialized from
79    /// the resolved placement *after* this runs – see
80    /// [`Parser::materialize_toc_attributes`](crate::Parser).
81    pub(crate) fn from_parser(parser: &Parser) -> Self {
82        // Asciidoctor: `toc_val = (attrs.delete 'toc2') ? 'left' : attrs['toc']`.
83        // `toc2` is a legacy alias for a left side-column TOC: when set it
84        // overrides the `toc` value with `left`. (A soft-unset `toc2!` records an
85        // `Unset` tombstone, which does not enable it.)
86        let toc_val = if parser.attribute_value("toc2") != InterpretedValue::Unset {
87            "left".to_string()
88        } else {
89            let toc = parser.attribute_value("toc");
90
91            // No `toc` (and no `toc2`): no table of contents is generated.
92            if toc == InterpretedValue::Unset {
93                return Self::Disabled;
94            }
95
96            // `toc` carries a built-in `auto` default, so a bare `:toc:` reads as
97            // `Value("auto")` (never `Set`); any other value is taken as-is, and a
98            // set-but-empty value folds to empty.
99            toc.as_maybe_str().unwrap_or_default().to_string()
100        };
101
102        // A bare `:toc:` carries this crate's built-in `auto` default; Asciidoctor
103        // treats a bare `toc` as an empty value, so fold `auto` back to empty to
104        // match its `toc_val.empty?` branches below.
105        let toc_val = toc_val.trim();
106        let toc_val = if toc_val == "auto" { "" } else { toc_val };
107
108        // `toc-placement` lets a document separate the *position* of the TOC from
109        // the *slot* it occupies. Asciidoctor fetches it with a `macro` fallback
110        // (`attrs.fetch 'toc-placement', 'macro'`) while its default attribute
111        // normally seeds `auto`; this crate seeds no such default, so a never-set
112        // `toc-placement` reads as `auto`, an explicit value is taken as-is, and a
113        // soft-unset tombstone – which Asciidoctor *deletes*, so its `fetch` yields
114        // the `macro` fallback – reads as `macro`.
115        let toc_placement_val = match parser.attribute_value("toc-placement") {
116            InterpretedValue::Value(v) => v.trim().to_string(),
117            InterpretedValue::Set => String::new(),
118            InterpretedValue::Unset if parser.has_attribute("toc-placement") => "macro".to_string(),
119            InterpretedValue::Unset => "auto".to_string(),
120        };
121
122        // Asciidoctor: `toc_placement_val && toc_placement_val != 'auto' ?
123        // toc_placement_val : attrs['toc-position']`. When `toc-placement` names
124        // anything other than `auto` it *is* the position (so `preamble`/`macro`,
125        // or a side keyword on `toc-placement`, override the `toc` value's own
126        // keyword); otherwise the separate `toc-position` attribute supplies it.
127        let toc_position_val = if toc_placement_val != "auto" {
128            toc_placement_val
129        } else {
130            parser
131                .attribute_value("toc-position")
132                .as_maybe_str()
133                .map(str::trim)
134                .unwrap_or_default()
135                .to_string()
136        };
137
138        // With neither a `toc` value nor a resolved position, the TOC is an
139        // ordinary automatically placed top TOC (Asciidoctor skips its
140        // normalization block, leaving `toc-position` unset).
141        if toc_val.is_empty() && toc_position_val.is_empty() {
142            return Self::Auto;
143        }
144
145        // A resolved `toc-position` wins over the `toc` value; when `toc-position`
146        // is empty the empty/empty early return above guarantees a non-empty `toc`
147        // value, which then supplies the position. (Asciidoctor's
148        // `default_toc_position` fallback of `left` is likewise unreachable once
149        // that case has returned.)
150        let position = if toc_position_val.is_empty() {
151            toc_val
152        } else {
153            toc_position_val.as_str()
154        };
155
156        // Map the resolved position to a placement, mirroring Asciidoctor's
157        // `case`. The direction shorthands arrive here already special-character
158        // substituted (`>` as `&gt;`, `<` as `&lt;`), so both spellings are
159        // matched; a literally typed `&gt;`/`&lt;` becomes `&amp;gt;`/`&amp;lt;`
160        // and correctly falls through to an automatic placement, as in
161        // Asciidoctor. An unrecognized position (including `content`, `auto`, or a
162        // bogus value) is an automatic placement.
163        match position {
164            "left" | "<" | "&lt;" => Self::Left,
165            "right" | ">" | "&gt;" => Self::Right,
166            "top" | "^" => Self::Top,
167            "bottom" | "v" => Self::Bottom,
168            "preamble" => Self::Preamble,
169            "macro" => Self::Macro,
170            _ => Self::Auto,
171        }
172    }
173
174    /// Returns `true` unless the `toc` attribute is unset (i.e. a table of
175    /// contents is generated somewhere in the document).
176    pub fn is_enabled(self) -> bool {
177        self != Self::Disabled
178    }
179
180    /// Returns the value of the derived `toc-position` document attribute for
181    /// this placement, or `None` when Asciidoctor leaves it unset (its `nil`
182    /// default). The positional placements report their side (`left` / `right`
183    /// / `top` / `bottom`); the content-flow placements (`preamble` /
184    /// `macro`) report `content`; an automatic top TOC (and a disabled TOC)
185    /// leave it unset.
186    ///
187    /// See [`Parser::materialize_toc_attributes`](crate::Parser).
188    pub(crate) fn derived_toc_position(self) -> Option<&'static str> {
189        match self {
190            Self::Left => Some("left"),
191            Self::Right => Some("right"),
192            Self::Top => Some("top"),
193            Self::Bottom => Some("bottom"),
194            Self::Preamble | Self::Macro => Some("content"),
195            Self::Auto | Self::Disabled => None,
196        }
197    }
198
199    /// Returns the value of the derived `toc-placement` document attribute for
200    /// this placement, or `None` when no TOC is generated. The automatic and
201    /// side-column placements all fold to `auto`; `preamble` / `macro` report
202    /// themselves. This is the placement keyword Asciidoctor exposes once the
203    /// `toc` shorthand has been folded into `toc-placement`.
204    pub(crate) fn derived_toc_placement(self) -> Option<&'static str> {
205        match self {
206            Self::Disabled => None,
207            Self::Preamble => Some("preamble"),
208            Self::Macro => Some("macro"),
209            Self::Auto | Self::Left | Self::Right | Self::Top | Self::Bottom => Some("auto"),
210        }
211    }
212
213    /// Returns the value the derived `toc-class` document attribute defaults to
214    /// for this placement when the author has not set `toc-class`, or `None`
215    /// when Asciidoctor leaves it unset (its `nil` default). Only a positional
216    /// (`left` / `right` / `top` / `bottom`) TOC introduces a default (`toc2`,
217    /// the side-column class).
218    pub(crate) fn derived_toc_class(self) -> Option<&'static str> {
219        match self {
220            Self::Left | Self::Right | Self::Top | Self::Bottom => Some(DEFAULT_TOC_CLASS_SIDE),
221            _ => None,
222        }
223    }
224}
225
226/// The depth of section levels included in a table of contents when the
227/// `toclevels` attribute is not set. Matches Asciidoctor's default of 2
228/// (sections up to and including `===`).
229pub(crate) const DEFAULT_TOCLEVELS: usize = 2;
230
231/// The title of the table of contents when the `toc-title` attribute is not
232/// set. Matches Asciidoctor's default.
233pub(crate) const DEFAULT_TOC_TITLE: &str = "Table of Contents";
234
235/// The CSS class applied to the table of contents container when the
236/// `toc-class` attribute is not set. Matches Asciidoctor's default.
237pub(crate) const DEFAULT_TOC_CLASS: &str = "toc";
238
239/// The CSS class applied to the table of contents container for a
240/// `left`/`right` side-column TOC when the `toc-class` attribute is not set.
241/// This is the class that drives the fixed side-column styling in Asciidoctor's
242/// standalone HTML.
243pub(crate) const DEFAULT_TOC_CLASS_SIDE: &str = "toc2";
244
245/// The resolved table-of-contents configuration for a document (or a nested
246/// AsciiDoc table cell, which resolves its own configuration independently).
247///
248/// Like [`TocMode`], the underlying attributes (`toc`, `toclevels`,
249/// `toc-title`, `toc-class`) are header-only, so this value is captured once
250/// the header has been processed and the parser still holds the document's
251/// resolved attribute state.
252#[derive(Clone, Debug, Eq, Hash, PartialEq)]
253pub(crate) struct TocConfig {
254    /// Where (and whether) the TOC is placed.
255    pub(crate) mode: TocMode,
256
257    /// The depth of section levels included in the TOC, from the `toclevels`
258    /// attribute (default [`DEFAULT_TOCLEVELS`]).
259    pub(crate) levels: usize,
260
261    /// The TOC title, from the `toc-title` attribute (default
262    /// [`DEFAULT_TOC_TITLE`]).
263    pub(crate) title: String,
264
265    /// The CSS class applied to the TOC container, from the `toc-class`
266    /// attribute (default [`DEFAULT_TOC_CLASS`]).
267    pub(crate) class: String,
268}
269
270impl TocConfig {
271    /// Resolves the full table-of-contents configuration from a parser's
272    /// current attribute state.
273    pub(crate) fn from_parser(parser: &Parser) -> Self {
274        let mode = TocMode::from_parser(parser);
275        Self {
276            mode,
277            levels: resolve_levels(parser),
278            title: resolve_title(parser),
279            class: resolve_class(parser, mode),
280        }
281    }
282
283    /// Returns a configuration with no table of contents, used as the default
284    /// for a structure that has not enabled a TOC.
285    #[cfg(test)]
286    pub(crate) fn disabled() -> Self {
287        Self {
288            mode: TocMode::Disabled,
289            levels: DEFAULT_TOCLEVELS,
290            title: DEFAULT_TOC_TITLE.to_string(),
291            class: DEFAULT_TOC_CLASS.to_string(),
292        }
293    }
294}
295
296/// Resolves the `toclevels` depth. Accepted values are the integers 0 through
297/// 5: the value `0` is coerced to `1` (this crate has no multipart-book parts,
298/// so level 0 sections never appear) and values above `5` are clamped to `5`,
299/// matching the documented range. Any unparseable value falls back to the
300/// default of 2.
301fn resolve_levels(parser: &Parser) -> usize {
302    parser
303        .attribute_value("toclevels")
304        .as_maybe_str()
305        .and_then(|s| s.trim().parse::<usize>().ok())
306        .map(|n| n.clamp(1, 5))
307        .unwrap_or(DEFAULT_TOCLEVELS)
308}
309
310/// Resolves the `toc-title`. An empty `:toc-title:` (set but with no value)
311/// yields an empty title, matching Asciidoctor; an unset attribute falls back
312/// to the default.
313fn resolve_title(parser: &Parser) -> String {
314    match parser.attribute_value("toc-title") {
315        InterpretedValue::Value(v) => v,
316        InterpretedValue::Set => String::new(),
317        InterpretedValue::Unset => DEFAULT_TOC_TITLE.to_string(),
318    }
319}
320
321/// Resolves the `toc-class`. An explicit, non-empty `toc-class` wins outright.
322/// Otherwise the default depends on placement: a positional (`left`/`right`/
323/// `top`/`bottom`) TOC uses `toc2` (the class that drives the side-column
324/// styling), matching Asciidoctor, which switches the default `toc-class` to
325/// `toc2` for those placements; every other placement uses the plain `toc`.
326fn resolve_class(parser: &Parser, mode: TocMode) -> String {
327    match parser.attribute_value("toc-class") {
328        InterpretedValue::Value(v) if !v.trim().is_empty() => v,
329        _ => default_toc_class(mode).to_string(),
330    }
331}
332
333/// Returns the default `toc-class` for a resolved placement: `toc2` for a
334/// positional (`left`/`right`/`top`/`bottom`) TOC, and the plain `toc` for
335/// every other placement.
336fn default_toc_class(mode: TocMode) -> &'static str {
337    match mode {
338        TocMode::Left | TocMode::Right | TocMode::Top | TocMode::Bottom => DEFAULT_TOC_CLASS_SIDE,
339        _ => DEFAULT_TOC_CLASS,
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use crate::{
346        Parser,
347        document::{InterpretedValue, TocMode},
348    };
349
350    /// Parses a minimal document with the given header attribute lines and
351    /// returns the parsed [`Document`](crate::Document) for inspection.
352    fn doc_with(header: &str) -> crate::Document<'static> {
353        let src = format!("= Title\n{header}\n\n== Section\n\ncontent");
354        Parser::default().parse(&src)
355    }
356
357    #[test]
358    fn mode_is_disabled_when_unset() {
359        assert_eq!(doc_with("").toc_mode(), TocMode::Disabled);
360        assert!(!TocMode::Disabled.is_enabled());
361    }
362
363    #[test]
364    fn mode_resolves_each_placement() {
365        assert_eq!(doc_with(":toc:").toc_mode(), TocMode::Auto);
366        assert_eq!(doc_with(":toc: auto").toc_mode(), TocMode::Auto);
367        assert_eq!(doc_with(":toc: left").toc_mode(), TocMode::Left);
368        assert_eq!(doc_with(":toc: right").toc_mode(), TocMode::Right);
369        assert_eq!(doc_with(":toc: preamble").toc_mode(), TocMode::Preamble);
370        assert_eq!(doc_with(":toc: macro").toc_mode(), TocMode::Macro);
371
372        for mode in [
373            TocMode::Auto,
374            TocMode::Left,
375            TocMode::Right,
376            TocMode::Preamble,
377            TocMode::Macro,
378        ] {
379            assert!(mode.is_enabled());
380        }
381    }
382
383    #[test]
384    fn unrecognized_mode_is_treated_as_auto() {
385        assert_eq!(doc_with(":toc: bogus").toc_mode(), TocMode::Auto);
386    }
387
388    #[test]
389    fn mode_resolves_direction_shorthands_and_keywords() {
390        // The `<`/`>`/`^`/`v` direction shorthands in the `toc` value resolve to
391        // the `left`/`right`/`top`/`bottom` positions, matching Asciidoctor. (The
392        // `<`/`>` shorthands reach resolution already special-character
393        // substituted to `&lt;`/`&gt;`, but resolve the same.)
394        assert_eq!(doc_with(":toc: <").toc_mode(), TocMode::Left);
395        assert_eq!(doc_with(":toc: >").toc_mode(), TocMode::Right);
396        assert_eq!(doc_with(":toc: ^").toc_mode(), TocMode::Top);
397        assert_eq!(doc_with(":toc: v").toc_mode(), TocMode::Bottom);
398
399        // The `top`/`bottom` keywords resolve likewise.
400        assert_eq!(doc_with(":toc: top").toc_mode(), TocMode::Top);
401        assert_eq!(doc_with(":toc: bottom").toc_mode(), TocMode::Bottom);
402
403        // A literally typed `&gt;`/`&lt;` entity is not a shorthand (it is
404        // special-character substituted to `&amp;gt;`/`&amp;lt;`), so it falls
405        // back to an automatic placement, as in Asciidoctor's header handling.
406        assert_eq!(doc_with(":toc: &gt;").toc_mode(), TocMode::Auto);
407        assert_eq!(doc_with(":toc: &lt;").toc_mode(), TocMode::Auto);
408    }
409
410    #[test]
411    fn toc_position_attribute_selects_the_side() {
412        // The separate `toc-position` attribute selects the side of an otherwise
413        // automatic TOC. It also overrides the `left` implied by the legacy `toc2`
414        // alias, and a bare side keyword in the `toc` value.
415        assert_eq!(
416            doc_with(":toc:\n:toc-position: right").toc_mode(),
417            TocMode::Right
418        );
419        assert_eq!(
420            doc_with(":toc2:\n:toc-position: right").toc_mode(),
421            TocMode::Right
422        );
423        assert_eq!(
424            doc_with(":toc: left\n:toc-position: right").toc_mode(),
425            TocMode::Right
426        );
427        assert_eq!(doc_with(":toc:\n:toc-position: ^").toc_mode(), TocMode::Top);
428    }
429
430    #[test]
431    fn toc_placement_side_resolves_the_position() {
432        // `toc-placement` is resolved into the position ahead of the `toc` value,
433        // so a `preamble`/`macro` placement (or a side keyword on `toc-placement`)
434        // wins over a conflicting keyword in the `toc` value.
435        assert_eq!(
436            doc_with(":toc: left\n:toc-placement: macro").toc_mode(),
437            TocMode::Macro
438        );
439        assert_eq!(
440            doc_with(":toc:\n:toc-placement: right").toc_mode(),
441            TocMode::Right
442        );
443
444        // A `toc-position` (consulted when `toc-placement` is `auto`) likewise
445        // wins over a `preamble` keyword in the `toc` value.
446        assert_eq!(
447            doc_with(":toc: preamble\n:toc-position: right").toc_mode(),
448            TocMode::Right
449        );
450
451        // A bare `:toc-placement:` (set with no value) supplies an empty position,
452        // so an otherwise-bare `:toc:` stays an automatic top TOC.
453        assert_eq!(doc_with(":toc:\n:toc-placement:").toc_mode(), TocMode::Auto);
454    }
455
456    #[test]
457    fn toc2_alias_enables_a_left_placed_toc() {
458        // `toc2` is a legacy alias for `:toc: left`: setting the bare attribute
459        // enables a left-positioned table of contents even though the `toc`
460        // attribute itself is unset.
461        assert_eq!(doc_with(":toc2:").toc_mode(), TocMode::Left);
462        assert!(doc_with(":toc2:").toc_mode().is_enabled());
463
464        // Its side-column placement also switches the default `toc-class` to
465        // `toc2`, like any other `left`/`right` TOC.
466        assert_eq!(doc_with(":toc2:").toc_class(), "toc2");
467
468        // A soft-unset `toc2!` does not enable the TOC.
469        assert_eq!(doc_with(":toc2!:").toc_mode(), TocMode::Disabled);
470    }
471
472    #[test]
473    fn placement_falls_back_to_toc_placement_attribute() {
474        // When the `toc` value carries no placement keyword, the separate
475        // `toc-placement` attribute determines the placement.
476        assert_eq!(
477            doc_with(":toc:\n:toc-placement: preamble").toc_mode(),
478            TocMode::Preamble
479        );
480        assert_eq!(
481            doc_with(":toc:\n:toc-placement: macro").toc_mode(),
482            TocMode::Macro
483        );
484        assert_eq!(
485            doc_with(":toc: auto\n:toc-placement: preamble").toc_mode(),
486            TocMode::Preamble
487        );
488
489        // A non-`auto` `toc-placement` supplies the position and wins over the
490        // `toc` value's own keyword (Asciidoctor resolves `toc-placement` into the
491        // position before consulting the `toc` value), so `toc-placement:
492        // preamble` overrides `toc: macro`.
493        assert_eq!(
494            doc_with(":toc: macro\n:toc-placement: preamble").toc_mode(),
495            TocMode::Preamble
496        );
497
498        // A bogus `toc-placement` falls back to an automatic placement.
499        assert_eq!(
500            doc_with(":toc:\n:toc-placement: bogus").toc_mode(),
501            TocMode::Auto
502        );
503    }
504
505    #[test]
506    fn soft_unset_toc_placement_resolves_to_macro() {
507        // With `toc` enabled, explicitly unsetting `toc-placement` defers the
508        // TOC to a `toc::[]` block macro (Asciidoctor's `macro` fetch fallback),
509        // whereas a `toc-placement` that was never set stays automatic.
510        assert_eq!(doc_with(":toc:").toc_mode(), TocMode::Auto);
511        assert_eq!(
512            doc_with(":toc:\n:toc-placement!:").toc_mode(),
513            TocMode::Macro
514        );
515        assert_eq!(
516            doc_with(":toc:\n:!toc-placement:").toc_mode(),
517            TocMode::Macro
518        );
519
520        // A soft-unset `toc-placement` resolves to `macro` even when the `toc`
521        // value names a different placement keyword: Asciidoctor deletes the
522        // attribute, so its `fetch` yields the `macro` fallback, which supplies
523        // the position and overrides `toc: preamble`.
524        assert_eq!(
525            doc_with(":toc: preamble\n:toc-placement!:").toc_mode(),
526            TocMode::Macro
527        );
528    }
529
530    #[test]
531    fn levels_default_and_overrides() {
532        assert_eq!(doc_with(":toc:").toc_levels(), 2);
533        assert_eq!(doc_with(":toc:\n:toclevels: 5").toc_levels(), 5);
534
535        // `0` is coerced to `1`, values above `5` are clamped to `5`, and an
536        // unparseable value falls back to the default.
537        assert_eq!(doc_with(":toc:\n:toclevels: 0").toc_levels(), 1);
538        assert_eq!(doc_with(":toc:\n:toclevels: 6").toc_levels(), 5);
539        assert_eq!(doc_with(":toc:\n:toclevels: nope").toc_levels(), 2);
540    }
541
542    #[test]
543    fn title_default_value_and_empty() {
544        assert_eq!(doc_with(":toc:").toc_title(), "Table of Contents");
545        assert_eq!(doc_with(":toc:\n:toc-title: My TOC").toc_title(), "My TOC");
546
547        // A `:toc-title:` set with no value yields an empty title.
548        assert_eq!(doc_with(":toc:\n:toc-title:").toc_title(), "");
549
550        // An explicitly unset `:toc-title!:` falls back to the built-in default.
551        assert_eq!(
552            doc_with(":toc:\n:toc-title!:").toc_title(),
553            "Table of Contents"
554        );
555    }
556
557    #[test]
558    fn class_default_value_and_empty() {
559        assert_eq!(doc_with(":toc:").toc_class(), "toc");
560        assert_eq!(doc_with(":toc:\n:toc-class: floaty").toc_class(), "floaty");
561
562        // An empty `:toc-class:` falls back to the default.
563        assert_eq!(doc_with(":toc:\n:toc-class:").toc_class(), "toc");
564    }
565
566    #[test]
567    fn class_defaults_to_toc2_for_side_column_placement() {
568        // A `left`/`right` side-column TOC switches the default class to `toc2`,
569        // matching Asciidoctor; every other placement keeps the plain `toc`.
570        assert_eq!(doc_with(":toc: left").toc_class(), "toc2");
571        assert_eq!(doc_with(":toc: right").toc_class(), "toc2");
572        assert_eq!(doc_with(":toc:\n:toc-placement: left").toc_class(), "toc2");
573        assert_eq!(doc_with(":toc:\n:toc-placement: right").toc_class(), "toc2");
574        assert_eq!(doc_with(":toc: preamble").toc_class(), "toc");
575        assert_eq!(doc_with(":toc: macro").toc_class(), "toc");
576
577        // An explicit `toc-class` still wins over the side-column default.
578        assert_eq!(
579            doc_with(":toc: left\n:toc-class: floaty").toc_class(),
580            "floaty"
581        );
582
583        // An explicit but empty `:toc-class:` resolves to the built-in `toc-class`
584        // default (`toc`) at the attribute layer, so the placement-derived `toc2`
585        // default only applies when `toc-class` is left entirely unset.
586        assert_eq!(doc_with(":toc: left\n:toc-class:").toc_class(), "toc");
587    }
588
589    /// The derived `toc-position` / `toc-placement` / `toc-class` attributes
590    /// are materialized so they are queryable via
591    /// `Document::attribute_value`, matching Asciidoctor (see the `verify
592    /// toc attribute matrix` upstream test).
593    #[test]
594    fn derived_attributes_are_materialized() {
595        use InterpretedValue::{Unset, Value};
596
597        // Reads the derived (`toc-position`, `toc-placement`, `toc-class`)
598        // document attributes as a `(position, placement, class)` triple.
599        let derived = |header: &str| {
600            let doc = doc_with(header);
601            (
602                doc.attribute_value("toc-position"),
603                doc.attribute_value("toc-placement"),
604                doc.attribute_value("toc-class"),
605            )
606        };
607
608        // An automatic top TOC: position and class stay unset, placement `auto`.
609        assert_eq!(derived(":toc:"), (Unset, Value("auto".into()), Unset));
610
611        // An unrecognized `toc` value still resolves to an automatic placement.
612        assert_eq!(
613            derived(":toc: beeboo"),
614            (Unset, Value("auto".into()), Unset)
615        );
616
617        // Side-column placements derive their side, keep placement `auto`, and
618        // default the class to `toc2`.
619        assert_eq!(
620            derived(":toc: left"),
621            (
622                Value("left".into()),
623                Value("auto".into()),
624                Value("toc2".into())
625            )
626        );
627        assert_eq!(
628            derived(":toc: right"),
629            (
630                Value("right".into()),
631                Value("auto".into()),
632                Value("toc2".into())
633            )
634        );
635
636        // The legacy `toc2` alias behaves like `toc=left`.
637        assert_eq!(
638            derived(":toc2:"),
639            (
640                Value("left".into()),
641                Value("auto".into()),
642                Value("toc2".into())
643            )
644        );
645
646        // Content-flow placements report `content` and leave the class unset.
647        assert_eq!(
648            derived(":toc: preamble"),
649            (Value("content".into()), Value("preamble".into()), Unset)
650        );
651        assert_eq!(
652            derived(":toc: macro"),
653            (Value("content".into()), Value("macro".into()), Unset)
654        );
655    }
656
657    #[test]
658    fn derived_placement_overwrites_author_supplied_values() {
659        use InterpretedValue::Value;
660
661        // A `toc-position` the resolved placement contradicts is overwritten:
662        // the `macro` placement forces `content`.
663        let doc = doc_with(":toc:\n:toc-placement: macro\n:toc-position: left");
664        assert_eq!(doc.attribute_value("toc-position"), Value("content".into()));
665        assert_eq!(doc.attribute_value("toc-placement"), Value("macro".into()));
666
667        // A soft-unset `toc-placement!` with `toc` set defers to a `toc::[]`
668        // macro; the derived `toc-placement` is re-materialized as `macro`,
669        // overwriting the unset tombstone.
670        let doc = doc_with(":toc:\n:toc-placement!:");
671        assert_eq!(doc.attribute_value("toc-position"), Value("content".into()));
672        assert_eq!(doc.attribute_value("toc-placement"), Value("macro".into()));
673    }
674
675    /// The derived `toc-position` / `toc-placement` / `toc-class` attributes
676    /// for the direction shorthands, the separate `toc-position` attribute,
677    /// and the `toc2` + `toc-position` override – all of which resolve a
678    /// side-column TOC (`toc2` class) in Asciidoctor.
679    #[test]
680    fn toc_position_normalization_derives_side_column() {
681        use InterpretedValue::Value;
682
683        let derived = |header: &str| {
684            let doc = doc_with(header);
685            (
686                doc.attribute_value("toc-position"),
687                doc.attribute_value("toc-placement"),
688                doc.attribute_value("toc-class"),
689            )
690        };
691        let right = || {
692            (
693                Value("right".into()),
694                Value("auto".into()),
695                Value("toc2".into()),
696            )
697        };
698
699        // A `>` direction shorthand derives a right side-column TOC.
700        assert_eq!(derived(":toc: >"), right());
701
702        // `:toc:` + `:toc-position: right` derives a right side column and defaults
703        // the class to `toc2` (was previously left as the plain `toc`).
704        assert_eq!(derived(":toc:\n:toc-position: right"), right());
705
706        // `:toc2:` + `:toc-position: right`: the explicit `toc-position` overrides
707        // the `left` implied by the `toc2` alias (was previously left).
708        assert_eq!(derived(":toc2:\n:toc-position: right"), right());
709
710        // The `^` shorthand derives a top column, likewise defaulting to `toc2`.
711        assert_eq!(
712            derived(":toc: ^"),
713            (
714                Value("top".into()),
715                Value("auto".into()),
716                Value("toc2".into())
717            )
718        );
719    }
720
721    #[test]
722    fn automatic_toc_clears_unrecognized_toc_position() {
723        // An enabled automatic TOC drops an author `toc-position` that resolves to
724        // no side: Asciidoctor's normalization deletes it in its `else` arm, so a
725        // bogus (or `content`) value does not leak into the derived state.
726        let doc = doc_with(":toc:\n:toc-position: bogus");
727        assert_eq!(doc.toc_mode(), TocMode::Auto);
728        assert!(!doc.has_attribute("toc-position"));
729
730        let doc = doc_with(":toc:\n:toc-position: content");
731        assert_eq!(doc.toc_mode(), TocMode::Auto);
732        assert!(!doc.has_attribute("toc-position"));
733    }
734
735    #[test]
736    fn explicit_toc_class_survives_side_column_default() {
737        // The side-column `toc2` default only fills in an *unset* `toc-class`;
738        // an explicit author value is left untouched.
739        assert_eq!(
740            doc_with(":toc: left\n:toc-class: floaty").attribute_value("toc-class"),
741            InterpretedValue::Value("floaty".into())
742        );
743    }
744
745    #[test]
746    fn derived_attributes_do_not_leak_across_parses() {
747        // The derived attributes live on each document's snapshot, not on the
748        // parser, so reusing a parser must not carry one document's TOC state
749        // into the next.
750        let mut parser = Parser::default();
751
752        // A first document with a `macro` placement materializes its derived
753        // attributes on its own snapshot.
754        let doc1 = parser.parse("= One\n:toc: macro\n\n== S\n\nx");
755        assert_eq!(doc1.toc_mode(), TocMode::Macro);
756        assert_eq!(
757            doc1.attribute_value("toc-placement"),
758            InterpretedValue::Value("macro".into())
759        );
760
761        // A second document that enables an automatic TOC must resolve `Auto` –
762        // if the first document's derived `toc-placement: macro` had leaked onto
763        // the parser, `TocMode::from_parser` would read it back and wrongly
764        // resolve `Macro` here.
765        let doc2 = parser.parse("= Two\n:toc:\n\n== S\n\nx");
766        assert_eq!(doc2.toc_mode(), TocMode::Auto);
767        assert_eq!(
768            doc2.attribute_value("toc-placement"),
769            InterpretedValue::Value("auto".into())
770        );
771
772        // The first document's derived `toc-position` (`content`) likewise does
773        // not linger: an automatic TOC leaves it unset.
774        assert!(!doc2.has_attribute("toc-position"));
775    }
776
777    #[test]
778    fn disabled_toc_materializes_no_derived_attributes() {
779        // With no TOC enabled, the whole family stays unset (Asciidoctor's
780        // defaults), so none of the attributes is even present.
781        let doc = doc_with("");
782        for name in ["toc-position", "toc-placement", "toc-class"] {
783            assert!(!doc.has_attribute(name), "{name} should be absent");
784        }
785    }
786
787    /// Exercises the derived-attribute mapping directly for every [`TocMode`]
788    /// variant, including [`TocMode::Disabled`] – which the parse path never
789    /// feeds to these helpers (materialization returns early for it), but whose
790    /// arms must still map to "no attribute".
791    #[test]
792    fn derived_attribute_mapping_covers_every_mode() {
793        for (mode, position, placement, class) in [
794            (TocMode::Disabled, None, None, None),
795            (TocMode::Auto, None, Some("auto"), None),
796            (TocMode::Left, Some("left"), Some("auto"), Some("toc2")),
797            (TocMode::Right, Some("right"), Some("auto"), Some("toc2")),
798            (TocMode::Top, Some("top"), Some("auto"), Some("toc2")),
799            (TocMode::Bottom, Some("bottom"), Some("auto"), Some("toc2")),
800            (TocMode::Preamble, Some("content"), Some("preamble"), None),
801            (TocMode::Macro, Some("content"), Some("macro"), None),
802        ] {
803            assert_eq!(
804                mode.derived_toc_position(),
805                position,
806                "position for {mode:?}"
807            );
808            assert_eq!(
809                mode.derived_toc_placement(),
810                placement,
811                "placement for {mode:?}"
812            );
813            assert_eq!(mode.derived_toc_class(), class, "class for {mode:?}");
814        }
815    }
816}