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