Skip to main content

asciidoc_parser/document/
document.rs

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