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` (or the legacy `toc2` alias is
34    /// set): an automatically placed TOC that, in standalone HTML, is rendered
35    /// as a fixed left-hand side column.
36    Left,
37
38    /// The `toc` attribute is set to `right`: an automatically placed TOC that,
39    /// in standalone HTML, is rendered as a fixed right-hand side column.
40    Right,
41
42    /// The placement resolves to `preamble` (via `:toc: preamble` or
43    /// `:toc-placement: preamble`): the TOC is generated immediately below the
44    /// document's preamble.
45    Preamble,
46
47    /// The placement resolves to `macro` (via `:toc: macro` or
48    /// `:toc-placement: macro`): the table of contents is generated only where
49    /// a `toc::[]` block macro appears.
50    Macro,
51}
52
53impl TocMode {
54    /// Resolves the table-of-contents placement from a parser's current `toc`
55    /// attribute state.
56    ///
57    /// This reads the raw stored `toc-placement`, distinguishing a never-set
58    /// attribute from an explicit unset tombstone. The derived `toc-position` /
59    /// `toc-placement` / `toc-class` document attributes are materialized from
60    /// the resolved placement *after* this runs — see
61    /// [`Parser::materialize_toc_attributes`](crate::Parser).
62    pub(crate) fn from_parser(parser: &Parser) -> Self {
63        let value = parser.attribute_value("toc");
64        if value == InterpretedValue::Unset {
65            // `toc2` is a legacy alias that enables a left-positioned table of
66            // contents (equivalent to `:toc: left`). When the `toc` attribute
67            // itself is unset, a set `toc2` still turns the TOC on. (A soft-unset
68            // `toc2!` records an `Unset` tombstone, which does not enable it.)
69            if parser.attribute_value("toc2") != InterpretedValue::Unset {
70                return Self::Left;
71            }
72            return Self::Disabled;
73        }
74
75        // `toc` has a built-in default of `auto`, so a bare `:toc:` resolves to
76        // `Value("auto")` (never `Set`). A placement keyword in the `toc` value
77        // itself is a shorthand that wins outright. Otherwise (`auto`, empty, or
78        // any unrecognized value) the placement comes from the separate
79        // `toc-placement` attribute, matching Asciidoctor, which folds the `toc`
80        // shorthand into `toc-placement` and treats the latter as the source of
81        // truth. A bogus `toc-placement` — like a bogus `toc` — falls back to an
82        // automatic placement.
83        match value.as_maybe_str().map(str::trim) {
84            Some("macro") => Self::Macro,
85            Some("left") => Self::Left,
86            Some("right") => Self::Right,
87            Some("preamble") => Self::Preamble,
88            _ => {
89                let placement = parser.attribute_value("toc-placement");
90                match placement.as_maybe_str().map(str::trim) {
91                    Some("macro") => Self::Macro,
92                    Some("left") => Self::Left,
93                    Some("right") => Self::Right,
94                    Some("preamble") => Self::Preamble,
95                    // `toc-placement` carries no recognized placement keyword.
96                    // When it has been *explicitly unset* (via `:toc-placement!:`
97                    // or an API unset) while `toc` is enabled, Asciidoctor's
98                    // placement lookup falls through its `auto` default to a
99                    // `macro` fetch fallback, so the TOC defers to a `toc::[]`
100                    // block macro. (Asciidoctor deletes the attribute's default;
101                    // this crate records the unset as an `Unset` tombstone, which
102                    // `has_attribute` still reports as present — distinguishing it
103                    // from a `toc-placement` that was never set.) A `toc-placement`
104                    // that was never set, or set to any other (bogus) value, falls
105                    // back to an automatic placement.
106                    _ if placement == InterpretedValue::Unset
107                        && parser.has_attribute("toc-placement") =>
108                    {
109                        Self::Macro
110                    }
111                    _ => Self::Auto,
112                }
113            }
114        }
115    }
116
117    /// Returns `true` unless the `toc` attribute is unset (i.e. a table of
118    /// contents is generated somewhere in the document).
119    pub fn is_enabled(self) -> bool {
120        self != Self::Disabled
121    }
122
123    /// Returns the value of the derived `toc-position` document attribute for
124    /// this placement, or `None` when Asciidoctor leaves it unset (its `nil`
125    /// default). The side-column placements report their side (`left` /
126    /// `right`); the content-flow placements (`preamble` / `macro`) report
127    /// `content`; an automatic top TOC (and a disabled TOC) leave it unset.
128    ///
129    /// See [`Parser::materialize_toc_attributes`](crate::Parser).
130    pub(crate) fn derived_toc_position(self) -> Option<&'static str> {
131        match self {
132            Self::Left => Some("left"),
133            Self::Right => Some("right"),
134            Self::Preamble | Self::Macro => Some("content"),
135            Self::Auto | Self::Disabled => None,
136        }
137    }
138
139    /// Returns the value of the derived `toc-placement` document attribute for
140    /// this placement, or `None` when no TOC is generated. The automatic and
141    /// side-column placements all fold to `auto`; `preamble` / `macro` report
142    /// themselves. This is the placement keyword Asciidoctor exposes once the
143    /// `toc` shorthand has been folded into `toc-placement`.
144    pub(crate) fn derived_toc_placement(self) -> Option<&'static str> {
145        match self {
146            Self::Disabled => None,
147            Self::Preamble => Some("preamble"),
148            Self::Macro => Some("macro"),
149            Self::Auto | Self::Left | Self::Right => Some("auto"),
150        }
151    }
152
153    /// Returns the value the derived `toc-class` document attribute defaults to
154    /// for this placement when the author has not set `toc-class`, or `None`
155    /// when Asciidoctor leaves it unset (its `nil` default). Only a `left` /
156    /// `right` side-column TOC introduces a default (`toc2`, the side-column
157    /// class).
158    pub(crate) fn derived_toc_class(self) -> Option<&'static str> {
159        match self {
160            Self::Left | Self::Right => Some(DEFAULT_TOC_CLASS_SIDE),
161            _ => None,
162        }
163    }
164}
165
166/// The depth of section levels included in a table of contents when the
167/// `toclevels` attribute is not set. Matches Asciidoctor's default of 2
168/// (sections up to and including `===`).
169pub(crate) const DEFAULT_TOCLEVELS: usize = 2;
170
171/// The title of the table of contents when the `toc-title` attribute is not
172/// set. Matches Asciidoctor's default.
173pub(crate) const DEFAULT_TOC_TITLE: &str = "Table of Contents";
174
175/// The CSS class applied to the table of contents container when the
176/// `toc-class` attribute is not set. Matches Asciidoctor's default.
177pub(crate) const DEFAULT_TOC_CLASS: &str = "toc";
178
179/// The CSS class applied to the table of contents container for a
180/// `left`/`right` side-column TOC when the `toc-class` attribute is not set.
181/// This is the class that drives the fixed side-column styling in Asciidoctor's
182/// standalone HTML.
183pub(crate) const DEFAULT_TOC_CLASS_SIDE: &str = "toc2";
184
185/// The resolved table-of-contents configuration for a document (or a nested
186/// AsciiDoc table cell, which resolves its own configuration independently).
187///
188/// Like [`TocMode`], the underlying attributes (`toc`, `toclevels`,
189/// `toc-title`, `toc-class`) are header-only, so this value is captured once
190/// the header has been processed and the parser still holds the document's
191/// resolved attribute state.
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub(crate) struct TocConfig {
194    /// Where (and whether) the TOC is placed.
195    pub(crate) mode: TocMode,
196
197    /// The depth of section levels included in the TOC, from the `toclevels`
198    /// attribute (default [`DEFAULT_TOCLEVELS`]).
199    pub(crate) levels: usize,
200
201    /// The TOC title, from the `toc-title` attribute (default
202    /// [`DEFAULT_TOC_TITLE`]).
203    pub(crate) title: String,
204
205    /// The CSS class applied to the TOC container, from the `toc-class`
206    /// attribute (default [`DEFAULT_TOC_CLASS`]).
207    pub(crate) class: String,
208}
209
210impl TocConfig {
211    /// Resolves the full table-of-contents configuration from a parser's
212    /// current attribute state.
213    pub(crate) fn from_parser(parser: &Parser) -> Self {
214        let mode = TocMode::from_parser(parser);
215        Self {
216            mode,
217            levels: resolve_levels(parser),
218            title: resolve_title(parser),
219            class: resolve_class(parser, mode),
220        }
221    }
222
223    /// Returns a configuration with no table of contents, used as the default
224    /// for a structure that has not enabled a TOC.
225    #[cfg(test)]
226    pub(crate) fn disabled() -> Self {
227        Self {
228            mode: TocMode::Disabled,
229            levels: DEFAULT_TOCLEVELS,
230            title: DEFAULT_TOC_TITLE.to_string(),
231            class: DEFAULT_TOC_CLASS.to_string(),
232        }
233    }
234}
235
236/// Resolves the `toclevels` depth. Accepted values are the integers 0 through
237/// 5: the value `0` is coerced to `1` (this crate has no multipart-book parts,
238/// so level 0 sections never appear) and values above `5` are clamped to `5`,
239/// matching the documented range. Any unparseable value falls back to the
240/// default of 2.
241fn resolve_levels(parser: &Parser) -> usize {
242    parser
243        .attribute_value("toclevels")
244        .as_maybe_str()
245        .and_then(|s| s.trim().parse::<usize>().ok())
246        .map(|n| n.clamp(1, 5))
247        .unwrap_or(DEFAULT_TOCLEVELS)
248}
249
250/// Resolves the `toc-title`. An empty `:toc-title:` (set but with no value)
251/// yields an empty title, matching Asciidoctor; an unset attribute falls back
252/// to the default.
253fn resolve_title(parser: &Parser) -> String {
254    match parser.attribute_value("toc-title") {
255        InterpretedValue::Value(v) => v,
256        InterpretedValue::Set => String::new(),
257        InterpretedValue::Unset => DEFAULT_TOC_TITLE.to_string(),
258    }
259}
260
261/// Resolves the `toc-class`. An explicit, non-empty `toc-class` wins outright.
262/// Otherwise the default depends on placement: a `left`/`right` side-column TOC
263/// uses `toc2` (the class that drives the side-column styling), matching
264/// Asciidoctor, which switches the default `toc-class` to `toc2` for those
265/// placements; every other placement uses the plain `toc`.
266fn resolve_class(parser: &Parser, mode: TocMode) -> String {
267    match parser.attribute_value("toc-class") {
268        InterpretedValue::Value(v) if !v.trim().is_empty() => v,
269        _ => default_toc_class(mode).to_string(),
270    }
271}
272
273/// Returns the default `toc-class` for a resolved placement: `toc2` for a
274/// `left`/`right` side-column TOC, and the plain `toc` for every other
275/// placement.
276fn default_toc_class(mode: TocMode) -> &'static str {
277    match mode {
278        TocMode::Left | TocMode::Right => DEFAULT_TOC_CLASS_SIDE,
279        _ => DEFAULT_TOC_CLASS,
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use crate::{
286        Parser,
287        document::{InterpretedValue, TocMode},
288    };
289
290    /// Parses a minimal document with the given header attribute lines and
291    /// returns the parsed [`Document`](crate::Document) for inspection.
292    fn doc_with(header: &str) -> crate::Document<'static> {
293        let src = format!("= Title\n{header}\n\n== Section\n\ncontent");
294        Parser::default().parse(&src)
295    }
296
297    #[test]
298    fn mode_is_disabled_when_unset() {
299        assert_eq!(doc_with("").toc_mode(), TocMode::Disabled);
300        assert!(!TocMode::Disabled.is_enabled());
301    }
302
303    #[test]
304    fn mode_resolves_each_placement() {
305        assert_eq!(doc_with(":toc:").toc_mode(), TocMode::Auto);
306        assert_eq!(doc_with(":toc: auto").toc_mode(), TocMode::Auto);
307        assert_eq!(doc_with(":toc: left").toc_mode(), TocMode::Left);
308        assert_eq!(doc_with(":toc: right").toc_mode(), TocMode::Right);
309        assert_eq!(doc_with(":toc: preamble").toc_mode(), TocMode::Preamble);
310        assert_eq!(doc_with(":toc: macro").toc_mode(), TocMode::Macro);
311
312        for mode in [
313            TocMode::Auto,
314            TocMode::Left,
315            TocMode::Right,
316            TocMode::Preamble,
317            TocMode::Macro,
318        ] {
319            assert!(mode.is_enabled());
320        }
321    }
322
323    #[test]
324    fn unrecognized_mode_is_treated_as_auto() {
325        assert_eq!(doc_with(":toc: bogus").toc_mode(), TocMode::Auto);
326    }
327
328    #[test]
329    fn toc2_alias_enables_a_left_placed_toc() {
330        // `toc2` is a legacy alias for `:toc: left`: setting the bare attribute
331        // enables a left-positioned table of contents even though the `toc`
332        // attribute itself is unset.
333        assert_eq!(doc_with(":toc2:").toc_mode(), TocMode::Left);
334        assert!(doc_with(":toc2:").toc_mode().is_enabled());
335        // Its side-column placement also switches the default `toc-class` to
336        // `toc2`, like any other `left`/`right` TOC.
337        assert_eq!(doc_with(":toc2:").toc_class(), "toc2");
338        // A soft-unset `toc2!` does not enable the TOC.
339        assert_eq!(doc_with(":toc2!:").toc_mode(), TocMode::Disabled);
340    }
341
342    #[test]
343    fn placement_falls_back_to_toc_placement_attribute() {
344        // When the `toc` value carries no placement keyword, the separate
345        // `toc-placement` attribute determines the placement.
346        assert_eq!(
347            doc_with(":toc:\n:toc-placement: preamble").toc_mode(),
348            TocMode::Preamble
349        );
350        assert_eq!(
351            doc_with(":toc:\n:toc-placement: macro").toc_mode(),
352            TocMode::Macro
353        );
354        assert_eq!(
355            doc_with(":toc: auto\n:toc-placement: preamble").toc_mode(),
356            TocMode::Preamble
357        );
358        // A placement keyword in the `toc` value wins over `toc-placement`.
359        assert_eq!(
360            doc_with(":toc: macro\n:toc-placement: preamble").toc_mode(),
361            TocMode::Macro
362        );
363        // A bogus `toc-placement` falls back to an automatic placement.
364        assert_eq!(
365            doc_with(":toc:\n:toc-placement: bogus").toc_mode(),
366            TocMode::Auto
367        );
368    }
369
370    #[test]
371    fn soft_unset_toc_placement_resolves_to_macro() {
372        // With `toc` enabled, explicitly unsetting `toc-placement` defers the
373        // TOC to a `toc::[]` block macro (Asciidoctor's `macro` fetch fallback),
374        // whereas a `toc-placement` that was never set stays automatic.
375        assert_eq!(doc_with(":toc:").toc_mode(), TocMode::Auto);
376        assert_eq!(
377            doc_with(":toc:\n:toc-placement!:").toc_mode(),
378            TocMode::Macro
379        );
380        assert_eq!(
381            doc_with(":toc:\n:!toc-placement:").toc_mode(),
382            TocMode::Macro
383        );
384        // A placement keyword in the `toc` value still wins over an unset
385        // `toc-placement`.
386        assert_eq!(
387            doc_with(":toc: preamble\n:toc-placement!:").toc_mode(),
388            TocMode::Preamble
389        );
390    }
391
392    #[test]
393    fn levels_default_and_overrides() {
394        assert_eq!(doc_with(":toc:").toc_levels(), 2);
395        assert_eq!(doc_with(":toc:\n:toclevels: 5").toc_levels(), 5);
396        // `0` is coerced to `1`, values above `5` are clamped to `5`, and an
397        // unparseable value falls back to the default.
398        assert_eq!(doc_with(":toc:\n:toclevels: 0").toc_levels(), 1);
399        assert_eq!(doc_with(":toc:\n:toclevels: 6").toc_levels(), 5);
400        assert_eq!(doc_with(":toc:\n:toclevels: nope").toc_levels(), 2);
401    }
402
403    #[test]
404    fn title_default_value_and_empty() {
405        assert_eq!(doc_with(":toc:").toc_title(), "Table of Contents");
406        assert_eq!(doc_with(":toc:\n:toc-title: My TOC").toc_title(), "My TOC");
407        // A `:toc-title:` set with no value yields an empty title.
408        assert_eq!(doc_with(":toc:\n:toc-title:").toc_title(), "");
409        // An explicitly unset `:toc-title!:` falls back to the built-in default.
410        assert_eq!(
411            doc_with(":toc:\n:toc-title!:").toc_title(),
412            "Table of Contents"
413        );
414    }
415
416    #[test]
417    fn class_default_value_and_empty() {
418        assert_eq!(doc_with(":toc:").toc_class(), "toc");
419        assert_eq!(doc_with(":toc:\n:toc-class: floaty").toc_class(), "floaty");
420        // An empty `:toc-class:` falls back to the default.
421        assert_eq!(doc_with(":toc:\n:toc-class:").toc_class(), "toc");
422    }
423
424    #[test]
425    fn class_defaults_to_toc2_for_side_column_placement() {
426        // A `left`/`right` side-column TOC switches the default class to `toc2`,
427        // matching Asciidoctor; every other placement keeps the plain `toc`.
428        assert_eq!(doc_with(":toc: left").toc_class(), "toc2");
429        assert_eq!(doc_with(":toc: right").toc_class(), "toc2");
430        assert_eq!(doc_with(":toc:\n:toc-placement: left").toc_class(), "toc2");
431        assert_eq!(doc_with(":toc:\n:toc-placement: right").toc_class(), "toc2");
432        assert_eq!(doc_with(":toc: preamble").toc_class(), "toc");
433        assert_eq!(doc_with(":toc: macro").toc_class(), "toc");
434
435        // An explicit `toc-class` still wins over the side-column default.
436        assert_eq!(
437            doc_with(":toc: left\n:toc-class: floaty").toc_class(),
438            "floaty"
439        );
440        // An explicit but empty `:toc-class:` resolves to the built-in `toc-class`
441        // default (`toc`) at the attribute layer, so the placement-derived `toc2`
442        // default only applies when `toc-class` is left entirely unset.
443        assert_eq!(doc_with(":toc: left\n:toc-class:").toc_class(), "toc");
444    }
445
446    /// The derived `toc-position` / `toc-placement` / `toc-class` attributes
447    /// are materialized so they are queryable via
448    /// `Document::attribute_value`, matching Asciidoctor (see the `verify
449    /// toc attribute matrix` upstream test).
450    #[test]
451    fn derived_attributes_are_materialized() {
452        use InterpretedValue::{Unset, Value};
453
454        // Reads the derived (`toc-position`, `toc-placement`, `toc-class`)
455        // document attributes as a `(position, placement, class)` triple.
456        let derived = |header: &str| {
457            let doc = doc_with(header);
458            (
459                doc.attribute_value("toc-position"),
460                doc.attribute_value("toc-placement"),
461                doc.attribute_value("toc-class"),
462            )
463        };
464
465        // An automatic top TOC: position and class stay unset, placement `auto`.
466        assert_eq!(derived(":toc:"), (Unset, Value("auto".into()), Unset));
467        // An unrecognized `toc` value still resolves to an automatic placement.
468        assert_eq!(
469            derived(":toc: beeboo"),
470            (Unset, Value("auto".into()), Unset)
471        );
472
473        // Side-column placements derive their side, keep placement `auto`, and
474        // default the class to `toc2`.
475        assert_eq!(
476            derived(":toc: left"),
477            (
478                Value("left".into()),
479                Value("auto".into()),
480                Value("toc2".into())
481            )
482        );
483        assert_eq!(
484            derived(":toc: right"),
485            (
486                Value("right".into()),
487                Value("auto".into()),
488                Value("toc2".into())
489            )
490        );
491        // The legacy `toc2` alias behaves like `toc=left`.
492        assert_eq!(
493            derived(":toc2:"),
494            (
495                Value("left".into()),
496                Value("auto".into()),
497                Value("toc2".into())
498            )
499        );
500
501        // Content-flow placements report `content` and leave the class unset.
502        assert_eq!(
503            derived(":toc: preamble"),
504            (Value("content".into()), Value("preamble".into()), Unset)
505        );
506        assert_eq!(
507            derived(":toc: macro"),
508            (Value("content".into()), Value("macro".into()), Unset)
509        );
510    }
511
512    #[test]
513    fn derived_placement_overwrites_author_supplied_values() {
514        use InterpretedValue::Value;
515
516        // A `toc-position` the resolved placement contradicts is overwritten:
517        // the `macro` placement forces `content`.
518        let doc = doc_with(":toc:\n:toc-placement: macro\n:toc-position: left");
519        assert_eq!(doc.attribute_value("toc-position"), Value("content".into()));
520        assert_eq!(doc.attribute_value("toc-placement"), Value("macro".into()));
521
522        // A soft-unset `toc-placement!` with `toc` set defers to a `toc::[]`
523        // macro; the derived `toc-placement` is re-materialized as `macro`,
524        // overwriting the unset tombstone.
525        let doc = doc_with(":toc:\n:toc-placement!:");
526        assert_eq!(doc.attribute_value("toc-position"), Value("content".into()));
527        assert_eq!(doc.attribute_value("toc-placement"), Value("macro".into()));
528    }
529
530    #[test]
531    fn explicit_toc_class_survives_side_column_default() {
532        // The side-column `toc2` default only fills in an *unset* `toc-class`;
533        // an explicit author value is left untouched.
534        assert_eq!(
535            doc_with(":toc: left\n:toc-class: floaty").attribute_value("toc-class"),
536            InterpretedValue::Value("floaty".into())
537        );
538    }
539
540    #[test]
541    fn derived_attributes_do_not_leak_across_parses() {
542        // The derived attributes live on each document's snapshot, not on the
543        // parser, so reusing a parser must not carry one document's TOC state
544        // into the next.
545        let mut parser = Parser::default();
546
547        // A first document with a `macro` placement materializes its derived
548        // attributes on its own snapshot.
549        let doc1 = parser.parse("= One\n:toc: macro\n\n== S\n\nx");
550        assert_eq!(doc1.toc_mode(), TocMode::Macro);
551        assert_eq!(
552            doc1.attribute_value("toc-placement"),
553            InterpretedValue::Value("macro".into())
554        );
555
556        // A second document that enables an automatic TOC must resolve `Auto` —
557        // if the first document's derived `toc-placement: macro` had leaked onto
558        // the parser, `TocMode::from_parser` would read it back and wrongly
559        // resolve `Macro` here.
560        let doc2 = parser.parse("= Two\n:toc:\n\n== S\n\nx");
561        assert_eq!(doc2.toc_mode(), TocMode::Auto);
562        assert_eq!(
563            doc2.attribute_value("toc-placement"),
564            InterpretedValue::Value("auto".into())
565        );
566        // The first document's derived `toc-position` (`content`) likewise does
567        // not linger: an automatic TOC leaves it unset.
568        assert!(!doc2.has_attribute("toc-position"));
569    }
570
571    #[test]
572    fn disabled_toc_materializes_no_derived_attributes() {
573        // With no TOC enabled, the whole family stays unset (Asciidoctor's
574        // defaults), so none of the attributes is even present.
575        let doc = doc_with("");
576        for name in ["toc-position", "toc-placement", "toc-class"] {
577            assert!(!doc.has_attribute(name), "{name} should be absent");
578        }
579    }
580
581    /// Exercises the derived-attribute mapping directly for every [`TocMode`]
582    /// variant, including [`TocMode::Disabled`] — which the parse path never
583    /// feeds to these helpers (materialization returns early for it), but whose
584    /// arms must still map to "no attribute".
585    #[test]
586    fn derived_attribute_mapping_covers_every_mode() {
587        for (mode, position, placement, class) in [
588            (TocMode::Disabled, None, None, None),
589            (TocMode::Auto, None, Some("auto"), None),
590            (TocMode::Left, Some("left"), Some("auto"), Some("toc2")),
591            (TocMode::Right, Some("right"), Some("auto"), Some("toc2")),
592            (TocMode::Preamble, Some("content"), Some("preamble"), None),
593            (TocMode::Macro, Some("content"), Some("macro"), None),
594        ] {
595            assert_eq!(
596                mode.derived_toc_position(),
597                position,
598                "position for {mode:?}"
599            );
600            assert_eq!(
601                mode.derived_toc_placement(),
602                placement,
603                "placement for {mode:?}"
604            );
605            assert_eq!(mode.derived_toc_class(), class, "class for {mode:?}");
606        }
607    }
608}