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