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