Skip to main content

asciidoc_parser/document/
document.rs

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