aboutsummaryrefslogblamecommitdiffstats
path: root/src/construct/heading_setext.rs
blob: d9ff96c60051904d9a718b3e832d73c25dfd5d0a (plain) (tree)



















































                                                                                                                                                               
                                                             
                             
                                                                         
                                       



                                  







                              
         








                                   


             





















                                              






                                                                      










                                                                       




                                                                                













                                                          
                                                                        
























                                                                          





                                                                   
                                                   

                                                   


                                                  
             
                                                                                       














                                                                            
                                           

                                         

                                                                    





                                                                                                  
                                                  

                                                   



























                                                                             





                                                                               




                                          























                                                                                      
                                                               
                                                                             











                                                                                                  
                
                                                       

                                    
                                                                                       


                     
                                                                                

















                                                                                
//! Heading (setext) is a construct that occurs in the [flow] content type.
//!
//! They’re formed with the following BNF:
//!
//! ```bnf
//! heading_setext ::= line *(eol line) eol whitespace_optional (1*'-' | 1*'=') whitespace_optional
//!
//! whitespace ::= 1*space_or_tab
//! whitespace_optional ::= [ whitespace ]
//! line ::= code - eol
//! eol ::= '\r' | '\r\n' | '\n'
//! ```
//!
//! Heading (setext) in markdown relates to the `<h1>` and `<h2>` elements in
//! HTML.
//! See [*§ 4.3.6 The `h1`, `h2`, `h3`, `h4`, `h5`, and `h6` elements* in the
//! HTML spec][html] for more info.
//!
//! In markdown, it is also possible to create headings with a
//! [heading (atx)][heading_atx] construct.
//! The benefit of setext headings is that their text can include line endings,
//! and by extensions also hard breaks (e.g., with
//! [hard break (escape)][hard_break_escape]).
//! However, their limit is that they cannot form `<h3>` through `<h6>`
//! headings.
//! Due to this limitation, it is recommended to use atx headings.
//!
//! [Thematic breaks][thematic_break] formed with dashes (without whitespace)
//! can also form heading (setext).
//!
//! > 🏛 **Background**: the word *setext* originates from a small markup
//! > language by Ian Feldman from 1991.
//! > See [*§ Setext* on Wikipedia][wiki-setext] for more info.
//! > The word *atx* originates from a tiny markup language by Aaron Swartz
//! > from 2002.
//! > See [*§ atx, the true structured text format* on `aaronsw.com`][atx] for
//! > more info.
//!
//! ## References
//!
//! *   [`setext-underline.js` in `micromark`](https://github.com/micromark/micromark/blob/main/packages/micromark-core-commonmark/dev/lib/setext-underline.js)
//! *   [*§ 4.3 Setext headings* in `CommonMark`](https://spec.commonmark.org/0.30/#setext-headings)
//!
//! [flow]: crate::content::flow
//! [heading_atx]: crate::construct::heading_atx
//! [thematic_break]: crate::construct::thematic_break
//! [hard_break_escape]: crate::construct::hard_break_escape
//! [html]: https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements
//! [wiki-setext]: https://en.wikipedia.org/wiki/Setext
//! [atx]: http://www.aaronsw.com/2002/atx/

use crate::constant::TAB_SIZE;
use crate::construct::partial_space_or_tab::space_or_tab_opt;
use crate::subtokenize::link;
use crate::tokenizer::{Code, State, StateFnResult, TokenType, Tokenizer};
use crate::util::span::from_exit_event;

/// Kind of underline.
#[derive(Debug, Clone, PartialEq)]
pub enum Kind {
    /// Dash (rank 2) heading.
    ///
    /// ## Example
    ///
    /// ```markdown
    /// alpha
    /// -----
    /// ```
    Dash,

    /// Equals to (rank 1) heading.
    ///
    /// ## Example
    ///
    /// ```markdown
    /// alpha
    /// =====
    /// ```
    EqualsTo,
}

impl Kind {
    /// Turn the kind into a [char].
    fn as_char(&self) -> char {
        match self {
            Kind::Dash => '-',
            Kind::EqualsTo => '=',
        }
    }
    /// Turn a [char] into a kind.
    ///
    /// ## Panics
    ///
    /// Panics if `char` is not `-` or `=`.
    fn from_char(char: char) -> Kind {
        match char {
            '-' => Kind::Dash,
            '=' => Kind::EqualsTo,
            _ => unreachable!("invalid char"),
        }
    }
}

/// Start of a heading (setext).
///
/// ```markdown
/// |alpha
/// ==
/// ```
pub fn start(tokenizer: &mut Tokenizer, code: Code) -> StateFnResult {
    tokenizer.enter(TokenType::HeadingSetext);
    tokenizer.go(space_or_tab_opt(), before)(tokenizer, code)
}

/// Start of a heading (setext), after whitespace.
///
/// ```markdown
/// |alpha
/// ==
/// ```
pub fn before(tokenizer: &mut Tokenizer, code: Code) -> StateFnResult {
    match code {
        Code::None | Code::CarriageReturnLineFeed | Code::Char('\n' | '\r') => {
            unreachable!("expected non-eol/eof");
        }
        _ => {
            tokenizer.enter(TokenType::HeadingSetextText);
            tokenizer.enter(TokenType::ChunkText);
            text_inside(tokenizer, code)
        }
    }
}

/// Inside text.
///
/// ```markdown
/// al|pha
/// bra|vo
/// ==
/// ```
fn text_inside(tokenizer: &mut Tokenizer, code: Code) -> StateFnResult {
    match code {
        Code::None => (State::Nok, None),
        Code::CarriageReturnLineFeed | Code::Char('\n' | '\r') => {
            tokenizer.exit(TokenType::ChunkText);
            tokenizer.exit(TokenType::HeadingSetextText);
            tokenizer.attempt(underline_before, |ok| {
                Box::new(if ok { after } else { text_continue })
            })(tokenizer, code)
        }
        _ => {
            tokenizer.consume(code);
            (State::Fn(Box::new(text_inside)), None)
        }
    }
}

/// At a line ending, not at an underline.
///
/// ```markdown
/// alpha
/// |bravo
/// ==
/// ```
fn text_continue(tokenizer: &mut Tokenizer, code: Code) -> StateFnResult {
    // Needed to connect the text.
    tokenizer.enter(TokenType::HeadingSetextText);
    tokenizer.events.pop();
    tokenizer.events.pop();

    match code {
        Code::CarriageReturnLineFeed | Code::Char('\n' | '\r') => {
            tokenizer.enter(TokenType::LineEnding);
            let index = tokenizer.events.len() - 1;
            link(&mut tokenizer.events, index);
            tokenizer.consume(code);
            tokenizer.exit(TokenType::LineEnding);

            (
                State::Fn(Box::new(tokenizer.go(space_or_tab_opt(), text_line_start))),
                None,
            )
        }
        _ => unreachable!("expected eol"),
    }
}

/// At a line ending after whitespace, not at an underline.
///
/// ```markdown
/// alpha
/// |bravo
/// ==
/// ```
fn text_line_start(tokenizer: &mut Tokenizer, code: Code) -> StateFnResult {
    let index = tokenizer.events.len() - 2;

    // Link the whitespace, if it exists.
    if tokenizer.events[index].token_type == TokenType::Whitespace {
        link(&mut tokenizer.events, index);
    }

    match code {
        // Blank lines not allowed.
        Code::None | Code::CarriageReturnLineFeed | Code::Char('\n' | '\r') => (State::Nok, None),
        _ => {
            tokenizer.enter(TokenType::ChunkText);
            let index = tokenizer.events.len() - 1;
            link(&mut tokenizer.events, index);
            text_inside(tokenizer, code)
        }
    }
}

/// After a heading (setext).
///
/// ```markdown
/// alpha
/// ==|
/// ```
fn after(tokenizer: &mut Tokenizer, code: Code) -> StateFnResult {
    tokenizer.exit(TokenType::HeadingSetext);
    (State::Ok, Some(vec![code]))
}

/// At a line ending, presumably an underline.
///
/// ```markdown
/// alpha|
/// ==
/// ```
fn underline_before(tokenizer: &mut Tokenizer, code: Code) -> StateFnResult {
    match code {
        Code::CarriageReturnLineFeed | Code::Char('\n' | '\r') => {
            tokenizer.enter(TokenType::LineEnding);
            tokenizer.consume(code);
            tokenizer.exit(TokenType::LineEnding);
            (
                State::Fn(Box::new(
                    tokenizer.go(space_or_tab_opt(), underline_sequence_start),
                )),
                None,
            )
        }
        _ => unreachable!("expected eol"),
    }
}

/// After optional whitespace, presumably an underline.
///
/// ```markdown
/// alpha
/// |==
/// ```
fn underline_sequence_start(tokenizer: &mut Tokenizer, code: Code) -> StateFnResult {
    let tail = tokenizer.events.last();
    let mut prefix = 0;

    if let Some(event) = tail {
        if event.token_type == TokenType::Whitespace {
            let span = from_exit_event(&tokenizer.events, tokenizer.events.len() - 1);
            prefix = span.end_index - span.start_index;
        }
    }

    // To do: 4+ should be okay if code (indented) is turned off!
    if prefix >= TAB_SIZE {
        return (State::Nok, None);
    }

    match code {
        Code::Char(char) if char == '-' || char == '=' => {
            tokenizer.enter(TokenType::HeadingSetextUnderline);
            underline_sequence_inside(tokenizer, code, Kind::from_char(char))
        }
        _ => (State::Nok, None),
    }
}

/// In an underline sequence.
///
/// ```markdown
/// alpha
/// =|=
/// ```
fn underline_sequence_inside(tokenizer: &mut Tokenizer, code: Code, kind: Kind) -> StateFnResult {
    match code {
        Code::Char(char) if char == kind.as_char() => {
            tokenizer.consume(code);
            (
                State::Fn(Box::new(move |t, c| underline_sequence_inside(t, c, kind))),
                None,
            )
        }
        _ => tokenizer.go(space_or_tab_opt(), underline_after)(tokenizer, code),
    }
}

/// After an underline sequence, after optional whitespace.
///
/// ```markdown
/// alpha
/// ==|
/// ```
fn underline_after(tokenizer: &mut Tokenizer, code: Code) -> StateFnResult {
    match code {
        Code::None | Code::CarriageReturnLineFeed | Code::Char('\n' | '\r') => {
            tokenizer.exit(TokenType::HeadingSetextUnderline);
            (State::Ok, Some(vec![code]))
        }
        _ => (State::Nok, None),
    }
}