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