Skip to main content

asciidoc_parser/blocks/
simple.rs

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