Skip to main content

asciidoc_parser/blocks/
break.rs

1use crate::{
2    HasSpan, Parser, Span,
3    attributes::Attrlist,
4    blocks::{ChildBlocks, ContentModel, IsBlock, metadata::BlockMetadata},
5    content::Content,
6    span::MatchedItem,
7    strings::CowStr,
8};
9
10/// A break block is used to represent a thematic or page break macro.
11#[derive(Clone, Debug, Eq, Hash, PartialEq)]
12pub struct Break<'src> {
13    type_: BreakType,
14    source: Span<'src>,
15    title_source: Option<Span<'src>>,
16    title: Option<Content<'src>>,
17    anchor: Option<Span<'src>>,
18    attrlist: Option<Attrlist<'src>>,
19}
20
21/// A break may be one of two different types.
22#[derive(Clone, Copy, Eq, Hash, PartialEq)]
23pub enum BreakType {
24    /// A thematic break (aka horizontal rule).
25    Thematic,
26
27    /// A hint to the converter to insert a page break.
28    Page,
29}
30
31impl std::fmt::Debug for BreakType {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            BreakType::Thematic => write!(f, "BreakType::Thematic"),
35            BreakType::Page => write!(f, "BreakType::Page"),
36        }
37    }
38}
39
40impl<'src> Break<'src> {
41    /// Returns a document-order iterator over this block's direct child blocks.
42    ///
43    /// A thematic break never has child blocks, so this iterator is always
44    /// empty. See [`FindBlocks`](crate::blocks::FindBlocks) to search from a
45    /// [`Block`](crate::blocks::Block) or [`Document`](crate::Document).
46    pub fn child_blocks(&'src self) -> ChildBlocks<'src> {
47        ChildBlocks::empty()
48    }
49
50    /// Returns the block's title as a mutable [`Content`], if the block has
51    /// one.
52    ///
53    /// This narrow seam exists for the document-order title resolution pass
54    /// (see `document::title_refs`), which installs the re-rendered title
55    /// after resolving any cross-references embedded in it. All other access
56    /// goes through the read-only [`IsBlock::title`] accessor.
57    pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
58        self.title.as_mut()
59    }
60
61    pub(crate) fn parse(
62        metadata: &BlockMetadata<'src>,
63        _parser: &mut Parser,
64    ) -> Option<MatchedItem<'src, Self>> {
65        let line = metadata.block_start.take_normalized_line();
66        let data = line.item.data();
67
68        let type_ = match data {
69            // The `-`/`*` markdown-style thematic breaks are spec-recognized.
70            // Asciidoctor additionally accepts the `_` forms (`___`, `_ _ _`)
71            // to ease Markdown migration, and this crate matches that. Only
72            // exactly three repeating characters count: a run of four or more
73            // underscores (`____`) is a quote block delimiter, so extended `_`
74            // runs are deliberately not matched here (unlike the apostrophe run
75            // handled below).
76            //
77            // Asciidoctor also tolerates 0–3 leading spaces before any of these
78            // markers. This crate intentionally does not: the marker must start
79            // at column 1, consistent with how AsciiDoc treats leading-space
80            // lines generally (they become literal paragraphs). This divergence
81            // is deliberate and settled, not a pending gap.
82            "---" | "- - -" | "***" | "* * *" | "___" | "_ _ _" => BreakType::Thematic,
83            "<<<" => BreakType::Page,
84
85            // A run of three or more apostrophes is a thematic break. The
86            // AsciiDoc language reference documents the canonical `'''` form,
87            // but Asciidoctor recognizes any longer run (`''''`, `'''''`, ...),
88            // and this crate matches that.
89            _ if data.len() >= 3 && data.bytes().all(|b| b == b'\'') => BreakType::Thematic,
90            _ => {
91                return None;
92            }
93        };
94
95        let source: Span = metadata.source.trim_remainder(line.after);
96        let source = source.slice(0..source.trim().len());
97
98        Some(MatchedItem {
99            item: Self {
100                type_,
101                source,
102                title_source: metadata.title_source,
103                title: metadata.title.clone(),
104                anchor: metadata.anchor,
105                attrlist: metadata.attrlist.clone(),
106            },
107
108            after: line.after.discard_empty_lines(),
109        })
110    }
111
112    /// Return the type of break detected.
113    pub fn type_(&self) -> BreakType {
114        self.type_
115    }
116}
117
118impl<'src> IsBlock<'src> for Break<'src> {
119    fn content_model(&self) -> ContentModel {
120        ContentModel::Empty
121    }
122
123    fn raw_context(&self) -> CowStr<'src> {
124        match self.type_ {
125            BreakType::Thematic => "thematic_break",
126            BreakType::Page => "page_break",
127        }
128        .into()
129    }
130
131    fn title_source(&'src self) -> Option<Span<'src>> {
132        self.title_source
133    }
134
135    fn title(&self) -> Option<&str> {
136        self.title.as_ref().map(Content::rendered_str)
137    }
138
139    fn anchor(&'src self) -> Option<Span<'src>> {
140        self.anchor
141    }
142
143    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
144        None
145    }
146
147    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
148        self.attrlist.as_ref()
149    }
150}
151
152impl<'src> HasSpan<'src> for Break<'src> {
153    fn span(&self) -> Span<'src> {
154        self.source
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    #![allow(clippy::unwrap_used)]
161
162    use std::ops::Deref;
163
164    use crate::{
165        blocks::{BreakType, ContentModel, metadata::BlockMetadata},
166        tests::prelude::*,
167    };
168
169    #[test]
170    fn impl_clone() {
171        // Silly test to mark the #[derive(...)] line as covered.
172        let mut parser = Parser::default();
173
174        let b1 = crate::blocks::Break::parse(&BlockMetadata::new("'''"), &mut parser)
175            .unwrap()
176            .item;
177
178        let b2 = b1.clone();
179        assert_eq!(b1, b2);
180    }
181
182    #[test]
183    fn err_empty_source() {
184        let mut parser = Parser::default();
185        assert!(crate::blocks::Break::parse(&BlockMetadata::new(""), &mut parser).is_none());
186    }
187
188    #[test]
189    fn err_only_spaces() {
190        let mut parser = Parser::default();
191        assert!(crate::blocks::Break::parse(&BlockMetadata::new("    "), &mut parser).is_none());
192    }
193
194    #[test]
195    fn err_unknown_break_pattern() {
196        let mut parser = Parser::default();
197        assert!(crate::blocks::Break::parse(&BlockMetadata::new("=="), &mut parser).is_none());
198        assert!(crate::blocks::Break::parse(&BlockMetadata::new("~~~"), &mut parser).is_none());
199        assert!(crate::blocks::Break::parse(&BlockMetadata::new("****"), &mut parser).is_none());
200        assert!(crate::blocks::Break::parse(&BlockMetadata::new(">>>"), &mut parser).is_none());
201    }
202
203    #[test]
204    fn thematic_break_triple_apostrophe() {
205        let mut parser = Parser::default();
206
207        let mi = crate::blocks::Break::parse(&BlockMetadata::new("'''"), &mut parser).unwrap();
208
209        assert_eq!(
210            mi.item,
211            Break {
212                type_: BreakType::Thematic,
213                source: Span {
214                    data: "'''",
215                    line: 1,
216                    col: 1,
217                    offset: 0,
218                },
219                title_source: None,
220                title: None,
221                anchor: None,
222                attrlist: None,
223            }
224        );
225
226        assert_eq!(
227            mi.after,
228            Span {
229                data: "",
230                line: 1,
231                col: 4,
232                offset: 3
233            }
234        );
235
236        assert_eq!(mi.item.content_model(), ContentModel::Empty);
237        assert_eq!(mi.item.raw_context().deref(), "thematic_break");
238        assert_eq!(mi.item.type_(), BreakType::Thematic);
239        assert!(mi.item.child_blocks().next().is_none());
240        assert!(mi.item.title_source().is_none());
241        assert!(mi.item.title().is_none());
242        assert!(mi.item.anchor().is_none());
243        assert!(mi.item.anchor_reftext().is_none());
244        assert!(mi.item.attrlist().is_none());
245        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
246    }
247
248    #[test]
249    fn thematic_break_extended_apostrophes() {
250        // Asciidoctor recognizes a run of three or more apostrophes as a
251        // thematic break, not just the canonical `'''`.
252        for line in ["''''", "'''''", "''''''"] {
253            let mut parser = Parser::default();
254            let mi = crate::blocks::Break::parse(&BlockMetadata::new(line), &mut parser).unwrap();
255            assert_eq!(
256                mi.item.type_(),
257                BreakType::Thematic,
258                "{line:?} should be a thematic break"
259            );
260            assert_eq!(mi.item.raw_context().deref(), "thematic_break");
261        }
262
263        // Fewer than three apostrophes is not a thematic break.
264        let mut parser = Parser::default();
265        assert!(crate::blocks::Break::parse(&BlockMetadata::new("''"), &mut parser).is_none());
266    }
267
268    #[test]
269    fn thematic_break_triple_hyphen() {
270        let mut parser = Parser::default();
271
272        let mi = crate::blocks::Break::parse(&BlockMetadata::new("---"), &mut parser).unwrap();
273
274        assert_eq!(
275            mi.item,
276            Break {
277                type_: BreakType::Thematic,
278                source: Span {
279                    data: "---",
280                    line: 1,
281                    col: 1,
282                    offset: 0,
283                },
284                title_source: None,
285                title: None,
286                anchor: None,
287                attrlist: None,
288            }
289        );
290
291        assert_eq!(
292            mi.after,
293            Span {
294                data: "",
295                line: 1,
296                col: 4,
297                offset: 3
298            }
299        );
300
301        assert_eq!(mi.item.content_model(), ContentModel::Empty);
302        assert_eq!(mi.item.raw_context().deref(), "thematic_break");
303        assert_eq!(mi.item.type_(), BreakType::Thematic);
304    }
305
306    #[test]
307    fn thematic_break_spaced_hyphen() {
308        let mut parser = Parser::default();
309
310        let mi = crate::blocks::Break::parse(&BlockMetadata::new("- - -"), &mut parser).unwrap();
311
312        assert_eq!(
313            mi.item,
314            Break {
315                type_: BreakType::Thematic,
316                source: Span {
317                    data: "- - -",
318                    line: 1,
319                    col: 1,
320                    offset: 0,
321                },
322                title_source: None,
323                title: None,
324                anchor: None,
325                attrlist: None,
326            }
327        );
328
329        assert_eq!(mi.item.type_(), BreakType::Thematic);
330    }
331
332    #[test]
333    fn thematic_break_triple_asterisk() {
334        let mut parser = Parser::default();
335
336        let mi = crate::blocks::Break::parse(&BlockMetadata::new("***"), &mut parser).unwrap();
337
338        assert_eq!(
339            mi.item,
340            Break {
341                type_: BreakType::Thematic,
342                source: Span {
343                    data: "***",
344                    line: 1,
345                    col: 1,
346                    offset: 0,
347                },
348                title_source: None,
349                title: None,
350                anchor: None,
351                attrlist: None,
352            }
353        );
354
355        assert_eq!(mi.item.content_model(), ContentModel::Empty);
356        assert_eq!(mi.item.raw_context().deref(), "thematic_break");
357        assert_eq!(mi.item.type_(), BreakType::Thematic);
358    }
359
360    #[test]
361    fn thematic_break_spaced_asterisk() {
362        let mut parser = Parser::default();
363
364        let mi = crate::blocks::Break::parse(&BlockMetadata::new("* * *"), &mut parser).unwrap();
365
366        assert_eq!(
367            mi.item,
368            Break {
369                type_: BreakType::Thematic,
370                source: Span {
371                    data: "* * *",
372                    line: 1,
373                    col: 1,
374                    offset: 0,
375                },
376                title_source: None,
377                title: None,
378                anchor: None,
379                attrlist: None,
380            }
381        );
382
383        assert_eq!(mi.item.type_(), BreakType::Thematic);
384    }
385
386    #[test]
387    fn thematic_break_triple_underscore() {
388        // Asciidoctor accepts `___` as a markdown-style thematic break (an
389        // extension beyond the AsciiDoc spec's `-`/`*` forms); this crate
390        // matches that.
391        let mut parser = Parser::default();
392
393        let mi = crate::blocks::Break::parse(&BlockMetadata::new("___"), &mut parser).unwrap();
394
395        assert_eq!(
396            mi.item,
397            Break {
398                type_: BreakType::Thematic,
399                source: Span {
400                    data: "___",
401                    line: 1,
402                    col: 1,
403                    offset: 0,
404                },
405                title_source: None,
406                title: None,
407                anchor: None,
408                attrlist: None,
409            }
410        );
411
412        assert_eq!(mi.item.content_model(), ContentModel::Empty);
413        assert_eq!(mi.item.raw_context().deref(), "thematic_break");
414        assert_eq!(mi.item.type_(), BreakType::Thematic);
415    }
416
417    #[test]
418    fn thematic_break_spaced_underscore() {
419        let mut parser = Parser::default();
420
421        let mi = crate::blocks::Break::parse(&BlockMetadata::new("_ _ _"), &mut parser).unwrap();
422
423        assert_eq!(
424            mi.item,
425            Break {
426                type_: BreakType::Thematic,
427                source: Span {
428                    data: "_ _ _",
429                    line: 1,
430                    col: 1,
431                    offset: 0,
432                },
433                title_source: None,
434                title: None,
435                anchor: None,
436                attrlist: None,
437            }
438        );
439
440        assert_eq!(mi.item.type_(), BreakType::Thematic);
441    }
442
443    #[test]
444    fn four_underscores_is_not_a_thematic_break() {
445        // A run of four or more underscores is a quote block delimiter, not a
446        // thematic break, so `Break::parse` must reject it. (Contrast the
447        // apostrophe run, where four or more `'` *is* a thematic break.)
448        let mut parser = Parser::default();
449        assert!(crate::blocks::Break::parse(&BlockMetadata::new("____"), &mut parser).is_none());
450
451        let mut parser = Parser::default();
452        assert!(crate::blocks::Break::parse(&BlockMetadata::new("_____"), &mut parser).is_none());
453    }
454
455    #[test]
456    fn page_break() {
457        let mut parser = Parser::default();
458
459        let mi = crate::blocks::Break::parse(&BlockMetadata::new("<<<"), &mut parser).unwrap();
460
461        assert_eq!(
462            mi.item,
463            Break {
464                type_: BreakType::Page,
465                source: Span {
466                    data: "<<<",
467                    line: 1,
468                    col: 1,
469                    offset: 0,
470                },
471                title_source: None,
472                title: None,
473                anchor: None,
474                attrlist: None,
475            }
476        );
477
478        assert_eq!(
479            mi.after,
480            Span {
481                data: "",
482                line: 1,
483                col: 4,
484                offset: 3
485            }
486        );
487
488        assert_eq!(mi.item.content_model(), ContentModel::Empty);
489        assert_eq!(mi.item.raw_context().deref(), "page_break");
490        assert_eq!(mi.item.type_(), BreakType::Page);
491        assert!(mi.item.child_blocks().next().is_none());
492        assert!(mi.item.title_source().is_none());
493        assert!(mi.item.title().is_none());
494        assert!(mi.item.anchor().is_none());
495        assert!(mi.item.anchor_reftext().is_none());
496        assert!(mi.item.attrlist().is_none());
497        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
498    }
499
500    #[test]
501    fn thematic_break_with_trailing_whitespace() {
502        let mut parser = Parser::default();
503
504        let mi = crate::blocks::Break::parse(&BlockMetadata::new("'''   "), &mut parser).unwrap();
505
506        assert_eq!(
507            mi.item,
508            Break {
509                type_: BreakType::Thematic,
510                source: Span {
511                    data: "'''",
512                    line: 1,
513                    col: 1,
514                    offset: 0,
515                },
516                title_source: None,
517                title: None,
518                anchor: None,
519                attrlist: None,
520            }
521        );
522
523        assert_eq!(mi.item.type_(), BreakType::Thematic);
524    }
525
526    #[test]
527    fn page_break_with_trailing_whitespace() {
528        let mut parser = Parser::default();
529
530        let mi = crate::blocks::Break::parse(&BlockMetadata::new("<<<   "), &mut parser).unwrap();
531
532        assert_eq!(
533            mi.item,
534            Break {
535                type_: BreakType::Page,
536                source: Span {
537                    data: "<<<",
538                    line: 1,
539                    col: 1,
540                    offset: 0,
541                },
542                title_source: None,
543                title: None,
544                anchor: None,
545                attrlist: None,
546            }
547        );
548
549        assert_eq!(mi.item.type_(), BreakType::Page);
550    }
551
552    mod break_type {
553        mod impl_debug {
554            use crate::blocks::BreakType;
555
556            #[test]
557            fn thematic() {
558                let break_type = BreakType::Thematic;
559                let debug_output = format!("{:?}", break_type);
560                assert_eq!(debug_output, "BreakType::Thematic");
561            }
562
563            #[test]
564            fn page() {
565                let break_type = BreakType::Page;
566                let debug_output = format!("{:?}", break_type);
567                assert_eq!(debug_output, "BreakType::Page");
568            }
569        }
570    }
571}