Skip to main content

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}