Skip to main content

asciidoc_parser/blocks/
list_item.rs

1use std::slice::Iter;
2
3use crate::{
4    HasSpan, Parser, Span,
5    attributes::Attrlist,
6    blocks::{
7        Block, CompoundDelimitedBlock, ContentModel, IsBlock, ListBlock, ListItemMarker,
8        RawDelimitedBlock, SimpleBlock, metadata::BlockMetadata,
9    },
10    content::Content,
11    internal::debug::DebugSliceReference,
12    span::MatchedItem,
13    strings::CowStr,
14    warnings::Warning,
15};
16
17/// A list item is a special kind of block that contains one or more blocks
18/// attached to it. In the simplest case, this will be a single [`SimpleBlock`]
19/// with the principal text for the list item. In other cases, it may be any
20/// number of blocks of any type which, together, form an entry in a list which
21/// is the immediate parent of this block.
22///
23/// [`SimpleBlock`]: crate::blocks::SimpleBlock
24#[derive(Clone, Eq, PartialEq)]
25pub struct ListItem<'src> {
26    marker: ListItemMarker<'src>,
27    blocks: Vec<Block<'src>>,
28    source: Span<'src>,
29    anchor: Option<Span<'src>>,
30    anchor_reftext: Option<Span<'src>>,
31    attrlist: Option<Attrlist<'src>>,
32}
33
34impl<'src> ListItem<'src> {
35    pub(crate) fn parse(
36        metadata: &BlockMetadata<'src>,
37        parent_list_markers: &[ListItemMarker<'src>],
38        parser: &mut Parser,
39        warnings: &mut Vec<Warning<'src>>,
40    ) -> Option<MatchedItem<'src, Self>> {
41        let source = metadata.block_start.discard_empty_lines();
42
43        let marker_mi = ListItemMarker::parse(source, parser)?;
44        let mut marker = marker_mi.item;
45
46        // Register any leading inline anchors in the description list term and apply
47        // macros substitution to render the anchor.
48        marker.register_leading_anchors(parser, warnings);
49
50        let mut list_markers_including_peer = parent_list_markers.to_vec();
51        list_markers_including_peer.push(marker.clone());
52
53        let mut blocks: Vec<Block<'src>> = vec![];
54
55        // Text after list item marker is always a simple block with no metadata.
56        let no_metadata = BlockMetadata {
57            title_source: None,
58            title: None,
59            anchor: None,
60            anchor_reftext: None,
61            attrlist: None,
62            source: marker_mi.after,
63            block_start: marker_mi.after,
64        };
65
66        // For description lists, the content after the marker can be empty.
67        // For other list types, we require content.
68        let mut next = if let Some(simple_block_mi) = SimpleBlock::parse_for_list_item(
69            &no_metadata,
70            parser,
71            false,
72            &list_markers_including_peer,
73        ) {
74            // If the principal text is empty (e.g. from {empty} attribute reference),
75            // drop it from the parse tree.
76            if !simple_block_mi.item.content().is_empty() {
77                blocks.push(Block::Simple(simple_block_mi.item));
78            }
79            simple_block_mi.after
80        } else if matches!(marker, ListItemMarker::DefinedTerm { .. }) {
81            // Description list items can have empty content on the same line as the marker.
82            // The content may be on subsequent lines, so we try to parse from the next
83            // non-empty line.
84            let mut next_source = marker_mi.after.discard_empty_lines();
85
86            // Skip comment lines (// but not ///) between term and continuation/content.
87            loop {
88                let peek = next_source.take_normalized_line();
89                if peek.item.data().starts_with("//") && !peek.item.data().starts_with("///") {
90                    next_source = peek.after.discard_empty_lines();
91                } else {
92                    break;
93                }
94            }
95
96            // Check for continuation marker before parsing. If a continuation marker is
97            // present, skip directly to the main loop which handles continuations properly.
98            let next_line_mi = next_source.take_normalized_line();
99
100            if next_line_mi.item.data() == "+" {
101                // Continuation marker found; skip straight to the main loop.
102                // Use next_source (not marker_mi.after) since we already skipped empty lines.
103                next_source
104            } else if ListItemMarker::parse(next_source, parser).is_some() {
105                // Next line is another list item marker (possibly a sibling term).
106                // Don't parse it as content; let the list parser handle it.
107                marker_mi.after
108            } else if RawDelimitedBlock::is_valid_delimiter(&next_line_mi.item)
109                || CompoundDelimitedBlock::is_valid_delimiter(&next_line_mi.item)
110            {
111                // Delimited block breaks the list.
112                marker_mi.after
113            } else if next_line_mi.item.data().starts_with('[')
114                && !next_line_mi.item.data().starts_with("[[")
115                && next_line_mi.item.data().ends_with(']')
116            {
117                // Block attribute line breaks the list.
118                marker_mi.after
119            } else if next_line_mi.item.data().starts_with("[[")
120                && next_line_mi.item.data().ends_with("]]")
121            {
122                // Block anchor line breaks the list.
123                marker_mi.after
124            } else {
125                let next_line_metadata = BlockMetadata {
126                    title_source: None,
127                    title: None,
128                    anchor: None,
129                    anchor_reftext: None,
130                    attrlist: None,
131                    source: next_source,
132                    block_start: next_source,
133                };
134
135                // For definition lists, indented content is treated as a paragraph
136                // (not literal), with the indentation stripped.
137                if let Some(simple_block_mi) =
138                    SimpleBlock::parse_for_definition_list(&next_line_metadata, parser)
139                {
140                    blocks.push(Block::Simple(simple_block_mi.item));
141                    simple_block_mi.after
142                } else {
143                    marker_mi.after
144                }
145            }
146        } else {
147            // Other list types require content after the marker.
148            return None;
149        };
150
151        let mut next_block_must_be_indented = false;
152        let mut continuation_active = false;
153        let mut had_content_starting_with_plus = false;
154
155        loop {
156            if next.is_empty() {
157                break;
158            }
159
160            let next_line_mi: MatchedItem<'_, Span<'_>> = next.take_normalized_line();
161
162            // Don't consume `+` as continuation if:
163            // - A continuation is already active (consecutive `+` - second one becomes
164            //   content)
165            // - We've already had a block that started with `+` as content (trailing `+`
166            //   markers)
167            if next_line_mi.item.data() == "+"
168                && !continuation_active
169                && !had_content_starting_with_plus
170            {
171                next = next_line_mi.after;
172                next_block_must_be_indented = false;
173                continuation_active = true;
174                continue;
175            }
176
177            if next_line_mi.item.data().is_empty() {
178                if parent_list_markers.is_empty() {
179                    next = next.discard_empty_lines();
180                    next_block_must_be_indented = true;
181                    continue;
182                } else if blocks.len() > 1 {
183                    // Item already has content beyond principal text (e.g.,
184                    // continuation-attached blocks or nested lists). Consume
185                    // all blank lines at this level.
186                    next = next.discard_empty_lines();
187                    break;
188                } else {
189                    // Item has only principal text. Consume one blank line
190                    // per level to support ancestor list continuation, where
191                    // each blank line signals moving up one nesting level.
192                    next = next_line_mi.after;
193                    break;
194                }
195            }
196
197            let is_indented = next.starts_with(' ') || next.starts_with('\t');
198            let metadata = BlockMetadata::parse(next, parser);
199
200            if let Some(list_item_marker_mi) =
201                ListItemMarker::parse(metadata.item.block_start, parser)
202            {
203                // We've found a new list item. How does it compare with the existing item in
204                // the hierarchy?
205                let new_item_marker = list_item_marker_mi.item;
206
207                if marker.is_match_for(&new_item_marker) {
208                    // New item is a peer to this item; nothing further for the current item.
209                    break;
210                }
211
212                if parent_list_markers
213                    .iter()
214                    .any(|parent| parent.is_match_for(&new_item_marker))
215                {
216                    // We matched a parent marker type. This list is complete; roll up the
217                    // hierarchy.
218                    break;
219                }
220
221                // We haven't encountered this marker before. Add a new nesting level. The new
222                // list will be a child block of this list item.
223
224                // But if we're after a blank line and the block is not indented
225                // (and no continuation is active), and there is a block attribute
226                // line or anchor before the new list marker, break the list
227                // instead of nesting. A blank line followed by a block attribute
228                // line signals the start of a new, separate list.
229                if next_block_must_be_indented
230                    && !is_indented
231                    && !continuation_active
232                    && !blocks.is_empty()
233                    && (metadata.item.attrlist.is_some() || metadata.item.anchor.is_some())
234                {
235                    break;
236                }
237
238                let mut nested_list_markers = parent_list_markers.to_owned();
239                nested_list_markers.push(marker.clone());
240
241                // NOTE: The call to `ListBlock::parse` *should* succeed (as in I can't think of
242                // a test case where it would fail). We use the `?` to provide a safe escape in
243                // case it doesn't.
244                let nested_list_mi = ListBlock::parse_inside_list(
245                    &metadata.item,
246                    &nested_list_markers,
247                    parser,
248                    warnings,
249                )?;
250
251                blocks.push(Block::List(nested_list_mi.item));
252
253                next = nested_list_mi.after;
254                continuation_active = false;
255                next_block_must_be_indented = true;
256                continue;
257            }
258
259            // If no list marker found directly after metadata, try extending
260            // metadata past empty lines. This handles block attribute lines
261            // (anchors, attrlists) separated by empty lines above nested lists.
262            if !metadata.item.is_empty() {
263                let mut ext_block_start = metadata.item.block_start;
264                let mut ext_anchor = metadata.item.anchor;
265                let mut ext_anchor_reftext = metadata.item.anchor_reftext;
266                let mut ext_attrlist = metadata.item.attrlist.clone();
267                let mut ext_title_source = metadata.item.title_source;
268                let mut ext_title = metadata.item.title.clone();
269
270                // Try to consume additional metadata past empty lines.
271                loop {
272                    let gap = ext_block_start.discard_empty_lines();
273                    if gap == ext_block_start {
274                        break;
275                    }
276
277                    let more_maw = BlockMetadata::parse(gap, parser);
278                    if more_maw.item.is_empty() {
279                        ext_block_start = gap;
280                        break;
281                    }
282
283                    // Merge additional metadata.
284                    if ext_anchor.is_none() {
285                        ext_anchor = more_maw.item.anchor;
286                        ext_anchor_reftext = more_maw.item.anchor_reftext;
287                    }
288
289                    if ext_attrlist.is_none() {
290                        ext_attrlist = more_maw.item.attrlist;
291                    }
292
293                    if ext_title_source.is_none() {
294                        ext_title_source = more_maw.item.title_source;
295                        ext_title = more_maw.item.title;
296                    }
297
298                    ext_block_start = more_maw.item.block_start;
299                }
300
301                if let Some(ext_marker_mi) = ListItemMarker::parse(ext_block_start, parser) {
302                    let new_item_marker = ext_marker_mi.item;
303
304                    if marker.is_match_for(&new_item_marker) {
305                        next = ext_block_start;
306                        break;
307                    }
308
309                    if parent_list_markers
310                        .iter()
311                        .any(|parent| parent.is_match_for(&new_item_marker))
312                    {
313                        next = ext_block_start;
314                        break;
315                    }
316
317                    // Found a nested list after metadata separated by empty lines.
318                    let ext_metadata = BlockMetadata {
319                        title_source: ext_title_source,
320                        title: ext_title,
321                        anchor: ext_anchor,
322                        anchor_reftext: ext_anchor_reftext,
323                        attrlist: ext_attrlist,
324                        source: metadata.item.source,
325                        block_start: ext_block_start,
326                    };
327
328                    let mut nested_list_markers = parent_list_markers.to_owned();
329                    nested_list_markers.push(marker.clone());
330
331                    let nested_list_mi = ListBlock::parse_inside_list(
332                        &ext_metadata,
333                        &nested_list_markers,
334                        parser,
335                        warnings,
336                    )?;
337
338                    blocks.push(Block::List(nested_list_mi.item));
339
340                    next = nested_list_mi.after;
341                    continuation_active = false;
342                    next_block_must_be_indented = true;
343                    continue;
344                }
345            }
346
347            if next_block_must_be_indented && !is_indented {
348                break;
349            }
350
351            // A delimited block without a continuation marker breaks the list.
352            if !continuation_active {
353                let next_block_line = metadata.item.block_start.take_normalized_line().item;
354                if RawDelimitedBlock::is_valid_delimiter(&next_block_line)
355                    || CompoundDelimitedBlock::is_valid_delimiter(&next_block_line)
356                {
357                    break;
358                }
359            }
360
361            // A block attribute line or block anchor without a continuation marker
362            // breaks the list.
363            if !continuation_active
364                && (metadata.item.attrlist.is_some() || metadata.item.anchor.is_some())
365            {
366                break;
367            }
368
369            // If there's block metadata but no block, just discard it and continue.
370            if metadata
371                .item
372                .block_start
373                .take_normalized_line()
374                .item
375                .is_empty()
376            {
377                next = metadata.item.block_start.discard_empty_lines();
378                continue;
379            }
380
381            // A list item does not terminate if subsequent blocks are indented (i.e. use
382            // literal syntax).
383            let indented_block_maw = Block::parse_for_list_item(
384                next,
385                parser,
386                &list_markers_including_peer,
387                continuation_active,
388            );
389            warnings.extend(indented_block_maw.warnings);
390
391            let Some(indented_block_mi) = indented_block_maw.item else {
392                break;
393            };
394
395            // After a continuation marker, subsequent blocks don't need to be indented.
396            // However, document attributes don't consume the continuation status.
397            let is_document_attribute =
398                matches!(indented_block_mi.item, Block::DocumentAttribute(_));
399
400            // Document attributes should not be added to the list item blocks.
401            // They're processed for their side effects but don't appear in the output.
402            // Similarly, orphaned metadata blocks shouldn't be added; they'll be
403            // re-parsed on the next iteration where they can attach to a real block.
404            if !is_document_attribute {
405                blocks.push(indented_block_mi.item);
406            }
407            next = indented_block_mi.after;
408
409            if is_document_attribute {
410                // Document attributes and orphaned metadata are transparent to
411                // continuation logic. Keep continuation_active
412                // and next_block_must_be_indented unchanged.
413            } else if continuation_active {
414                // This block consumed the continuation.
415                // The next block after this one will need to be indented (or have another
416                // continuation).
417                //
418                // If the block started with `+` as content (not as continuation), mark it
419                // so we don't allow more continuation markers. This handles odd input like
420                // consecutive `+` markers.
421                if next_line_mi.item.data() == "+" {
422                    had_content_starting_with_plus = true;
423                }
424                continuation_active = false;
425                next_block_must_be_indented = true;
426            } else {
427                // No active continuation; next block must be indented.
428                next_block_must_be_indented = true;
429            }
430        }
431
432        let source = source.trim_remainder(next).trim_trailing_whitespace();
433
434        Some(MatchedItem {
435            item: Self {
436                marker,
437                blocks,
438                source,
439                anchor: metadata.anchor,
440                anchor_reftext: metadata.anchor_reftext,
441                attrlist: metadata.attrlist.clone(),
442            },
443            after: next,
444        })
445    }
446
447    /// Returns the list item marker that was used for this item.
448    pub fn list_item_marker(&self) -> ListItemMarker<'src> {
449        self.marker.clone()
450    }
451}
452
453impl<'src> IsBlock<'src> for ListItem<'src> {
454    fn content_model(&self) -> ContentModel {
455        ContentModel::Compound
456    }
457
458    fn content_mut(&mut self) -> Option<&mut Content<'src>> {
459        // A description-list item's resolvable content is its term.
460        self.marker.term_mut()
461    }
462
463    fn nested_blocks_mut(&mut self) -> &mut [Block<'src>] {
464        &mut self.blocks
465    }
466
467    fn raw_context(&self) -> CowStr<'src> {
468        "list_item".into()
469    }
470
471    fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
472        self.blocks.iter()
473    }
474
475    fn title_source(&'src self) -> Option<Span<'src>> {
476        None
477    }
478
479    fn title(&self) -> Option<&str> {
480        None
481    }
482
483    fn anchor(&'src self) -> Option<Span<'src>> {
484        self.anchor
485    }
486
487    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
488        self.anchor_reftext
489    }
490
491    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
492        self.attrlist.as_ref()
493    }
494}
495
496impl<'src> HasSpan<'src> for ListItem<'src> {
497    fn span(&self) -> Span<'src> {
498        self.source
499    }
500}
501
502impl std::fmt::Debug for ListItem<'_> {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        f.debug_struct("ListItem")
505            .field("marker", &self.marker)
506            .field("blocks", &DebugSliceReference(&self.blocks))
507            .field("source", &self.source)
508            .field("anchor", &self.anchor)
509            .field("anchor_reftext", &self.anchor_reftext)
510            .field("attrlist", &self.attrlist)
511            .finish()
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    #![allow(clippy::panic)]
518    #![allow(clippy::unwrap_used)]
519
520    use crate::{
521        blocks::{ContentModel, metadata::BlockMetadata},
522        span::MatchedItem,
523        tests::prelude::*,
524        warnings::Warning,
525    };
526
527    fn li_parse<'a>(source: &'a str) -> Option<MatchedItem<'a, crate::blocks::ListItem<'a>>> {
528        let mut parser = crate::Parser::default();
529        let mut warnings: Vec<Warning<'a>> = vec![];
530
531        let metadata = BlockMetadata::parse(crate::Span::new(source), &mut parser).item;
532
533        let result =
534            crate::blocks::list_item::ListItem::parse(&metadata, &[], &mut parser, &mut warnings);
535
536        assert!(warnings.is_empty());
537
538        result
539    }
540
541    #[test]
542    fn hyphen() {
543        assert!(li_parse("-xyz").is_none());
544        assert!(li_parse("-- x").is_none());
545
546        let li = li_parse("- blah").unwrap();
547
548        assert_eq!(
549            li.item,
550            ListItem {
551                marker: ListItemMarker::Hyphen(Span {
552                    data: "-",
553                    line: 1,
554                    col: 1,
555                    offset: 0,
556                },),
557                blocks: &[Block::Simple(SimpleBlock {
558                    content: Content {
559                        original: Span {
560                            data: "blah",
561                            line: 1,
562                            col: 3,
563                            offset: 2,
564                        },
565                        rendered: "blah",
566                    },
567                    source: Span {
568                        data: "blah",
569                        line: 1,
570                        col: 3,
571                        offset: 2,
572                    },
573                    style: SimpleBlockStyle::Paragraph,
574                    title_source: None,
575                    title: None,
576                    caption: None,
577                    number: None,
578                    anchor: None,
579                    anchor_reftext: None,
580                    attrlist: None,
581                },),],
582                source: Span {
583                    data: "- blah",
584                    line: 1,
585                    col: 1,
586                    offset: 0,
587                },
588                anchor: None,
589                anchor_reftext: None,
590                attrlist: None,
591            }
592        );
593
594        assert_eq!(li.item.content_model(), ContentModel::Compound);
595        assert_eq!(li.item.raw_context().as_ref(), "list_item");
596
597        let mut li_blocks = li.item.nested_blocks();
598
599        assert_eq!(
600            li_blocks.next().unwrap(),
601            &Block::Simple(SimpleBlock {
602                content: Content {
603                    original: Span {
604                        data: "blah",
605                        line: 1,
606                        col: 3,
607                        offset: 2,
608                    },
609                    rendered: "blah",
610                },
611                source: Span {
612                    data: "blah",
613                    line: 1,
614                    col: 3,
615                    offset: 2,
616                },
617                style: SimpleBlockStyle::Paragraph,
618                title_source: None,
619                title: None,
620                caption: None,
621                number: None,
622                anchor: None,
623                anchor_reftext: None,
624                attrlist: None,
625            })
626        );
627        assert!(li_blocks.next().is_none());
628
629        assert!(li.item.title_source().is_none());
630        assert!(li.item.title().is_none());
631        assert!(li.item.anchor().is_none());
632        assert!(li.item.anchor_reftext().is_none());
633        assert!(li.item.attrlist().is_none());
634
635        assert_eq!(
636            li.item.span(),
637            Span {
638                data: "- blah",
639                line: 1,
640                col: 1,
641                offset: 0,
642            }
643        );
644
645        assert_eq!(
646            li.after,
647            Span {
648                data: "",
649                line: 1,
650                col: 7,
651                offset: 6,
652            }
653        );
654
655        assert_eq!(
656            format!("{:#?}", li.item),
657            "ListItem {\n    marker: ListItemMarker::Hyphen(\n        Span {\n            data: \"-\",\n            line: 1,\n            col: 1,\n            offset: 0,\n        },\n    ),\n    blocks: &[\n        Block::Simple(\n            SimpleBlock {\n                content: Content {\n                    original: Span {\n                        data: \"blah\",\n                        line: 1,\n                        col: 3,\n                        offset: 2,\n                    },\n                    rendered: \"blah\",\n                },\n                source: Span {\n                    data: \"blah\",\n                    line: 1,\n                    col: 3,\n                    offset: 2,\n                },\n                style: SimpleBlockStyle::Paragraph,\n                title_source: None,\n                title: None,\n                caption: None,\n                number: None,\n                anchor: None,\n                anchor_reftext: None,\n                attrlist: None,\n            },\n        ),\n    ],\n    source: Span {\n        data: \"- blah\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n    anchor: None,\n    anchor_reftext: None,\n    attrlist: None,\n}"
658        );
659    }
660
661    #[test]
662    fn non_description_list_marker_with_no_content() {
663        // A non-description-list marker with no content after it returns None.
664        assert!(li_parse("* ").is_none());
665    }
666
667    #[test]
668    fn asterisks() {
669        assert!(li_parse("*").is_none());
670        assert!(li_parse("*xyz").is_none());
671        assert!(li_parse("*- xyz").is_none());
672
673        let li = li_parse("* blah").unwrap();
674
675        assert_eq!(
676            li.item,
677            ListItem {
678                marker: ListItemMarker::Asterisks(Span {
679                    data: "*",
680                    line: 1,
681                    col: 1,
682                    offset: 0,
683                },),
684                blocks: &[Block::Simple(SimpleBlock {
685                    content: Content {
686                        original: Span {
687                            data: "blah",
688                            line: 1,
689                            col: 3,
690                            offset: 2,
691                        },
692                        rendered: "blah",
693                    },
694                    source: Span {
695                        data: "blah",
696                        line: 1,
697                        col: 3,
698                        offset: 2,
699                    },
700                    style: SimpleBlockStyle::Paragraph,
701                    title_source: None,
702                    title: None,
703                    caption: None,
704                    number: None,
705                    anchor: None,
706                    anchor_reftext: None,
707                    attrlist: None,
708                },),],
709                source: Span {
710                    data: "* blah",
711                    line: 1,
712                    col: 1,
713                    offset: 0,
714                },
715                anchor: None,
716                anchor_reftext: None,
717                attrlist: None,
718            }
719        );
720
721        assert_eq!(
722            li.item.span(),
723            Span {
724                data: "* blah",
725                line: 1,
726                col: 1,
727                offset: 0,
728            }
729        );
730
731        assert_eq!(
732            li.after,
733            Span {
734                data: "",
735                line: 1,
736                col: 7,
737                offset: 6,
738            }
739        );
740    }
741}