Skip to main content

asciidoc_parser/blocks/
break.rs

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