diff options
| author | gennyble <gen@nyble.dev> | 2026-09-27 03:00:09 -0500 |
|---|---|---|
| committer | gennyble <gen@nyble.dev> | 2026-09-27 03:00:09 -0500 |
| commit | 34965266351952c13db1213be141b61514ec2b60 (patch) | |
| tree | bf961a61d3414308938c56eb5d5cb7a02a866e5d /markup/src | |
| parent | 8df3edb9ab13e7b7db58d0e605e0f6dd004325b5 (diff) | |
rework breakdown
Diffstat (limited to 'markup/src')
| -rw-r--r-- | markup/src/breakdown.rs | 352 |
1 files changed, 178 insertions, 174 deletions
diff --git a/markup/src/breakdown.rs b/markup/src/breakdown.rs index 00fbb5f..8e32408 100644 --- a/markup/src/breakdown.rs +++ b/markup/src/breakdown.rs @@ -1,39 +1,57 @@ -use std::fmt; +use std::{fmt, ops::Deref}; use line_span::{LineSpan, LineSpans}; -use crate::breakdown::MultilineBlockResult::LineIgnored; - fn process<'raw>(raw: &'raw str) -> Document<'raw> { - let mut ctx = Context::new(raw); + let tokens: Vec<TokenLine<'raw>> = raw + .line_spans() + .map(|span| TokenLine { + tok: Token::detect(span.as_str()), + span, + }) + .collect(); let mut blocks = vec![]; - for line in raw.line_spans() { - if line.is_empty() { - continue; + let mut curr = &tokens[..]; + loop { + if curr.is_empty() { + break; } - if let Some(block) = make_block(line, &mut ctx) { - blocks.push(block); + let tok = curr[0]; + match tok.tok { + Token::Text => blocks.push(Block::Paragraph(tok.span.as_str())), + Token::Blank => (), + Token::Header { level, text } => blocks.push(Block::header(level, text)), + Token::Escaped { text } => blocks.push(Block::Paragraph(text)), + Token::ListItem { text } => { + let mut mur = map_until(&curr[1..], extract_listitem); + mur.items.insert(0, text); + + blocks.push(Block::list(mur.items)); + curr = mur.remaining; + continue; + } + Token::CodeFence { lang } => { + let mur = map_until(&curr[1..], extract_codeblock_body); + let body = StrOrMany::from_spans(raw, &mur.items); + blocks.push(Block::code(lang, body)); + + // Advance remaining by one to skip the close + curr = &mur.remaining[1..]; + continue; + } } + + curr = &curr[1..]; } Document { blocks } } -fn make_block<'raw>(span: LineSpan<'raw>, ctx: &mut Context<'raw>) -> Option<Block<'raw>> { - let line = match ctx.handle_multiline_blocks(span) { - MultilineBlockResult::Block(blk) => return Some(blk), - MultilineBlockResult::BlockAlreadyActive => unreachable!(), - MultilineBlockResult::LineConsumed => return None, - MultilineBlockResult::LineEscaped(escaped) => escaped, - MultilineBlockResult::LineIgnored => span.as_str(), - }; - - /* Single Line Blocks */ - // "# Header" +fn maybe_header<'raw>(line: &'raw str) -> Option<Token<'raw>> { if line.starts_with('#') { - if let Some((level_str, text)) = line.split_once(char::is_whitespace) { + if let Some((level_str, text)) = line.split_once(|c| c == ' ') { let level = level_str .chars() .fold(0, |acc, c| if c == '#' { acc + 1 } else { acc }); @@ -42,129 +60,140 @@ fn make_block<'raw>(span: LineSpan<'raw>, ctx: &mut Context<'raw>) -> Option<Blo // equal to level_str.len(), not every character was a hash and this // header is invalid if level == level_str.len() { - return Some(Block::header(level as u8, text)); + return Some(Token::Header { + level: level as u8, + text, + }); } } } - // "- List Item" - if let Some(stripped) = line.strip_prefix('-') { - return Some(Block::ListItem(stripped.trim_start())); - } - - Some(Block::Paragraph(line)) + None } -enum MultilineBlockResult<'raw> { - LineIgnored, - LineEscaped(&'raw str), - LineConsumed, - BlockAlreadyActive, - Block(Block<'raw>), +fn extract_listitem<'raw>(tok: &TokenLine<'raw>) -> Option<&'raw str> { + if let Token::ListItem { text } = tok.tok { + Some(text) + } else { + None + } } -struct Context<'raw> { - raw: &'raw str, - lines: Vec<SpanOrStr<'raw>>, - - code_block: bool, - code_block_language: Option<&'raw str>, +fn extract_codeblock_body<'raw>(tok: &TokenLine<'raw>) -> Option<SpanOrStr<'raw>> { + match tok.tok { + Token::CodeFence { .. } => None, + _ => Some(tok.as_code_body()), + } } -impl<'raw> Context<'raw> { - pub fn new(raw: &'raw str) -> Self { - Self { - raw, - lines: Vec::with_capacity(10), - code_block: false, - code_block_language: None, +fn map_until<'raw, 'tok, F, T>(toks: &'tok [TokenLine<'raw>], f: F) -> MapUntilReturn<'raw, 'tok, T> +where + F: Fn(&TokenLine<'raw>) -> Option<T>, +{ + let mut items = vec![]; + for (idx, tok) in toks.iter().enumerate() { + match f(tok) { + Some(item) => items.push(item), + None => { + return MapUntilReturn { + items, + remaining: &toks[idx..], + }; + } } } - pub fn push_line<L: Into<SpanOrStr<'raw>>>(&mut self, line: L) { - self.lines.push(line.into()); + MapUntilReturn { + items, + remaining: &[], } +} - pub fn handle_multiline_blocks(&mut self, line: LineSpan<'raw>) -> MultilineBlockResult<'raw> { - let handled = match PrefixedLine::detect(line.as_str()) { - Some(pre) => match pre.kind { - PrefixedLineKind::CodeFence => self.handle_code_block(pre), +struct MapUntilReturn<'raw, 'tok, T> { + items: Vec<T>, + remaining: &'tok [TokenLine<'raw>], +} - // Line is escaped, but escaped in an active block, so we - // want to keep this line for the block - PrefixedLineKind::Escaped if self.has_active_block() => { - self.push_line(pre.stripped); - MultilineBlockResult::LineConsumed - } +#[derive(Copy, Clone, Debug)] +struct TokenLine<'raw> { + span: LineSpan<'raw>, + tok: Token<'raw>, +} - // Line escaped and no block active. - // Give the line, minus the escape, to the caller - PrefixedLineKind::Escaped => MultilineBlockResult::LineEscaped(pre.stripped), - }, +impl<'raw> Deref for TokenLine<'raw> { + type Target = Token<'raw>; - // Line didn't contain one of our block prefixes, but we have an - // active block, so push it there - None if self.has_active_block() => { - self.push_line(line); - MultilineBlockResult::LineConsumed - } - None => LineIgnored, - }; + fn deref(&self) -> &Self::Target { + &self.tok + } +} - match handled { - // We matched a block prefix, but there is already a block active. - // We don't attempt to close the active block, just push the line - MultilineBlockResult::BlockAlreadyActive => { - self.push_line(line); - MultilineBlockResult::LineConsumed - } - handled => handled, +impl<'raw> TokenLine<'raw> { + pub fn as_code_body(&self) -> SpanOrStr<'raw> { + if let Token::Escaped { text } = self.tok { + return text.into(); } - } - pub fn set_code_block(&mut self, lang: Option<&'raw str>) { - self.code_block = true; - self.code_block_language = lang; + self.span.into() } +} + +#[derive(Copy, Clone, Debug)] +enum Token<'raw> { + Text, + Blank, + Escaped { text: &'raw str }, + Header { level: u8, text: &'raw str }, + CodeFence { lang: Option<&'raw str> }, + ListItem { text: &'raw str }, +} + +impl<'raw> Token<'raw> { + pub fn detect(line: &'raw str) -> Self { + if line.is_empty() { + return Token::Blank; + } - pub fn handle_code_block( - &mut self, - prefixed: PrefixedLine<'raw>, - ) -> MultilineBlockResult<'raw> { - if self.code_block { - self.code_block = false; - MultilineBlockResult::Block(Block::code( - self.code_block_language.take(), - self.consume_lines(), - )) - } else if self.has_active_block() { - MultilineBlockResult::BlockAlreadyActive + let mut escaped = None; + let working = if let Some(stripped) = line.strip_prefix('\\') { + escaped = Some(stripped); + stripped } else { - self.set_code_block(prefixed.stripped_option()); - MultilineBlockResult::LineConsumed + line + }; + + macro_rules! maybe_escaped { + () => { + if let Some(text) = escaped { + return Token::Escaped { text }; + } + }; + } + + if let Some(header_tok) = maybe_header(working) { + maybe_escaped!(); + return header_tok; } - } - pub fn has_active_block(&self) -> bool { - self.code_block + if let Some(stripped) = working.strip_prefix("```") { + maybe_escaped!(); + return Token::CodeFence { + lang: Self::str_empty_option(stripped), + }; + } + + if let Some(stripped) = working.strip_prefix('-') { + maybe_escaped!(); + return Token::ListItem { text: stripped }; + } + + Token::Text } - fn consume_lines(&mut self) -> StrOrMany<'raw> { - if SpanOrStr::slice_has_str(&self.lines) { - StrOrMany::Many(self.lines.drain(..).map(SpanOrStr::as_str).collect()) - } else { - let mut drain = self.lines.drain(..); - match drain.next() { - None => StrOrMany::One(""), - Some(SpanOrStr::Span(first)) => match drain.last() { - Some(SpanOrStr::Span(last)) => { - StrOrMany::One(&self.raw[first.start()..last.end()]) - } - Some(SpanOrStr::Str(_)) => unreachable!(), - None => StrOrMany::One(first.as_str()), - }, - Some(SpanOrStr::Str(_)) => unreachable!(), - } + pub fn str_empty_option(s: &str) -> Option<&str> { + match s.is_empty() { + true => None, + false => Some(s), } } } @@ -185,7 +214,7 @@ impl<'raw> SpanOrStr<'raw> { false } - pub fn as_str(self) -> &'raw str { + pub fn as_str(&self) -> &'raw str { match self { Self::Span(span) => span.as_str(), Self::Str(s) => s, @@ -205,57 +234,6 @@ impl<'raw> From<&'raw str> for SpanOrStr<'raw> { } } -#[derive(Clone, Copy, Debug)] -struct PrefixedLine<'raw> { - stripped: &'raw str, - kind: PrefixedLineKind, -} - -impl<'raw> PrefixedLine<'raw> { - pub fn new(stripped: &'raw str, kind: PrefixedLineKind) -> Self { - Self { stripped, kind } - } - - pub fn detect(line: &'raw str) -> Option<Self> { - let mut escaped = None; - let working = if let Some(stripped) = line.strip_prefix('\\') { - escaped = Some(stripped); - stripped - } else { - line - }; - - macro_rules! escaped_or_kind { - ($stripped:ident, $kind:expr) => { - if let Some(escaped_line) = escaped { - return Some(PrefixedLine::new(escaped_line, PrefixedLineKind::Escaped)); - } else { - return Some(PrefixedLine::new($stripped, $kind)); - } - }; - } - - if let Some(stripped) = working.strip_prefix("```") { - escaped_or_kind!(stripped, PrefixedLineKind::CodeFence); - } - - None - } - - pub fn stripped_option(&self) -> Option<&'raw str> { - match self.stripped.is_empty() { - true => None, - false => Some(self.stripped), - } - } -} - -#[derive(Copy, Clone, Debug, PartialEq)] -enum PrefixedLineKind { - Escaped, - CodeFence, -} - #[derive(Clone, Debug, PartialEq)] pub struct Document<'raw> { blocks: Vec<Block<'raw>>, @@ -283,6 +261,31 @@ impl<'raw> From<&'raw str> for StrOrMany<'raw> { } } +impl<'raw> StrOrMany<'raw> { + fn from_spans(raw: &'raw str, slice: &[SpanOrStr<'raw>]) -> Self { + if SpanOrStr::slice_has_str(slice) { + StrOrMany::Many(slice.iter().map(|sp| sp.as_str()).collect()) + } else { + match [slice.first(), slice.last()] { + // Slice is empty + [None, _] => return StrOrMany::One(""), + + // These can't happen because we just checked if the slice has a Str variant + [Some(SpanOrStr::Str(_)), _] => unreachable!(), + [_, Some(SpanOrStr::Str(_))] => unreachable!(), + + // This can't happen because the last would be the first + [Some(SpanOrStr::Span(_)), None] => unreachable!(), + + // Return one large slice encompassing all the Span in the slice + [Some(SpanOrStr::Span(first)), Some(SpanOrStr::Span(last))] => { + StrOrMany::One(&raw[first.start()..last.end()]) + } + } + } + } +} + impl<'raw> fmt::Display for StrOrMany<'raw> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -309,7 +312,9 @@ pub enum Block<'raw> { text: &'raw str, }, Paragraph(&'raw str), - ListItem(&'raw str), + List { + items: Vec<&'raw str>, + }, Code { language: Option<&'raw str>, lines: StrOrMany<'raw>, @@ -325,8 +330,8 @@ impl<'raw> Block<'raw> { Self::Paragraph(text) } - pub fn list_item(text: &'raw str) -> Self { - Self::ListItem(text) + pub fn list(items: Vec<&'raw str>) -> Self { + Self::List { items } } pub fn code<L: Into<StrOrMany<'raw>>>(language: Option<&'raw str>, lines: L) -> Self { @@ -393,7 +398,7 @@ mod test { pub fn list_item() { let raw = "- list item"; let actual = process(raw); - let expected = doc!(1 Block::ListItem("list item")); + let expected = doc!(1 Block::list(vec![" list item"])); assert_eq!(actual, expected) } @@ -452,8 +457,7 @@ Paragraph! let expected_blocks = vec![ Block::header(1, "Header h1"), Block::para("Paragraph!"), - Block::list_item("Item 1"), - Block::list_item("Item 2"), + Block::list(vec![" Item 1", " Item 2"]), Block::header(2, "Header h2"), Block::code(Some("html"), "<p>some html</p>"), ]; |
