Skip to main content

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}