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