asciidoc_parser/document/document.rs
1//! Describes the top-level document structure.
2
3use std::{marker::PhantomData, slice::Iter};
4
5use self_cell::self_cell;
6
7use crate::{
8 Parser, Span,
9 attributes::Attrlist,
10 blocks::{Block, ContentModel, IsBlock, Preamble, parse_utils::parse_blocks_until},
11 document::{Catalog, Docinfo, DocinfoLocation, Header, TocConfig, TocMode},
12 internal::debug::DebugSliceReference,
13 parser::{
14 CatalogResolver, DeferredWarning, InlineSubstitutionRenderer, ReferenceResolver,
15 ReferenceWarning, SourceMap,
16 },
17 strings::CowStr,
18 warnings::Warning,
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 show_doctitle: bool,
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 let internal = Internal::new(owned_source, |owned_src| {
74 let source = Span::new(owned_src);
75
76 let mi = Header::parse(source, parser);
77 let after_header = mi.item.after;
78
79 parser.sectnumlevels = parser
80 .attribute_value("sectnumlevels")
81 .as_maybe_str()
82 .and_then(|s| s.parse::<usize>().ok())
83 .unwrap_or(3);
84
85 let header = mi.item.item;
86 let mut warnings = mi.warnings;
87
88 // Derive the `iconsdir` default from `imagesdir` (`{imagesdir}/icons`)
89 // now that the header is fully parsed, unless the author set
90 // `iconsdir` explicitly in the header (in which case it wins).
91 let iconsdir_set_in_header = header.attributes().any(|a| a.name().data() == "iconsdir");
92 parser.apply_iconsdir_default(iconsdir_set_in_header);
93
94 let mut maw_blocks = parse_blocks_until(after_header, |_| false, parser);
95
96 if !maw_blocks.warnings.is_empty() {
97 warnings.append(&mut maw_blocks.warnings);
98 }
99
100 // Warnings recorded while replacing attribute references (e.g. a
101 // reference to a missing attribute under `attribute-missing=warn`)
102 // are collected on the parser, where only owned offsets — not
103 // borrowed spans — can live. Now that the document's owned source is
104 // available, turn each one back into a spanned `Warning`.
105 let root = Span::new(owned_src);
106
107 // Warnings raised during preprocessing (e.g. an unresolved include
108 // directive) are carried the same way and reconstituted here.
109 for pw in preprocessor_warnings {
110 warnings.push(Warning {
111 source: root.slice(pw.offset..pw.offset + pw.len),
112 warning: pw.warning,
113 });
114 }
115
116 for sw in parser.take_substitution_warnings() {
117 warnings.push(Warning {
118 source: root.slice(sw.offset..sw.offset + sw.len),
119 warning: sw.warning,
120 });
121 }
122
123 let mut blocks = maw_blocks.item.item;
124 let mut has_content_blocks = false;
125 let mut preamble_split_index: Option<usize> = None;
126
127 // Only look for preamble content if document has a title.
128 // Asciidoctor only creates a preamble when there's a document title.
129 if header.title().is_some() {
130 for (index, block) in blocks.iter().enumerate() {
131 match block {
132 Block::DocumentAttribute(_) => (),
133 Block::Section(_) => {
134 if has_content_blocks {
135 preamble_split_index = Some(index);
136 }
137 break;
138 }
139 _ => {
140 has_content_blocks = true;
141 }
142 }
143 }
144 }
145
146 if let Some(index) = preamble_split_index {
147 let mut section_blocks = blocks.split_off(index);
148
149 let preamble = Preamble::from_blocks(blocks, after_header);
150
151 section_blocks.insert(0, Block::Preamble(preamble));
152 blocks = section_blocks;
153 }
154
155 // Whether the document title renders as an `<h1>`. An embedded
156 // document shows its title only when `showtitle` is set (the
157 // default is hidden), so resolve it from the final attribute state.
158 let show_doctitle = parser.resolve_show_title(false);
159
160 // The `toc` family of attributes is header-only, so the resolved
161 // placement, depth, title, and class are fixed once the header (and
162 // body) have been processed. Capture them here, while the parser
163 // still holds the document's resolved attribute state.
164 let toc = TocConfig::from_parser(parser);
165
166 // Resolve docinfo from the final attribute state and the parser's
167 // configured docinfo file handler (empty when no handler is set).
168 let docinfo = Docinfo::resolve(parser);
169
170 InternalDependent {
171 header,
172 blocks,
173 source: source.trim_trailing_whitespace(),
174 warnings,
175 source_map,
176 catalog: parser.take_catalog(),
177 show_doctitle,
178 toc,
179 docinfo,
180 }
181 });
182
183 Self {
184 internal,
185 _phantom: PhantomData,
186 }
187 }
188
189 /// Return the document header.
190 pub fn header(&self) -> &Header<'_> {
191 &self.internal.borrow_dependent().header
192 }
193
194 /// Return the document title (the level-0 `= Title`), if there was one.
195 pub fn doctitle(&self) -> Option<&str> {
196 self.header().title()
197 }
198
199 /// Return whether the document title should be displayed (as an `<h1>`).
200 ///
201 /// This reflects the effective `showtitle`/`notitle` attribute state: an
202 /// embedded document shows its title only when `showtitle` is set.
203 pub fn show_doctitle(&self) -> bool {
204 self.internal.borrow_dependent().show_doctitle
205 }
206
207 /// Return where (and whether) this document's table of contents is
208 /// generated, resolved from the [`toc` attribute].
209 ///
210 /// [`toc` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/
211 pub fn toc_mode(&self) -> TocMode {
212 self.internal.borrow_dependent().toc.mode
213 }
214
215 /// Return the depth of section levels included in this document's table of
216 /// contents, resolved from the [`toclevels` attribute] (default `2`).
217 ///
218 /// [`toclevels` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/levels/
219 pub fn toc_levels(&self) -> usize {
220 self.internal.borrow_dependent().toc.levels
221 }
222
223 /// Return the title of this document's table of contents, resolved from the
224 /// [`toc-title` attribute] (default _Table of Contents_).
225 ///
226 /// [`toc-title` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/title/
227 pub fn toc_title(&self) -> &str {
228 &self.internal.borrow_dependent().toc.title
229 }
230
231 /// Return the CSS class applied to this document's table of contents
232 /// container, resolved from the [`toc-class` attribute] (default `toc`).
233 ///
234 /// [`toc-class` attribute]: https://docs.asciidoctor.org/asciidoc/latest/toc/
235 pub fn toc_class(&self) -> &str {
236 &self.internal.borrow_dependent().toc.class
237 }
238
239 /// Return this document's resolved [docinfo] content for `location`.
240 ///
241 /// [Docinfo] is custom content read from external *docinfo files* and
242 /// injected into the head, header, or footer of the converted output. The
243 /// returned string is the concatenation of the applicable shared and
244 /// private docinfo files (shared first, matching Asciidoctor), with
245 /// `docinfosubs` substitutions already applied.
246 ///
247 /// An empty string is returned when no docinfo applies to the location —
248 /// for example when no [`DocinfoFileHandler`] was configured on the parser,
249 /// the `docinfo` attribute did not enable that scope/location, or no
250 /// matching file was found. Docinfo files are resolved through a
251 /// caller-supplied [`DocinfoFileHandler`], since this crate does not read
252 /// from the filesystem itself.
253 ///
254 /// [docinfo]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
255 /// [Docinfo]: https://docs.asciidoctor.org/asciidoc/latest/docinfo/
256 /// [`DocinfoFileHandler`]: crate::parser::DocinfoFileHandler
257 pub fn docinfo(&self, location: DocinfoLocation) -> &str {
258 self.internal.borrow_dependent().docinfo.content(location)
259 }
260
261 /// Return an iterator over any warnings found during parsing.
262 pub fn warnings(&self) -> Iter<'_, Warning<'_>> {
263 self.internal.borrow_dependent().warnings.iter()
264 }
265
266 /// Return a [`Span`] describing the entire document source.
267 pub fn span(&self) -> Span<'_> {
268 self.internal.borrow_dependent().source
269 }
270
271 /// Return the source map that tracks original file locations.
272 pub fn source_map(&self) -> &SourceMap {
273 &self.internal.borrow_dependent().source_map
274 }
275
276 /// Return the document catalog for accessing referenceable elements.
277 pub fn catalog(&self) -> &Catalog {
278 &self.internal.borrow_dependent().catalog
279 }
280
281 /// Resolve the document's deferred cross-references using a caller-supplied
282 /// [`ReferenceResolver`] and [`InlineSubstitutionRenderer`].
283 ///
284 /// This is the entry point for multi-document workflows: parse each
285 /// document with [`Parser::parse_deferred`], then call this with a
286 /// resolver that resolves targets against whatever combined index the
287 /// caller has built (this crate does not merge catalogs). The resolver
288 /// binds the "from" document, so a single shared resolver can be
289 /// parametrized per call site.
290 ///
291 /// Resolution is non-destructive and may be repeated (e.g. for incremental
292 /// builds or multiple output targets): the original target text is
293 /// retained, so re-resolving is always possible.
294 ///
295 /// Each call is a **full, independent resolution sweep**. Every
296 /// cross-reference is re-resolved against `resolver`, overwriting any
297 /// result from a previous pass, and the returned [`ReferenceWarning`]s
298 /// reflect only what *this* `resolver` could not resolve — a prior pass
299 /// having resolved a target does not suppress a warning here.
300 /// Consequently, resolving with a resolver that knows fewer targets
301 /// than an earlier pass (for example, calling this after
302 /// [`Parser::parse`] has already auto-resolved against the document's
303 /// own catalog) will re-report those now-unknown targets as unresolved.
304 /// Multi-document pipelines should therefore start from
305 /// [`Parser::parse_deferred`], which does not auto-resolve.
306 pub fn resolve_references(
307 &mut self,
308 resolver: &dyn ReferenceResolver,
309 renderer: &dyn InlineSubstitutionRenderer,
310 ) -> Vec<ReferenceWarning> {
311 let mut warnings = Vec::new();
312
313 self.internal.with_dependent_mut(|_owner, dependent| {
314 for block in dependent.blocks.iter_mut() {
315 block.resolve_references(resolver, renderer, &mut warnings);
316 }
317
318 // Footnote text is extracted out of block content, so its
319 // cross-references are resolved here rather than by the block pass
320 // above. The host resolver does not alias the catalog, so the
321 // footnotes can be borrowed mutably in place.
322 for footnote in dependent.catalog.footnotes.iter_mut() {
323 footnote.resolve_references(resolver, renderer, &mut warnings);
324 }
325 });
326
327 warnings
328 }
329
330 /// Resolve the document's deferred cross-references against its own
331 /// catalog.
332 ///
333 /// This is the single-document convenience path used by [`Parser::parse`].
334 pub(crate) fn resolve_against_own_catalog(
335 &mut self,
336 renderer: &dyn InlineSubstitutionRenderer,
337 ) -> Vec<ReferenceWarning> {
338 let mut warnings = Vec::new();
339
340 self.internal.with_dependent_mut(|_owner, dependent| {
341 // The footnotes are moved out of the catalog so they can be resolved
342 // mutably while the `CatalogResolver` borrows the (footnote-free)
343 // catalog. Footnotes are never cross-reference *targets*, so their
344 // absence does not affect resolution.
345 let mut footnotes = dependent.catalog.take_footnotes();
346
347 let resolver = CatalogResolver::new(&dependent.catalog);
348 for block in dependent.blocks.iter_mut() {
349 block.resolve_references(&resolver, renderer, &mut warnings);
350 }
351
352 // Footnote text is extracted out of block content, so its
353 // cross-references are resolved here rather than by the block pass
354 // above.
355 for footnote in footnotes.iter_mut() {
356 footnote.resolve_references(&resolver, renderer, &mut warnings);
357 }
358
359 dependent.catalog.restore_footnotes(footnotes);
360 });
361
362 warnings
363 }
364}
365
366impl<'src> IsBlock<'src> for Document<'src> {
367 fn content_model(&self) -> ContentModel {
368 ContentModel::Compound
369 }
370
371 fn raw_context(&self) -> CowStr<'src> {
372 "document".into()
373 }
374
375 fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
376 self.internal.borrow_dependent().blocks.iter()
377 }
378
379 fn title_source(&'src self) -> Option<Span<'src>> {
380 // Document title is reflected in the Header.
381 None
382 }
383
384 fn title(&self) -> Option<&str> {
385 // Document title is reflected in the Header.
386 None
387 }
388
389 fn anchor(&'src self) -> Option<Span<'src>> {
390 None
391 }
392
393 fn anchor_reftext(&'src self) -> Option<Span<'src>> {
394 None
395 }
396
397 fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
398 // Document attributes are reflected in the Header.
399 None
400 }
401}
402
403impl std::fmt::Debug for Document<'_> {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 let dependent = self.internal.borrow_dependent();
406 f.debug_struct("Document")
407 .field("header", &dependent.header)
408 .field("blocks", &DebugSliceReference(&dependent.blocks))
409 .field("source", &dependent.source)
410 .field("warnings", &DebugSliceReference(&dependent.warnings))
411 .field("source_map", &dependent.source_map)
412 .field("catalog", &dependent.catalog)
413 .finish()
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 #![allow(clippy::unwrap_used)]
420
421 use std::{collections::HashMap, ops::Deref};
422
423 use crate::{
424 blocks::{ContentModel, MediaType},
425 document::RefType,
426 tests::prelude::*,
427 };
428
429 #[test]
430 fn empty_source() {
431 let doc = Parser::default().parse("");
432
433 assert_eq!(doc.content_model(), ContentModel::Compound);
434 assert_eq!(doc.raw_context().deref(), "document");
435 assert_eq!(doc.resolved_context().deref(), "document");
436 assert!(doc.declared_style().is_none());
437 assert!(doc.id().is_none());
438 assert!(doc.roles().is_empty());
439 assert!(doc.title_source().is_none());
440 assert!(doc.title().is_none());
441 assert!(doc.anchor().is_none());
442 assert!(doc.anchor_reftext().is_none());
443 assert!(doc.attrlist().is_none());
444 assert_eq!(doc.substitution_group(), SubstitutionGroup::Normal);
445
446 assert_eq!(
447 doc,
448 Document {
449 header: Header {
450 title_source: None,
451 title: None,
452 attributes: &[],
453 author_line: None,
454 revision_line: None,
455 comments: &[],
456 source: Span {
457 data: "",
458 line: 1,
459 col: 1,
460 offset: 0
461 },
462 },
463 source: Span {
464 data: "",
465 line: 1,
466 col: 1,
467 offset: 0
468 },
469 blocks: &[],
470 warnings: &[],
471 source_map: SourceMap(&[]),
472 catalog: Catalog::default(),
473 }
474 );
475 }
476
477 #[test]
478 fn only_spaces() {
479 assert_eq!(
480 Parser::default().parse(" "),
481 Document {
482 header: Header {
483 title_source: None,
484 title: None,
485 attributes: &[],
486 author_line: None,
487 revision_line: None,
488 comments: &[],
489 source: Span {
490 data: "",
491 line: 1,
492 col: 5,
493 offset: 4
494 },
495 },
496 source: Span {
497 data: "",
498 line: 1,
499 col: 1,
500 offset: 0
501 },
502 blocks: &[],
503 warnings: &[],
504 source_map: SourceMap(&[]),
505 catalog: Catalog::default(),
506 }
507 );
508 }
509
510 #[test]
511 fn one_simple_block() {
512 let doc = Parser::default().parse("abc");
513 assert_eq!(
514 doc,
515 Document {
516 header: Header {
517 title_source: None,
518 title: None,
519 attributes: &[],
520 author_line: None,
521 revision_line: None,
522 comments: &[],
523 source: Span {
524 data: "",
525 line: 1,
526 col: 1,
527 offset: 0
528 },
529 },
530 source: Span {
531 data: "abc",
532 line: 1,
533 col: 1,
534 offset: 0
535 },
536 blocks: &[Block::Simple(SimpleBlock {
537 content: Content {
538 original: Span {
539 data: "abc",
540 line: 1,
541 col: 1,
542 offset: 0,
543 },
544 rendered: "abc",
545 },
546 source: Span {
547 data: "abc",
548 line: 1,
549 col: 1,
550 offset: 0,
551 },
552 style: SimpleBlockStyle::Paragraph,
553 title_source: None,
554 title: None,
555 caption: None,
556 number: None,
557 anchor: None,
558 anchor_reftext: None,
559 attrlist: None,
560 })],
561 warnings: &[],
562 source_map: SourceMap(&[]),
563 catalog: Catalog::default(),
564 }
565 );
566
567 assert!(doc.anchor().is_none());
568 assert!(doc.anchor_reftext().is_none());
569 }
570
571 #[test]
572 fn two_simple_blocks() {
573 assert_eq!(
574 Parser::default().parse("abc\n\ndef"),
575 Document {
576 header: Header {
577 title_source: None,
578 title: None,
579 attributes: &[],
580 author_line: None,
581 revision_line: None,
582 comments: &[],
583 source: Span {
584 data: "",
585 line: 1,
586 col: 1,
587 offset: 0
588 },
589 },
590 source: Span {
591 data: "abc\n\ndef",
592 line: 1,
593 col: 1,
594 offset: 0
595 },
596 blocks: &[
597 Block::Simple(SimpleBlock {
598 content: Content {
599 original: Span {
600 data: "abc",
601 line: 1,
602 col: 1,
603 offset: 0,
604 },
605 rendered: "abc",
606 },
607 source: Span {
608 data: "abc",
609 line: 1,
610 col: 1,
611 offset: 0,
612 },
613 style: SimpleBlockStyle::Paragraph,
614 title_source: None,
615 title: None,
616 caption: None,
617 number: None,
618 anchor: None,
619 anchor_reftext: None,
620 attrlist: None,
621 }),
622 Block::Simple(SimpleBlock {
623 content: Content {
624 original: Span {
625 data: "def",
626 line: 3,
627 col: 1,
628 offset: 5,
629 },
630 rendered: "def",
631 },
632 source: Span {
633 data: "def",
634 line: 3,
635 col: 1,
636 offset: 5,
637 },
638 style: SimpleBlockStyle::Paragraph,
639 title_source: None,
640 title: None,
641 caption: None,
642 number: None,
643 anchor: None,
644 anchor_reftext: None,
645 attrlist: None,
646 })
647 ],
648 warnings: &[],
649 source_map: SourceMap(&[]),
650 catalog: Catalog::default(),
651 }
652 );
653 }
654
655 #[test]
656 fn two_blocks_and_title() {
657 assert_eq!(
658 Parser::default().parse("= Example Title\n\nabc\n\ndef"),
659 Document {
660 header: Header {
661 title_source: Some(Span {
662 data: "Example Title",
663 line: 1,
664 col: 3,
665 offset: 2,
666 }),
667 title: Some("Example Title"),
668 attributes: &[],
669 author_line: None,
670 revision_line: None,
671 comments: &[],
672 source: Span {
673 data: "= Example Title",
674 line: 1,
675 col: 1,
676 offset: 0,
677 }
678 },
679 blocks: &[
680 Block::Simple(SimpleBlock {
681 content: Content {
682 original: Span {
683 data: "abc",
684 line: 3,
685 col: 1,
686 offset: 17,
687 },
688 rendered: "abc",
689 },
690 source: Span {
691 data: "abc",
692 line: 3,
693 col: 1,
694 offset: 17,
695 },
696 style: SimpleBlockStyle::Paragraph,
697 title_source: None,
698 title: None,
699 caption: None,
700 number: None,
701 anchor: None,
702 anchor_reftext: None,
703 attrlist: None,
704 }),
705 Block::Simple(SimpleBlock {
706 content: Content {
707 original: Span {
708 data: "def",
709 line: 5,
710 col: 1,
711 offset: 22,
712 },
713 rendered: "def",
714 },
715 source: Span {
716 data: "def",
717 line: 5,
718 col: 1,
719 offset: 22,
720 },
721 style: SimpleBlockStyle::Paragraph,
722 title_source: None,
723 title: None,
724 caption: None,
725 number: None,
726 anchor: None,
727 anchor_reftext: None,
728 attrlist: None,
729 })
730 ],
731 source: Span {
732 data: "= Example Title\n\nabc\n\ndef",
733 line: 1,
734 col: 1,
735 offset: 0
736 },
737 warnings: &[],
738 source_map: SourceMap(&[]),
739 catalog: Catalog::default(),
740 }
741 );
742 }
743
744 #[test]
745 fn blank_lines_before_header() {
746 let doc = Parser::default().parse("\n\n= Example Title\n\nabc\n\ndef");
747
748 assert_eq!(
749 doc,
750 Document {
751 header: Header {
752 title_source: Some(Span {
753 data: "Example Title",
754 line: 3,
755 col: 3,
756 offset: 4,
757 },),
758 title: Some("Example Title",),
759 attributes: &[],
760 author_line: None,
761 revision_line: None,
762 comments: &[],
763 source: Span {
764 data: "= Example Title",
765 line: 3,
766 col: 1,
767 offset: 2,
768 },
769 },
770 blocks: &[
771 Block::Simple(SimpleBlock {
772 content: Content {
773 original: Span {
774 data: "abc",
775 line: 5,
776 col: 1,
777 offset: 19,
778 },
779 rendered: "abc",
780 },
781 source: Span {
782 data: "abc",
783 line: 5,
784 col: 1,
785 offset: 19,
786 },
787 style: SimpleBlockStyle::Paragraph,
788 title_source: None,
789 title: None,
790 caption: None,
791 number: None,
792 anchor: None,
793 anchor_reftext: None,
794 attrlist: None,
795 },),
796 Block::Simple(SimpleBlock {
797 content: Content {
798 original: Span {
799 data: "def",
800 line: 7,
801 col: 1,
802 offset: 24,
803 },
804 rendered: "def",
805 },
806 source: Span {
807 data: "def",
808 line: 7,
809 col: 1,
810 offset: 24,
811 },
812 style: SimpleBlockStyle::Paragraph,
813 title_source: None,
814 title: None,
815 caption: None,
816 number: None,
817 anchor: None,
818 anchor_reftext: None,
819 attrlist: None,
820 },),
821 ],
822 source: Span {
823 data: "\n\n= Example Title\n\nabc\n\ndef",
824 line: 1,
825 col: 1,
826 offset: 0,
827 },
828 warnings: &[],
829 source_map: SourceMap(&[]),
830 catalog: Catalog::default(),
831 }
832 );
833 }
834
835 #[test]
836 fn blank_lines_and_comment_before_header() {
837 let doc =
838 Parser::default().parse("\n// ignore this comment\n= Example Title\n\nabc\n\ndef");
839
840 assert_eq!(
841 doc,
842 Document {
843 header: Header {
844 title_source: Some(Span {
845 data: "Example Title",
846 line: 3,
847 col: 3,
848 offset: 26,
849 },),
850 title: Some("Example Title",),
851 attributes: &[],
852 author_line: None,
853 revision_line: None,
854 comments: &[Span {
855 data: "// ignore this comment",
856 line: 2,
857 col: 1,
858 offset: 1,
859 },],
860 source: Span {
861 data: "// ignore this comment\n= Example Title",
862 line: 2,
863 col: 1,
864 offset: 1,
865 },
866 },
867 blocks: &[
868 Block::Simple(SimpleBlock {
869 content: Content {
870 original: Span {
871 data: "abc",
872 line: 5,
873 col: 1,
874 offset: 41,
875 },
876 rendered: "abc",
877 },
878 source: Span {
879 data: "abc",
880 line: 5,
881 col: 1,
882 offset: 41,
883 },
884 style: SimpleBlockStyle::Paragraph,
885 title_source: None,
886 title: None,
887 caption: None,
888 number: None,
889 anchor: None,
890 anchor_reftext: None,
891 attrlist: None,
892 },),
893 Block::Simple(SimpleBlock {
894 content: Content {
895 original: Span {
896 data: "def",
897 line: 7,
898 col: 1,
899 offset: 46,
900 },
901 rendered: "def",
902 },
903 source: Span {
904 data: "def",
905 line: 7,
906 col: 1,
907 offset: 46,
908 },
909 style: SimpleBlockStyle::Paragraph,
910 title_source: None,
911 title: None,
912 caption: None,
913 number: None,
914 anchor: None,
915 anchor_reftext: None,
916 attrlist: None,
917 },),
918 ],
919 source: Span {
920 data: "\n// ignore this comment\n= Example Title\n\nabc\n\ndef",
921 line: 1,
922 col: 1,
923 offset: 0,
924 },
925 warnings: &[],
926 source_map: SourceMap(&[]),
927 catalog: Catalog::default(),
928 }
929 );
930 }
931
932 #[test]
933 fn extra_space_before_title() {
934 assert_eq!(
935 Parser::default().parse("= Example Title\n\nabc"),
936 Document {
937 header: Header {
938 title_source: Some(Span {
939 data: "Example Title",
940 line: 1,
941 col: 5,
942 offset: 4,
943 }),
944 title: Some("Example Title"),
945 attributes: &[],
946 author_line: None,
947 revision_line: None,
948 comments: &[],
949 source: Span {
950 data: "= Example Title",
951 line: 1,
952 col: 1,
953 offset: 0,
954 }
955 },
956 blocks: &[Block::Simple(SimpleBlock {
957 content: Content {
958 original: Span {
959 data: "abc",
960 line: 3,
961 col: 1,
962 offset: 19,
963 },
964 rendered: "abc",
965 },
966 source: Span {
967 data: "abc",
968 line: 3,
969 col: 1,
970 offset: 19,
971 },
972 style: SimpleBlockStyle::Paragraph,
973 title_source: None,
974 title: None,
975 caption: None,
976 number: None,
977 anchor: None,
978 anchor_reftext: None,
979 attrlist: None,
980 })],
981 source: Span {
982 data: "= Example Title\n\nabc",
983 line: 1,
984 col: 1,
985 offset: 0
986 },
987 warnings: &[],
988 source_map: SourceMap(&[]),
989 catalog: Catalog::default(),
990 }
991 );
992 }
993
994 #[test]
995 fn err_bad_header() {
996 assert_eq!(
997 Parser::default().parse(
998 "= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28\nnot an attribute\n"
999 ),
1000 Document {
1001 header: Header {
1002 title_source: Some(Span {
1003 data: "Title",
1004 line: 1,
1005 col: 3,
1006 offset: 2,
1007 }),
1008 title: Some("Title"),
1009 attributes: &[],
1010 author_line: Some(AuthorLine {
1011 authors: &[Author {
1012 name: "Jane Smith",
1013 firstname: "Jane",
1014 middlename: None,
1015 lastname: Some("Smith"),
1016 email: Some("jane@example.com"),
1017 }],
1018 source: Span {
1019 data: "Jane Smith <jane@example.com>",
1020 line: 2,
1021 col: 1,
1022 offset: 8,
1023 },
1024 }),
1025 revision_line: Some(RevisionLine {
1026 revnumber: Some("1",),
1027 revdate: "2025-09-28",
1028 revremark: None,
1029 source: Span {
1030 data: "v1, 2025-09-28",
1031 line: 3,
1032 col: 1,
1033 offset: 38,
1034 },
1035 },),
1036 comments: &[],
1037 source: Span {
1038 data: "= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28",
1039 line: 1,
1040 col: 1,
1041 offset: 0,
1042 }
1043 },
1044 blocks: &[Block::Simple(SimpleBlock {
1045 content: Content {
1046 original: Span {
1047 data: "not an attribute",
1048 line: 4,
1049 col: 1,
1050 offset: 53,
1051 },
1052 rendered: "not an attribute",
1053 },
1054 source: Span {
1055 data: "not an attribute",
1056 line: 4,
1057 col: 1,
1058 offset: 53,
1059 },
1060 style: SimpleBlockStyle::Paragraph,
1061 title_source: None,
1062 title: None,
1063 caption: None,
1064 number: None,
1065 anchor: None,
1066 anchor_reftext: None,
1067 attrlist: None,
1068 })],
1069 source: Span {
1070 data: "= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28\nnot an attribute",
1071 line: 1,
1072 col: 1,
1073 offset: 0
1074 },
1075 warnings: &[Warning {
1076 source: Span {
1077 data: "not an attribute",
1078 line: 4,
1079 col: 1,
1080 offset: 53,
1081 },
1082 warning: WarningType::DocumentHeaderNotTerminated,
1083 },],
1084 source_map: SourceMap(&[]),
1085 catalog: Catalog::default(),
1086 }
1087 );
1088 }
1089
1090 #[test]
1091 fn err_bad_header_and_bad_macro() {
1092 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]");
1093
1094 assert_eq!(
1095 Document {
1096 header: Header {
1097 title_source: Some(Span {
1098 data: "Title",
1099 line: 1,
1100 col: 3,
1101 offset: 2,
1102 }),
1103 title: Some("Title"),
1104 attributes: &[],
1105 author_line: Some(AuthorLine {
1106 authors: &[Author {
1107 name: "Jane Smith",
1108 firstname: "Jane",
1109 middlename: None,
1110 lastname: Some("Smith"),
1111 email: Some("jane@example.com"),
1112 }],
1113 source: Span {
1114 data: "Jane Smith <jane@example.com>",
1115 line: 2,
1116 col: 1,
1117 offset: 8,
1118 },
1119 }),
1120 revision_line: Some(RevisionLine {
1121 revnumber: Some("1"),
1122 revdate: "2025-09-28",
1123 revremark: None,
1124 source: Span {
1125 data: "v1, 2025-09-28",
1126 line: 3,
1127 col: 1,
1128 offset: 38,
1129 },
1130 },),
1131 comments: &[],
1132 source: Span {
1133 data: "= Title\nJane Smith <jane@example.com>\nv1, 2025-09-28",
1134 line: 1,
1135 col: 1,
1136 offset: 0,
1137 }
1138 },
1139 blocks: &[
1140 Block::Preamble(Preamble {
1141 blocks: &[Block::Simple(SimpleBlock {
1142 content: Content {
1143 original: Span {
1144 data: "not an attribute",
1145 line: 4,
1146 col: 1,
1147 offset: 53,
1148 },
1149 rendered: "not an attribute",
1150 },
1151 source: Span {
1152 data: "not an attribute",
1153 line: 4,
1154 col: 1,
1155 offset: 53,
1156 },
1157 style: SimpleBlockStyle::Paragraph,
1158 title_source: None,
1159 title: None,
1160 caption: None,
1161 number: None,
1162 anchor: None,
1163 anchor_reftext: None,
1164 attrlist: None,
1165 },),],
1166 source: Span {
1167 data: "not an attribute",
1168 line: 4,
1169 col: 1,
1170 offset: 53,
1171 },
1172 },),
1173 Block::Section(SectionBlock {
1174 level: 1,
1175 section_title: Content {
1176 original: Span {
1177 data: "Section Title",
1178 line: 6,
1179 col: 4,
1180 offset: 74,
1181 },
1182 rendered: "Section Title",
1183 },
1184 blocks: &[Block::Media(MediaBlock {
1185 type_: MediaType::Image,
1186 target: Span {
1187 data: "bar",
1188 line: 8,
1189 col: 8,
1190 offset: 96,
1191 },
1192 macro_attrlist: Attrlist {
1193 attributes: &[
1194 ElementAttribute {
1195 name: Some("alt"),
1196 shorthand_items: &[],
1197 value: "Sunset"
1198 },
1199 ElementAttribute {
1200 name: Some("width"),
1201 shorthand_items: &[],
1202 value: "300"
1203 },
1204 ElementAttribute {
1205 name: Some("height"),
1206 shorthand_items: &[],
1207 value: "400"
1208 },
1209 ],
1210 anchor: None,
1211 source: Span {
1212 data: "alt=Sunset,width=300,,height=400",
1213 line: 8,
1214 col: 12,
1215 offset: 100,
1216 },
1217 },
1218 source: Span {
1219 data: "image::bar[alt=Sunset,width=300,,height=400]",
1220 line: 8,
1221 col: 1,
1222 offset: 89,
1223 },
1224 title_source: None,
1225 title: None,
1226 caption: None,
1227 number: None,
1228 anchor: None,
1229 anchor_reftext: None,
1230 attrlist: None,
1231 },),],
1232 source: Span {
1233 data: "== Section Title\n\nimage::bar[alt=Sunset,width=300,,height=400]",
1234 line: 6,
1235 col: 1,
1236 offset: 71,
1237 },
1238 title_source: None,
1239 title: None,
1240 anchor: None,
1241 anchor_reftext: None,
1242 attrlist: None,
1243 section_type: SectionType::Normal,
1244 section_id: Some("_section_title"),
1245 caption: None,
1246 section_number: None,
1247 },)
1248 ],
1249 source: Span {
1250 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]",
1251 line: 1,
1252 col: 1,
1253 offset: 0
1254 },
1255 warnings: &[
1256 Warning {
1257 source: Span {
1258 data: "not an attribute",
1259 line: 4,
1260 col: 1,
1261 offset: 53,
1262 },
1263 warning: WarningType::DocumentHeaderNotTerminated,
1264 },
1265 Warning {
1266 source: Span {
1267 data: "alt=Sunset,width=300,,height=400",
1268 line: 8,
1269 col: 12,
1270 offset: 100,
1271 },
1272 warning: WarningType::EmptyAttributeValue,
1273 },
1274 ],
1275 source_map: SourceMap(&[]),
1276 catalog: Catalog {
1277 refs: HashMap::from([(
1278 "_section_title",
1279 RefEntry {
1280 id: "_section_title",
1281 reftext: Some("Section Title",),
1282 ref_type: RefType::Section,
1283 }
1284 ),]),
1285 reftext_to_id: HashMap::from([("Section Title", "_section_title"),]),
1286 }
1287 },
1288 doc
1289 );
1290 }
1291
1292 #[test]
1293 fn impl_debug() {
1294 let doc = Parser::default().parse("= Example Title\n\nabc\n\ndef");
1295
1296 assert_eq!(
1297 format!("{doc:#?}"),
1298 r#"Document {
1299 header: Header {
1300 title_source: Some(
1301 Span {
1302 data: "Example Title",
1303 line: 1,
1304 col: 3,
1305 offset: 2,
1306 },
1307 ),
1308 title: Some(
1309 "Example Title",
1310 ),
1311 attributes: &[],
1312 author_line: None,
1313 revision_line: None,
1314 comments: &[],
1315 source: Span {
1316 data: "= Example Title",
1317 line: 1,
1318 col: 1,
1319 offset: 0,
1320 },
1321 },
1322 blocks: &[
1323 Block::Simple(
1324 SimpleBlock {
1325 content: Content {
1326 original: Span {
1327 data: "abc",
1328 line: 3,
1329 col: 1,
1330 offset: 17,
1331 },
1332 rendered: "abc",
1333 },
1334 source: Span {
1335 data: "abc",
1336 line: 3,
1337 col: 1,
1338 offset: 17,
1339 },
1340 style: SimpleBlockStyle::Paragraph,
1341 title_source: None,
1342 title: None,
1343 caption: None,
1344 number: None,
1345 anchor: None,
1346 anchor_reftext: None,
1347 attrlist: None,
1348 },
1349 ),
1350 Block::Simple(
1351 SimpleBlock {
1352 content: Content {
1353 original: Span {
1354 data: "def",
1355 line: 5,
1356 col: 1,
1357 offset: 22,
1358 },
1359 rendered: "def",
1360 },
1361 source: Span {
1362 data: "def",
1363 line: 5,
1364 col: 1,
1365 offset: 22,
1366 },
1367 style: SimpleBlockStyle::Paragraph,
1368 title_source: None,
1369 title: None,
1370 caption: None,
1371 number: None,
1372 anchor: None,
1373 anchor_reftext: None,
1374 attrlist: None,
1375 },
1376 ),
1377 ],
1378 source: Span {
1379 data: "= Example Title\n\nabc\n\ndef",
1380 line: 1,
1381 col: 1,
1382 offset: 0,
1383 },
1384 warnings: &[],
1385 source_map: SourceMap(&[]),
1386 catalog: Catalog {
1387 refs: HashMap::from([]),
1388 reftext_to_id: HashMap::from([]),
1389 footnotes: [],
1390 },
1391}"#
1392 );
1393 }
1394}