diff options
| author | gennyble <gen@nyble.dev> | 2026-09-26 18:28:00 -0500 |
|---|---|---|
| committer | gennyble <gen@nyble.dev> | 2026-09-26 18:28:00 -0500 |
| commit | 5d8c6ead334146ef20910559251b73dbb45e3f63 (patch) | |
| tree | 45d8290d7102bd44f7240a7e85be2ef9d2cf6b18 /markup/src | |
| parent | aad370012461f9b75e18d4306ecb68873a80c51e (diff) | |
implement breakdown
Diffstat (limited to 'markup/src')
| -rw-r--r-- | markup/src/breakdown.rs | 462 | ||||
| -rw-r--r-- | markup/src/lib.rs | 1 | ||||
| -rw-r--r-- | markup/src/lineup/mod.rs | 4 |
3 files changed, 460 insertions, 7 deletions
diff --git a/markup/src/breakdown.rs b/markup/src/breakdown.rs index 86efe56..edec7ee 100644 --- a/markup/src/breakdown.rs +++ b/markup/src/breakdown.rs @@ -1,11 +1,467 @@ +use std::{convert::Infallible, fmt, str::FromStr}; + +use line_span::{LineSpan, LineSpans}; + +use crate::breakdown::MultilineBlockResult::LineIgnored; + fn process<'raw>(raw: &'raw str) -> Document<'raw> { - todo!() + let mut ctx = Context::new(raw); + + let mut blocks = vec![]; + for line in raw.line_spans() { + if line.is_empty() { + continue; + } + + if let Some(block) = make_block(line, &mut ctx) { + blocks.push(block); + } + } + + 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" + if line.starts_with('#') { + if let Some((level_str, text)) = line.split_once(char::is_whitespace) { + let level = level_str + .chars() + .fold(0, |acc, c| if c == '#' { acc + 1 } else { acc }); + + // If the level (which is the count of '#' in the level_str) is not + // 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)); + } + } + } + + // "- List Item" + if let Some(stripped) = line.strip_prefix('-') { + return Some(Block::ListItem(stripped.trim_start())); + } + + Some(Block::Paragraph(line)) } -struct Document<'raw> { +enum MultilineBlockResult<'raw> { + LineIgnored, + LineEscaped(&'raw str), + LineConsumed, + BlockAlreadyActive, + Block(Block<'raw>), +} + +struct Context<'raw> { + raw: &'raw str, + lines: Vec<SpanOrStr<'raw>>, + + code_block: bool, + code_block_language: Option<&'raw str>, +} + +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, + } + } + + pub fn push_line<L: Into<SpanOrStr<'raw>>>(&mut self, line: L) { + self.lines.push(line.into()); + } + + 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), + + // 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 + } + + // Line escaped and no block active. + // Give the line, minus the escape, to the caller + PrefixedLineKind::Escaped => MultilineBlockResult::LineEscaped(pre.stripped), + }, + + // 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, + }; + + 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, + } + } + + pub fn set_code_block(&mut self, lang: Option<&'raw str>) { + self.code_block = true; + self.code_block_language = lang; + } + + pub fn handle_code_block( + &mut self, + prefixed: PrefixedLine<'raw>, + ) -> MultilineBlockResult<'raw> { + if self.code_block { + MultilineBlockResult::Block(Block::code( + self.code_block_language.take(), + self.consume_lines(), + )) + } else if self.has_active_block() { + MultilineBlockResult::BlockAlreadyActive + } else { + self.set_code_block(prefixed.stripped_option()); + MultilineBlockResult::LineConsumed + } + } + + pub fn has_active_block(&self) -> bool { + self.code_block + } + + 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!(), + } + } + } +} + +enum SpanOrStr<'raw> { + Span(LineSpan<'raw>), + Str(&'raw str), +} + +impl<'raw> SpanOrStr<'raw> { + pub fn slice_has_str(slice: &[SpanOrStr<'raw>]) -> bool { + for sos in slice { + if let SpanOrStr::Str(_) = sos { + return true; + } + } + + false + } + + pub fn as_str(self) -> &'raw str { + match self { + Self::Span(span) => span.as_str(), + Self::Str(s) => s, + } + } +} + +impl<'raw> From<LineSpan<'raw>> for SpanOrStr<'raw> { + fn from(span: LineSpan<'raw>) -> Self { + Self::Span(span) + } +} + +impl<'raw> From<&'raw str> for SpanOrStr<'raw> { + fn from(s: &'raw str) -> Self { + Self::Str(s) + } +} + +#[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>>, } -enum Block<'raw> { +impl<'raw> Document<'raw> { + pub fn parse(raw: &'raw str) -> Self { + process(raw) + } + + pub fn blocks(&self) -> &[Block<'raw>] { + &self.blocks + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum StrOrMany<'raw> { + One(&'raw str), + Many(Vec<&'raw str>), +} + +impl<'raw> From<&'raw str> for StrOrMany<'raw> { + fn from(s: &'raw str) -> Self { + StrOrMany::One(s) + } +} + +impl<'raw> fmt::Display for StrOrMany<'raw> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::One(s) => write!(f, "{s}"), + Self::Many(strs) => match strs.len() { + 0 => write!(f, ""), + 1 => write!(f, "{}", strs[1]), + len => { + let last = strs.last().unwrap(); + for s in &strs[..len - 1] { + writeln!(f, "{s}")?; + } + write!(f, "{last}") + } + }, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Block<'raw> { + Header { + level: u8, + text: &'raw str, + }, Paragraph(&'raw str), + ListItem(&'raw str), + Code { + language: Option<&'raw str>, + lines: StrOrMany<'raw>, + }, +} + +impl<'raw> Block<'raw> { + pub fn header(level: u8, text: &'raw str) -> Self { + Self::Header { level, text } + } + + pub fn para(text: &'raw str) -> Self { + Self::Paragraph(text) + } + + pub fn list_item(text: &'raw str) -> Self { + Self::ListItem(text) + } + + pub fn code<L: Into<StrOrMany<'raw>>>(language: Option<&'raw str>, lines: L) -> Self { + Self::Code { + language, + lines: lines.into(), + } + } +} + +#[cfg(test)] +mod test { + use super::{Block, Document, process}; + + macro_rules! doc { + (1p $text:literal) => { + Document { + blocks: vec![Block::Paragraph($text)], + } + }; + + (1 $someblock:expr) => { + Document { + blocks: vec![$someblock], + } + }; + } + + #[test] + pub fn header() { + let raw = "# Level one"; + let actual = process(raw); + let expected = doc!(1 Block::header(1, "Level one")); + + assert_eq!(actual, expected) + } + + #[test] + pub fn many_headers() { + let raw = "# One\n## Two\n### Three"; + + let actual = process(raw); + let expected = Document { + blocks: vec![ + Block::header(1, "One"), + Block::header(2, "Two"), + Block::header(3, "Three"), + ], + }; + + assert_eq!(actual, expected) + } + + #[test] + pub fn paragraph() { + let raw = "paragraph"; + let actual = process(raw); + let expected = doc!(1p "paragraph"); + + assert_eq!(actual, expected) + } + + #[test] + pub fn list_item() { + let raw = "- list item"; + let actual = process(raw); + let expected = doc!(1 Block::ListItem("list item")); + + assert_eq!(actual, expected) + } + + #[test] + pub fn empty_code_block_no_lang() { + let raw = "```\n```"; + let actual = process(raw); + let expected = doc!(1 Block::code(None, "")); + + assert_eq!(actual, expected) + } + + #[test] + pub fn empty_code_block_with_lang() { + let raw = "```lang\n```"; + let actual = process(raw); + let expected = doc!(1 Block::code(Some("lang"), "")); + + assert_eq!(actual, expected) + } + + #[test] + pub fn code_block_no_lang() { + let raw = "```\nsome code\n```"; + let actual = process(raw); + let expected = doc!(1 Block::code(None, "some code")); + + assert_eq!(actual, expected) + } + + #[test] + pub fn code_block_with_lang() { + let raw = "```html\n<p>paragraph</p>\n```"; + let actual = process(raw); + let expected = doc!(1 Block::code(Some("html"), "<p>paragraph</p>")); + + assert_eq!(actual, expected) + } + + const ALL: &'static str = " +# Header h1 +Paragraph! +- Item 1 +- Item 2 + +## Header h2 +```html +<p>some html</p> +``` +"; + + #[test] + pub fn all_together_now() { + let actual = process(ALL); + let expected_blocks = vec![ + Block::header(1, "Header h1"), + Block::para("Paragraph!"), + Block::list_item("Item 1"), + Block::list_item("Item 2"), + Block::header(2, "Header h2"), + Block::code(Some("html"), "<p>some html</p>"), + ]; + + assert_eq!( + actual, + Document { + blocks: expected_blocks + } + ) + } } diff --git a/markup/src/lib.rs b/markup/src/lib.rs index c224ad7..fcbfb29 100644 --- a/markup/src/lib.rs +++ b/markup/src/lib.rs @@ -1,2 +1,3 @@ +pub mod breakdown; pub mod legacy; pub mod lineup; diff --git a/markup/src/lineup/mod.rs b/markup/src/lineup/mod.rs index 9545222..e0ae37d 100644 --- a/markup/src/lineup/mod.rs +++ b/markup/src/lineup/mod.rs @@ -369,10 +369,6 @@ impl<'raw> CollectingCharIter<'raw> { } } } - - pub fn last(&self) -> Option<(usize, char)> { - self.last - } } impl<'raw> From<&'raw str> for CollectingCharIter<'raw> { |
