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