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