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