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    // Source span of each surviving line, kept in lockstep with `filtered_lines`
337    // so the attribute-references substitution can locate an
338    // `attribute-missing=warn` warning at the precise source offset of the
339    // offending reference (see `Content::from_filtered_lines`).
340    let mut filtered_line_spans: Vec<Span<'src>> = vec![];
341    let mut skipped_comment_line = false;
342
343    // Determine how much indentation to strip from literal paragraphs.
344    // In definition list continuations, use minimum indentation across all
345    // lines to preserve relative indent. In outline list continuations and
346    // non-continuation contexts, strip based on the first line's indent.
347    let in_definition_list = parent_list_markers
348        .iter()
349        .any(|m| matches!(m, ListItemMarker::DefinedTerm { .. }));
350
351    let strip_indent =
352        if preserve_literal_indent && style == SimpleBlockStyle::Literal && in_definition_list {
353            // Two-pass approach: find minimum indentation across all lines.
354            let mut scan = source;
355            let mut min_indent = first_line_indent;
356            let mut line_count = 0;
357
358            while let Some(line_mi) = scan.take_non_empty_line() {
359                let line = line_mi.item;
360
361                // Apply same stop conditions as the main loop.
362                if line_count > 0 && line.data() == "+" {
363                    break;
364                }
365
366                if let Some(n) = line.position(|c| c != ' ' && c != '\t') {
367                    min_indent = min_indent.min(n);
368                }
369
370                line_count += 1;
371                scan = line_mi.after;
372            }
373            min_indent
374        } else {
375            first_line_indent
376        };
377
378    while let Some(line_mi) = next.take_non_empty_line() {
379        let mut line = line_mi.item;
380
381        // If we've skipped a comment line and this is a section header, stop here
382        // so the section can be parsed as a separate block. Only do this at the
383        // top level (not inside lists), indicated by stop_for_list_items being false.
384        if !stop_for_list_items
385            && skipped_comment_line
386            && style == SimpleBlockStyle::Paragraph
387            && is_section_header(line.data())
388        {
389            break;
390        }
391
392        // There are several stop conditions for simple paragraph blocks. These
393        // "shouldn't" be encountered on the first line (we shouldn't be calling
394        // `SimpleBlock::parse` in these conditions), but in case it is, we simply
395        // ignore them on the first line.
396        if !filtered_lines.is_empty() {
397            // In indented literal mode, only stop for list markers that are NOT indented
398            // (at column 1). This allows definition list items to be properly separated.
399            let should_check_for_list_marker =
400                stop_for_list_items && (!indented_literal_mode || line.col() == 1);
401
402            // If we've already started accumulating content for this list item paragraph,
403            // we don't stop for list markers at any level other than our own or a parent
404            // level.
405            if should_check_for_list_marker
406                && let Some(marker_mi) = ListItemMarker::parse(line, parser)
407            {
408                // In description list continuation context, don't stop for
409                // deeper-nested description list markers (e.g., ::: when the
410                // current context is ::). They are treated as paragraph text.
411                let is_ancestor_list = parent_list_markers
412                    .iter()
413                    .any(|p| p.is_match_for(&marker_mi.item));
414
415                if is_ancestor_list || !preserve_literal_indent {
416                    break;
417                }
418            }
419
420            if line.data() == "+" {
421                break;
422            }
423
424            if line.starts_with('[') && line.ends_with(']') {
425                break;
426            }
427
428            if (line.starts_with('/')
429                || line.starts_with('-')
430                || line.starts_with('.')
431                || line.starts_with('+')
432                || line.starts_with('=')
433                || line.starts_with('*')
434                || line.starts_with('_')
435                || line.starts_with('`'))
436                && (RawDelimitedBlock::is_valid_delimiter(&line)
437                    || CompoundDelimitedBlock::is_valid_delimiter(&line))
438            {
439                break;
440            }
441        }
442
443        next = line_mi.after;
444
445        // Only strip comment lines in paragraph style. In literal/listing/source
446        // blocks, "//" lines are preserved as content; likewise a `[comment]`
447        // paragraph retains its content verbatim (raw).
448        if !comment_style
449            && style == SimpleBlockStyle::Paragraph
450            && line.starts_with("//")
451            && !line.starts_with("///")
452        {
453            skipped_comment_line = true;
454            continue;
455        }
456
457        // Strip at most the calculated indentation amount.
458        let should_strip_indent = strip_indent > 0;
459
460        if should_strip_indent && let Some(n) = line.position(|c| c != ' ' && c != '\t') {
461            line = line.into_parse_result(n.min(strip_indent)).after;
462        };
463
464        let line = line.trim_trailing_whitespace();
465        filtered_line_spans.push(line);
466        filtered_lines.push(line.data());
467    }
468
469    let source = source.trim_remainder(next).trim_trailing_whitespace();
470    if source.is_empty() {
471        return None;
472    }
473
474    let mut content: Content<'src> =
475        Content::from_filtered_lines(source, &filtered_lines, filtered_line_spans);
476
477    // A `[comment]`-styled paragraph is the single-paragraph form of a comment
478    // block. Its content is retained in the parsed model (this parser does not
479    // discard comments) but is not interpreted: no substitutions are applied,
480    // matching the raw content model of a `////` comment block.
481    let sub_group = if comment_style {
482        SubstitutionGroup::None
483    } else {
484        base_substitution_group(style).override_via_attrlist(attrlist.as_ref())
485    };
486
487    sub_group.apply(&mut content, parser, attrlist.as_ref());
488
489    Some(MatchedItem {
490        item: (content, style),
491        after: next,
492    })
493}
494
495/// The base substitution group for a simple block of the given style, before
496/// any `subs` override from the attribute list is applied.
497///
498/// Only literal paragraphs (detected by indentation) use verbatim
499/// substitutions; the listing and source styles declared via an attribute list
500/// still use normal substitutions.
501fn base_substitution_group(style: SimpleBlockStyle) -> SubstitutionGroup {
502    match style {
503        SimpleBlockStyle::Literal => SubstitutionGroup::Verbatim,
504        SimpleBlockStyle::Listing | SimpleBlockStyle::Source | SimpleBlockStyle::Paragraph => {
505            SubstitutionGroup::Normal
506        }
507    }
508}
509
510/// Returns `true` if this paragraph carries the `comment` block style (i.e.
511/// `[comment]`), making it a comment paragraph whose content is retained but
512/// not interpreted.
513fn is_comment_style(attrlist: Option<&Attrlist<'_>>) -> bool {
514    attrlist.and_then(|attrlist| attrlist.block_style()) == Some("comment")
515}
516
517impl<'src> IsBlock<'src> for SimpleBlock<'src> {
518    fn content_model(&self) -> ContentModel {
519        ContentModel::Simple
520    }
521
522    fn content_mut(&mut self) -> Option<&mut Content<'src>> {
523        Some(&mut self.content)
524    }
525
526    fn rendered_content(&self) -> Option<&str> {
527        Some(self.content.rendered())
528    }
529
530    fn raw_context(&self) -> CowStr<'src> {
531        "paragraph".into()
532    }
533
534    fn title_source(&'src self) -> Option<Span<'src>> {
535        self.title_source
536    }
537
538    fn title(&self) -> Option<&str> {
539        self.title.as_deref()
540    }
541
542    fn caption(&self) -> Option<&str> {
543        self.caption.as_deref()
544    }
545
546    fn number(&self) -> Option<usize> {
547        self.number
548    }
549
550    fn anchor(&'src self) -> Option<Span<'src>> {
551        self.anchor
552    }
553
554    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
555        self.anchor_reftext
556    }
557
558    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
559        self.attrlist.as_ref()
560    }
561
562    fn substitution_group(&'src self) -> SubstitutionGroup {
563        // Mirror the group actually applied to this block's content in
564        // `parse_lines`: a `[comment]` paragraph is raw (no substitutions),
565        // otherwise the base group derived from the style, then any `subs`
566        // override from the attribute list.
567        if is_comment_style(self.attrlist.as_ref()) {
568            SubstitutionGroup::None
569        } else {
570            base_substitution_group(self.style).override_via_attrlist(self.attrlist.as_ref())
571        }
572    }
573}
574
575impl<'src> HasSpan<'src> for SimpleBlock<'src> {
576    fn span(&self) -> Span<'src> {
577        self.source
578    }
579}
580
581/// Returns true if the line looks like a section header.
582/// Matches `== `, `=== `, etc. (AsciiDoc) or `## `, `### `, etc. (Markdown).
583fn is_section_header(line: &str) -> bool {
584    // AsciiDoc style: `== `, `=== `, etc. (at least 2 `=` followed by space)
585    if line.starts_with("==") {
586        let rest = line.trim_start_matches('=');
587        if rest.starts_with(' ') {
588            return true;
589        }
590    }
591
592    // Markdown style: `## `, `### `, etc. (at least 2 `#` followed by space)
593    if line.starts_with("##") {
594        let rest = line.trim_start_matches('#');
595        if rest.starts_with(' ') {
596            return true;
597        }
598    }
599
600    false
601}
602
603#[cfg(test)]
604mod tests {
605    #![allow(clippy::unwrap_used)]
606
607    use std::ops::Deref;
608
609    use crate::{
610        blocks::{ContentModel, SimpleBlockStyle, metadata::BlockMetadata},
611        tests::prelude::*,
612    };
613
614    #[test]
615    fn impl_clone() {
616        // Silly test to mark the #[derive(...)] line as covered.
617        let mut parser = Parser::default();
618
619        let b1 =
620            crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc"), &mut parser).unwrap();
621
622        let b2 = b1.item.clone();
623        assert_eq!(b1.item, b2);
624    }
625
626    #[test]
627    fn style_enum_impl_debug() {
628        assert_eq!(
629            format!("{:?}", SimpleBlockStyle::Paragraph),
630            "SimpleBlockStyle::Paragraph"
631        );
632
633        assert_eq!(
634            format!("{:?}", SimpleBlockStyle::Literal),
635            "SimpleBlockStyle::Literal"
636        );
637
638        assert_eq!(
639            format!("{:?}", SimpleBlockStyle::Listing),
640            "SimpleBlockStyle::Listing"
641        );
642
643        assert_eq!(
644            format!("{:?}", SimpleBlockStyle::Source),
645            "SimpleBlockStyle::Source"
646        );
647    }
648
649    #[test]
650    fn empty_source() {
651        let mut parser = Parser::default();
652        assert!(crate::blocks::SimpleBlock::parse(&BlockMetadata::new(""), &mut parser).is_none());
653    }
654
655    #[test]
656    fn only_spaces() {
657        let mut parser = Parser::default();
658        assert!(
659            crate::blocks::SimpleBlock::parse(&BlockMetadata::new("    "), &mut parser).is_none()
660        );
661    }
662
663    #[test]
664    fn single_line() {
665        let mut parser = Parser::default();
666        let mi =
667            crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc"), &mut parser).unwrap();
668
669        assert_eq!(
670            mi.item,
671            SimpleBlock {
672                content: Content {
673                    original: Span {
674                        data: "abc",
675                        line: 1,
676                        col: 1,
677                        offset: 0,
678                    },
679                    rendered: "abc",
680                },
681                source: Span {
682                    data: "abc",
683                    line: 1,
684                    col: 1,
685                    offset: 0,
686                },
687                style: SimpleBlockStyle::Paragraph,
688                title_source: None,
689                title: None,
690                caption: None,
691                number: None,
692                anchor: None,
693                anchor_reftext: None,
694                attrlist: None,
695            },
696        );
697
698        assert_eq!(mi.item.content_model(), ContentModel::Simple);
699        assert_eq!(mi.item.rendered_content().unwrap(), "abc");
700        assert_eq!(mi.item.raw_context().deref(), "paragraph");
701        assert_eq!(mi.item.resolved_context().deref(), "paragraph");
702        assert!(mi.item.declared_style().is_none());
703        assert!(mi.item.id().is_none());
704        assert!(mi.item.roles().is_empty());
705        assert!(mi.item.options().is_empty());
706        assert!(mi.item.title_source().is_none());
707        assert!(mi.item.title().is_none());
708        assert!(mi.item.anchor().is_none());
709        assert!(mi.item.anchor_reftext().is_none());
710        assert!(mi.item.attrlist().is_none());
711        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
712
713        assert_eq!(
714            mi.after,
715            Span {
716                data: "",
717                line: 1,
718                col: 4,
719                offset: 3
720            }
721        );
722    }
723
724    #[test]
725    fn multiple_lines() {
726        let mut parser = Parser::default();
727        let mi = crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc\ndef"), &mut parser)
728            .unwrap();
729
730        assert_eq!(
731            mi.item,
732            SimpleBlock {
733                content: Content {
734                    original: Span {
735                        data: "abc\ndef",
736                        line: 1,
737                        col: 1,
738                        offset: 0,
739                    },
740                    rendered: "abc\ndef",
741                },
742                source: Span {
743                    data: "abc\ndef",
744                    line: 1,
745                    col: 1,
746                    offset: 0,
747                },
748                style: SimpleBlockStyle::Paragraph,
749                title_source: None,
750                title: None,
751                caption: None,
752                number: None,
753                anchor: None,
754                anchor_reftext: None,
755                attrlist: None,
756            }
757        );
758
759        assert_eq!(
760            mi.after,
761            Span {
762                data: "",
763                line: 2,
764                col: 4,
765                offset: 7
766            }
767        );
768
769        assert_eq!(mi.item.rendered_content().unwrap(), "abc\ndef");
770    }
771
772    #[test]
773    fn consumes_blank_lines_after() {
774        let mut parser = Parser::default();
775        let mi = crate::blocks::SimpleBlock::parse(&BlockMetadata::new("abc\n\ndef"), &mut parser)
776            .unwrap();
777
778        assert_eq!(
779            mi.item,
780            SimpleBlock {
781                content: Content {
782                    original: Span {
783                        data: "abc",
784                        line: 1,
785                        col: 1,
786                        offset: 0,
787                    },
788                    rendered: "abc",
789                },
790                source: Span {
791                    data: "abc",
792                    line: 1,
793                    col: 1,
794                    offset: 0,
795                },
796                style: SimpleBlockStyle::Paragraph,
797                title_source: None,
798                title: None,
799                caption: None,
800                number: None,
801                anchor: None,
802                anchor_reftext: None,
803                attrlist: None,
804            }
805        );
806
807        assert_eq!(
808            mi.after,
809            Span {
810                data: "def",
811                line: 3,
812                col: 1,
813                offset: 5
814            }
815        );
816    }
817
818    #[test]
819    fn overrides_sub_group_via_subs_attribute() {
820        let mut parser = Parser::default();
821        let mi = crate::blocks::SimpleBlock::parse(
822            &BlockMetadata::new("[subs=quotes]\na<b>c *bold*\n\ndef"),
823            &mut parser,
824        )
825        .unwrap();
826
827        assert_eq!(
828            mi.item,
829            SimpleBlock {
830                content: Content {
831                    original: Span {
832                        data: "a<b>c *bold*",
833                        line: 2,
834                        col: 1,
835                        offset: 14,
836                    },
837                    rendered: "a<b>c <strong>bold</strong>",
838                },
839                source: Span {
840                    data: "[subs=quotes]\na<b>c *bold*",
841                    line: 1,
842                    col: 1,
843                    offset: 0,
844                },
845                style: SimpleBlockStyle::Paragraph,
846                title_source: None,
847                title: None,
848                caption: None,
849                number: None,
850                anchor: None,
851                anchor_reftext: None,
852                attrlist: Some(Attrlist {
853                    attributes: &[ElementAttribute {
854                        name: Some("subs"),
855                        value: "quotes",
856                        shorthand_items: &[],
857                    },],
858                    anchor: None,
859                    source: Span {
860                        data: "subs=quotes",
861                        line: 1,
862                        col: 2,
863                        offset: 1,
864                    },
865                },),
866            }
867        );
868
869        assert_eq!(
870            mi.after,
871            Span {
872                data: "def",
873                line: 4,
874                col: 1,
875                offset: 28
876            }
877        );
878
879        assert_eq!(
880            mi.item.rendered_content().unwrap(),
881            "a<b>c <strong>bold</strong>"
882        );
883    }
884}