diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f14ce13..bdaba01 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -79,7 +79,7 @@ jobs: # https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability strategy: matrix: - msrv: [1.67.1] #version at the moment of creation + msrv: [1.80.0] # due to Option::take_if usage name: ubuntu / ${{ matrix.msrv }} steps: - uses: actions/checkout@v4 diff --git a/Cargo.toml b/Cargo.toml index b33315f..e87f0b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +1,17 @@ [package] name = "yamd" -version = "0.13.3" +description = "Yet Another Markdown Document (flavour)" +version = "0.14.0" edition = "2021" license = "MIT OR Apache-2.0" -description = "Yet Another Markdown Document (flavor)" repository = "https://github.com/Lurk/yamd" readme = "README.md" keywords = ["markdown", "parser"] +[dependencies] +serde = { version = "1.0.197", features = ["derive"] } + [dev-dependencies] pretty_assertions = "1.4.0" -[dependencies] -serde = { version = "1.0.197", features = ["derive"] } -chrono = { version = "0.4.37", features = ["serde"] } -serde_yaml = "0.9.34" + diff --git a/readme.md b/readme.md index 461d11e..b2d3d7b 100644 --- a/readme.md +++ b/readme.md @@ -1,42 +1,73 @@ -# Yet another markdown document flavour (YAMD) +# yamd + [![codecov](https://codecov.io/gh/Lurk/yamd/branch/main/graph/badge.svg?token=F8KRUYI1AA)](https://codecov.io/gh/Lurk/yamd) [![crates.io](https://img.shields.io/crates/v/yamd.svg)](https://crates.io/crates/yamd) [![Released API docs](https://docs.rs/yamd/badge.svg)](https://docs.rs/yamd) -## Status + + +YAMD - Yet Another Markdown Document (flavour) + +Simplified version of [CommonMark](https://spec.commonmark.org/). + +For formatting check YAMD struct documentation. + +## Reasoning + +Simplified set of rules allows to have simpler, more efficient, parser and renderer. +YAMD does not provide render functionality, instead it is a [serde] +serializable structure that allows you to write any renderer for that structure. All HTML +equivalents in this doc are provided as an example to what it can be rendered. + +## Difference from CommonMark + +### Escaping + +Escaping done on a [lexer] level. Every symbol following the `\` symbol will be treated as a +literal. + +Example: + +| YAMD | HTML equivalent | +|-----------|-----------------| +| `\**foo**`|`

**foo**

` | + +### Escape character + +To get `\` - `\\` must be used. + +Example: -It is not ready to poke around. There is significant API changes expected. +| YAMD | HTML equivalent | +|---------------|-----------------------| +| `\\**foo**` |`

\foo

` | -## Why? -Initial idea was to create human readable text format for my blog. Why not existing flavour? -Existing flavours do not have elements like image gallery, dividers, highlight, etc. +### Precedence -## Features +[CommonMark](https://spec.commonmark.org/0.31.2/#precedence) defines container blocks and leaf +blocks. And that container block indicator has higher precedence. YAMD does not discriminate by +block type, every node (block) is the same. In practice, there are no additional rules to encode +and remember. -Deserialize markdown to YAMD struct, Serialize YAMD struct to markdown. +Example: -## Example +| YAMD | HTML equivalent | +|-----------------------|-----------------------------------------------| +| ``- `one\n- two` `` | `
  1. one\n- two
` | -```rust -use yamd::{deserialize, serialize}; -let input = r#"--- -title: YAMD documnet showcase -date: 2023-08-13T15:42:00+02:00 -preview: here is how you can serialize ande deserialize YAMD document -tags: -- yamd -- markdown ---- -# This is a new Yamd document +If you want to have two ListItem's use escaping: -Check out [documentation](https://docs.rs/yamd/latest/yamd/) to get what elements **Yamd** format supports. +| YAMD | HTML equivalent | +|---------------------------|-------------------------------------------| +| ``- \`one\n- two\` `` | ``
  1. `one
  2. two`
    1. `` | -"#; -let yamd = deserialize(input).unwrap(); -let output = serialize(&yamd); -``` +The reasoning is that those kind issues can be caught with tooling like linters/lsp. That tooling +does not exist yet. +### Nodes +List of supported [nodes](https://docs.rs/yamd/latest/yamd/nodes/) and their formatting slightly defers from CommonSpec. + diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs new file mode 100644 index 0000000..4a8520d --- /dev/null +++ b/src/lexer/mod.rs @@ -0,0 +1,727 @@ +/// # Lexer for YAMD. +/// +/// ## Usage: +/// +/// ```rust +/// use yamd::lexer::Lexer; +/// let lexer = Lexer::new("string"); +/// for slice in lexer.map(|t|t.slice){ +/// print!("{}", slice); +/// } +/// ``` +mod token; + +use std::{char, collections::VecDeque, iter::Peekable, str::CharIndices}; + +pub use token::{Position, Token, TokenKind}; + +pub struct Lexer<'input> { + literal_start: Option, + position: Position, + input: &'input str, + iter: Peekable>, + queue: VecDeque>, + token: Option>, +} + +impl<'input> Lexer<'input> { + pub fn new(input: &'input str) -> Self { + Self { + input, + position: Position::default(), + iter: input.char_indices().peekable(), + literal_start: None, + queue: VecDeque::with_capacity(2), + token: None, + } + } + + fn emit_literal_if_started(&mut self, end_byte_index: usize) { + if let Some(start_position) = self.literal_start.take() { + if let Some(token) = self.token.take() { + self.queue.push_back(token); + } + self.queue.push_back(Token::new( + TokenKind::Literal, + &self.input[start_position.byte_index..end_byte_index], + start_position, + )); + } + } + + fn eol(&mut self, position: Position, len_in_bytes: usize) { + self.emit_literal_if_started(position.byte_index); + self.position.row += 1; + self.position.column = 0; + let Some(t) = self + .token + .replace(self.to_token(TokenKind::Eol, position, len_in_bytes)) + else { + return; + }; + if t.kind == TokenKind::Eol { + self.token.replace(self.to_token( + TokenKind::Terminator, + t.position, + t.slice.len() + len_in_bytes, + )); + return; + } + self.queue.push_back(t); + } + + fn emit(&mut self, token: Token<'input>) { + self.emit_literal_if_started(token.position.byte_index); + if let Some(l) = self.token.replace(token) { + self.queue.push_back(l); + } + } + + fn next_is(&mut self, char: char) -> bool { + let Some((_, next_char)) = self.iter.peek() else { + return false; + }; + if *next_char == char { + self.next_char(false); + return true; + } + false + } + + fn next_char(&mut self, escaped: bool) -> Option<(Position, char)> { + if let Some((byte_offset, char)) = self.iter.next() { + self.position.byte_index = byte_offset; + let res = Some((self.position.clone(), char)); + if char != '\\' || escaped { + self.position.column += 1; + } + return res; + } + None + } + + fn to_token(&self, kind: TokenKind, position: Position, len_in_bytes: usize) -> Token<'input> { + Token::new( + kind, + &self.input[position.byte_index..position.byte_index + len_in_bytes], + position, + ) + } + + fn take_while(&mut self, c: char, kind: TokenKind, start: Position) { + while self.next_is(c) {} + self.emit(Token::new( + kind, + &self.input[start.byte_index..self.position.byte_index + 1], + start, + )) + } + + fn parse(&mut self, position: Position, char: char) { + match char { + '\n' => self.eol(position, 1), + '\r' if self.next_is('\n') => self.eol(position, 2), + '{' if self.next_is('%') => { + self.emit(self.to_token(TokenKind::CollapsibleStart, position, 2)) + } + '%' if self.next_is('}') => { + self.emit(self.to_token(TokenKind::CollapsibleEnd, position, 2)) + } + '\\' => { + self.emit_literal_if_started(position.byte_index); + if let Some((pos, _)) = self.next_char(true) { + self.literal_start.get_or_insert(pos); + } + } + '~' => self.take_while('~', TokenKind::Tilde, position), + '*' => self.take_while('*', TokenKind::Star, position), + '}' => self.take_while('}', TokenKind::RightCurlyBrace, position), + '{' => self.take_while('{', TokenKind::LeftCurlyBrace, position), + ' ' => self.take_while(' ', TokenKind::Space, position), + '-' => self.take_while('-', TokenKind::Minus, position), + '#' => self.take_while('#', TokenKind::Hash, position), + '>' => self.take_while('>', TokenKind::GreaterThan, position), + '!' => self.take_while('!', TokenKind::Bang, position), + '`' => self.take_while('`', TokenKind::Backtick, position), + '+' => self.take_while('+', TokenKind::Plus, position), + '[' => self.emit(self.to_token(TokenKind::LeftSquareBracket, position, 1)), + ']' => self.emit(self.to_token(TokenKind::RightSquareBracket, position, 1)), + '(' => self.emit(self.to_token(TokenKind::LeftParenthesis, position, 1)), + ')' => self.emit(self.to_token(TokenKind::RightParenthesis, position, 1)), + '_' => self.emit(self.to_token(TokenKind::Underscore, position, 1)), + '|' => self.emit(self.to_token(TokenKind::Pipe, position, 1)), + _ => { + self.literal_start.get_or_insert(position); + } + } + } + + fn advance(&mut self) { + while self.queue.is_empty() { + if let Some((position, char)) = self.next_char(false) { + self.parse(position, char); + } else { + self.position.byte_index = self.input.len(); + self.emit_literal_if_started(self.position.byte_index); + if let Some(token) = self.token.take() { + self.queue.push_back(token) + } + return; + } + } + } +} + +impl<'input> Iterator for Lexer<'input> { + type Item = Token<'input>; + + fn next(&mut self) -> Option { + self.advance(); + self.queue.pop_front() + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::lexer::{Lexer, Position, Token, TokenKind}; + + #[test] + fn left_square_bracket() { + assert_eq!( + Lexer::new("[").collect::>(), + vec![Token::new( + TokenKind::LeftSquareBracket, + "[", + Position::default() + )], + ); + } + + #[test] + fn double_bang_with_left_square_bracket_afterwards() { + assert_eq!( + Lexer::new("!![").collect::>(), + vec![ + Token::new(TokenKind::Bang, "!!", Position::default()), + Token::new( + TokenKind::LeftSquareBracket, + "[", + Position { + byte_index: 2, + column: 2, + row: 0, + } + ) + ] + ) + } + + #[test] + fn double_bang_with_blob_afterwards() { + assert_eq!( + Lexer::new("!!g").collect::>(), + vec![ + Token::new(TokenKind::Bang, "!!", Position::default()), + Token::new( + TokenKind::Literal, + "g", + Position { + byte_index: 2, + column: 2, + row: 0, + } + ) + ] + ) + } + + #[test] + fn escaped_bang() { + assert_eq!( + Lexer::new("\\![").collect::>(), + vec![ + Token::new( + TokenKind::Literal, + "!", + Position { + byte_index: 1, + column: 0, + row: 0 + } + ), + Token::new( + TokenKind::LeftSquareBracket, + "[", + Position { + byte_index: 2, + column: 1, + row: 0 + } + ) + ] + ) + } + + #[test] + fn bang_open_brace() { + assert_eq!( + Lexer::new("![").collect::>(), + vec![ + Token::new(TokenKind::Bang, "!", Position::default()), + Token::new( + TokenKind::LeftSquareBracket, + "[", + Position { + byte_index: 1, + column: 1, + row: 0 + } + ) + ] + ) + } + + #[test] + fn triple_bang() { + assert_eq!( + Lexer::new("!!!").collect::>(), + vec![Token::new(TokenKind::Bang, "!!!", Position::default())] + ); + } + + #[test] + fn hastag() { + assert_eq!( + Lexer::new("[#####").collect::>(), + vec![ + Token::new(TokenKind::LeftSquareBracket, "[", Position::default()), + Token::new( + TokenKind::Hash, + "#####", + Position { + byte_index: 1, + column: 1, + row: 0 + } + ) + ] + ); + } + + #[test] + fn space() { + assert_eq!( + Lexer::new("###\\ ").collect::>(), + vec![ + Token::new(TokenKind::Hash, "###", Position::default()), + Token::new( + TokenKind::Literal, + " ", + Position { + byte_index: 4, + column: 3, + row: 0, + } + ), + Token::new( + TokenKind::Space, + " ", + Position { + byte_index: 5, + column: 4, + row: 0 + } + ) + ] + ); + } + + #[test] + fn eol() { + assert_eq!( + Lexer::new("\n").collect::>(), + vec![Token::new(TokenKind::Eol, "\n", Position::default())] + ); + } + + #[test] + fn double_eol() { + assert_eq!( + Lexer::new("\n\n").collect::>(), + vec![Token::new( + TokenKind::Terminator, + "\n\n", + Position::default() + )] + ); + } + + #[test] + fn triple_eol() { + assert_eq!( + Lexer::new("\n\n\n").collect::>(), + vec![ + Token::new(TokenKind::Terminator, "\n\n", Position::default()), + Token::new( + TokenKind::Eol, + "\n", + Position { + byte_index: 2, + column: 0, + row: 2 + } + ) + ] + ); + } + + #[test] + fn windows_eol() { + assert_eq!( + Lexer::new("\r\n").collect::>(), + vec![Token::new(TokenKind::Eol, "\r\n", Position::default())] + ); + } + + #[test] + fn windows_double_eol() { + assert_eq!( + Lexer::new("\r\n\r\n").collect::>(), + vec![Token::new( + TokenKind::Terminator, + "\r\n\r\n", + Position::default() + )] + ); + } + + #[test] + fn blob_that_ends_with_emoji() { + assert_eq!( + Lexer::new("hello blob😉").collect::>(), + vec![ + Token::new(TokenKind::Literal, "hello", Position::default()), + Token::new( + TokenKind::Space, + " ", + Position { + byte_index: 5, + column: 5, + row: 0 + } + ), + Token::new( + TokenKind::Literal, + "blob😉", + Position { + byte_index: 6, + column: 6, + row: 0 + } + ) + ] + ) + } + + #[test] + fn correct_position_utf8() { + assert_eq!( + Lexer::new("blob😉 ").collect::>(), + vec![ + Token::new(TokenKind::Literal, "blob😉", Position::default()), + Token::new( + TokenKind::Space, + " ", + Position { + byte_index: 8, + column: 5, + row: 0 + } + ) + ] + ) + } + + #[test] + fn double_open_brace() { + assert_eq!( + Lexer::new("{{").collect::>(), + vec![Token::new( + TokenKind::LeftCurlyBrace, + "{{", + Position::default() + )] + ) + } + + #[test] + fn colapsible_start() { + assert_eq!( + Lexer::new("{%").collect::>(), + vec![Token::new( + TokenKind::CollapsibleStart, + "{%", + Position::default() + )] + ) + } + + #[test] + fn colapsible_end() { + assert_eq!( + Lexer::new("{%").collect::>(), + vec![Token::new( + TokenKind::CollapsibleStart, + "{%", + Position::default() + )] + ) + } + + #[test] + fn right_square_bracket() { + assert_eq!( + Lexer::new("]").collect::>(), + vec![Token::new( + TokenKind::RightSquareBracket, + "]", + Position::default() + )] + ) + } + + #[test] + fn open_parenthesis() { + assert_eq!( + Lexer::new("(").collect::>(), + vec![Token::new( + TokenKind::LeftParenthesis, + "(", + Position::default() + )] + ) + } + + #[test] + fn closing_parenthesis() { + assert_eq!( + Lexer::new(")").collect::>(), + vec![Token::new( + TokenKind::RightParenthesis, + ")", + Position::default() + )] + ); + } + + #[test] + fn empty_input() { + assert_eq!(Lexer::new("").collect::>(), vec![]); + } + + #[test] + fn double_escape() { + assert_eq!( + Lexer::new("\\\\").collect::>(), + vec![Token::new( + TokenKind::Literal, + "\\", + Position { + byte_index: 1, + column: 0, + row: 0 + } + )] + ) + } + + #[test] + fn escape_after_literal() { + assert_eq!( + Lexer::new("literal\\[[").collect::>(), + vec![ + Token::new(TokenKind::Literal, "literal", Position::default()), + Token::new( + TokenKind::Literal, + "[", + Position { + byte_index: 8, + column: 7, + row: 0, + }, + ), + Token { + kind: TokenKind::LeftSquareBracket, + slice: "[", + position: Position { + byte_index: 9, + column: 8, + row: 0, + }, + }, + ] + ) + } + + #[test] + fn double_star() { + assert_eq!( + Lexer::new("**").collect::>(), + vec![Token::new(TokenKind::Star, "**", Position::default()),] + ) + } + + #[test] + fn tirple_backtick() { + assert_eq!( + Lexer::new("````").collect::>(), + vec![Token::new(TokenKind::Backtick, "````", Position::default()),] + ); + } + + #[test] + fn underscore() { + assert_eq!( + Lexer::new("_").collect::>(), + vec![Token::new(TokenKind::Underscore, "_", Position::default())] + ) + } + + #[test] + fn plus() { + assert_eq!( + Lexer::new("+").collect::>(), + vec![Token::new(TokenKind::Plus, "+", Position::default())] + ) + } + + #[test] + fn minus() { + assert_eq!( + Lexer::new("-").collect::>(), + vec![Token::new(TokenKind::Minus, "-", Position::default())] + ) + } + + #[test] + fn multiple_minus() { + assert_eq!( + Lexer::new("----").collect::>(), + vec![Token::new(TokenKind::Minus, "----", Position::default())] + ) + } + + #[test] + fn greater_than() { + assert_eq!( + Lexer::new(">>> >>\n>").collect::>(), + vec![ + Token::new(TokenKind::GreaterThan, ">>>", Position::default()), + Token::new( + TokenKind::Space, + " ", + Position { + byte_index: 3, + column: 3, + row: 0, + } + ), + Token::new( + TokenKind::GreaterThan, + ">>", + Position { + byte_index: 4, + column: 4, + row: 0, + } + ), + Token::new( + TokenKind::Eol, + "\n", + Position { + byte_index: 6, + column: 6, + row: 0, + } + ), + Token::new( + TokenKind::GreaterThan, + ">", + Position { + byte_index: 7, + column: 0, + row: 1 + } + ) + ] + ) + } + + #[test] + fn backtick() { + assert_eq!( + Lexer::new("``").collect::>(), + vec![Token::new(TokenKind::Backtick, "``", Position::default(),),] + ) + } + + #[test] + fn strikethrough() { + assert_eq!( + Lexer::new("~~").collect::>(), + vec![Token::new(TokenKind::Tilde, "~~", Position::default())] + ) + } + + #[test] + fn strikethrough_after_blob() { + assert_eq!( + Lexer::new("abc~~").collect::>(), + vec![ + Token::new(TokenKind::Literal, "abc", Position::default()), + Token::new( + TokenKind::Tilde, + "~~", + Position { + byte_index: 3, + column: 3, + row: 0 + } + ) + ] + ) + } + + #[test] + fn embed_end() { + assert_eq!( + Lexer::new("}}").collect::>(), + vec![Token::new( + TokenKind::RightCurlyBrace, + "}}", + Position::default() + )] + ) + } + + #[test] + fn eol_after_literal() { + assert_eq!( + Lexer::new("r\n").collect::>(), + vec![ + Token::new(TokenKind::Literal, "r", Position::default()), + Token::new( + TokenKind::Eol, + "\n", + Position { + byte_index: 1, + column: 1, + row: 0 + } + ) + ] + ) + } +} diff --git a/src/lexer/token.rs b/src/lexer/token.rs new file mode 100644 index 0000000..aedf086 --- /dev/null +++ b/src/lexer/token.rs @@ -0,0 +1,94 @@ +use std::fmt::{Display, Formatter}; + +#[derive(Debug, PartialEq, Clone)] +pub enum TokenKind { + /// Two consecutive [TokenKind::Eol] + Terminator, + /// Exactly one `\n` or `\r\n` combination + Eol, + /// One or more `{` + LeftCurlyBrace, + /// One or more `}` + RightCurlyBrace, + /// Exactly one `{%` combination + CollapsibleStart, + /// Exactly one `%}` combination + CollapsibleEnd, + /// One or more `~` + Tilde, + /// One or more `*` + Star, + /// One or more ` ` + Space, + /// One or more `-` + Minus, + /// One or more `#` + Hash, + /// One or more `>` + GreaterThan, + /// One or more `!` + Bang, + /// One or more `` ` `` + Backtick, + /// One or more `+` + Plus, + /// Exactly one `[` + LeftSquareBracket, + /// Exactly one `]` + RightSquareBracket, + /// Exactly one `(` + LeftParenthesis, + /// Exactly one `)` + RightParenthesis, + /// Exactly one `_` + Underscore, + /// Exactly one `|` + Pipe, + /// One or more chars that does not fall to one of the rules from above. + Literal, +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct Position { + pub byte_index: usize, + pub column: usize, + pub row: usize, +} + +#[derive(Debug, PartialEq)] +pub struct Token<'input> { + pub kind: TokenKind, + pub slice: &'input str, + pub position: Position, +} + +impl<'input> Token<'input> { + pub fn new(kind: TokenKind, slice: &'input str, position: Position) -> Self { + Self { + kind, + slice, + position, + } + } +} + +impl Display for Token<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(self.slice) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::lexer::{Position, Token, TokenKind}; + + #[test] + fn display() { + assert_eq!( + Token::new(TokenKind::Literal, "str", Position::default()).to_string(), + "str" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index f1a254c..c27ca0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,254 +1,100 @@ -//! # Yamd - yet another markdown document flavour +//! YAMD - Yet Another Markdown Document (flavour) //! -//! Yamd is a markdown document flavour that allows to create rich documents with images, code, and more. +//! Simplified version of [CommonMark](https://spec.commonmark.org/). //! -//! ## Syntax +//! For formatting check [YAMD](crate::nodes::Yamd) struct documentation. //! -//! Each yamd document starts with metadata section which is YAML document surrounded by "---". Metadata has next -//! fields: title, date, image, preview, is_draft, and tags. +//! # Reasoning //! -//! Timestamp format: "%Y-%m-%dT%H:%M:%S%z" ([specifiers description](https://docs.rs/chrono/latest/chrono/format/strftime/index.html)) +//! Simplified set of rules allows to have simpler, more efficient, parser and renderer. +//! [YAMD](crate::nodes::Yamd) does not provide render functionality, instead it is a [serde] +//! serializable structure that allows you to write any renderer for that structure. All HTML +//! equivalents in this doc are provided as an example to what it can be rendered. //! -//! Tags are array of strings. +//! # Difference from CommonMark //! -//! is_draft is a boolean value. +//! While YAMD tries to utilize as much CommonMark syntax as possible, there are differences. //! -//! Example: -//! ```text -//! --- -//! title: Yamd - yet another markdown document flavour -//! date: 2023-01-01 00:00:00 +0000 -//! image: /image.png -//! preview: Here you can find out more about yamd -//! is_draft: true -//! tags: -//! - markdown -//! - rust -//! --- -//! -//! ``` -//! -//! ## Elements: -//! -//! ### List -//! -//! Can contain nested lists. Each nesting level equals to number of spaces before list item. +//! ## Escaping //! -//! #### Unordered list -//! -//! Starts with "- " and ends with a new line -//! -//! #### Ordered list -//! -//! Starts with "+ " and ends with a new line +//! Escaping done on a [lexer] level. Every symbol following the `\` symbol will be treated as a +//! [literal](crate::lexer::TokenKind::Literal). //! //! Example: -//! ```text -//! - item 1 -//! - item 2 -//! + ordered nested list -//! - can have nested unordered list inside ordered list -//! + one more ordered item -//! - item 3 -//! ``` //! -//! ### Code +//! | YAMD | HTML equivalent | +//! |-----------|-----------------| +//! | `\**foo**`|`

      **foo**

      ` | //! -//! Element that starts with "\`\`\`lang\n", ends with "\n```" and has code in between. +//! ## Precedence //! -//! Example: -//! ```text -//! \```rust -//! let x = 1; -//! \``` -//! ``` -//! ^ did not figured out how to escape \`\`\` in rustdoc -//! -//! ### Image -//! -//! Element that starts with "!" has image alt text in [] and followed by image url in () -//! -//! Example: -//! ```text -//! ![alt text](url) -//! ``` -//! -//! ### ImageGallery -//! -//! Element that starts with "!!!\n", ends with "\n!!!", and has image elements in between +//! [CommonMark](https://spec.commonmark.org/0.31.2/#precedence) defines container blocks and leaf +//! blocks. And that container block indicator has higher precedence. YAMD does not discriminate by +//! block type, every node (block) is the same. In practice, there are no additional rules to encode +//! and remember. //! //! Example: -//! ```text -//! !!! -//! ![alt text](url) -//! ![alt text](url) -//! !!! -//! ``` -//! -//! ### Highlight -//! -//! Element that starts with ">>>\n", followed by optional title that starts with ">> " and ends with a new line, -//! followed by optional icon specifier that starts with "> " and ends with a new line, followed by body that can -//! contain any number of paragraph elements -//! -//! Example: -//! ```text -//! >>> -//! >> Title -//! > icon -//! body -//! -//! can be multiple paragraphs long -//! >>> -//! ``` -//! no header and no icon: -//! ```text -//! >>> -//! body -//! >>> -//! ``` -//! -//! ### Divider -//! -//! Element that consist of five "-" characters in a row and ends with a new line or EOF. -//! -//! Example: ```-----``` -//! -//! ### Embed -//! -//! Element that starts with "{{" followed by embed type, followed by "|" followed by embed url, followed by "}}" -//! and ends with a new line or EOF. -//! -//! Example: ```{{youtube|https://www.youtube.com/embed/wsfdjlkjsdf}}``` -//! -//! -//! ### Paragraph -//! -//! Element that starts with any character that is not a special character and ends with a new line or EOF. -//! Can contain text, bold text, italic text, strikethrough text, anchors, and inline code. -//! -//! #### Anchor -//! -//! element that starts with "[" followed by text, followed by "]" followed by "(" followed by url, followed by ")" -//! -//! example: ```[Yamd repo](https://github.com/Lurk/yamd)``` -//! -//! #### Inline code //! -//! element that starts with "\`" followed by text and ends with "\`" +//! | YAMD | HTML equivalent | +//! |-----------------------|-----------------------------------------------| +//! | ``- `one\n- two` `` | `
      1. one\n- two
      ` | //! -//! example: ``` `inline code` ``` //! -//! #### Italic text +//! If you want to have two [ListItem](crate::nodes::ListItem)'s use escaping: //! -//! element that starts with "\_" followed by text and ends with "\_" +//! | YAMD | HTML equivalent | +//! |---------------------------|-------------------------------------------| +//! | ``- \`one\n- two\` `` | ``
      1. `one
      2. two`
        1. `` | //! -//! example: ``` _italic text_ ``` +//! The reasoning is that those kind issues can be caught with tooling like linters/lsp. That tooling +//! does not exist yet. //! -//! #### Strikethrough text +//! ## Nodes //! -//! element that starts with "\~\~" followed by text and ends with "\~\~" +//! List of supported [nodes](crate::nodes) and their formatting. The best starting point is +//! [YAMD](crate::nodes::Yamd). //! -//! example: ``` ~~strikethrough text~~ ``` +//! # MSRV //! -//! #### Bold text +//! YAMD minimal supported Rust version is 1.80.0 due to [Option::take_if] usage //! -//! element that starts with "\*\*" followed by text and ends with "\*\*" -//! -//! example: ``` **bold text** ``` -//! -//! Bold text can also contain italic text and strikethrough text -//! -//! example: ``` **bold _italic_ text** ``` or ``` **bold ~~strikethrough~~ text** ``` -//! -//! Altogether: ``` text **bold _italic_ text** ~~strikethrough~~ text `inline code` [Yamd repo](url) ``` will be parsed into Paragraph -//! -//! ### Collapsible -//! -//! Collapsible element can contain all from the above -//! -//! Example: -//! -//! ```text -//! {% Title -//! some random text -//! %} -//! ``` -//! -//! Nested collapsible elements example: -//! -//! ```text -//! {% Title -//! some random text -//! -//! {% Nested title -//! some random text -//! %} -//! %} -//! ``` -//! -//! ### Heading -//! -//! Element that starts with one to seven "#" characters followed by space, followed by text and/or link, and ends -//! with a new line or EOF -//! -//! Example: ```# header``` or ```###### header``` or ```# [header](url)``` or ```# header [link](url)``` -//! - -use nodes::yamd::Yamd; -use toolkit::parser::Parse; - +pub mod lexer; pub mod nodes; -mod toolkit; +mod parser; + +#[doc(inline)] +pub use nodes::Yamd; +use parser::{yamd, Parser}; /// Deserialize a string into a Yamd struct /// # Example /// ``` /// use yamd::deserialize; /// let input = "# header"; -/// let yamd = deserialize(input).unwrap(); -/// ``` -pub fn deserialize(input: &str) -> Option { - Yamd::parse(input, 0).map(|(yamd, _)| yamd) -} - -/// Serialize a Yamd struct into a string -/// # Example -/// ``` -/// use yamd::{deserialize, serialize}; -/// let input = "# header"; -/// let yamd = deserialize(input).unwrap(); -/// let output = serialize(&yamd); +/// let yamd = deserialize(input); /// ``` -pub fn serialize(input: &Yamd) -> String { - input.to_string() +pub fn deserialize(str: &str) -> Yamd { + let mut p = Parser::new(str); + yamd(&mut p, |_| false) } #[cfg(test)] mod tests { - use super::*; - use crate::nodes::{anchor::Anchor, heading::Heading, paragraph::Paragraph, text::Text}; use pretty_assertions::assert_eq; + use crate::{ + deserialize, + nodes::{Anchor, Heading, Paragraph, Yamd}, + }; + #[test] fn test_deserialize() { let input = "# header"; let expected = Yamd::new( None, - vec![Heading::new(1, vec![Text::new("header").into()]).into()], - ); - let actual = deserialize(input).unwrap(); - assert_eq!(expected, actual); - } - - #[test] - fn test_serialize() { - let input = Yamd::new( - None, - vec![Heading::new(1, vec![Text::new("header").into()]).into()], + vec![Heading::new(1, vec![String::from("header").into()]).into()], ); - let expected = "# header"; - let actual = serialize(&input); + let actual = deserialize(input); assert_eq!(expected, actual); } @@ -258,11 +104,11 @@ mod tests { let expected = Yamd::new( None, vec![ - Heading::new(2, vec![Text::new("🤔").into()]).into(), + Heading::new(2, vec![String::from("🤔").into()]).into(), Paragraph::new(vec![Anchor::new("link 😉", "url").into()]).into(), ], ); - let actual = deserialize(input).unwrap(); + let actual = deserialize(input); assert_eq!(expected, actual); } } diff --git a/src/nodes/anchor.rs b/src/nodes/anchor.rs index ccfc1b3..0926d68 100644 --- a/src/nodes/anchor.rs +++ b/src/nodes/anchor.rs @@ -1,11 +1,35 @@ -use std::fmt::{Display, Formatter}; - use serde::Serialize; -use crate::toolkit::parser::Parse; - -/// Representation of an anchor -#[derive(Debug, PartialEq, Serialize, Clone)] +/// # Anchor +/// +/// Anchor has two required parts. +/// +/// [Text](Anchor::text) can contain any character and is surrounded by square brackets +/// [LeftSquareBracket](type@crate::lexer::TokenKind::LeftSquareBracket) and +/// [RightSquareBracket](type@crate::lexer::TokenKind::RightSquareBracket) respectively. +/// +/// [URL](Anchor::url) can contain any character surrounded by parenthesis +/// [LeftParenthesis](type@crate::lexer::TokenKind::LeftParenthesis) and +/// [RightParenthesis](type@crate::lexer::TokenKind::RightParenthesis). Must support any number of +/// nested parenthesis. +/// +/// Examples: +/// +/// | yamd | html equivalent | +/// |---------------------------------------|-----------------------------------------------| +/// | `[link](url)` | `link` | +/// | `[link [nested squares\]](url)` | `link [nested squares]` | +/// | `[link](url(with nested)paren)` | `link` | +/// +/// Examples of things that are not valid Anchor: +/// +/// | yamd | html equivalent | +/// |---------------------------------------|-----------------------------------------------| +/// | `[link]` | `

          [link]

          ` | +/// | `[link](url with unclosed paren` | `

          [link](url with unclosed paren

          ` | +/// +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct Anchor { pub text: String, pub url: String, @@ -19,82 +43,3 @@ impl Anchor { } } } - -impl Display for Anchor { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "[{}]({})", self.text, self.url) - } -} - -impl Parse for Anchor { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - if input[current_position..].starts_with('[') { - if let Some(middle) = input[current_position + 1..].find("](") { - let mut level = 1; - for (i, c) in input[current_position + middle + 3..].char_indices() { - if c == '(' { - level += 1; - } else if c == ')' { - level -= 1; - } - if level == 0 { - return Some(( - Anchor::new( - &input[current_position + 1..current_position + middle + 1], - &input[current_position + middle + 3 - ..current_position + middle + 3 + i], - ), - middle + 3 + i + 1, - )); - } - } - } - } - None - } -} - -#[cfg(test)] -mod tests { - use crate::toolkit::parser::Parse; - - use super::Anchor; - use pretty_assertions::assert_eq; - - #[test] - fn happy_path() { - let a = Anchor::new("nice link", "https://test.io"); - assert_eq!(a.text, "nice link"); - assert_eq!(a.url, "https://test.io"); - } - - #[test] - fn serialize() { - let a: String = Anchor::new("nice link", "https://test.io").to_string(); - assert_eq!(a, "[nice link](https://test.io)".to_string()); - } - - #[test] - fn parse() { - assert_eq!(Anchor::parse("[1](2)", 0), Some((Anchor::new("1", "2"), 6))); - assert_eq!(Anchor::parse("[1", 0), None); - assert_eq!(Anchor::parse("[1](2", 0), None); - } - - #[test] - fn deserilalze_with_parentesis_in_url() { - assert_eq!( - Anchor::parse( - "[the Rope data structure](https://en.wikipedia.org/wiki/Rope_(data_structure))", - 0 - ), - Some(( - Anchor::new( - "the Rope data structure", - "https://en.wikipedia.org/wiki/Rope_(data_structure)" - ), - 78 - )) - ); - } -} diff --git a/src/nodes/bold.rs b/src/nodes/bold.rs index d33860d..b126104 100644 --- a/src/nodes/bold.rs +++ b/src/nodes/bold.rs @@ -1,18 +1,13 @@ -use std::fmt::Display; - use serde::Serialize; -use crate::{ - nodes::{italic::Italic, strikethrough::Strikethrough, text::Text}, - toolkit::parser::{parse_to_consumer, parse_to_parser, Branch, Consumer, Parse, Parser}, -}; +use super::{Italic, Strikethrough}; -#[derive(Debug, PartialEq, Serialize, Clone)] -#[serde(tag = "type")] +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] +#[serde(tag = "type", content = "value")] pub enum BoldNodes { Italic(Italic), Strikethrough(Strikethrough), - Text(Text), + Text(String), } impl From for BoldNodes { @@ -27,125 +22,49 @@ impl From for BoldNodes { } } -impl From for BoldNodes { - fn from(t: Text) -> Self { +impl From for BoldNodes { + fn from(t: String) -> Self { BoldNodes::Text(t) } } -#[derive(Debug, PartialEq, Serialize, Clone, Default)] +/// # Bold +/// +/// Any token except [Terminator](type@crate::lexer::TokenKind::Terminator) surrounded by +/// [Star](type@crate::lexer::TokenKind::Star) of length 2. +/// +/// [Body](Bold::body) can contain one or more: +/// +/// - [Italic] +/// - [Strikethrough] +/// - [String] +/// +/// Example: +/// +/// ```text +/// **Bold can contain an [anchor](#) and _italic_, or ~~strikethrough~~, or regular text** +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +/// +/// Bold can contain an +/// anchor +/// and +/// italic +/// , or +/// strikethrough +/// , or regular text +/// +/// ``` +#[derive(Debug, PartialEq, Serialize, Clone, Default, Eq)] pub struct Bold { - nodes: Vec, -} - -impl Branch for Bold { - fn get_parsers(&self) -> Vec> { - vec![ - parse_to_parser::(), - parse_to_parser::(), - ] - } - - fn push_node(&mut self, node: BoldNodes) { - self.nodes.push(node); - } - - fn get_consumer(&self) -> Option> { - Some(parse_to_consumer::()) - } -} - -impl Parse for Bold { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - if input[current_position..].starts_with("**") { - if let Some(end) = input[current_position + 2..].find("**") { - let b = Bold::new(vec![]); - return Some(( - b.parse_branch(&input[current_position + 2..current_position + 2 + end], "") - .expect("bold should always succed"), - end + 4, - )); - } - } - None - } + pub body: Vec, } impl Bold { - pub fn new(nodes: Vec) -> Self { - Self { nodes } - } -} - -impl Display for BoldNodes { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - BoldNodes::Text(node) => write!(f, "{}", node), - BoldNodes::Italic(node) => write!(f, "{}", node), - BoldNodes::Strikethrough(node) => write!(f, "{}", node), - } - } -} - -impl Display for Bold { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "**{}**", - self.nodes - .iter() - .map(|element| { element.to_string() }) - .collect::>() - .concat() - ) - } -} - -#[cfg(test)] -mod tests { - use crate::{ - nodes::{bold::Bold, italic::Italic, strikethrough::Strikethrough, text::Text}, - toolkit::parser::{Branch, Parse}, - }; - use pretty_assertions::assert_eq; - - #[test] - fn only_text() { - let mut b = Bold::default(); - b.push_node(Text::new("B as bold").into()); - let str = b.to_string(); - assert_eq!(str, "**B as bold**".to_string()); - } - - #[test] - fn from_vec() { - let b: String = Bold::new(vec![ - Text::new("B as bold ").into(), - Italic::new("Italic").into(), - Strikethrough::new("Strikethrough").into(), - ]) - .to_string(); - assert_eq!(b, "**B as bold _Italic_~~Strikethrough~~**".to_string()); - } - - #[test] - fn from_string() { - assert_eq!( - Bold::parse("**b**", 0), - Some((Bold::new(vec![Text::new("b").into()]), 5)) - ); - - assert_eq!( - Bold::parse("**b ~~st~~ _i t_**", 0), - Some(( - Bold::new(vec![ - Text::new("b ").into(), - Strikethrough::new("st").into(), - Text::new(" ").into(), - Italic::new("i t").into() - ]), - 18 - )) - ); + pub fn new(body: Vec) -> Self { + Self { body } } } diff --git a/src/nodes/code.rs b/src/nodes/code.rs index 6646a0d..73f6f14 100644 --- a/src/nodes/code.rs +++ b/src/nodes/code.rs @@ -1,10 +1,31 @@ -use std::fmt::Display; - use serde::Serialize; -use crate::toolkit::parser::Parse; - -#[derive(Debug, PartialEq, Serialize, Clone)] +/// # Code +/// +/// Starts with [Backtick](type@crate::lexer::TokenKind::Backtick) of length < 3. +/// +/// [Lang](Code::lang) is every token except [Terminator](type@crate::lexer::TokenKind::Terminator) +/// between [Backtick](type@crate::lexer::TokenKind::Backtick) of length < 3 and +/// [EOL](type@crate::lexer::TokenKind::Eol). +/// +/// [Code](Code::code) is every token until [Backtick](type@crate::lexer::TokenKind::Backtick) of +/// length < 3. +/// +/// Example: +/// +/// ~~~text +/// ```rust +/// let a = 42; +/// ``` +/// ~~~ +/// +/// HTML equivalent: +/// +/// ```html +///
          let a = 42;
          +/// ``` + +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct Code { pub lang: String, pub code: String, @@ -18,57 +39,3 @@ impl Code { } } } - -impl Display for Code { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "```{}\n{}\n```", self.lang, self.code) - } -} - -impl Parse for Code { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> - where - Self: Sized, - { - if input[current_position..].starts_with("```") { - if let Some(lang) = input[current_position + 3..].find('\n') { - if let Some(end) = input[current_position + 3 + lang + 1..].find("\n```") { - return Some(( - Code::new( - &input[current_position + 3..current_position + 3 + lang], - &input[current_position + 3 + lang + 1 - ..current_position + 3 + lang + 1 + end], - ), - 3 + lang + 1 + end + 4, - )); - } - } - } - None - } -} - -#[cfg(test)] -mod tests { - use crate::{nodes::code::Code, toolkit::parser::Parse}; - use pretty_assertions::assert_eq; - - #[test] - fn serialize() { - assert_eq!( - Code::new("rust", "let foo:usize=1;").to_string(), - String::from("```rust\nlet foo:usize=1;\n```") - ); - } - - #[test] - fn parser() { - assert_eq!( - Code::parse("```rust\nlet a=1;\n```", 0), - Some((Code::new("rust", "let a=1;"), 20)) - ); - assert_eq!(Code::parse("```rust\nlet a=1;\n", 0), None); - assert_eq!(Code::parse("not a code block", 0), None); - assert_eq!(Code::parse("``````", 0), None); - } -} diff --git a/src/nodes/code_span.rs b/src/nodes/code_span.rs new file mode 100644 index 0000000..8910eb1 --- /dev/null +++ b/src/nodes/code_span.rs @@ -0,0 +1,28 @@ +use serde::Serialize; + +/// # Code span +/// +/// Any characters except [Terminator](type@crate::lexer::TokenKind::Terminator) surrounded by a +/// [Backtick](type@crate::lexer::TokenKind::Backtick) of length 1. +/// +/// Example: +/// +/// ```text +/// `anything even EOL +/// can be it` +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +/// anything even EOL +/// can be it +/// ``` +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] +pub struct CodeSpan(pub String); + +impl CodeSpan { + pub fn new>(body: Body) -> Self { + CodeSpan(body.into()) + } +} diff --git a/src/nodes/collapsible.rs b/src/nodes/collapsible.rs index a1d8424..54be762 100644 --- a/src/nodes/collapsible.rs +++ b/src/nodes/collapsible.rs @@ -1,329 +1,48 @@ -use std::fmt::Display; - use serde::Serialize; -use crate::toolkit::parser::{parse_to_consumer, parse_to_parser, Branch, Consumer, Parse, Parser}; - -use super::{ - code::Code, divider::Divider, embed::Embed, heading::Heading, image::Image, - image_gallery::ImageGallery, list::List, paragraph::Paragraph, -}; - -#[derive(Debug, PartialEq, Serialize, Clone)] -#[serde(tag = "type")] -pub enum CollapsibleNodes { - P(Paragraph), - H(Heading), - Image(Image), - ImageGallery(ImageGallery), - List(List), - Embed(Embed), - Divider(Divider), - Code(Code), - Collapsible(Collapsible), -} - -impl Display for CollapsibleNodes { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CollapsibleNodes::P(node) => write!(f, "{}", node), - CollapsibleNodes::H(node) => write!(f, "{}", node), - CollapsibleNodes::Image(node) => write!(f, "{}", node), - CollapsibleNodes::ImageGallery(node) => write!(f, "{}", node), - CollapsibleNodes::List(node) => write!(f, "{}", node), - CollapsibleNodes::Embed(node) => write!(f, "{}", node), - CollapsibleNodes::Divider(node) => write!(f, "{}", node), - CollapsibleNodes::Code(node) => write!(f, "{}", node), - CollapsibleNodes::Collapsible(node) => write!(f, "{}", node), - } - } -} - -impl From for CollapsibleNodes { - fn from(value: Paragraph) -> Self { - Self::P(value) - } -} - -impl From for CollapsibleNodes { - fn from(value: Heading) -> Self { - Self::H(value) - } -} - -impl From for CollapsibleNodes { - fn from(value: Image) -> Self { - Self::Image(value) - } -} - -impl From for CollapsibleNodes { - fn from(value: ImageGallery) -> Self { - Self::ImageGallery(value) - } -} - -impl From for CollapsibleNodes { - fn from(value: List) -> Self { - Self::List(value) - } -} - -impl From for CollapsibleNodes { - fn from(value: Embed) -> Self { - Self::Embed(value) - } -} - -impl From for CollapsibleNodes { - fn from(value: Divider) -> Self { - Self::Divider(value) - } -} - -impl From for CollapsibleNodes { - fn from(value: Code) -> Self { - Self::Code(value) - } -} - -impl From for CollapsibleNodes { - fn from(value: Collapsible) -> Self { - Self::Collapsible(value) - } -} - -#[derive(Debug, PartialEq, Serialize, Clone)] +use super::YamdNodes; + +/// # Collapsible +/// +/// Starts with [CollapsibleStart](type@crate::lexer::TokenKind::CollapsibleStart). +/// +/// [Title](Collapsible::title) every token except +/// [Terminator](type@crate::lexer::TokenKind::Terminator) between +/// [space](type@crate::lexer::TokenKind::Space) and [EOL](type@crate::lexer::TokenKind::Eol) +/// +/// [Body](Collapsible::body) Every token until [CollapsibleEnd](type@crate::lexer::TokenKind::CollapsibleEnd), +/// nested collapsible are supported. +/// +/// Example: +/// +/// ```text +/// {% collapsible +/// ![alt](src) +/// %} +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +///
          +/// +/// +///
          +/// alt +///
          +///
          +/// ``` +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct Collapsible { pub title: String, - pub nodes: Vec, + pub body: Vec, } impl Collapsible { - pub fn new>(title: S, nodes: Vec) -> Self { + pub fn new>(title: S, body: Vec) -> Self { Self { - nodes, + body, title: title.into(), } } } - -impl Display for Collapsible { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{{% {title}\n{nodes}\n%}}", - title = self.title, - nodes = self - .nodes - .iter() - .map(|node| node.to_string()) - .collect::>() - .join("\n\n"), - ) - } -} - -impl Branch for Collapsible { - fn get_parsers(&self) -> Vec> { - vec![ - parse_to_parser::(), - parse_to_parser::(), - parse_to_parser::(), - parse_to_parser::(), - parse_to_parser::(), - parse_to_parser::(), - parse_to_parser::(), - parse_to_parser::(), - ] - } - - fn get_consumer(&self) -> Option> { - Some(parse_to_consumer::()) - } - - fn push_node(&mut self, node: CollapsibleNodes) { - self.nodes.push(node); - } -} - -impl Parse for Collapsible { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - if input[current_position..].starts_with("{% ") { - let start = current_position + 3; - if let Some(end_of_title) = input[start..].find('\n') { - let title = &input[start..start + end_of_title]; - let mut level = 1; - for (index, _) in input[start + end_of_title..].char_indices() { - if input[index + start + end_of_title + 1..].starts_with("{% ") { - level += 1; - } else if input[index + start + end_of_title + 1..].starts_with("\n%}") { - level -= 1; - } - if level == 0 { - let colapsible = Collapsible::new(title, vec![]); - - return Some(( - colapsible - .parse_branch( - &input[start + end_of_title + 1 - ..start + end_of_title + 1 + index], - "\n\n", - ) - .expect("collapsible branch should always succeed"), - 3 + end_of_title + 1 + index + 3, - )); - } - } - } - } - None - } -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - - use crate::{ - nodes::{ - bold::Bold, - code::Code, - collapsible::Collapsible, - divider::Divider, - embed::Embed, - heading::Heading, - image::Image, - image_gallery::ImageGallery, - list::{List, ListTypes::*}, - list_item::ListItem, - paragraph::Paragraph, - text::Text, - }, - toolkit::parser::Parse, - }; - - #[test] - fn test_collapsible_parse() { - assert_eq!( - Collapsible::parse("{% Title\n# Heading\n%}", 0), - Some(( - Collapsible::new( - "Title", - vec![Heading::new(1, vec![Text::new("Heading").into()]).into()] - ), - 21 - )) - ); - } - - #[test] - fn test_collapsible_serialize() { - assert_eq!( - Collapsible::new( - "Title", - vec![Heading::new(1, vec![Text::new("Heading").into()]).into()] - ) - .to_string(), - "{% Title\n# Heading\n%}" - ); - } - - #[test] - fn fail_to_parse_collapsible() { - assert_eq!(Collapsible::parse("I am not an accordion tab", 0), None); - assert_eq!(Collapsible::parse("{% \n%}", 0), None); - } - - #[test] - fn with_all_nodes() { - let input = r#"{% Title -# hello - -```rust -let a=1; -``` - -t**b** - -![a](u) - -!!! -![a2](u2) -![a3](u3) -!!! - ------ - -- one - - two - -{{youtube|123}} - -{{cloudinary_gallery|cloud_name&tag}} - -{% nested collapsible -# nested -%} -%}"#; - let tab = Collapsible::new( - "Title", - vec![ - Heading::new(1, vec![Text::new("hello").into()]).into(), - Code::new("rust", "let a=1;").into(), - Paragraph::new(vec![ - Text::new("t").into(), - Bold::new(vec![Text::new("b").into()]).into(), - ]) - .into(), - Image::new('a', 'u').into(), - ImageGallery::new(vec![ - Image::new("a2", "u2").into(), - Image::new("a3", "u3").into(), - ]) - .into(), - Divider::new().into(), - List::new( - Unordered, - 0, - vec![ListItem::new( - Unordered, - 0, - Paragraph::new(vec![Text::new("one").into()]), - Some(List::new( - Unordered, - 1, - vec![ListItem::new( - Unordered, - 1, - Paragraph::new(vec![Text::new("two").into()]), - None, - ) - .into()], - )), - ) - .into()], - ) - .into(), - Embed::new("youtube", "123").into(), - Embed::new("cloudinary_gallery", "cloud_name&tag").into(), - Collapsible::new( - "nested collapsible", - vec![Heading::new(1, vec![Text::new("nested").into()]).into()], - ) - .into(), - ], - ); - assert_eq!(tab.to_string(), input); - assert_eq!(Collapsible::parse(input, 0), Some((tab, input.len()))); - } - - #[test] - fn parse_empty() { - let input = "{% Title\n\n%}"; - assert_eq!( - Collapsible::parse(input, 0), - Some((Collapsible::new("Title", vec![]), input.len())) - ); - } -} diff --git a/src/nodes/divider.rs b/src/nodes/divider.rs deleted file mode 100644 index b8b4ac0..0000000 --- a/src/nodes/divider.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::fmt::Display; - -use serde::Serialize; - -use crate::toolkit::parser::Parse; - -#[derive(Debug, PartialEq, Serialize, Clone, Default)] -pub struct Divider {} - -impl Divider { - pub fn new() -> Self { - Self {} - } -} - -impl Display for Divider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "-----") - } -} - -impl Parse for Divider { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> - where - Self: Sized, - { - if input[current_position..].starts_with("-----") { - Some((Divider::new(), 5)) - } else { - None - } - } -} -#[cfg(test)] -mod tests { - use crate::{nodes::divider::Divider, toolkit::parser::Parse}; - use pretty_assertions::assert_eq; - - #[test] - fn parse() { - assert_eq!(Divider::parse("-----", 0), Some((Divider {}, 5))); - } - - #[test] - fn serialize() { - assert_eq!(Divider::new().to_string(), String::from("-----")); - } -} diff --git a/src/nodes/embed.rs b/src/nodes/embed.rs index 5c3ae55..23cb82b 100644 --- a/src/nodes/embed.rs +++ b/src/nodes/embed.rs @@ -1,79 +1,37 @@ -use std::fmt::Display; - use serde::Serialize; -use crate::toolkit::parser::Parse; - -#[derive(Debug, PartialEq, Serialize, Clone)] +/// # Embed +/// +/// Starts with [LeftCurlyBrace](type@crate::lexer::TokenKind::LeftCurlyBrace) of length 2. +/// +/// [Kind](Embed::kind) every token except [Terminator](type@crate::lexer::TokenKind::Terminator) +/// until [Pipe](type@crate::lexer::TokenKind::Pipe). +/// +/// [Args](Embed::args) every token except [Terminator](type@crate::lexer::TokenKind::Terminator) +/// until [LeftCurlyBrace](type@crate::lexer::TokenKind::LeftCurlyBrace) of length 2. +/// +/// Examples: +/// +/// ```text +/// {{youtube|dQw4w9WgXcQ}} +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +/// +/// ``` +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct Embed { - pub args: String, pub kind: String, + pub args: String, } impl Embed { - pub fn new>(kind: S, args: S) -> Self { + pub fn new, A: Into>(kind: K, args: A) -> Self { Self { kind: kind.into(), args: args.into(), } } } - -impl Display for Embed { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{{{{{}|{}}}}}", self.kind, self.args) - } -} - -impl Parse for Embed { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> - where - Self: Sized, - { - if input[current_position..].starts_with("{{") { - if let Some(middle) = input[current_position + 2..].find('|') { - if let Some(end) = input[current_position + 2 + middle..].find("}}") { - return Some(( - Embed::new( - &input[current_position + 2..current_position + 2 + middle], - &input[current_position + 2 + middle + 1 - ..current_position + 2 + middle + end], - ), - 2 + middle + end + 2, - )); - } - } - } - None - } -} - -#[cfg(test)] -mod tests { - use crate::{nodes::embed::Embed, toolkit::parser::Parse}; - - #[test] - fn serializer() { - assert_eq!( - Embed::new("youtube", "https://www.youtube.com/embed/wsfdjlkjsdf",).to_string(), - "{{youtube|https://www.youtube.com/embed/wsfdjlkjsdf}}" - ); - } - - #[test] - fn parse() { - assert_eq!( - Embed::parse("{{youtube|https://www.youtube.com/embed/wsfdjlkjsdf}}", 0), - Some(( - Embed::new("youtube", "https://www.youtube.com/embed/wsfdjlkjsdf",), - 53 - )) - ); - } - - #[test] - fn failed_parse() { - assert_eq!(Embed::parse("{{youtube}}", 0), None); - assert_eq!(Embed::parse("{{youtube|", 0), None); - } -} diff --git a/src/nodes/heading.rs b/src/nodes/heading.rs index 5116427..eecfcff 100644 --- a/src/nodes/heading.rs +++ b/src/nodes/heading.rs @@ -1,179 +1,58 @@ -use std::fmt::Display; - use serde::Serialize; -use crate::toolkit::parser::{parse_to_consumer, parse_to_parser, Branch, Consumer, Parse, Parser}; - -use super::{anchor::Anchor, text::Text}; +use super::Anchor; -#[derive(Debug, PartialEq, Serialize, Clone)] -#[serde(tag = "type")] +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] +#[serde(tag = "type", content = "value")] pub enum HeadingNodes { - Text(Text), - A(Anchor), + Text(String), + Anchor(Anchor), } -impl From for HeadingNodes { - fn from(text: Text) -> Self { +impl From for HeadingNodes { + fn from(text: String) -> Self { Self::Text(text) } } impl From for HeadingNodes { fn from(anchor: Anchor) -> Self { - Self::A(anchor) - } -} - -impl Display for HeadingNodes { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Text(text) => write!(f, "{}", text), - Self::A(anchor) => write!(f, "{}", anchor), - } - } -} - -#[derive(Debug, PartialEq, Serialize, Clone)] + Self::Anchor(anchor) + } +} + +/// # Heading +/// +/// Starts with [Hash](type@crate::lexer::TokenKind::Hash) of length < 7, followed by +/// [Space](type@crate::lexer::TokenKind::Space). +/// +/// [Level](Heading::level) is determined by the amount of [Hash](type@crate::lexer::TokenKind::Hash)'es +/// before [Space](type@crate::lexer::TokenKind::Space). +/// +/// [Body](Heading::body) can contain one or more: +/// +/// - [Anchor] +/// - [String] +/// +/// Example: +/// +/// ```text +/// ### Header can contain an [anchor](#) or regular text. +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +///

          Header can contain an anchor or regular text.

          +/// ``` +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct Heading { pub level: u8, - pub nodes: Vec, + pub body: Vec, } impl Heading { pub fn new(level: u8, nodes: Vec) -> Self { - let normalized_level = match level { - 0 => 1, - 7.. => 6, - l => l, - }; - Heading { - nodes, - level: normalized_level, - } - } -} - -impl Branch for Heading { - fn push_node(&mut self, node: HeadingNodes) { - self.nodes.push(node); - } - - fn get_parsers(&self) -> Vec> { - vec![parse_to_parser::()] - } - - fn get_consumer(&self) -> Option> { - Some(parse_to_consumer::()) - } -} - -impl Parse for Heading { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - let start_tokens = ["# ", "## ", "### ", "#### ", "##### ", "###### "]; - - for start_token in start_tokens.iter() { - if input[current_position..].starts_with(start_token) { - let end = input[current_position + start_token.len()..] - .find("\n\n") - .unwrap_or(input[current_position + start_token.len()..].len()); - let heading = Heading::new((start_token.len() - 1).try_into().unwrap_or(1), vec![]); - - return Some(( - heading - .parse_branch( - &input[current_position + start_token.len() - ..current_position + start_token.len() + end], - "", - ) - .expect("heading should always succeed"), - start_token.len() + end, - )); - } - } - - None - } -} - -impl Display for Heading { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let level = String::from('#').repeat(self.level as usize); - write!( - f, - "{} {}", - level, - self.nodes.iter().map(|n| n.to_string()).collect::() - ) - } -} - -#[cfg(test)] -mod tests { - use super::Heading; - use crate::{ - nodes::{anchor::Anchor, text::Text}, - toolkit::parser::Parse, - }; - use pretty_assertions::assert_eq; - - #[test] - fn level_one() { - assert_eq!( - Heading::new(1, vec![Text::new("Header").into()]).to_string(), - "# Header" - ); - } - - #[test] - fn level_gt_six() { - let h = Heading::new(7, vec![Text::new("Header").into()]).to_string(); - assert_eq!(h, "###### Header"); - let h = Heading::new(34, vec![Text::new("Header").into()]).to_string(); - assert_eq!(h, "###### Header"); - } - - #[test] - fn level_eq_zero() { - let h = Heading::new(0, vec![Text::new("Header").into()]).to_string(); - assert_eq!(h, "# Header"); - } - - #[test] - fn level_eq_four() { - let h = Heading::new(4, vec![Text::new("Header").into()]).to_string(); - assert_eq!(h, "#### Header"); - } - - #[test] - fn from_string() { - assert_eq!( - Heading::parse("## Header", 0), - Some((Heading::new(2, vec![Text::new("Header").into()]), 9)) - ); - assert_eq!( - Heading::parse("### Head", 0), - Some((Heading::new(3, vec![Text::new("Head").into()]), 8)) - ); - assert_eq!(Heading::parse("not a header", 0), None); - assert_eq!(Heading::parse("######", 0), None); - assert_eq!(Heading::parse("######also not a header", 0), None); - } - - #[test] - fn with_anchor() { - let str = "## hey [a](b)"; - let h = Heading::parse(str, 0); - assert_eq!( - h, - Some(( - Heading::new( - 2, - vec![Text::new("hey ").into(), Anchor::new("a", "b").into()] - ), - 13 - )) - ); - assert_eq!(h.map(|(node, _)| node.to_string()).unwrap(), str); + Self { level, body: nodes } } } diff --git a/src/nodes/highlight.rs b/src/nodes/highlight.rs index 3f8c25e..b752644 100644 --- a/src/nodes/highlight.rs +++ b/src/nodes/highlight.rs @@ -1,182 +1,69 @@ -use std::fmt::Display; - use serde::Serialize; -use crate::toolkit::parser::Parse; - -use super::paragraph::Paragraph; +use super::Paragraph; -#[derive(Debug, PartialEq, Serialize, Clone)] +/// # Highlight +/// +/// Must start and end with [Bang](type@crate::lexer::TokenKind::Bang) of length 2. +/// +/// [Title](Highlight::title) is sequence of tokens between first +/// [Bang](type@crate::lexer::TokenKind::Bang) of length 2 followed by +/// [Space](type@crate::lexer::TokenKind::Space) and [Eol](type@crate::lexer::TokenKind::Eol). +/// Can be omitted. +/// +/// [Icon](Highlight::icon) is sequence of tokens between +/// [Bang](type@crate::lexer::TokenKind::Bang) of length 1 followed by +/// [Space](type@crate::lexer::TokenKind::Space) and [Eol](type@crate::lexer::TokenKind::Eol). +/// Can be omitted. +/// +/// [Title](Highlight::title) and [Icon](Highlight::icon) can not contain +/// [Terminator](type@crate::lexer::TokenKind::Terminator). +/// +/// [Body](Highlight::body) is one or more [Paragraph]'s. +/// +/// Example: +/// +/// ```text +/// !! Tile +/// ! Icon +/// body +/// !! +/// ``` +/// +/// Example without title: +/// +/// ```text +/// !! +/// ! Icon +/// body +/// !! +/// ``` +/// +/// Example without icon: +/// +/// ```text +/// !! Tile +/// body +/// !! +/// ``` +/// +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct Highlight { pub title: Option, pub icon: Option, - pub nodes: Vec, + pub body: Vec, } impl Highlight { pub fn new, I: Into>( title: Option, icon: Option, - nodes: Vec, + body: Vec, ) -> Self { Self { title: title.map(|title| title.into()), icon: icon.map(|icon| icon.into()), - nodes, + body, } } } - -impl Display for Highlight { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let title = match &self.title { - Some(title) => format!(">> {title}\n"), - None => String::new(), - }; - let icon = match &self.icon { - Some(icon) => format!("> {icon}\n"), - None => String::new(), - }; - write!( - f, - ">>>\n{title}{icon}{}\n>>>", - self.nodes - .iter() - .map(|node| node.to_string()) - .collect::>() - .join("\n\n"), - title = title, - icon = icon, - ) - } -} - -impl Parse for Highlight { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> - where - Self: Sized, - { - if input[current_position..].starts_with(">>>\n") { - if let Some(end) = input[current_position + 4..].find("\n>>>") { - let mut start = current_position + 4; - let mut title = None; - let mut icon = None; - if input[start..current_position + 4 + end].starts_with(">> ") { - start += 3; - if let Some(local_end) = input[start..current_position + 4 + end].find('\n') { - title = Some(input[start..start + local_end].to_string()); - start += local_end + 1; - } - } - if input[start..current_position + 4 + end].starts_with("> ") { - start += 2; - if let Some(local_end) = input[start..current_position + 4 + end].find('\n') { - icon = Some(input[start..start + local_end].to_string()); - start += local_end + 1; - } - } - let mut nodes = vec![]; - input[start..current_position + 4 + end] - .split("\n\n") - .for_each(|node| { - let (node, _) = Paragraph::parse(node, 0) - .expect("Paragraph should never fail to parse"); - nodes.push(node); - }); - return Some(( - Highlight::new(title, icon, nodes), - input[current_position..current_position + 4 + end + 4].len(), - )); - } - } - None - } -} - -#[cfg(test)] -mod tests { - use crate::{ - nodes::{highlight::Highlight, paragraph::Paragraph, text::Text}, - toolkit::parser::Parse, - }; - use pretty_assertions::assert_eq; - - #[test] - fn serialize() { - assert_eq!( - Highlight::new( - Some("h"), - Some("i"), - vec![ - Paragraph::new(vec![Text::new("t").into()]), - Paragraph::new(vec![Text::new("t").into()]) - ] - ) - .to_string(), - String::from(">>>\n>> h\n> i\nt\n\nt\n>>>") - ); - assert_eq!( - Highlight::new::( - None, - None, - vec![ - Paragraph::new(vec![Text::new("t").into()]), - Paragraph::new(vec![Text::new("t").into()]) - ] - ) - .to_string(), - String::from(">>>\nt\n\nt\n>>>") - ); - } - - #[test] - fn parse() { - assert_eq!( - Highlight::parse(">>>\n>> h\n> i\nt\n\nt\n>>>", 0), - Some(( - Highlight::new( - Some("h"), - Some("i"), - vec![ - Paragraph::new(vec![Text::new("t").into()]), - Paragraph::new(vec![Text::new("t").into()]) - ] - ), - 21 - )) - ); - } - - #[test] - fn empty_highlight() { - let highlight = Highlight::new::(None, None, vec![]); - assert_eq!(highlight.to_string(), ">>>\n\n>>>"); - } - - #[test] - fn starts_with_delimeter() { - let input = ">>> - - -test - -test2 ->>>"; - let highlight = Highlight::parse(input, 0).unwrap(); - assert_eq!( - highlight, - ( - Highlight::new::<&str, &str>( - None, - None, - vec![ - Paragraph::new(vec![]).into(), - Paragraph::new(vec![Text::new("test").into()]).into(), - Paragraph::new(vec![Text::new("test2").into()]).into(), - ] - ), - 21 - ) - ); - } -} diff --git a/src/nodes/image.rs b/src/nodes/image.rs index aa9c1a3..ae18968 100644 --- a/src/nodes/image.rs +++ b/src/nodes/image.rs @@ -1,10 +1,36 @@ -use std::fmt::Display; - use serde::Serialize; -use crate::toolkit::parser::Parse; - -#[derive(Debug, PartialEq, Serialize, Clone)] +/// # Image +/// +/// Starts with [Bang](type@crate::lexer::TokenKind::Bang) of length 1, and has two required parts. +/// +/// [Alt](Image::alt) can contain any character and is surrounded by square brackets +/// [LeftSquareBracket](type@crate::lexer::TokenKind::LeftSquareBracket) and +/// [RightSquareBracket](type@crate::lexer::TokenKind::RightSquareBracket) respectively. +/// +/// [Src](Image::src) can contain any character surrounded by parenthesis +/// [LeftParenthesis](type@crate::lexer::TokenKind::LeftParenthesis) and +/// [RightParenthesis](type@crate::lexer::TokenKind::RightParenthesis). Must support any number of +/// nested parenthesis. +/// +/// Examples: +/// +/// | yamd | html equivalent | +/// |---------------------------------------|---------------------------------------------------| +/// | `![alt](src)` | `alt` | +/// | `![alt [nested squares\]](src)` | `alt [nested squares]` | +/// | `![alt](src(with nested)paren)` | `alt` | +/// | `![alt](src(with(unclosed)nested` | `alt` | +/// +/// Examples of things that are not valid Image: +/// +/// | yamd | html equivalent | +/// |---------------------------------------|-----------------------------------------------| +/// | `![alt]` | `

          ![alt]

          ` | +/// | `![alt](src with unclosed paren` | `

          ![alt](src with unclosed paren

          ` | +/// + +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct Image { pub alt: String, pub src: String, @@ -18,73 +44,3 @@ impl Image { } } } - -impl Display for Image { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "![{}]({})", self.alt, self.src) - } -} - -impl Parse for Image { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - if input[current_position..].starts_with("![") { - if let Some(middle) = input[current_position + 2..].find("](") { - let mut level = 1; - for (i, c) in input[current_position + 2 + middle + 2..].char_indices() { - if c == '(' { - level += 1; - } else if c == ')' { - level -= 1; - } - if level == 0 { - return Some(( - Image::new( - &input[current_position + 2..current_position + 2 + middle], - &input[current_position + 2 + middle + 2 - ..current_position + 2 + middle + 2 + i], - ), - 2 + middle + 2 + i + 1, - )); - } - } - } - } - None - } -} - -#[cfg(test)] -mod tests { - use crate::toolkit::parser::Parse; - - use super::Image; - use pretty_assertions::assert_eq; - - #[test] - fn serializer() { - assert_eq!(Image::new('a', 'u').to_string(), String::from("![a](u)")); - } - - #[test] - fn parser() { - assert_eq!( - Image::parse("![alt](url)", 0), - Some((Image::new("alt", "url"), 11)) - ); - assert_eq!(Image::parse("![alt](url", 0), None); - assert_eq!(Image::parse("[alt](url)", 0), None); - assert_eq!(Image::parse("![alt]", 0), None); - } - - #[test] - fn nested() { - let input = "![hello [there]](url with (parenthesis))"; - assert_eq!( - Image::parse("![hello [there]](url with (parenthesis))", 0), - Some(( - Image::new("hello [there]", "url with (parenthesis)"), - input.len() - )) - ) - } -} diff --git a/src/nodes/image_gallery.rs b/src/nodes/image_gallery.rs deleted file mode 100644 index f55251f..0000000 --- a/src/nodes/image_gallery.rs +++ /dev/null @@ -1,148 +0,0 @@ -use std::fmt::{Display, Formatter}; - -use serde::Serialize; - -use crate::toolkit::parser::{parse_to_parser, Branch, Consumer, Parse, Parser}; - -use super::image::Image; - -#[derive(Debug, PartialEq, Serialize, Clone)] -#[serde(tag = "type")] -pub enum ImageGalleryNodes { - Image(Image), -} - -impl Display for ImageGalleryNodes { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - match self { - ImageGalleryNodes::Image(node) => write!(f, "{}", node), - } - } -} - -impl From for ImageGalleryNodes { - fn from(value: Image) -> Self { - ImageGalleryNodes::Image(value) - } -} - -/// Image Gallery node is a node that contains multiple Image nodes -/// it starts with `!!!\n` and ends with `\n!!!` -#[derive(Debug, PartialEq, Serialize, Clone)] -pub struct ImageGallery { - pub nodes: Vec, -} - -impl ImageGallery { - pub fn new(nodes: Vec) -> Self { - Self { nodes } - } -} - -impl Default for ImageGallery { - fn default() -> Self { - Self::new(vec![]) - } -} - -impl Display for ImageGallery { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!( - f, - "!!!\n{}\n!!!", - self.nodes - .iter() - .map(|node| node.to_string()) - .collect::>() - .join("\n") - ) - } -} - -impl Branch for ImageGallery { - fn get_parsers(&self) -> Vec> { - vec![parse_to_parser::()] - } - - fn get_consumer(&self) -> Option> { - None - } - - fn push_node(&mut self, node: ImageGalleryNodes) { - self.nodes.push(node); - } -} - -impl Parse for ImageGallery { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> - where - Self: Sized, - { - if input[current_position..].starts_with("!!!\n") { - if let Some(end) = input[current_position + 4..].find("\n!!!") { - let gallery = ImageGallery::new(vec![]); - if let Some(node) = gallery.parse_branch( - &input[current_position + 4..current_position + 4 + end], - "\n", - ) { - return Some((node, 4 + end + 4)); - } - } - } - None - } -} - -#[cfg(test)] -mod tests { - use super::ImageGallery; - use crate::{nodes::image::Image, toolkit::parser::Parse}; - use pretty_assertions::assert_eq; - - #[test] - fn serialize() { - assert_eq!( - ImageGallery::new(vec![ - Image::new("a", "u").into(), - Image::new("a2", "u2").into() - ],) - .to_string(), - "!!!\n![a](u)\n![a2](u2)\n!!!" - ); - assert_eq!( - ImageGallery::new(vec![ - Image::new("a", "u").into(), - Image::new("a2", "u2").into() - ],) - .to_string(), - "!!!\n![a](u)\n![a2](u2)\n!!!" - ); - } - - #[test] - fn parse() { - assert_eq!( - ImageGallery::parse("!!!\n![a](u)\n![a2](u2)\n!!!", 0), - Some(( - ImageGallery::new(vec![ - Image::new("a", "u").into(), - Image::new("a2", "u2").into() - ]), - 25 - )) - ); - } - - #[test] - fn default() { - assert_eq!(ImageGallery::default().to_string(), "!!!\n\n!!!"); - } - - #[test] - fn fail_parse() { - assert_eq!(ImageGallery::parse("not a gallery", 0), None); - assert_eq!(ImageGallery::parse("!!!\n![a](u)\n![a2](u2)!!!", 0), None); - assert_eq!(ImageGallery::parse("!!!\n![a](u)\n![a2](u2)\n", 0), None); - assert_eq!(ImageGallery::parse("!!!\nrandom\n!!!", 0), None); - } -} diff --git a/src/nodes/images.rs b/src/nodes/images.rs new file mode 100644 index 0000000..463a106 --- /dev/null +++ b/src/nodes/images.rs @@ -0,0 +1,35 @@ +use serde::Serialize; + +use super::Image; + +/// # Images +/// +/// One or more [Image]'s separated by [EOL](type@crate::lexer::TokenKind::Eol). There is +/// no 1:1 match for that in HTML. +/// +/// Example: +/// +/// ```text +/// ![alt](src) +/// ![alt](src) +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +///
          +/// alt +/// alt +///
          +/// ``` + +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] +pub struct Images { + pub body: Vec, +} + +impl Images { + pub fn new(body: Vec) -> Self { + Self { body } + } +} diff --git a/src/nodes/inline_code.rs b/src/nodes/inline_code.rs deleted file mode 100644 index 57a28e8..0000000 --- a/src/nodes/inline_code.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::fmt::Display; - -use serde::Serialize; - -use crate::toolkit::parser::Parse; - -#[derive(Debug, PartialEq, Serialize, Clone)] -pub struct InlineCode { - pub text: String, -} - -impl InlineCode { - pub fn new>(text: S) -> Self { - InlineCode { text: text.into() } - } -} - -impl Display for InlineCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "`{}`", self.text) - } -} - -impl Parse for InlineCode { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - if input[current_position..].starts_with('`') { - if let Some(end) = input[current_position + 1..].find('`') { - return Some(( - InlineCode::new(&input[current_position + 1..current_position + end + 1]), - end + 2, - )); - } - } - None - } -} - -#[cfg(test)] -mod tests { - use crate::toolkit::parser::Parse; - - use super::InlineCode; - use pretty_assertions::assert_eq; - - #[test] - fn to_string() { - let inline_code: String = InlineCode::new("const bar = 'baz'").to_string(); - assert_eq!(inline_code, "`const bar = 'baz'`".to_string()) - } - - #[test] - fn from_string() { - assert_eq!(InlineCode::parse("`1`", 0), Some((InlineCode::new('1'), 3))); - assert_eq!( - InlineCode::parse("`const \nfoo='bar'`", 0), - Some((InlineCode::new("const \nfoo='bar'"), 18)) - ); - assert_eq!(InlineCode::parse("`a", 0), None); - } -} diff --git a/src/nodes/italic.rs b/src/nodes/italic.rs index dae8f11..259a0e1 100644 --- a/src/nodes/italic.rs +++ b/src/nodes/italic.rs @@ -1,79 +1,28 @@ -use std::fmt::{Display, Formatter}; - use serde::Serialize; -use crate::toolkit::parser::Parse; - -/// Representation of an Italic text -#[derive(Debug, PartialEq, Serialize, Clone)] -pub struct Italic { - text: String, -} +/// # Italic +/// +/// Any token except [Terminator](type@crate::lexer::TokenKind::Terminator) surrounded by +/// [Underscore](type@crate::lexer::TokenKind::Underscore). +/// +/// Example: +/// +/// ```text +/// _Italic can contain any token +/// even EOL_ +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +/// Italic can contain any token +/// even EOL +/// ``` +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] +pub struct Italic(pub String); impl Italic { - pub fn new>(text: IS) -> Self { - Italic { text: text.into() } - } -} - -impl Parse for Italic { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - if input[current_position..].starts_with('_') { - if let Some(end) = input[current_position + 1..].find('_') { - return Some(( - Italic::new(&input[current_position + 1..current_position + 1 + end]), - end + 2, - )); - } - } - None - } -} - -impl Display for Italic { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "_{}_", self.text) - } -} - -#[cfg(test)] -mod tests { - use super::Italic; - use crate::toolkit::parser::Parse; - use pretty_assertions::assert_eq; - - #[test] - fn happy_path() { - let i = Italic::new("italic"); - assert_eq!(i.text, "italic".to_string()); - } - - #[test] - fn to_string() { - let i = Italic::new("italic").to_string(); - assert_eq!(i, "_italic_".to_string()); - } - - #[test] - fn from_string() { - assert_eq!( - Italic::parse("_italic_", 0), - Some((Italic::new("italic"), 8)) - ); - assert_eq!( - Italic::parse("_italic_not", 0), - Some((Italic::new("italic"), 8)) - ); - assert_eq!( - Italic::parse("_it alic_not", 0), - Some((Italic::new("it alic"), 9)) - ); - assert_eq!(Italic::parse("not italic_not", 0), None); - assert_eq!(Italic::parse("*italic not", 0), None); - assert_eq!( - Italic::parse("_ita\nlic_", 0), - Some((Italic::new("ita\nlic"), 9)) - ); - assert_eq!(Italic::parse("_italic", 0), None); + pub fn new>(body: Body) -> Self { + Italic(body.into()) } } diff --git a/src/nodes/list.rs b/src/nodes/list.rs index 800ab3d..a42b737 100644 --- a/src/nodes/list.rs +++ b/src/nodes/list.rs @@ -1,366 +1,137 @@ -use std::{fmt::Display, usize}; - use serde::Serialize; -use crate::toolkit::parser::Parse; - -use super::{list_item::ListItem, paragraph::Paragraph}; +use super::ListItem; -#[derive(Debug, PartialEq, Clone, Serialize)] +#[derive(Debug, PartialEq, Clone, Serialize, Eq)] pub enum ListTypes { + /// List item starts with `-` ([Minus](type@crate::lexer::TokenKind::Minus) of length 1) followed by space + /// [Space](type@crate::lexer::TokenKind::Space). Must be rendered as bullet marked list. Unordered, + /// List item starts with `+` ([Plus](type@crate::lexer::TokenKind::Plus) of length 1) followed by space + /// [Space](type@crate::lexer::TokenKind::Space). Must be rendered as numeric list. Ordered, } -#[derive(Debug, PartialEq, Serialize, Clone)] +/// # List +/// +/// ## Types +/// +/// List can be two types (check [ListTypes] for more info). Each list can contain sub list of any type, but type can +/// not be mixed on a same level. +/// +/// Examples: +/// +/// ```text +/// - Unordered list item +/// + Ordered list item +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +///
          • Unordered list item
            1. Ordered list item
          +/// ``` +/// +/// ---- +/// +/// ```text +/// - Unordered list item +/// + Even though this looks like ordered list item, it will be part of Unordered list item +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +///
          • Unordered list item +/// + Even though this looks like ordered list item, it will be part of Unordered list item
          +/// +/// ``` +/// +/// ## Level +/// +/// List level determined by amount of spaces ([Space](type@crate::lexer::TokenKind::Space)) before list type marker. +/// No space means level `0`. Level can be increased only by 1. Level can be decreased to any +/// level. +/// +/// Examples: +/// +/// ```text +/// - Level 0 +/// - Level 1 +/// - Level 2 +/// - Level 1 +/// - Level 0 +/// - Level 1 +/// - Level 2 +/// - Level 0 +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +///
            +///
          • +/// Level 0 +///
              +///
            • +/// Level 1 +///
                +///
              • +/// Level 2 +///
              • +///
              +///
            • +///
            • +/// Level 1 +///
            • +///
            +///
          • +///
          • +/// Level 0 +///
              +///
            • +/// Level 1 +///
                +///
              • +/// Level 2 +///
              • +///
              +///
            • +///
            +///
          • +///
          • +/// Level 0 +///
          • +///
          +/// ``` +/// +/// ----- +/// +/// ```text +/// + level 0 +/// + this will be part of level 0 (notice two spaces before `+`) +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +///
          1. level 0 +/// + this will be part of level 0 (notice two spaces before `+`)
          +/// ``` +/// +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct List { pub list_type: ListTypes, pub level: usize, - pub nodes: Vec, + pub body: Vec, } impl List { - pub fn new(list_type: ListTypes, level: usize, nodes: Vec) -> Self { + pub fn new(list_type: ListTypes, level: usize, body: Vec) -> Self { Self { list_type, level, - nodes, - } - } - - fn get_text_slice_and_nested_list<'a>(&self, input: &'a str) -> (&'a str, Option) { - if let Some((left, right)) = input.split_once('\n') { - if let Some((list, consumed)) = List::parse_with_level(right, 0, self.level + 1) { - if consumed == right.len() { - return (left, Some(list)); - } - } - } - (input, None) - } - - fn parse_list_items(&mut self, input: &str) -> usize { - let mut end = 2 + self.level; - while end < input.len() { - let list_type = match self.list_type { - ListTypes::Unordered => '-', - ListTypes::Ordered => '+', - }; - let new_position = input[end..] - .find(format!("\n{}{} ", " ".repeat(self.level), list_type).as_str()) - .map_or(input.len(), |pos| pos + end); - - let (text_slice, nested_list) = - self.get_text_slice_and_nested_list(&input[end..new_position]); - - self.nodes.push(ListItem::new( - self.list_type.clone(), - self.level, - Paragraph::parse(text_slice, 0) - .map(|(paragraph, _)| paragraph) - .expect("paragraph should always succeed"), - nested_list, - )); - - end = if new_position == input.len() { - new_position - } else { - new_position + 3 + self.level - }; - } - end - } - - fn parse_with_level( - input: &str, - current_position: usize, - level: usize, - ) -> Option<(Self, usize)> { - let mut list_type: Option = None; - if input[current_position..].starts_with(format!("{}- ", " ".repeat(level)).as_str()) { - list_type = Some(ListTypes::Unordered); - } - - if input[current_position..].starts_with(format!("{}+ ", " ".repeat(level)).as_str()) { - list_type = Some(ListTypes::Ordered); - } - - if let Some(list_type) = list_type { - let end = input[current_position..] - .find("\n\n") - .map_or(input.len(), |pos| pos + current_position); - let mut list = List::new(list_type, level, vec![]); - let end = list.parse_list_items(&input[current_position..end]); - return Some((list, end)); + body, } - None - } -} - -impl Parse for List { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - List::parse_with_level(input, current_position, 0) - } -} - -impl Display for List { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - self.nodes - .iter() - .map(|node| node.to_string()) - .collect::>() - .join("\n") - ) - } -} - -#[cfg(test)] -mod tests { - use super::{List, ListTypes}; - use crate::{ - nodes::{list_item::ListItem, paragraph::Paragraph, text::Text}, - toolkit::parser::Parse, - }; - use pretty_assertions::assert_eq; - - #[test] - fn serialize_unordered() { - assert_eq!( - List { - list_type: ListTypes::Unordered, - level: 0, - nodes: vec![ - ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("unordered list item").into()],), - None - ), - ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("unordered list item").into()],), - None - ) - ], - } - .to_string(), - "- unordered list item\n- unordered list item" - ); - assert_eq!( - List { - list_type: ListTypes::Unordered, - level: 0, - nodes: vec![ - ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("unordered list item").into()],), - None - ), - ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("unordered list item").into()],), - None - ) - ], - } - .to_string(), - "- unordered list item\n- unordered list item" - ); - } - - #[test] - fn serialize_ordered() { - let list = List::new( - ListTypes::Ordered, - 0, - vec![ - ListItem::new( - ListTypes::Ordered, - 0, - Paragraph::new(vec![Text::new("ordered list item").into()]), - None, - ), - ListItem::new( - ListTypes::Ordered, - 0, - Paragraph::new(vec![Text::new("ordered list item").into()]), - None, - ), - ], - ); - - assert_eq!(list.to_string(), "+ ordered list item\n+ ordered list item"); - } - - #[test] - fn parse_wrong_level() { - assert_eq!(List::parse_with_level("- level 0\n- level 0", 0, 1), None); - } - - #[test] - fn parse_unordered() { - assert_eq!( - List::parse("- level 0\n- level 0", 0), - Some(( - List::new( - ListTypes::Unordered, - 0, - vec![ - ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("level 0").into()]), - None - ), - ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("level 0").into()]), - None - ) - ], - ), - 19 - )) - ); - } - - #[test] - fn parse_ordered() { - assert_eq!( - List::parse("+ level 0\n+ level 0", 0), - Some(( - List::new( - ListTypes::Ordered, - 0, - vec![ - ListItem::new( - ListTypes::Ordered, - 0, - Paragraph::new(vec![Text::new("level 0").into()]), - None - ), - ListItem::new( - ListTypes::Ordered, - 0, - Paragraph::new(vec![Text::new("level 0").into()]), - None - ), - ], - ), - 19 - )) - ); - } - - #[test] - fn parse_mixed() { - let list = List::new( - ListTypes::Ordered, - 0, - vec![ListItem::new( - ListTypes::Ordered, - 0, - Paragraph::new(vec![Text::new("level 0").into()]), - Some(List::new( - ListTypes::Unordered, - 1, - vec![ListItem::new( - ListTypes::Unordered, - 1, - Paragraph::new(vec![Text::new("level 0").into()]), - None, - )], - )), - ) - .into()], - ); - - assert_eq!(List::parse("+ level 0\n - level 0", 0), Some((list, 20))); - } - - #[test] - fn parsed_nested() { - let list = List::new( - ListTypes::Unordered, - 0, - vec![ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("one").into()]).into(), - Some(List::new( - ListTypes::Unordered, - 1, - vec![ListItem::new( - ListTypes::Unordered, - 1, - Paragraph::new(vec![Text::new("two").into()]), - None, - )], - )), - )], - ); - - let input = r#"- one - - two"#; - assert_eq!(List::parse(input, 0), Some((list, 12))); - } - - #[test] - fn parse_nested() { - let input = r#"- one - - two -something"#; - let list = List::new( - ListTypes::Unordered, - 0, - vec![ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("one").into()]).into(), - Some(List::new( - ListTypes::Unordered, - 1, - vec![ListItem::new( - ListTypes::Unordered, - 1, - Paragraph::new(vec![Text::new("two\nsomething").into()]), - None, - )], - )), - )], - ); - - assert_eq!(List::parse(input, 0), Some((list, input.len()))); - } - - #[test] - fn ordered_suranded_by_text() { - let input = "some text\n\n+ one\n+ two\n\nsome text"; - let list = List::new( - ListTypes::Ordered, - 0, - vec![ - ListItem::new( - ListTypes::Ordered, - 0, - Paragraph::new(vec![Text::new("one").into()]), - None, - ), - ListItem::new( - ListTypes::Ordered, - 0, - Paragraph::new(vec![Text::new("two").into()]), - None, - ), - ], - ); - assert_eq!(List::parse(input, 11).unwrap(), (list, 11)); } } diff --git a/src/nodes/list_item.rs b/src/nodes/list_item.rs index 0b50781..ad67929 100644 --- a/src/nodes/list_item.rs +++ b/src/nodes/list_item.rs @@ -1,108 +1,15 @@ -use std::fmt::Display; - use serde::Serialize; -use super::{ - list::{List, ListTypes}, - paragraph::Paragraph, -}; +use super::{List, ParagraphNodes}; -#[derive(Debug, PartialEq, Serialize, Clone)] +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct ListItem { - pub list_type: ListTypes, - pub level: usize, - pub text: Paragraph, + pub text: Vec, pub nested_list: Option, } impl ListItem { - pub fn new( - list_type: ListTypes, - level: usize, - text: Paragraph, - nested_list: Option, - ) -> Self { - Self { - list_type, - level, - text, - nested_list, - } - } -} - -impl Display for ListItem { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let list_type = match self.list_type { - ListTypes::Unordered => '-', - ListTypes::Ordered => '+', - }; - write!( - f, - "{}{} {}{}", - String::from(' ').repeat(self.level), - list_type, - self.text, - self.nested_list - .as_ref() - .map_or("".to_string(), |list| format!("\n{}", list)) - ) - } -} - -#[cfg(test)] -mod tests { - use super::ListItem; - use crate::nodes::{ - list::{List, ListTypes}, - paragraph::Paragraph, - text::Text, - }; - use pretty_assertions::assert_eq; - - #[test] - fn serialize() { - assert_eq!( - ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("test").into()]), - None - ) - .to_string(), - "- test".to_string() - ); - - assert_eq!( - ListItem::new( - ListTypes::Ordered, - 0, - Paragraph::new(vec![Text::new("test").into()]), - None - ) - .to_string(), - "+ test".to_string() - ); - - assert_eq!( - ListItem::new( - ListTypes::Unordered, - 0, - Paragraph::new(vec![Text::new("test").into()]), - Some(List::new( - ListTypes::Unordered, - 1, - vec![ListItem::new( - ListTypes::Unordered, - 1, - Paragraph::new(vec![Text::new("test").into()]), - None - ) - .into()] - )) - ) - .to_string(), - "- test\n - test".to_string() - ); + pub fn new(text: Vec, nested_list: Option) -> Self { + Self { text, nested_list } } } diff --git a/src/nodes/metadata.rs b/src/nodes/metadata.rs deleted file mode 100644 index 1d18c36..0000000 --- a/src/nodes/metadata.rs +++ /dev/null @@ -1,162 +0,0 @@ -use std::fmt::Display; - -use chrono::{DateTime, FixedOffset}; -use serde::{Deserialize, Serialize}; - -use crate::toolkit::parser::Parse; - -#[derive(Debug, PartialEq, Serialize, Default, Clone, Deserialize)] -pub struct Metadata { - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub date: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub image: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub preview: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub is_draft: Option, -} - -impl Metadata { - pub fn new>( - title: Option, - date: Option>, - image: Option, - preview: Option, - tags: Option>, - ) -> Self { - Self { - title: title.map(|h| h.into()), - date, - image: image.map(|i| i.into()), - preview: preview.map(|p| p.into()), - is_draft: None, - tags, - } - } -} - -impl Parse for Metadata { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - if input[current_position..].starts_with("---\n") { - let start = current_position + 4; - if let Some(end) = input[start..].find("\n---") { - let meta: Metadata = serde_yaml::from_str(&input[start..start + end]).ok()?; - return Some((meta, end + 8)); - } - } - None - } -} - -impl Display for Metadata { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.title.is_none() - && self.date.is_none() - && self.image.is_none() - && self.preview.is_none() - && self.tags.is_none() - { - Ok(()) - } else { - write!(f, "---\n{}---", serde_yaml::to_string(self).unwrap()) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn test_serialize() { - let metadata = Metadata::new( - Some("header"), - Some(DateTime::parse_from_rfc3339("2022-01-01T00:00:00+02:00").unwrap()), - Some("image"), - Some("preview"), - Some(vec!["tag1".to_string(), "tag2".to_string()]), - ); - assert_eq!( - metadata.to_string(), - "---\ntitle: header\ndate: 2022-01-01T00:00:00+02:00\nimage: image\npreview: preview\ntags:\n- tag1\n- tag2\n---" - ); - } - - #[test] - fn test_parse() { - let metadata = Metadata { - title: Some("title".to_string()), - date: Some(DateTime::parse_from_rfc3339("2022-12-30T20:33:55+01:00").unwrap()), - image: Some("image".to_string()), - preview: Some("preview".to_string()), - tags: Some(vec!["tag1".to_string(), "tag2".to_string()]), - is_draft: Some(true), - }; - let str = "---\ntitle: title\ndate: 2022-12-30T20:33:55+01:00\nimage: image\npreview: preview\ntags:\n- tag1\n- tag2\nis_draft: true\n---"; - assert_eq!(Metadata::parse(str, 0), Some((metadata, str.len()))); - } - - #[test] - fn parse_empty() { - assert_eq!( - Metadata::parse("---\n\n---", 0), - Some(( - Metadata { - title: None, - date: None, - image: None, - preview: None, - tags: None, - is_draft: None, - }, - 8 - )) - ); - } - - #[test] - fn parse_fail() { - assert_eq!(Metadata::parse("random string", 0), None); - assert_eq!(Metadata::parse("---\nrandom string---", 0), None); - } - - #[test] - fn parse_only_with_title() { - assert_eq!( - Metadata::parse("---\ntitle: header\n---", 0), - Some(( - Metadata { - title: Some("header".to_string()), - preview: None, - date: None, - image: None, - tags: None, - is_draft: None, - }, - 21 - )) - ); - } - - #[test] - fn default() { - assert_eq!( - Metadata::default(), - Metadata::new::<&str>(None, None, None, None, None) - ); - assert_eq!(Metadata::default().to_string(), ""); - } - - #[test] - fn deserialize_with_quotes() { - let input = "---\ntitle: \"header\"\n---"; - let m = Metadata::parse(input, 0); - assert_eq!(input.len(), m.unwrap().1); - } -} diff --git a/src/nodes/mod.rs b/src/nodes/mod.rs index 2c0d16f..9d9f0e8 100644 --- a/src/nodes/mod.rs +++ b/src/nodes/mod.rs @@ -1,19 +1,35 @@ -pub mod anchor; -pub mod bold; -pub mod code; -pub mod collapsible; -pub mod divider; -pub mod embed; -pub mod heading; -pub mod highlight; -pub mod image; -pub mod image_gallery; -pub mod inline_code; -pub mod italic; -pub mod list; -pub mod list_item; -pub mod metadata; -pub mod paragraph; -pub mod strikethrough; -pub mod text; -pub mod yamd; +mod anchor; +mod bold; +mod code; +mod code_span; +mod collapsible; +mod embed; +mod heading; +mod highlight; +mod image; +mod images; +mod italic; +mod list; +mod list_item; +mod paragraph; +mod strikethrough; +mod thematic_break; +mod yamd; + +pub use anchor::Anchor; +pub use bold::{Bold, BoldNodes}; +pub use code::Code; +pub use code_span::CodeSpan; +pub use collapsible::Collapsible; +pub use embed::Embed; +pub use heading::{Heading, HeadingNodes}; +pub use highlight::Highlight; +pub use image::Image; +pub use images::Images; +pub use italic::Italic; +pub use list::{List, ListTypes}; +pub use list_item::ListItem; +pub use paragraph::{Paragraph, ParagraphNodes}; +pub use strikethrough::Strikethrough; +pub use thematic_break::ThematicBreak; +pub use yamd::{Yamd, YamdNodes}; diff --git a/src/nodes/paragraph.rs b/src/nodes/paragraph.rs index 9a6ba58..141c811 100644 --- a/src/nodes/paragraph.rs +++ b/src/nodes/paragraph.rs @@ -1,83 +1,100 @@ -use std::fmt::Display; - use serde::Serialize; -use crate::{ - nodes::{ - anchor::Anchor, bold::Bold, inline_code::InlineCode, italic::Italic, - strikethrough::Strikethrough, text::Text, - }, - toolkit::parser::{parse_to_consumer, parse_to_parser, Branch, Consumer, Parse, Parser}, -}; +use super::{Anchor, Bold, CodeSpan, Italic, Strikethrough}; -#[derive(Debug, PartialEq, Serialize, Clone)] -#[serde(tag = "type")] +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] +#[serde(tag = "type", content = "value")] pub enum ParagraphNodes { - A(Anchor), - B(Bold), - I(Italic), - S(Strikethrough), - Text(Text), - InlineCode(InlineCode), + Anchor(Anchor), + Bold(Bold), + Italic(Italic), + Strikethrough(Strikethrough), + Text(String), + CodeSpan(CodeSpan), } impl From for ParagraphNodes { fn from(value: Anchor) -> Self { - ParagraphNodes::A(value) + ParagraphNodes::Anchor(value) } } impl From for ParagraphNodes { fn from(value: Bold) -> Self { - ParagraphNodes::B(value) + ParagraphNodes::Bold(value) } } impl From for ParagraphNodes { fn from(value: Italic) -> Self { - ParagraphNodes::I(value) + ParagraphNodes::Italic(value) } } impl From for ParagraphNodes { fn from(value: Strikethrough) -> Self { - ParagraphNodes::S(value) + ParagraphNodes::Strikethrough(value) } } -impl From for ParagraphNodes { - fn from(value: Text) -> Self { +impl From for ParagraphNodes { + fn from(value: String) -> Self { ParagraphNodes::Text(value) } } -impl From for ParagraphNodes { - fn from(value: InlineCode) -> Self { - ParagraphNodes::InlineCode(value) - } -} - -impl Display for ParagraphNodes { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParagraphNodes::A(node) => write!(f, "{}", node), - ParagraphNodes::B(node) => write!(f, "{}", node), - ParagraphNodes::I(node) => write!(f, "{}", node), - ParagraphNodes::S(node) => write!(f, "{}", node), - ParagraphNodes::Text(node) => write!(f, "{}", node), - ParagraphNodes::InlineCode(node) => write!(f, "{}", node), - } +impl From for ParagraphNodes { + fn from(value: CodeSpan) -> Self { + ParagraphNodes::CodeSpan(value) } } -#[derive(Debug, PartialEq, Serialize, Clone)] +/// # Paragraph +/// +/// Any token until [Terminator](type@crate::lexer::TokenKind::Terminator) or end of input. +/// +/// [Body](Paragraph::body) can contain one or more: +/// +/// - [Anchor] +/// - [CodeSpan] +/// - [Bold] +/// - [Italic] +/// - [Strikethrough] +/// - [String] +/// +/// Example: +/// +/// ```text +/// Paragraph can contain an [anchor](#), a `code span`, and **bold**, or _italic_, or ~~strikethrough~~, or +/// regular text. +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +///

          +/// Paragraph can contain an +/// anchor +/// , a +/// code span +/// , and +/// bold +/// , or +/// italic +/// , or +/// strikethrough +/// , or regular text. +///

          +/// ``` +/// +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] pub struct Paragraph { - pub nodes: Vec, + pub body: Vec, } impl Paragraph { pub fn new(nodes: Vec) -> Self { - Self { nodes } + Self { body: nodes } } } @@ -86,112 +103,3 @@ impl Default for Paragraph { Self::new(vec![]) } } - -impl Display for Paragraph { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - self.nodes - .iter() - .map(|node| node.to_string()) - .collect::>() - .concat(), - ) - } -} - -impl Branch for Paragraph { - fn get_parsers(&self) -> Vec> { - vec![ - parse_to_parser::(), - parse_to_parser::(), - parse_to_parser::(), - parse_to_parser::(), - parse_to_parser::(), - ] - } - - fn get_consumer(&self) -> Option> { - Some(parse_to_consumer::()) - } - - fn push_node(&mut self, node: ParagraphNodes) { - self.nodes.push(node); - } -} - -impl Parse for Paragraph { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - let end = input[current_position..] - .find("\n\n") - .unwrap_or(input.len()); - let paragraph = Paragraph::default(); - Some(( - paragraph - .parse_branch(&input[current_position..end], "") - .expect("paragraph should always succed"), - end, - )) - } -} - -#[cfg(test)] -mod tests { - use super::Paragraph; - use crate::{ - nodes::{ - anchor::Anchor, bold::Bold, inline_code::InlineCode, italic::Italic, - strikethrough::Strikethrough, text::Text, - }, - toolkit::parser::{Branch, Parse}, - }; - use pretty_assertions::assert_eq; - - #[test] - fn push() { - let mut p = Paragraph::default(); - p.push_node(Text::new("simple text ").into()); - p.push_node(Bold::new(vec![Text::new("bold text").into()]).into()); - p.push_node(InlineCode::new("let foo='bar';").into()); - - assert_eq!( - p.to_string(), - "simple text **bold text**`let foo='bar';`".to_string() - ); - } - - #[test] - fn serialize() { - assert_eq!( - Paragraph::new(vec![ - Text::new("simple text ").into(), - Bold::new(vec![Text::new("bold text").into()]).into(), - InlineCode::new("let foo='bar';").into(), - Anchor::new("a", "u").into(), - Italic::new("I").into(), - Strikethrough::new("S").into() - ],) - .to_string(), - "simple text **bold text**`let foo='bar';`[a](u)_I_~~S~~".to_string() - ); - } - - #[test] - fn parse() { - assert_eq!( - Paragraph::parse("simple text **bold text**`let foo='bar';`[t](u)_I_~~S~~", 0), - Some(( - Paragraph::new(vec![ - Text::new("simple text ").into(), - Bold::new(vec![Text::new("bold text").into()]).into(), - InlineCode::new("let foo='bar';").into(), - Anchor::new("t", "u").into(), - Italic::new("I").into(), - Strikethrough::new("S").into() - ]), - 55 - )) - ); - } -} diff --git a/src/nodes/strikethrough.rs b/src/nodes/strikethrough.rs index b4235c8..19d1d1f 100644 --- a/src/nodes/strikethrough.rs +++ b/src/nodes/strikethrough.rs @@ -1,71 +1,29 @@ -use crate::toolkit::parser::Parse; use serde::Serialize; -use std::fmt::{Display, Formatter}; -/// Representation of strike through -#[derive(Debug, PartialEq, Serialize, Clone)] -pub struct Strikethrough { - pub text: String, -} +/// # Strikethrough +/// +/// Any token except [Terminator](type@crate::lexer::TokenKind::Terminator) surrounded by +/// [Tilde](type@crate::lexer::TokenKind::Tilde) of length 2. +/// +/// Example: +/// +/// ```text +/// ~~Strikethrough can contain any token +/// even EOL~~ +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +/// Strikethrough can contain any token +/// even EOL +/// ``` + +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] +pub struct Strikethrough(pub String); impl Strikethrough { - pub fn new>(text: IS) -> Self { - Strikethrough { text: text.into() } - } -} - -impl Parse for Strikethrough { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - if input[current_position..].starts_with("~~") { - if let Some(end) = input[current_position + 2..].find("~~") { - return Some(( - Strikethrough::new(&input[current_position + 2..current_position + 2 + end]), - end + 4, - )); - } - } - None - } -} - -impl Display for Strikethrough { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "~~{}~~", self.text) - } -} - -#[cfg(test)] -mod tests { - use super::Strikethrough; - use crate::toolkit::parser::Parse; - use pretty_assertions::assert_eq; - - #[test] - fn happy_path() { - let s = Strikethrough::new("2+2=5"); - assert_eq!(s.text, "2+2=5".to_string()); - } - - #[test] - fn to_string() { - let s: String = Strikethrough::new("2+2=5").to_string(); - assert_eq!(s, "~~2+2=5~~".to_string()); - } - - #[test] - fn parse() { - assert_eq!( - Strikethrough::parse("~~2+2=5~~", 0), - Some((Strikethrough::new("2+2=5"), 9)) - ); - assert_eq!( - Strikethrough::parse("~~is~~not", 0), - Some((Strikethrough::new("is"), 6)) - ); - assert_eq!(Strikethrough::parse("~~not", 0), None); - assert_eq!( - Strikethrough::parse("~~i\ns~~", 0), - Some((Strikethrough::new("i\ns"), 7)) - ); + pub fn new>(body: Body) -> Self { + Strikethrough(body.into()) } } diff --git a/src/nodes/text.rs b/src/nodes/text.rs deleted file mode 100644 index 48dfee5..0000000 --- a/src/nodes/text.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::toolkit::parser::Parse; -use serde::Serialize; -use std::fmt::Display; - -#[derive(Debug, PartialEq, Serialize, Clone)] -pub struct Text { - text: String, -} - -impl Text { - pub fn new>(text: IS) -> Self { - Text { text: text.into() } - } -} - -impl Parse for Text { - fn parse(input: &str, current_position: usize) -> Option<(Self, usize)> { - Some(( - Text::new(&input[current_position..]), - input.len() - current_position, - )) - } -} - -impl Display for Text { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.text) - } -} - -#[cfg(test)] -mod tests { - use crate::toolkit::parser::Parse; - - use super::Text; - use pretty_assertions::assert_eq; - - #[test] - fn happy_path() { - let text = Text::new("shiny text"); - assert_eq!(text.text, "shiny text".to_string()); - } - - #[test] - fn to_string() { - let text: String = Text::new("shiny text").to_string(); - assert_eq!(text, "shiny text".to_string()); - } - - #[test] - fn from_string() { - assert_eq!(Text::parse("t", 0), Some((Text::new("t"), 1))); - } -} diff --git a/src/nodes/thematic_break.rs b/src/nodes/thematic_break.rs new file mode 100644 index 0000000..527412c --- /dev/null +++ b/src/nodes/thematic_break.rs @@ -0,0 +1,24 @@ +use serde::Serialize; + +/// # Thematic Break +/// +/// [Minus](type@crate::lexer::TokenKind::Minus) with length five. +/// +/// Example: +/// ```text +/// ----- +/// ``` +/// +/// HTML equivalent: +/// +/// ```html +///
          +/// ``` +#[derive(Debug, PartialEq, Serialize, Clone, Default, Eq)] +pub struct ThematicBreak {} + +impl ThematicBreak { + pub fn new() -> Self { + Self {} + } +} diff --git a/src/nodes/yamd.rs b/src/nodes/yamd.rs index 252581f..3649c5b 100644 --- a/src/nodes/yamd.rs +++ b/src/nodes/yamd.rs @@ -1,39 +1,33 @@ -use std::fmt::Display; - use serde::Serialize; -use crate::toolkit::parser::{parse_to_consumer, parse_to_parser, Branch, Consumer, Parse, Parser}; - use super::{ - code::Code, collapsible::Collapsible, divider::Divider, embed::Embed, heading::Heading, - highlight::Highlight, image::Image, image_gallery::ImageGallery, list::List, - metadata::Metadata, paragraph::Paragraph, + Code, Collapsible, Embed, Heading, Highlight, Image, Images, List, Paragraph, ThematicBreak, }; -#[derive(Debug, PartialEq, Serialize, Clone)] -#[serde(tag = "type")] +#[derive(Debug, PartialEq, Serialize, Clone, Eq)] +#[serde(tag = "type", content = "value")] pub enum YamdNodes { - P(Paragraph), - H(Heading), + Pargargaph(Paragraph), + Heading(Heading), Image(Image), + Images(Images), Code(Code), List(List), - ImageGallery(ImageGallery), Highlight(Highlight), - Divider(Divider), + ThematicBreak(ThematicBreak), Embed(Embed), Collapsible(Collapsible), } impl From for YamdNodes { fn from(value: Paragraph) -> Self { - YamdNodes::P(value) + YamdNodes::Pargargaph(value) } } impl From for YamdNodes { fn from(value: Heading) -> Self { - YamdNodes::H(value) + YamdNodes::Heading(value) } } @@ -55,9 +49,9 @@ impl From for YamdNodes { } } -impl From for YamdNodes { - fn from(value: ImageGallery) -> Self { - YamdNodes::ImageGallery(value) +impl From for YamdNodes { + fn from(value: Images) -> Self { + YamdNodes::Images(value) } } @@ -67,9 +61,9 @@ impl From for YamdNodes { } } -impl From for YamdNodes { - fn from(value: Divider) -> Self { - YamdNodes::Divider(value) +impl From for YamdNodes { + fn from(value: ThematicBreak) -> Self { + YamdNodes::ThematicBreak(value) } } @@ -85,399 +79,161 @@ impl From for YamdNodes { } } -impl Display for YamdNodes { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - YamdNodes::P(node) => write!(f, "{}", node), - YamdNodes::H(node) => write!(f, "{}", node), - YamdNodes::Image(node) => write!(f, "{}", node), - YamdNodes::Code(node) => write!(f, "{}", node), - YamdNodes::List(node) => write!(f, "{}", node), - YamdNodes::ImageGallery(node) => write!(f, "{}", node), - YamdNodes::Highlight(node) => write!(f, "{}", node), - YamdNodes::Divider(node) => write!(f, "{}", node), - YamdNodes::Embed(node) => write!(f, "{}", node), - YamdNodes::Collapsible(node) => write!(f, "{}", node), - } - } -} - -/// Yamd is a parent node for every node. -#[derive(Debug, PartialEq, Serialize, Clone, Default)] +/// # Yamd +/// +/// [Metadata](Yamd::metadata) is optional Frontmatter. +/// +/// Can be only in the beginning of the document surrounded by [Minus](type@crate::lexer::TokenKind::Minus) +/// of length 3 followed by [EOL](type@crate::lexer::TokenKind::Eol) and [EOL](type@crate::lexer::TokenKind::Eol) +/// followed by [Minus](type@crate::lexer::TokenKind::Minus) of length 3. Can contain any string that is +/// parsable by the consumer. +/// +/// For example toml: +/// +/// ```text +/// --- +/// title: "Yamd" +/// tags: +/// - software +/// - rust +/// --- +/// ``` +/// +/// [Body](Yamd::body) can contain one or more: +/// +/// - [Paragraph] +/// - [Heading] +/// - [Image] +/// - [Images] +/// - [Code] +/// - [List] +/// - [Highlight] +/// - [ThematicBreak] +/// - [Embed] +/// - [Collapsible] +/// +/// Separated by [Terminator](type@crate::lexer::TokenKind::Terminator). +/// +/// Example: +/// +/// ~~~markdown +/// Yamd can contain a Paragraph. Or a +/// +/// # Heading +/// +/// Or one image: +/// +/// ![alt](src) +/// +/// Or code: +/// +/// ```rust +/// let a="or a code block"; +/// ``` +/// +/// Or unordered list: +/// +/// - Level 0 +/// - Level 1 +/// +/// Or ordered list: +/// + Level 0 +/// + Level 0 +/// + Level 1 +/// +/// It also can have a thematic break: +/// +/// ----- +/// +/// Or embed: +/// +/// {{youtube|url}} +/// +/// Or multiple images combined into gallery. There is no 1:1 match for that in HTML, and multiple +/// ways to do it depending on how it will be rendered: +/// +/// ![alt](src) +/// ![alt](src) +/// +/// Or a highlight: +/// +/// >> Highlight title +/// > warning +/// There is no 1:1 equivalent to a highlight in HTML. +/// +/// Highlight body can contain multiple paragraphs. +/// >> +/// +/// {% Or collapsible +/// Which is also does not have 1:1 equivalent in HTML +/// %} +/// ~~~ +/// +/// HTML equivalent: +/// +/// ```html +///

          Yamd can contain a Paragraph. Or a

          +///

          Heading

          +///

          Or one image:

          +/// alt +///

          Or code:

          +///
          let a="or a code block";
          +///

          Or unordered list:

          +///
            +///
          • +/// Level 0 +///
              +///
            • Level 1
            • +///
            +///
          • +///
          +///

          Or ordered list:

          +///
            +///
          1. Level 0
          2. +///
          3. +/// Level 1 +///
              +///
            1. Level 1
            2. +///
            +///
          4. +///
          +///

          It also can have a thematic break:

          +///
          +///

          Or embed:

          +///