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