Skip to main content

asciidoc_parser/document/
document.rs

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