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