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