asciidoc_parser/document/document.rs
1//! Describes the top-level document structure.
2
3use std::{marker::PhantomData, slice::Iter};
4
5use self_cell::self_cell;
6
7use crate::{
8 Parser, Span,
9 attributes::Attrlist,
10 blocks::{Block, ContentModel, IsBlock, Preamble, parse_utils::parse_blocks_until},
11 document::{Catalog, Docinfo, DocinfoLocation, Header, InterpretedValue, TocConfig, TocMode},
12 internal::debug::DebugSliceReference,
13 parser::{
14 CatalogResolver, DeferredWarning, InlineSubstitutionRenderer, ReferenceResolver,
15 ReferenceWarning, ResolvedAttributes, SourceMap,
16 },
17 strings::CowStr,
18 warnings::Warning,
19};
20
21/// A document represents the top-level block element in AsciiDoc. It consists
22/// of an optional document header and either a) one or more sections preceded
23/// by an optional preamble or b) a sequence of top-level blocks only.
24///
25/// The document can be configured using a document header. The header is not a
26/// block itself, but contributes metadata to the document, such as the document
27/// title and document attributes.
28///
29/// The `Document` structure is a self-contained package of the original content
30/// that was parsed and the data structures that describe that parsed content.
31/// The API functions on this struct can be used to understand the parse
32/// results.
33#[derive(Eq, PartialEq)]
34pub struct Document<'src> {
35 internal: Internal,
36 _phantom: PhantomData<&'src ()>,
37}
38
39/// Internal dependent struct containing the actual data members that reference
40/// the owned source.
41#[derive(Debug, Eq, PartialEq)]
42struct InternalDependent<'src> {
43 header: Header<'src>,
44 blocks: Vec<Block<'src>>,
45 source: Span<'src>,
46 warnings: Vec<Warning<'src>>,
47 source_map: SourceMap,
48 catalog: Catalog,
49 attributes: ResolvedAttributes,
50 toc: TocConfig,
51 docinfo: Docinfo,
52}
53
54self_cell! {
55 /// Internal implementation struct containing the actual data members.
56 struct Internal {
57 owner: String,
58 #[covariant]
59 dependent: InternalDependent,
60 }
61 impl {Debug, Eq, PartialEq}
62}
63
64impl<'src> Document<'src> {
65 pub(crate) fn parse(
66 source: &str,
67 source_map: SourceMap,
68 preprocessor_warnings: Vec<DeferredWarning>,
69 parser: &mut Parser,
70 ) -> Self {
71 let owned_source = source.to_string();
72
73 let internal = Internal::new(owned_source, |owned_src| {
74 let source = Span::new(owned_src);
75
76 let mi = Header::parse(source, parser);
77 let after_header = mi.item.after;
78
79 parser.sectnumlevels = parser
80 .attribute_value("sectnumlevels")
81 .as_maybe_str()
82 .and_then(|s| s.parse::<usize>().ok())
83 .unwrap_or(3);
84
85 let header = mi.item.item;
86 let mut warnings = mi.warnings;
87
88 // Derive the `iconsdir` default from `imagesdir` (`{imagesdir}/icons`)
89 // now that the header is fully parsed, unless the author set
90 // `iconsdir` explicitly in the header (in which case it wins).
91 let iconsdir_set_in_header = header.attributes().any(|a| a.name().data() == "iconsdir");
92 parser.apply_iconsdir_default(iconsdir_set_in_header);
93
94 let mut maw_blocks = parse_blocks_until(after_header, |_| false, parser);
95
96 if !maw_blocks.warnings.is_empty() {
97 warnings.append(&mut maw_blocks.warnings);
98 }
99
100 // Warnings recorded while replacing attribute references (e.g. a
101 // reference to a missing attribute under `attribute-missing=warn`)
102 // are collected on the parser, where only owned offsets — not
103 // borrowed spans — can live. Now that the document's owned source is
104 // available, turn each one back into a spanned `Warning`.
105 let root = Span::new(owned_src);
106
107 // Warnings raised during preprocessing (e.g. an unresolved include
108 // directive) are carried the same way and reconstituted here.
109 for pw in preprocessor_warnings {
110 warnings.push(Warning {
111 source: root.slice(pw.offset..pw.offset + pw.len),
112 warning: pw.warning,
113 });
114 }
115
116 for sw in parser.take_substitution_warnings() {
117 warnings.push(Warning {
118 source: root.slice(sw.offset..sw.offset + sw.len),
119 warning: sw.warning,
120 });
121 }
122
123 let mut blocks = maw_blocks.item.item;
124 let mut has_content_blocks = false;
125 let mut preamble_split_index: Option<usize> = None;
126
127 // Only look for preamble content if document has a title.
128 // Asciidoctor only creates a preamble when there's a document title.
129 if header.title().is_some() {
130 for (index, block) in blocks.iter().enumerate() {
131 match block {
132 Block::DocumentAttribute(_) => (),
133 Block::Section(_) => {
134 if has_content_blocks {
135 preamble_split_index = Some(index);
136 }
137 break;
138 }
139 _ => {
140 has_content_blocks = true;
141 }
142 }
143 }
144 }
145
146 if let Some(index) = preamble_split_index {
147 let mut section_blocks = blocks.split_off(index);
148
149 let preamble = Preamble::from_blocks(blocks, after_header);
150
151 section_blocks.insert(0, Block::Preamble(preamble));
152 blocks = section_blocks;
153 }
154
155 // Capture the parser's fully-resolved attribute state so it can be
156 // read back through the `Document` (via `attribute_value`,
157 // `has_attribute`, and `is_attribute_set`) without a `Parser` in
158 // hand — the embed path a renderer uses for `convert_document`.
159 let attributes = parser.snapshot_attributes();
160
161 // The `toc` family of attributes is header-only, so the resolved
162 // placement, depth, title, and class are fixed once the header (and
163 // body) have been processed. Capture them here, while the parser
164 // still holds the document's resolved attribute state.
165 let toc = TocConfig::from_parser(parser);
166
167 // Resolve docinfo from the final attribute state and the parser's
168 // configured docinfo file handler (empty when no handler is set).
169 let docinfo = Docinfo::resolve(parser);
170
171 InternalDependent {
172 header,
173 blocks,
174 source: source.trim_trailing_whitespace(),
175 warnings,
176 source_map,
177 catalog: parser.take_catalog(),
178 attributes,
179 toc,
180 docinfo,
181 }
182 });
183
184 Self {
185 internal,
186 _phantom: PhantomData,
187 }
188 }
189
190 /// Return the document header.
191 pub fn header(&self) -> &Header<'_> {
192 &self.internal.borrow_dependent().header
193 }
194
195 /// Return the document title (the level-0 `= Title`), if there was one.
196 pub fn doctitle(&self) -> Option<&str> {
197 self.header().title()
198 }
199
200 /// Returns the resolved interpreted value of the named [document
201 /// attribute], as of the end of parsing.
202 ///
203 /// This mirrors [`Parser::attribute_value`] and is the accessor to use on
204 /// the *embed* path — rendering a [`Document`] you already hold, without a
205 /// [`Parser`] in hand. The value reflects the document's final attribute
206 /// state: built-in defaults, values set in the header or body, and the
207 /// current value of any counter of the same name. An attribute that is not
208 /// present, or is present but explicitly [unset], resolves to
209 /// [`InterpretedValue::Unset`].
210 ///
211 /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
212 /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
213 /// [`Parser::attribute_value`]: crate::Parser::attribute_value
214 pub fn attribute_value<N: AsRef<str>>(&self, name: N) -> InterpretedValue {
215 self.internal
216 .borrow_dependent()
217 .attributes
218 .attribute_value(name)
219 }
220
221 /// Returns `true` if the document has a [document attribute] by this name
222 /// (whether or not it is set), as of the end of parsing.
223 ///
224 /// This mirrors [`Parser::has_attribute`].
225 ///
226 /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
227 /// [`Parser::has_attribute`]: crate::Parser::has_attribute
228 pub fn has_attribute<N: AsRef<str>>(&self, name: N) -> bool {
229 self.internal
230 .borrow_dependent()
231 .attributes
232 .has_attribute(name)
233 }
234
235 /// Returns `true` if the document has a [document attribute] by this name
236 /// which has been set (i.e. is present and not [unset]), as of the end of
237 /// parsing.
238 ///
239 /// This mirrors [`Parser::is_attribute_set`].
240 ///
241 /// [document attribute]: https://docs.asciidoctor.org/asciidoc/latest/attributes/document-attributes/
242 /// [unset]: https://docs.asciidoctor.org/asciidoc/latest/attributes/unset-attributes/
243 /// [`Parser::is_attribute_set`]: crate::Parser::is_attribute_set
244 pub fn is_attribute_set<N: AsRef<str>>(&self, name: N) -> bool {
245 self.internal
246 .borrow_dependent()
247 .attributes
248 .is_attribute_set(name)
249 }
250
251 /// Return where (and whether) this document's table of contents is
252 /// generated, resolved from the [`toc` attribute].
253 ///
254 /// [`toc` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/
255 pub fn toc_mode(&self) -> TocMode {
256 self.internal.borrow_dependent().toc.mode
257 }
258
259 /// Return the depth of section levels included in this document's table of
260 /// contents, resolved from the [`toclevels` attribute] (default `2`).
261 ///
262 /// [`toclevels` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/levels/
263 pub fn toc_levels(&self) -> usize {
264 self.internal.borrow_dependent().toc.levels
265 }
266
267 /// Return the title of this document's table of contents, resolved from the
268 /// [`toc-title` attribute] (default _Table of Contents_).
269 ///
270 /// [`toc-title` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/title/
271 pub fn toc_title(&self) -> &str {
272 &self.internal.borrow_dependent().toc.title
273 }
274
275 /// Return the CSS class applied to this document's table of contents
276 /// container, resolved from the [`toc-class` attribute] (default `toc`).
277 ///
278 /// [`toc-class` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/
279 pub fn toc_class(&self) -> &str {
280 &self.internal.borrow_dependent().toc.class
281 }
282
283 /// Return this document's resolved [docinfo] content for `location`.
284 ///
285 /// [Docinfo] is custom content read from external *docinfo files* and
286 /// injected into the head, header, or footer of the converted output. The
287 /// returned string is the concatenation of the applicable shared and
288 /// private docinfo files (shared first, matching Asciidoctor), with
289 /// `docinfosubs` substitutions already applied.
290 ///
291 /// An empty string is returned when no docinfo applies to the location —
292 /// for example when no [`DocinfoFileHandler`] was configured on the parser,
293 /// the `docinfo` attribute did not enable that scope/location, or no
294 /// matching file was found. Docinfo files are resolved through a
295 /// caller-supplied [`DocinfoFileHandler`], since this crate does not read
296 /// from the filesystem itself.
297 ///
298 /// [docinfo]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
299 /// [Docinfo]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
300 /// [`DocinfoFileHandler`]: crate::parser::DocinfoFileHandler
301 pub fn docinfo(&self, location: DocinfoLocation) -> &str {
302 self.internal.borrow_dependent().docinfo.content(location)
303 }
304
305 /// Return an iterator over any warnings found during parsing.
306 pub fn warnings(&self) -> Iter<'_, Warning<'_>> {
307 self.internal.borrow_dependent().warnings.iter()
308 }
309
310 /// Return a [`Span`] describing the entire document source.
311 pub fn span(&self) -> Span<'_> {
312 self.internal.borrow_dependent().source
313 }
314
315 /// Return the source map that tracks original file locations.
316 pub fn source_map(&self) -> &SourceMap {
317 &self.internal.borrow_dependent().source_map
318 }
319
320 /// Return the document catalog for accessing referenceable elements.
321 pub fn catalog(&self) -> &Catalog {
322 &self.internal.borrow_dependent().catalog
323 }
324
325 /// Resolve the document's deferred cross-references using a caller-supplied
326 /// [`ReferenceResolver`] and [`InlineSubstitutionRenderer`].
327 ///
328 /// This is the entry point for multi-document workflows: parse each
329 /// document with [`Parser::parse_deferred`], then call this with a
330 /// resolver that resolves targets against whatever combined index the
331 /// caller has built (this crate does not merge catalogs). The resolver
332 /// binds the "from" document, so a single shared resolver can be
333 /// parametrized per call site.
334 ///
335 /// Resolution is non-destructive and may be repeated (e.g. for incremental
336 /// builds or multiple output targets): the original target text is
337 /// retained, so re-resolving is always possible.
338 ///
339 /// Each call is a **full, independent resolution sweep**. Every
340 /// cross-reference is re-resolved against `resolver`, overwriting any
341 /// result from a previous pass, and the returned [`ReferenceWarning`]s
342 /// reflect only what *this* `resolver` could not resolve — a prior pass
343 /// having resolved a target does not suppress a warning here.
344 /// Consequently, resolving with a resolver that knows fewer targets
345 /// than an earlier pass (for example, calling this after
346 /// [`Parser::parse`] has already auto-resolved against the document's
347 /// own catalog) will re-report those now-unknown targets as unresolved.
348 /// Multi-document pipelines should therefore start from
349 /// [`Parser::parse_deferred`], which does not auto-resolve.
350 pub fn resolve_references(
351 &mut self,
352 resolver: &dyn ReferenceResolver,
353 renderer: &dyn InlineSubstitutionRenderer,
354 ) -> Vec<ReferenceWarning> {
355 let mut warnings = Vec::new();
356
357 self.internal.with_dependent_mut(|_owner, dependent| {
358 for block in dependent.blocks.iter_mut() {
359 block.resolve_references(resolver, renderer, &mut warnings);
360 }
361
362 // Footnote text is extracted out of block content, so its
363 // cross-references are resolved here rather than by the block pass
364 // above. The host resolver does not alias the catalog, so the
365 // footnotes can be borrowed mutably in place.
366 for footnote in dependent.catalog.footnotes.iter_mut() {
367 footnote.resolve_references(resolver, renderer, &mut warnings);
368 }
369 });
370
371 warnings
372 }
373
374 /// Resolve the document's deferred cross-references against its own
375 /// catalog.
376 ///
377 /// This is the single-document convenience path used by [`Parser::parse`].
378 pub(crate) fn resolve_against_own_catalog(
379 &mut self,
380 renderer: &dyn InlineSubstitutionRenderer,
381 ) -> Vec<ReferenceWarning> {
382 let mut warnings = Vec::new();
383
384 self.internal.with_dependent_mut(|_owner, dependent| {
385 // The footnotes are moved out of the catalog so they can be resolved
386 // mutably while the `CatalogResolver` borrows the (footnote-free)
387 // catalog. Footnotes are never cross-reference *targets*, so their
388 // absence does not affect resolution.
389 let mut footnotes = dependent.catalog.take_footnotes();
390
391 let resolver = CatalogResolver::new(&dependent.catalog);
392 for block in dependent.blocks.iter_mut() {
393 block.resolve_references(&resolver, renderer, &mut warnings);
394 }
395
396 // Footnote text is extracted out of block content, so its
397 // cross-references are resolved here rather than by the block pass
398 // above.
399 for footnote in footnotes.iter_mut() {
400 footnote.resolve_references(&resolver, renderer, &mut warnings);
401 }
402
403 dependent.catalog.restore_footnotes(footnotes);
404 });
405
406 warnings
407 }
408}
409
410impl<'src> IsBlock<'src> for Document<'src> {
411 fn content_model(&self) -> ContentModel {
412 ContentModel::Compound
413 }
414
415 fn raw_context(&self) -> CowStr<'src> {
416 "document".into()
417 }
418
419 fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
420 self.internal.borrow_dependent().blocks.iter()
421 }
422
423 fn title_source(&'src self) -> Option<Span<'src>> {
424 // Document title is reflected in the Header.
425 None
426 }
427
428 fn title(&self) -> Option<&str> {
429 // Document title is reflected in the Header.
430 None
431 }
432
433 fn anchor(&'src self) -> Option<Span<'src>> {
434 None
435 }
436
437 fn anchor_reftext(&'src self) -> Option<Span<'src>> {
438 None
439 }
440
441 fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
442 // Document attributes are reflected in the Header.
443 None
444 }
445}
446
447impl std::fmt::Debug for Document<'_> {
448 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 let dependent = self.internal.borrow_dependent();
450 f.debug_struct("Document")
451 .field("header", &dependent.header)
452 .field("blocks", &DebugSliceReference(&dependent.blocks))
453 .field("source", &dependent.source)
454 .field("warnings", &DebugSliceReference(&dependent.warnings))
455 .field("source_map", &dependent.source_map)
456 .field("catalog", &dependent.catalog)
457 .finish()
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 #![allow(clippy::unwrap_used)]
464
465 use std::{collections::HashMap, ops::Deref};
466
467 use crate::{
468 blocks::{ContentModel, MediaType},
469 document::RefType,
470 tests::prelude::*,
471 };
472
473 #[test]
474 fn empty_source() {
475 let doc = Parser::default().parse("");
476
477 assert_eq!(doc.content_model(), ContentModel::Compound);
478 assert_eq!(doc.raw_context().deref(), "document");
479 assert_eq!(doc.resolved_context().deref(), "document");
480 assert!(doc.declared_style().is_none());
481 assert!(doc.id().is_none());
482 assert!(doc.roles().is_empty());
483 assert!(doc.title_source().is_none());
484 assert!(doc.title().is_none());
485 assert!(doc.anchor().is_none());
486 assert!(doc.anchor_reftext().is_none());
487 assert!(doc.attrlist().is_none());
488 assert_eq!(doc.substitution_group(), SubstitutionGroup::Normal);
489
490 assert_eq!(
491 doc,
492 Document {
493 header: Header {
494 title_source: None,
495 title: None,
496 attributes: &[],
497 author_line: None,
498 revision_line: None,
499 comments: &[],
500 source: Span {
501 data: "",
502 line: 1,
503 col: 1,
504 offset: 0
505 },
506 },
507 source: Span {
508 data: "",
509 line: 1,
510 col: 1,
511 offset: 0
512 },
513 blocks: &[],
514 warnings: &[],
515 source_map: SourceMap(&[]),
516 catalog: Catalog::default(),
517 }
518 );
519 }
520
521 #[test]
522 fn only_spaces() {
523 assert_eq!(
524 Parser::default().parse(" "),
525 Document {
526 header: Header {
527 title_source: None,
528 title: None,
529 attributes: &[],
530 author_line: None,
531 revision_line: None,
532 comments: &[],
533 source: Span {
534 data: "",
535 line: 1,
536 col: 5,
537 offset: 4
538 },
539 },
540 source: Span {
541 data: "",
542 line: 1,
543 col: 1,
544 offset: 0
545 },
546 blocks: &[],
547 warnings: &[],
548 source_map: SourceMap(&[]),
549 catalog: Catalog::default(),
550 }
551 );
552 }
553
554 #[test]
555 fn one_simple_block() {
556 let doc = Parser::default().parse("abc");
557 assert_eq!(
558 doc,
559 Document {
560 header: Header {
561 title_source: None,
562 title: None,
563 attributes: &[],
564 author_line: None,
565 revision_line: None,
566 comments: &[],
567 source: Span {
568 data: "",
569 line: 1,
570 col: 1,
571 offset: 0
572 },
573 },
574 source: Span {
575 data: "abc",
576 line: 1,
577 col: 1,
578 offset: 0
579 },
580 blocks: &[Block::Simple(SimpleBlock {
581 content: Content {
582 original: Span {
583 data: "abc",
584 line: 1,
585 col: 1,
586 offset: 0,
587 },
588 rendered: "abc",
589 },
590 source: Span {
591 data: "abc",
592 line: 1,
593 col: 1,
594 offset: 0,
595 },
596 style: SimpleBlockStyle::Paragraph,
597 title_source: None,
598 title: None,
599 caption: None,
600 number: None,
601 anchor: None,
602 anchor_reftext: None,
603 attrlist: None,
604 })],
605 warnings: &[],
606 source_map: SourceMap(&[]),
607 catalog: Catalog::default(),
608 }
609 );
610
611 assert!(doc.anchor().is_none());
612 assert!(doc.anchor_reftext().is_none());
613 }
614
615 #[test]
616 fn two_simple_blocks() {
617 assert_eq!(
618 Parser::default().parse("abc\n\ndef"),
619 Document {
620 header: Header {
621 title_source: None,
622 title: None,
623 attributes: &[],
624 author_line: None,
625 revision_line: None,
626 comments: &[],
627 source: Span {
628 data: "",
629 line: 1,
630 col: 1,
631 offset: 0
632 },
633 },
634 source: Span {
635 data: "abc\n\ndef",
636 line: 1,
637 col: 1,
638 offset: 0
639 },
640 blocks: &[
641 Block::Simple(SimpleBlock {
642 content: Content {
643 original: Span {
644 data: "abc",
645 line: 1,
646 col: 1,
647 offset: 0,
648 },
649 rendered: "abc",
650 },
651 source: Span {
652 data: "abc",
653 line: 1,
654 col: 1,
655 offset: 0,
656 },
657 style: SimpleBlockStyle::Paragraph,
658 title_source: None,
659 title: None,
660 caption: None,
661 number: None,
662 anchor: None,
663 anchor_reftext: None,
664 attrlist: None,
665 }),
666 Block::Simple(SimpleBlock {
667 content: Content {
668 original: Span {
669 data: "def",
670 line: 3,
671 col: 1,
672 offset: 5,
673 },
674 rendered: "def",
675 },
676 source: Span {
677 data: "def",
678 line: 3,
679 col: 1,
680 offset: 5,
681 },
682 style: SimpleBlockStyle::Paragraph,
683 title_source: None,
684 title: None,
685 caption: None,
686 number: None,
687 anchor: None,
688 anchor_reftext: None,
689 attrlist: None,
690 })
691 ],
692 warnings: &[],
693 source_map: SourceMap(&[]),
694 catalog: Catalog::default(),
695 }
696 );
697 }
698
699 #[test]
700 fn two_blocks_and_title() {
701 assert_eq!(
702 Parser::default().parse("= Example Title\n\nabc\n\ndef"),
703 Document {
704 header: Header {
705 title_source: Some(Span {
706 data: "Example Title",
707 line: 1,
708 col: 3,
709 offset: 2,
710 }),
711 title: Some("Example Title"),
712 attributes: &[],
713 author_line: None,
714 revision_line: None,
715 comments: &[],
716 source: Span {
717 data: "= Example Title",
718 line: 1,
719 col: 1,
720 offset: 0,
721 }
722 },
723 blocks: &[
724 Block::Simple(SimpleBlock {
725 content: Content {
726 original: Span {
727 data: "abc",
728 line: 3,
729 col: 1,
730 offset: 17,
731 },
732 rendered: "abc",
733 },
734 source: Span {
735 data: "abc",
736 line: 3,
737 col: 1,
738 offset: 17,
739 },
740 style: SimpleBlockStyle::Paragraph,
741 title_source: None,
742 title: None,
743 caption: None,
744 number: None,
745 anchor: None,
746 anchor_reftext: None,
747 attrlist: None,
748 }),
749 Block::Simple(SimpleBlock {
750 content: Content {
751 original: Span {
752 data: "def",
753 line: 5,
754 col: 1,
755 offset: 22,
756 },
757 rendered: "def",
758 },
759 source: Span {
760 data: "def",
761 line: 5,
762 col: 1,
763 offset: 22,
764 },
765 style: SimpleBlockStyle::Paragraph,
766 title_source: None,
767 title: None,
768 caption: None,
769 number: None,
770 anchor: None,
771 anchor_reftext: None,
772 attrlist: None,
773 })
774 ],
775 source: Span {
776 data: "= Example Title\n\nabc\n\ndef",
777 line: 1,
778 col: 1,
779 offset: 0
780 },
781 warnings: &[],
782 source_map: SourceMap(&[]),
783 catalog: Catalog::default(),
784 }
785 );
786 }
787
788 #[test]
789 fn blank_lines_before_header() {
790 let doc = Parser::default().parse("\n\n= Example Title\n\nabc\n\ndef");
791
792 assert_eq!(
793 doc,
794 Document {
795 header: Header {
796 title_source: Some(Span {
797 data: "Example Title",
798 line: 3,
799 col: 3,
800 offset: 4,
801 },),
802 title: Some("Example Title",),
803 attributes: &[],
804 author_line: None,
805 revision_line: None,
806 comments: &[],
807 source: Span {
808 data: "= Example Title",
809 line: 3,
810 col: 1,
811 offset: 2,
812 },
813 },
814 blocks: &[
815 Block::Simple(SimpleBlock {
816 content: Content {
817 original: Span {
818 data: "abc",
819 line: 5,
820 col: 1,
821 offset: 19,
822 },
823 rendered: "abc",
824 },
825 source: Span {
826 data: "abc",
827 line: 5,
828 col: 1,
829 offset: 19,
830 },
831 style: SimpleBlockStyle::Paragraph,
832 title_source: None,
833 title: None,
834 caption: None,
835 number: None,
836 anchor: None,
837 anchor_reftext: None,
838 attrlist: None,
839 },),
840 Block::Simple(SimpleBlock {
841 content: Content {
842 original: Span {
843 data: "def",
844 line: 7,
845 col: 1,
846 offset: 24,
847 },
848 rendered: "def",
849 },
850 source: Span {
851 data: "def",
852 line: 7,
853 col: 1,
854 offset: 24,
855 },
856 style: SimpleBlockStyle::Paragraph,
857 title_source: None,
858 title: None,
859 caption: None,
860 number: None,
861 anchor: None,
862 anchor_reftext: None,
863 attrlist: None,
864 },),
865 ],
866 source: Span {
867 data: "\n\n= Example Title\n\nabc\n\ndef",
868 line: 1,
869 col: 1,
870 offset: 0,
871 },
872 warnings: &[],
873 source_map: SourceMap(&[]),
874 catalog: Catalog::default(),
875 }
876 );
877 }
878
879 #[test]
880 fn blank_lines_and_comment_before_header() {
881 let doc =
882 Parser::default().parse("\n// ignore this comment\n= Example Title\n\nabc\n\ndef");
883
884 assert_eq!(
885 doc,
886 Document {
887 header: Header {
888 title_source: Some(Span {
889 data: "Example Title",
890 line: 3,
891 col: 3,
892 offset: 26,
893 },),
894 title: Some("Example Title",),
895 attributes: &[],
896 author_line: None,
897 revision_line: None,
898 comments: &[Span {
899 data: "// ignore this comment",
900 line: 2,
901 col: 1,
902 offset: 1,
903 },],
904 source: Span {
905 data: "// ignore this comment\n= Example Title",
906 line: 2,
907 col: 1,
908 offset: 1,
909 },
910 },
911 blocks: &[
912 Block::Simple(SimpleBlock {
913 content: Content {
914 original: Span {
915 data: "abc",
916 line: 5,
917 col: 1,
918 offset: 41,
919 },
920 rendered: "abc",
921 },
922 source: Span {
923 data: "abc",
924 line: 5,
925 col: 1,
926 offset: 41,
927 },
928 style: SimpleBlockStyle::Paragraph,
929 title_source: None,
930 title: None,
931 caption: None,
932 number: None,
933 anchor: None,
934 anchor_reftext: None,
935 attrlist: None,
936 },),
937 Block::Simple(SimpleBlock {
938 content: Content {
939 original: Span {
940 data: "def",
941 line: 7,
942 col: 1,
943 offset: 46,
944 },
945 rendered: "def",
946 },
947 source: Span {
948 data: "def",
949 line: 7,
950 col: 1,
951 offset: 46,
952 },
953 style: SimpleBlockStyle::Paragraph,
954 title_source: None,
955 title: None,
956 caption: None,
957 number: None,
958 anchor: None,
959 anchor_reftext: None,
960 attrlist: None,
961 },),
962 ],
963 source: Span {
964 data: "\n// ignore this comment\n= Example Title\n\nabc\n\ndef",
965 line: 1,
966 col: 1,
967 offset: 0,
968 },
969 warnings: &[],
970 source_map: SourceMap(&[]),
971 catalog: Catalog::default(),
972 }
973 );
974 }
975
976 #[test]
977 fn extra_space_before_title() {
978 assert_eq!(
979 Parser::default().parse("= Example Title\n\nabc"),
980 Document {
981 header: Header {
982 title_source: Some(Span {
983 data: "Example Title",
984 line: 1,
985 col: 5,
986 offset: 4,
987 }),
988 title: Some("Example Title"),
989 attributes: &[],
990 author_line: None,
991 revision_line: None,
992 comments: &[],
993 source: Span {
994 data: "= Example Title",
995 line: 1,
996 col: 1,
997 offset: 0,
998 }
999 },
1000 blocks: &[Block::Simple(SimpleBlock {
1001 content: Content {
1002 original: Span {
1003 data: "abc",
1004 line: 3,
1005 col: 1,
1006 offset: 19,
1007 },
1008 rendered: "abc",
1009 },
1010 source: Span {
1011 data: "abc",
1012 line: 3,
1013 col: 1,
1014 offset: 19,
1015 },
1016 style: SimpleBlockStyle::Paragraph,
1017 title_source: None,
1018 title: None,
1019 caption: None,
1020 number: None,
1021 anchor: None,
1022 anchor_reftext: None,
1023 attrlist: None,
1024 })],
1025 source: Span {
1026 data: "= Example Title\n\nabc",
1027 line: 1,
1028 col: 1,
1029 offset: 0
1030 },
1031 warnings: &[],
1032 source_map: SourceMap(&[]),
1033 catalog: Catalog::default(),
1034 }
1035 );
1036 }
1037
1038 #[test]
1039 fn err_bad_header() {
1040 assert_eq!(
1041 Parser::default().parse(
1042 "= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28\nnot an attribute\n"
1043 ),
1044 Document {
1045 header: Header {
1046 title_source: Some(Span {
1047 data: "Title",
1048 line: 1,
1049 col: 3,
1050 offset: 2,
1051 }),
1052 title: Some("Title"),
1053 attributes: &[],
1054 author_line: Some(AuthorLine {
1055 authors: &[Author {
1056 name: "Jane Smith",
1057 firstname: "Jane",
1058 middlename: None,
1059 lastname: Some("Smith"),
1060 email: Some("jane@example.com"),
1061 }],
1062 source: Span {
1063 data: "Jane Smith <jane@example.com>",
1064 line: 2,
1065 col: 1,
1066 offset: 8,
1067 },
1068 }),
1069 revision_line: Some(RevisionLine {
1070 revnumber: Some("1",),
1071 revdate: "2025-09-28",
1072 revremark: None,
1073 source: Span {
1074 data: "v1, 2025-09-28",
1075 line: 3,
1076 col: 1,
1077 offset: 38,
1078 },
1079 },),
1080 comments: &[],
1081 source: Span {
1082 data: "= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28",
1083 line: 1,
1084 col: 1,
1085 offset: 0,
1086 }
1087 },
1088 blocks: &[Block::Simple(SimpleBlock {
1089 content: Content {
1090 original: Span {
1091 data: "not an attribute",
1092 line: 4,
1093 col: 1,
1094 offset: 53,
1095 },
1096 rendered: "not an attribute",
1097 },
1098 source: Span {
1099 data: "not an attribute",
1100 line: 4,
1101 col: 1,
1102 offset: 53,
1103 },
1104 style: SimpleBlockStyle::Paragraph,
1105 title_source: None,
1106 title: None,
1107 caption: None,
1108 number: None,
1109 anchor: None,
1110 anchor_reftext: None,
1111 attrlist: None,
1112 })],
1113 source: Span {
1114 data: "= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28\nnot an attribute",
1115 line: 1,
1116 col: 1,
1117 offset: 0
1118 },
1119 warnings: &[Warning {
1120 source: Span {
1121 data: "not an attribute",
1122 line: 4,
1123 col: 1,
1124 offset: 53,
1125 },
1126 warning: WarningType::DocumentHeaderNotTerminated,
1127 },],
1128 source_map: SourceMap(&[]),
1129 catalog: Catalog::default(),
1130 }
1131 );
1132 }
1133
1134 #[test]
1135 fn err_bad_header_and_bad_macro() {
1136 let doc = Parser::default().parse("= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28\nnot an attribute\n\n== Section Title\n\nimage::bar[alt=Sunset,width=300,,height=400]");
1137
1138 assert_eq!(
1139 Document {
1140 header: Header {
1141 title_source: Some(Span {
1142 data: "Title",
1143 line: 1,
1144 col: 3,
1145 offset: 2,
1146 }),
1147 title: Some("Title"),
1148 attributes: &[],
1149 author_line: Some(AuthorLine {
1150 authors: &[Author {
1151 name: "Jane Smith",
1152 firstname: "Jane",
1153 middlename: None,
1154 lastname: Some("Smith"),
1155 email: Some("jane@example.com"),
1156 }],
1157 source: Span {
1158 data: "Jane Smith <jane@example.com>",
1159 line: 2,
1160 col: 1,
1161 offset: 8,
1162 },
1163 }),
1164 revision_line: Some(RevisionLine {
1165 revnumber: Some("1"),
1166 revdate: "2025-09-28",
1167 revremark: None,
1168 source: Span {
1169 data: "v1, 2025-09-28",
1170 line: 3,
1171 col: 1,
1172 offset: 38,
1173 },
1174 },),
1175 comments: &[],
1176 source: Span {
1177 data: "= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28",
1178 line: 1,
1179 col: 1,
1180 offset: 0,
1181 }
1182 },
1183 blocks: &[
1184 Block::Preamble(Preamble {
1185 blocks: &[Block::Simple(SimpleBlock {
1186 content: Content {
1187 original: Span {
1188 data: "not an attribute",
1189 line: 4,
1190 col: 1,
1191 offset: 53,
1192 },
1193 rendered: "not an attribute",
1194 },
1195 source: Span {
1196 data: "not an attribute",
1197 line: 4,
1198 col: 1,
1199 offset: 53,
1200 },
1201 style: SimpleBlockStyle::Paragraph,
1202 title_source: None,
1203 title: None,
1204 caption: None,
1205 number: None,
1206 anchor: None,
1207 anchor_reftext: None,
1208 attrlist: None,
1209 },),],
1210 source: Span {
1211 data: "not an attribute",
1212 line: 4,
1213 col: 1,
1214 offset: 53,
1215 },
1216 },),
1217 Block::Section(SectionBlock {
1218 level: 1,
1219 section_title: Content {
1220 original: Span {
1221 data: "Section Title",
1222 line: 6,
1223 col: 4,
1224 offset: 74,
1225 },
1226 rendered: "Section Title",
1227 },
1228 blocks: &[Block::Media(MediaBlock {
1229 type_: MediaType::Image,
1230 target: Span {
1231 data: "bar",
1232 line: 8,
1233 col: 8,
1234 offset: 96,
1235 },
1236 macro_attrlist: Attrlist {
1237 attributes: &[
1238 ElementAttribute {
1239 name: Some("alt"),
1240 shorthand_items: &[],
1241 value: "Sunset"
1242 },
1243 ElementAttribute {
1244 name: Some("width"),
1245 shorthand_items: &[],
1246 value: "300"
1247 },
1248 ElementAttribute {
1249 name: Some("height"),
1250 shorthand_items: &[],
1251 value: "400"
1252 },
1253 ],
1254 anchor: None,
1255 source: Span {
1256 data: "alt=Sunset,width=300,,height=400",
1257 line: 8,
1258 col: 12,
1259 offset: 100,
1260 },
1261 },
1262 source: Span {
1263 data: "image::bar[alt=Sunset,width=300,,height=400]",
1264 line: 8,
1265 col: 1,
1266 offset: 89,
1267 },
1268 title_source: None,
1269 title: None,
1270 caption: None,
1271 number: None,
1272 anchor: None,
1273 anchor_reftext: None,
1274 attrlist: None,
1275 },),],
1276 source: Span {
1277 data: "== Section Title\n\nimage::bar[alt=Sunset,width=300,,height=400]",
1278 line: 6,
1279 col: 1,
1280 offset: 71,
1281 },
1282 title_source: None,
1283 title: None,
1284 anchor: None,
1285 anchor_reftext: None,
1286 attrlist: None,
1287 section_type: SectionType::Normal,
1288 section_id: Some("_section_title"),
1289 caption: None,
1290 section_number: None,
1291 },)
1292 ],
1293 source: Span {
1294 data: "= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28\nnot an attribute\n\n== Section Title\n\nimage::bar[alt=Sunset,width=300,,height=400]",
1295 line: 1,
1296 col: 1,
1297 offset: 0
1298 },
1299 warnings: &[
1300 Warning {
1301 source: Span {
1302 data: "not an attribute",
1303 line: 4,
1304 col: 1,
1305 offset: 53,
1306 },
1307 warning: WarningType::DocumentHeaderNotTerminated,
1308 },
1309 Warning {
1310 source: Span {
1311 data: "alt=Sunset,width=300,,height=400",
1312 line: 8,
1313 col: 12,
1314 offset: 100,
1315 },
1316 warning: WarningType::EmptyAttributeValue,
1317 },
1318 ],
1319 source_map: SourceMap(&[]),
1320 catalog: Catalog {
1321 refs: HashMap::from([(
1322 "_section_title",
1323 RefEntry {
1324 id: "_section_title",
1325 reftext: Some("Section Title",),
1326 ref_type: RefType::Section,
1327 }
1328 ),]),
1329 reftext_to_id: HashMap::from([("Section Title", "_section_title"),]),
1330 }
1331 },
1332 doc
1333 );
1334 }
1335
1336 #[test]
1337 fn impl_debug() {
1338 let doc = Parser::default().parse("= Example Title\n\nabc\n\ndef");
1339
1340 assert_eq!(
1341 format!("{doc:#?}"),
1342 r#"Document {
1343 header: Header {
1344 title_source: Some(
1345 Span {
1346 data: "Example Title",
1347 line: 1,
1348 col: 3,
1349 offset: 2,
1350 },
1351 ),
1352 title: Some(
1353 "Example Title",
1354 ),
1355 attributes: &[],
1356 author_line: None,
1357 revision_line: None,
1358 comments: &[],
1359 source: Span {
1360 data: "= Example Title",
1361 line: 1,
1362 col: 1,
1363 offset: 0,
1364 },
1365 },
1366 blocks: &[
1367 Block::Simple(
1368 SimpleBlock {
1369 content: Content {
1370 original: Span {
1371 data: "abc",
1372 line: 3,
1373 col: 1,
1374 offset: 17,
1375 },
1376 rendered: "abc",
1377 },
1378 source: Span {
1379 data: "abc",
1380 line: 3,
1381 col: 1,
1382 offset: 17,
1383 },
1384 style: SimpleBlockStyle::Paragraph,
1385 title_source: None,
1386 title: None,
1387 caption: None,
1388 number: None,
1389 anchor: None,
1390 anchor_reftext: None,
1391 attrlist: None,
1392 },
1393 ),
1394 Block::Simple(
1395 SimpleBlock {
1396 content: Content {
1397 original: Span {
1398 data: "def",
1399 line: 5,
1400 col: 1,
1401 offset: 22,
1402 },
1403 rendered: "def",
1404 },
1405 source: Span {
1406 data: "def",
1407 line: 5,
1408 col: 1,
1409 offset: 22,
1410 },
1411 style: SimpleBlockStyle::Paragraph,
1412 title_source: None,
1413 title: None,
1414 caption: None,
1415 number: None,
1416 anchor: None,
1417 anchor_reftext: None,
1418 attrlist: None,
1419 },
1420 ),
1421 ],
1422 source: Span {
1423 data: "= Example Title\n\nabc\n\ndef",
1424 line: 1,
1425 col: 1,
1426 offset: 0,
1427 },
1428 warnings: &[],
1429 source_map: SourceMap(&[]),
1430 catalog: Catalog {
1431 refs: HashMap::from([]),
1432 reftext_to_id: HashMap::from([]),
1433 footnotes: [],
1434 },
1435}"#
1436 );
1437 }
1438
1439 mod attribute_access {
1440 use crate::{document::InterpretedValue, tests::prelude::*};
1441
1442 #[test]
1443 fn built_in_default() {
1444 // `doctype` is a built-in attribute with a default of `article`; it
1445 // should read back through the `Document` even though the source
1446 // never sets it.
1447 let doc = Parser::default().parse("Hello.");
1448
1449 assert!(doc.has_attribute("doctype"));
1450 assert!(doc.is_attribute_set("doctype"));
1451 assert_eq!(
1452 doc.attribute_value("doctype"),
1453 InterpretedValue::Value("article".to_string())
1454 );
1455 }
1456
1457 #[test]
1458 fn header_set_attribute() {
1459 let doc = Parser::default().parse("= Title\n:lang: fr\n\nBonjour.");
1460
1461 assert!(doc.has_attribute("lang"));
1462 assert!(doc.is_attribute_set("lang"));
1463 assert_eq!(
1464 doc.attribute_value("lang"),
1465 InterpretedValue::Value("fr".to_string())
1466 );
1467 }
1468
1469 #[test]
1470 fn body_set_attribute() {
1471 // An attribute set in the document body (not the header) is part of
1472 // the final resolved state and must be visible on the `Document`.
1473 let doc = Parser::default().parse("First paragraph.\n\n:foo: bar\n\nSecond paragraph.");
1474
1475 assert!(doc.has_attribute("foo"));
1476 assert!(doc.is_attribute_set("foo"));
1477 assert_eq!(
1478 doc.attribute_value("foo"),
1479 InterpretedValue::Value("bar".to_string())
1480 );
1481 }
1482
1483 #[test]
1484 fn set_flag_attribute() {
1485 // A bare `:sectnums:` turns the attribute on; its resolved value is
1486 // the built-in default `all`.
1487 let doc = Parser::default().parse("= Title\n:sectnums:\n\nBody.");
1488
1489 assert!(doc.has_attribute("sectnums"));
1490 assert!(doc.is_attribute_set("sectnums"));
1491 assert_eq!(
1492 doc.attribute_value("sectnums"),
1493 InterpretedValue::Value("all".to_string())
1494 );
1495 }
1496
1497 #[test]
1498 fn unset_attribute() {
1499 // `sectnums` exists in the built-in table but is unset by default.
1500 let doc = Parser::default().parse("Hello.");
1501
1502 assert!(doc.has_attribute("sectnums"));
1503 assert!(!doc.is_attribute_set("sectnums"));
1504 assert_eq!(doc.attribute_value("sectnums"), InterpretedValue::Unset);
1505 }
1506
1507 #[test]
1508 fn explicitly_unset_attribute() {
1509 // `:!sectnums:` explicitly unsets an otherwise-set attribute: it is
1510 // present but not set.
1511 let doc = Parser::default().parse("= Title\n:sectnums:\n:!sectnums:\n\nBody.");
1512
1513 assert!(doc.has_attribute("sectnums"));
1514 assert!(!doc.is_attribute_set("sectnums"));
1515 assert_eq!(doc.attribute_value("sectnums"), InterpretedValue::Unset);
1516 }
1517
1518 #[test]
1519 fn absent_attribute() {
1520 let doc = Parser::default().parse("Hello.");
1521
1522 assert!(!doc.has_attribute("no-such-attribute"));
1523 assert!(!doc.is_attribute_set("no-such-attribute"));
1524 assert_eq!(
1525 doc.attribute_value("no-such-attribute"),
1526 InterpretedValue::Unset
1527 );
1528 }
1529
1530 #[test]
1531 fn matches_parser_state() {
1532 // The values read back through the `Document` must equal what the
1533 // `Parser` itself reports after `parse`.
1534 let mut parser = Parser::default();
1535 let doc = parser.parse("= Title\n:lang: de\n:sectnums:\n\nBody.");
1536
1537 for name in [
1538 "lang",
1539 "sectnums",
1540 "doctype",
1541 "notitle",
1542 "no-such-attribute",
1543 ] {
1544 assert_eq!(doc.attribute_value(name), parser.attribute_value(name));
1545 assert_eq!(doc.has_attribute(name), parser.has_attribute(name));
1546 assert_eq!(doc.is_attribute_set(name), parser.is_attribute_set(name));
1547 }
1548 }
1549
1550 #[test]
1551 fn counter_value() {
1552 // A counter's current value is part of the resolved attribute state
1553 // and supersedes any like-named attribute.
1554 let doc = Parser::default().parse("{counter:my-counter}\n\n{counter:my-counter}");
1555
1556 assert!(doc.has_attribute("my-counter"));
1557 assert!(doc.is_attribute_set("my-counter"));
1558 assert_eq!(
1559 doc.attribute_value("my-counter"),
1560 InterpretedValue::Value("2".to_string())
1561 );
1562 }
1563 }
1564}