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