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, 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}