Skip to main content

asciidoc_parser/blocks/
simple.rs

1use crate::{
2    HasSpan, Parser, Span,
3    attributes::Attrlist,
4    blocks::{
5        CompoundDelimitedBlock, ContentModel, IsBlock, ListItemMarker, RawDelimitedBlock,
6        caption::assign_block_caption, metadata::BlockMetadata,
7    },
8    content::{Content, SubstitutionGroup},
9    span::MatchedItem,
10    strings::CowStr,
11};
12
13/// The style of a simple block.
14#[derive(Clone, Copy, Eq, PartialEq)]
15pub enum SimpleBlockStyle {
16    /// A paragraph block with normal substitutions.
17    Paragraph,
18
19    /// A literal block with no substitutions.
20    Literal,
21
22    /// Blocks and paragraphs assigned the listing style display their rendered
23    /// content exactly as you see it in the source. Listing content is
24    /// converted to preformatted text (i.e., `<pre>`). The content is presented
25    /// in a fixed-width font and endlines are preserved. Only [special
26    /// characters] and callouts are replaced when the document is converted.
27    ///
28    /// [special characters]: https://docs.asciidoctor.org/asciidoc/latest/subs/special-characters/
29    Listing,
30
31    /// A source block is a specialization of a listing block. Developers are
32    /// accustomed to seeing source code colorized to emphasize the code’s
33    /// structure (i.e., keywords, types, delimiters, etc.).
34    Source,
35}
36
37impl std::fmt::Debug for SimpleBlockStyle {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            SimpleBlockStyle::Paragraph => write!(f, "SimpleBlockStyle::Paragraph"),
41            SimpleBlockStyle::Literal => write!(f, "SimpleBlockStyle::Literal"),
42            SimpleBlockStyle::Listing => write!(f, "SimpleBlockStyle::Listing"),
43            SimpleBlockStyle::Source => write!(f, "SimpleBlockStyle::Source"),
44        }
45    }
46}
47
48/// A block that's treated as contiguous lines of paragraph text (and subject to
49/// normal substitutions) (e.g., a paragraph block).
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct SimpleBlock<'src> {
52    content: Content<'src>,
53    source: Span<'src>,
54    style: SimpleBlockStyle,
55    title_source: Option<Span<'src>>,
56    title: Option<Content<'src>>,
57    caption: Option<String>,
58    number: Option<usize>,
59    anchor: Option<Span<'src>>,
60    anchor_reftext: Option<Span<'src>>,
61    attrlist: Option<Attrlist<'src>>,
62}
63
64impl<'src> SimpleBlock<'src> {
65    /// Returns the block's title as a mutable [`Content`], if the block has
66    /// one.
67    ///
68    /// This narrow seam exists for the document-order title resolution pass
69    /// (see `document::title_refs`), which installs the re-rendered title
70    /// after resolving any cross-references embedded in it. All other access
71    /// goes through the read-only [`IsBlock::title`] accessor.
72    pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
73        self.title.as_mut()
74    }
75
76    pub(crate) fn parse(
77        metadata: &BlockMetadata<'src>,
78        parser: &mut Parser,
79    ) -> Option<MatchedItem<'src, Self>> {
80        let MatchedItem {
81            item: (content, style),
82            after,
83        } = parse_lines(
84            metadata.block_start,
85            &metadata.attrlist,
86            false,
87            false,
88            false,
89            parser,
90            &[],
91        )?;
92
93        // A paragraph carrying a captionable block style (e.g. `[example]`) is
94        // captioned and numbered just like its delimited counterpart. The raw
95        // context is `paragraph`; `assign_block_caption` resolves the block
96        // style to the captioning context.
97        let caption = assign_block_caption(
98            parser,
99            "paragraph",
100            metadata.attrlist.as_ref(),
101            metadata.title.is_some(),
102        );
103        let number = caption.as_ref().and_then(|caption| caption.number);
104        let caption = caption.map(|caption| caption.prefix);
105
106        Some(MatchedItem {
107            item: Self {
108                content,
109                source: metadata
110                    .source
111                    .trim_remainder(after)
112                    .trim_trailing_whitespace(),
113                style,
114                title_source: metadata.title_source,
115                title: metadata.title.clone(),
116                caption,
117                number,
118                anchor: metadata.anchor,
119                anchor_reftext: metadata.anchor_reftext,
120                attrlist: metadata.attrlist.clone(),
121            },
122            after: after.discard_empty_lines(),
123        })
124    }
125
126    pub(crate) fn parse_for_list_item(
127        metadata: &BlockMetadata<'src>,
128        parser: &mut Parser,
129        is_continuation: bool,
130        parent_list_markers: &[ListItemMarker<'src>],
131    ) -> Option<MatchedItem<'src, Self>> {
132        let MatchedItem {
133            item: (content, style),
134            after,
135        } = parse_lines(
136            metadata.block_start,
137            &metadata.attrlist,
138            true,
139            false,
140            is_continuation,
141            parser,
142            parent_list_markers,
143        )?;
144
145        // A paragraph carrying a captionable block style (e.g. `[example]`) is
146        // captioned and numbered just like its delimited counterpart. The raw
147        // context is `paragraph`; `assign_block_caption` resolves the block
148        // style to the captioning context.
149        let caption = assign_block_caption(
150            parser,
151            "paragraph",
152            metadata.attrlist.as_ref(),
153            metadata.title.is_some(),
154        );
155        let number = caption.as_ref().and_then(|caption| caption.number);
156        let caption = caption.map(|caption| caption.prefix);
157
158        Some(MatchedItem {
159            item: Self {
160                content,
161                source: metadata
162                    .source
163                    .trim_remainder(after)
164                    .trim_trailing_whitespace(),
165                style,
166                title_source: metadata.title_source,
167                title: metadata.title.clone(),
168                caption,
169                number,
170                anchor: metadata.anchor,
171                anchor_reftext: metadata.anchor_reftext,
172                attrlist: metadata.attrlist.clone(),
173            },
174            after,
175        })
176    }
177
178    /// Parse a simple block for use in a definition list item.
179    ///
180    /// In definition lists, indented content is treated as a paragraph
181    /// with the indentation stripped, not as a literal block.
182    pub(crate) fn parse_for_definition_list(
183        metadata: &BlockMetadata<'src>,
184        parser: &mut Parser,
185    ) -> Option<MatchedItem<'src, Self>> {
186        let MatchedItem {
187            item: (content, style),
188            after,
189        } = parse_lines(
190            metadata.block_start,
191            &metadata.attrlist,
192            true,
193            true,
194            false,
195            parser,
196            &[],
197        )?;
198
199        // A paragraph carrying a captionable block style (e.g. `[example]`) is
200        // captioned and numbered just like its delimited counterpart. The raw
201        // context is `paragraph`; `assign_block_caption` resolves the block
202        // style to the captioning context.
203        let caption = assign_block_caption(
204            parser,
205            "paragraph",
206            metadata.attrlist.as_ref(),
207            metadata.title.is_some(),
208        );
209        let number = caption.as_ref().and_then(|caption| caption.number);
210        let caption = caption.map(|caption| caption.prefix);
211
212        Some(MatchedItem {
213            item: Self {
214                content,
215                source: metadata
216                    .source
217                    .trim_remainder(after)
218                    .trim_trailing_whitespace(),
219                style,
220                title_source: metadata.title_source,
221                title: metadata.title.clone(),
222                caption,
223                number,
224                anchor: metadata.anchor,
225                anchor_reftext: metadata.anchor_reftext,
226                attrlist: metadata.attrlist.clone(),
227            },
228            after,
229        })
230    }
231
232    pub(crate) fn parse_fast(
233        source: Span<'src>,
234        parser: &Parser,
235    ) -> Option<MatchedItem<'src, Self>> {
236        let MatchedItem {
237            item: (content, style),
238            after,
239        } = parse_lines(source, &None, false, false, false, parser, &[])?;
240
241        let source = content.original();
242
243        Some(MatchedItem {
244            item: Self {
245                content,
246                source,
247                style,
248                title_source: None,
249                title: None,
250                caption: None,
251                number: None,
252                anchor: None,
253                anchor_reftext: None,
254                attrlist: None,
255            },
256            after: after.discard_empty_lines(),
257        })
258    }
259
260    /// Return the interpreted content of this block.
261    pub fn content(&self) -> &Content<'src> {
262        &self.content
263    }
264
265    /// Return the style of this block.
266    pub fn style(&self) -> SimpleBlockStyle {
267        self.style
268    }
269}
270
271/// Parse the content-bearing lines for this block.
272///
273/// If `force_paragraph_style` is true, indented content is treated as a
274/// paragraph (with indentation stripped) rather than as a literal block. This
275/// is used for definition list items where indentation is purely visual
276/// formatting.
277///
278/// If `preserve_literal_indent` is true and the content is a literal block,
279/// indentation is preserved as-is (used for `+` continuation content).
280fn parse_lines<'src>(
281    source: Span<'src>,
282    attrlist: &Option<Attrlist<'src>>,
283    mut stop_for_list_items: bool,
284    force_paragraph_style: bool,
285    preserve_literal_indent: bool,
286    parser: &Parser,
287    parent_list_markers: &[ListItemMarker<'src>],
288) -> Option<MatchedItem<'src, (Content<'src>, SimpleBlockStyle)>> {
289    let source_after_whitespace = source.discard_whitespace();
290    let first_line_indent = source_after_whitespace.col() - 1;
291
292    // Track if we're in "indented literal" mode (literal style from indentation).
293    // In this mode, we should still stop for list markers that are NOT indented.
294    let mut indented_literal_mode = false;
295
296    let mut style = if source_after_whitespace.col() == source.col() || force_paragraph_style {
297        // When force_paragraph_style is true, we still need to track that the content
298        // is indented so we can properly stop at unindented list markers.
299        if source_after_whitespace.col() != source.col() {
300            indented_literal_mode = true;
301        }
302        SimpleBlockStyle::Paragraph
303    } else {
304        // Indented content treated as literal: don't stop for list markers
305        // (they become part of the literal content).
306        stop_for_list_items = false;
307        SimpleBlockStyle::Literal
308    };
309
310    // Block style can override the interpretation of literal from reading
311    // indentation.
312    if let Some(attrlist) = attrlist {
313        match attrlist.block_style() {
314            Some("normal") => {
315                style = SimpleBlockStyle::Paragraph;
316            }
317
318            Some("literal") => {
319                stop_for_list_items = false;
320                indented_literal_mode = false;
321                style = SimpleBlockStyle::Literal;
322            }
323
324            Some("listing") => {
325                stop_for_list_items = false;
326                indented_literal_mode = false;
327                style = SimpleBlockStyle::Listing;
328            }
329
330            Some("source") => {
331                stop_for_list_items = false;
332                indented_literal_mode = false;
333                style = SimpleBlockStyle::Source;
334            }
335
336            _ => {}
337        }
338    }
339
340    // A `[comment]` paragraph is raw: its content is retained verbatim and not
341    // interpreted, so (like the `////` and `[comment]` open-block forms) inner
342    // `//` lines must be preserved rather than stripped as line comments.
343    let comment_style = is_comment_style(attrlist.as_ref());
344
345    let mut next = source;
346    let mut filtered_lines: Vec<&'src str> = vec![];
347    // Source span of each surviving line, kept in lockstep with `filtered_lines`
348    // so the attribute-references substitution can locate an
349    // `attribute-missing=warn` warning at the precise source offset of the
350    // offending reference (see `Content::from_filtered_lines`).
351    let mut filtered_line_spans: Vec<Span<'src>> = vec![];
352    let mut skipped_comment_line = false;
353
354    // Determine how much indentation to strip from literal paragraphs.
355    // In definition list continuations, use minimum indentation across all
356    // lines to preserve relative indent. In outline list continuations and
357    // non-continuation contexts, strip based on the first line's indent.
358    let in_definition_list = parent_list_markers
359        .iter()
360        .any(|m| matches!(m, ListItemMarker::DefinedTerm { .. }));
361
362    let strip_indent =
363        if preserve_literal_indent && style == SimpleBlockStyle::Literal && in_definition_list {
364            // Two-pass approach: find minimum indentation across all lines.
365            let mut scan = source;
366            let mut min_indent = first_line_indent;
367            let mut line_count = 0;
368
369            while let Some(line_mi) = scan.take_non_empty_line() {
370                let line = line_mi.item;
371
372                // Apply same stop conditions as the main loop.
373                if line_count > 0 && line.data() == "+" {
374                    break;
375                }
376
377                if let Some(n) = line.position(|c| c != ' ' && c != '\t') {
378                    min_indent = min_indent.min(n);
379                }
380
381                line_count += 1;
382                scan = line_mi.after;
383            }
384            min_indent
385        } else {
386            first_line_indent
387        };
388
389    while let Some(line_mi) = next.take_non_empty_line() {
390        let mut line = line_mi.item;
391
392        // If we've skipped a comment line and this is a section header, stop here
393        // so the section can be parsed as a separate block. Only do this at the
394        // top level (not inside lists), indicated by stop_for_list_items being false.
395        if !stop_for_list_items
396            && skipped_comment_line
397            && style == SimpleBlockStyle::Paragraph
398            && is_section_header(line.data(), parser.level_offset())
399        {
400            break;
401        }
402
403        // There are several stop conditions for simple paragraph blocks. These
404        // "shouldn't" be encountered on the first line (we shouldn't be calling
405        // `SimpleBlock::parse` in these conditions), but in case it is, we simply
406        // ignore them on the first line.
407        if !filtered_lines.is_empty() {
408            // In indented literal mode, only stop for list markers that are NOT indented
409            // (at column 1). This allows definition list items to be properly separated.
410            let should_check_for_list_marker =
411                stop_for_list_items && (!indented_literal_mode || line.col() == 1);
412
413            // If we've already started accumulating content for this list item paragraph,
414            // we don't stop for list markers at any level other than our own or a parent
415            // level.
416            if should_check_for_list_marker
417                && let Some(marker_mi) = ListItemMarker::parse(line, parser)
418            {
419                // In description list continuation context, don't stop for
420                // deeper-nested description list markers (e.g., ::: when the
421                // current context is ::). They are treated as paragraph text.
422                let is_ancestor_list = parent_list_markers
423                    .iter()
424                    .any(|p| p.is_match_for(&marker_mi.item));
425
426                if is_ancestor_list || !preserve_literal_indent {
427                    break;
428                }
429            }
430
431            if line.data() == "+" {
432                break;
433            }
434
435            if line.starts_with('[') && line.ends_with(']') {
436                break;
437            }
438
439            if (line.starts_with('/')
440                || line.starts_with('-')
441                || line.starts_with('.')
442                || line.starts_with('+')
443                || line.starts_with('=')
444                || line.starts_with('*')
445                || line.starts_with('_')
446                || line.starts_with('`'))
447                && (RawDelimitedBlock::is_valid_delimiter(&line)
448                    || CompoundDelimitedBlock::is_valid_delimiter(&line))
449            {
450                break;
451            }
452        }
453
454        next = line_mi.after;
455
456        // Only strip comment lines in paragraph style. In literal/listing/source
457        // blocks, "//" lines are preserved as content; likewise a `[comment]`
458        // paragraph retains its content verbatim (raw).
459        if !comment_style
460            && style == SimpleBlockStyle::Paragraph
461            && line.starts_with("//")
462            && !line.starts_with("///")
463        {
464            skipped_comment_line = true;
465            continue;
466        }
467
468        // Strip at most the calculated indentation amount.
469        let should_strip_indent = strip_indent > 0;
470
471        if should_strip_indent && let Some(n) = line.position(|c| c != ' ' && c != '\t') {
472            line = line.into_parse_result(n.min(strip_indent)).after;
473        };
474
475        let line = line.trim_trailing_whitespace();
476        filtered_line_spans.push(line);
477        filtered_lines.push(line.data());
478    }
479
480    let source = source.trim_remainder(next).trim_trailing_whitespace();
481    if source.is_empty() {
482        return None;
483    }
484
485    let mut content: Content<'src> =
486        Content::from_filtered_lines(source, &filtered_lines, filtered_line_spans);
487
488    // A `[comment]`-styled paragraph is the single-paragraph form of a comment
489    // block. Its content is retained in the parsed model (this parser does not
490    // discard comments) but is not interpreted: no substitutions are applied,
491    // matching the raw content model of a `////` comment block.
492    let sub_group = if comment_style {
493        SubstitutionGroup::None
494    } else {
495        base_substitution_group(style).override_via_attrlist(attrlist.as_ref(), Some(parser))
496    };
497
498    sub_group.apply(&mut content, parser, attrlist.as_ref());
499
500    Some(MatchedItem {
501        item: (content, style),
502        after: next,
503    })
504}
505
506/// The base substitution group for a simple block of the given style, before
507/// any `subs` override from the attribute list is applied.
508///
509/// Only literal paragraphs (detected by indentation) use verbatim
510/// substitutions; the listing and source styles declared via an attribute list
511/// still use normal substitutions.
512fn base_substitution_group(style: SimpleBlockStyle) -> SubstitutionGroup {
513    match style {
514        SimpleBlockStyle::Literal => SubstitutionGroup::Verbatim,
515        SimpleBlockStyle::Listing | SimpleBlockStyle::Source | SimpleBlockStyle::Paragraph => {
516            SubstitutionGroup::Normal
517        }
518    }
519}
520
521/// Returns `true` if this paragraph carries the `comment` block style (i.e.
522/// `[comment]`), making it a comment paragraph whose content is retained but
523/// not interpreted.
524fn is_comment_style(attrlist: Option<&Attrlist<'_>>) -> bool {
525    attrlist.and_then(|attrlist| attrlist.block_style()) == Some("comment")
526}
527
528impl<'src> IsBlock<'src> for SimpleBlock<'src> {
529    fn content_model(&self) -> ContentModel {
530        ContentModel::Simple
531    }
532
533    fn content_mut(&mut self) -> Option<&mut Content<'src>> {
534        Some(&mut self.content)
535    }
536
537    fn rendered_content(&self) -> Option<&str> {
538        Some(self.content.rendered())
539    }
540
541    fn raw_context(&self) -> CowStr<'src> {
542        "paragraph".into()
543    }
544
545    fn title_source(&'src self) -> Option<Span<'src>> {
546        self.title_source
547    }
548
549    fn title(&self) -> Option<&str> {
550        self.title.as_ref().map(Content::rendered_str)
551    }
552
553    fn caption(&self) -> Option<&str> {
554        self.caption.as_deref()
555    }
556
557    fn number(&self) -> Option<usize> {
558        self.number
559    }
560
561    fn anchor(&'src self) -> Option<Span<'src>> {
562        self.anchor
563    }
564
565    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
566        self.anchor_reftext
567    }
568
569    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
570        self.attrlist.as_ref()
571    }
572
573    fn substitution_group(&'src self) -> SubstitutionGroup {
574        // Mirror the group actually applied to this block's content in
575        // `parse_lines`: a `[comment]` paragraph is raw (no substitutions),
576        // otherwise the base group derived from the style, then any `subs`
577        // override from the attribute list.
578        if is_comment_style(self.attrlist.as_ref()) {
579            SubstitutionGroup::None
580        } else {
581            base_substitution_group(self.style).override_via_attrlist(self.attrlist.as_ref(), None)
582        }
583    }
584}
585
586impl<'src> HasSpan<'src> for SimpleBlock<'src> {
587    fn span(&self) -> Span<'src> {
588        self.source
589    }
590}
591
592/// Reports whether `line` is a section heading (so a paragraph that has already
593/// swallowed a leading comment line must stop before it, letting the heading be
594/// parsed as its own section).
595///
596/// `level_offset` is the running `leveloffset` document attribute, which folds
597/// into the heading's effective level exactly as in
598/// [`SectionBlock::parse`](crate::blocks::SectionBlock). A heading of two or
599/// more markers (`== `/`## `, effective level 1+, or clamped up to 1 under a
600/// negative offset) is always a section. A single-marker heading (`= `/`# `) is
601/// a document title rather than a section *unless* a positive offset lifts it
602/// to level 1 or beyond — mirroring the level-0 rule in
603/// [`parse_title_line`](crate::blocks::section).
604pub(crate) fn is_section_header(line: &str, level_offset: i32) -> bool {
605    // AsciiDoc `=` style or Markdown `#` style.
606    let rest = if line.starts_with('=') {
607        line.trim_start_matches('=')
608    } else if line.starts_with('#') {
609        line.trim_start_matches('#')
610    } else {
611        return false;
612    };
613
614    // A section marker is one to six characters followed by a blank (space or
615    // tab), matching the whitespace `parse_title_line` requires after the
616    // marker (`take_required_whitespace`).
617    let count = line.len() - rest.len();
618    if count == 0 || count > 6 || !rest.starts_with([' ', '\t']) {
619        return false;
620    }
621
622    // A bare `=`/`#` (syntactic level 0) heads a section only when a positive
623    // `leveloffset` promotes it to level 1 or deeper; otherwise it is a
624    // document title, not a section. Any deeper marker is always a section (a
625    // negative offset that would push it below level 1 is clamped, not
626    // rejected).
627    let syntactic_level = (count - 1) as i32;
628    syntactic_level > 0 || syntactic_level.saturating_add(level_offset) >= 1
629}
630
631#[cfg(test)]
632mod tests {
633    #![allow(clippy::unwrap_used)]
634
635    use std::ops::Deref;
636
637    use crate::{
638        blocks::{ContentModel, SimpleBlockStyle, metadata::BlockMetadata},
639        tests::prelude::*,
640    };
641
642    #[test]
643    fn impl_clone() {
644        // Silly test to mark the #[derive(...)] line as covered.
645        let mut parser = Parser::default();
646
647        let b1 =
648            crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc"), &mut parser).unwrap();
649
650        let b2 = b1.item.clone();
651        assert_eq!(b1.item, b2);
652    }
653
654    #[test]
655    fn style_enum_impl_debug() {
656        assert_eq!(
657            format!("{:?}", SimpleBlockStyle::Paragraph),
658            "SimpleBlockStyle::Paragraph"
659        );
660
661        assert_eq!(
662            format!("{:?}", SimpleBlockStyle::Literal),
663            "SimpleBlockStyle::Literal"
664        );
665
666        assert_eq!(
667            format!("{:?}", SimpleBlockStyle::Listing),
668            "SimpleBlockStyle::Listing"
669        );
670
671        assert_eq!(
672            format!("{:?}", SimpleBlockStyle::Source),
673            "SimpleBlockStyle::Source"
674        );
675    }
676
677    #[test]
678    fn empty_source() {
679        let mut parser = Parser::default();
680        assert!(crate::blocks::SimpleBlock::parse(&BlockMetadata::new(""), &mut parser).is_none());
681    }
682
683    #[test]
684    fn only_spaces() {
685        let mut parser = Parser::default();
686        assert!(
687            crate::blocks::SimpleBlock::parse(&BlockMetadata::new("    "), &mut parser).is_none()
688        );
689    }
690
691    #[test]
692    fn single_line() {
693        let mut parser = Parser::default();
694        let mi =
695            crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc"), &mut parser).unwrap();
696
697        assert_eq!(
698            mi.item,
699            SimpleBlock {
700                content: Content {
701                    original: Span {
702                        data: "abc",
703                        line: 1,
704                        col: 1,
705                        offset: 0,
706                    },
707                    rendered: "abc",
708                },
709                source: Span {
710                    data: "abc",
711                    line: 1,
712                    col: 1,
713                    offset: 0,
714                },
715                style: SimpleBlockStyle::Paragraph,
716                title_source: None,
717                title: None,
718                caption: None,
719                number: None,
720                anchor: None,
721                anchor_reftext: None,
722                attrlist: None,
723            },
724        );
725
726        assert_eq!(mi.item.content_model(), ContentModel::Simple);
727        assert_eq!(mi.item.rendered_content().unwrap(), "abc");
728        assert_eq!(mi.item.raw_context().deref(), "paragraph");
729        assert_eq!(mi.item.resolved_context().deref(), "paragraph");
730        assert!(mi.item.declared_style().is_none());
731        assert!(mi.item.id().is_none());
732        assert!(mi.item.roles().is_empty());
733        assert!(mi.item.options().is_empty());
734        assert!(mi.item.title_source().is_none());
735        assert!(mi.item.title().is_none());
736        assert!(mi.item.anchor().is_none());
737        assert!(mi.item.anchor_reftext().is_none());
738        assert!(mi.item.attrlist().is_none());
739        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
740
741        assert_eq!(
742            mi.after,
743            Span {
744                data: "",
745                line: 1,
746                col: 4,
747                offset: 3
748            }
749        );
750    }
751
752    #[test]
753    fn multiple_lines() {
754        let mut parser = Parser::default();
755        let mi = crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc\ndef"), &mut parser)
756            .unwrap();
757
758        assert_eq!(
759            mi.item,
760            SimpleBlock {
761                content: Content {
762                    original: Span {
763                        data: "abc\ndef",
764                        line: 1,
765                        col: 1,
766                        offset: 0,
767                    },
768                    rendered: "abc\ndef",
769                },
770                source: Span {
771                    data: "abc\ndef",
772                    line: 1,
773                    col: 1,
774                    offset: 0,
775                },
776                style: SimpleBlockStyle::Paragraph,
777                title_source: None,
778                title: None,
779                caption: None,
780                number: None,
781                anchor: None,
782                anchor_reftext: None,
783                attrlist: None,
784            }
785        );
786
787        assert_eq!(
788            mi.after,
789            Span {
790                data: "",
791                line: 2,
792                col: 4,
793                offset: 7
794            }
795        );
796
797        assert_eq!(mi.item.rendered_content().unwrap(), "abc\ndef");
798    }
799
800    #[test]
801    fn consumes_blank_lines_after() {
802        let mut parser = Parser::default();
803        let mi = crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc\n\ndef"), &mut parser)
804            .unwrap();
805
806        assert_eq!(
807            mi.item,
808            SimpleBlock {
809                content: Content {
810                    original: Span {
811                        data: "abc",
812                        line: 1,
813                        col: 1,
814                        offset: 0,
815                    },
816                    rendered: "abc",
817                },
818                source: Span {
819                    data: "abc",
820                    line: 1,
821                    col: 1,
822                    offset: 0,
823                },
824                style: SimpleBlockStyle::Paragraph,
825                title_source: None,
826                title: None,
827                caption: None,
828                number: None,
829                anchor: None,
830                anchor_reftext: None,
831                attrlist: None,
832            }
833        );
834
835        assert_eq!(
836            mi.after,
837            Span {
838                data: "def",
839                line: 3,
840                col: 1,
841                offset: 5
842            }
843        );
844    }
845
846    #[test]
847    fn overrides_sub_group_via_subs_attribute() {
848        let mut parser = Parser::default();
849        let mi = crate::blocks::SimpleBlock::parse(
850            &BlockMetadata::new("[subs=quotes]\na<b>c *bold*\n\ndef"),
851            &mut parser,
852        )
853        .unwrap();
854
855        assert_eq!(
856            mi.item,
857            SimpleBlock {
858                content: Content {
859                    original: Span {
860                        data: "a<b>c *bold*",
861                        line: 2,
862                        col: 1,
863                        offset: 14,
864                    },
865                    rendered: "a<b>c <strong>bold</strong>",
866                },
867                source: Span {
868                    data: "[subs=quotes]\na<b>c *bold*",
869                    line: 1,
870                    col: 1,
871                    offset: 0,
872                },
873                style: SimpleBlockStyle::Paragraph,
874                title_source: None,
875                title: None,
876                caption: None,
877                number: None,
878                anchor: None,
879                anchor_reftext: None,
880                attrlist: Some(Attrlist {
881                    attributes: &[ElementAttribute {
882                        name: Some("subs"),
883                        value: "quotes",
884                        shorthand_items: &[],
885                    },],
886                    anchor: None,
887                    source: Span {
888                        data: "subs=quotes",
889                        line: 1,
890                        col: 2,
891                        offset: 1,
892                    },
893                },),
894            }
895        );
896
897        assert_eq!(
898            mi.after,
899            Span {
900                data: "def",
901                line: 4,
902                col: 1,
903                offset: 28
904            }
905        );
906
907        assert_eq!(
908            mi.item.rendered_content().unwrap(),
909            "a<b>c <strong>bold</strong>"
910        );
911    }
912
913    mod is_section_header {
914        use super::super::is_section_header;
915
916        #[test]
917        fn multi_marker_is_always_a_section_regardless_of_offset() {
918            // Two or more markers followed by a space are always a section, at
919            // any offset (a negative offset that would push the level below 1 is
920            // clamped in `parse_title_line`, not rejected).
921            assert!(is_section_header("== Section", 0));
922            assert!(is_section_header("=== Section", 0));
923            assert!(is_section_header("## Section", 0));
924            assert!(is_section_header("== Section", -1));
925        }
926
927        #[test]
928        fn single_marker_is_a_section_only_under_positive_offset() {
929            // A bare `=`/`#` is a document title, not a section, unless a
930            // positive `leveloffset` promotes it to level 1 or beyond.
931            assert!(!is_section_header("= Title", 0));
932            assert!(!is_section_header("# Title", 0));
933            assert!(is_section_header("= Title", 1));
934            assert!(is_section_header("# Title", 1));
935            assert!(is_section_header("= Title", 2));
936        }
937
938        #[test]
939        fn requires_a_blank_after_the_marker() {
940            assert!(!is_section_header("==nospace", 0));
941            assert!(!is_section_header("=nospace", 1));
942            assert!(!is_section_header("##nospace", 0));
943        }
944
945        #[test]
946        fn accepts_a_tab_after_the_marker() {
947            // `parse_title_line` accepts a tab (not just a space) after the
948            // marker, so the lookahead must too, or a valid tab-delimited
949            // heading would be swallowed as paragraph text.
950            assert!(is_section_header("==\tSection", 0));
951            assert!(is_section_header("=\tSection", 1));
952            assert!(!is_section_header("=\tSection", 0));
953        }
954
955        #[test]
956        fn non_marker_and_over_long_marker_are_not_sections() {
957            assert!(!is_section_header("paragraph", 1));
958            // Seven markers exceed the level-5 maximum, so this is not a heading.
959            assert!(!is_section_header("======= Too deep", 0));
960        }
961    }
962}