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, 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}