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