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}