about summary refs log tree commit diff
path: root/markup/src
diff options
context:
space:
mode:
authorgennyble <gen@nyble.dev>2026-09-26 18:28:00 -0500
committergennyble <gen@nyble.dev>2026-09-26 18:28:00 -0500
commit5d8c6ead334146ef20910559251b73dbb45e3f63 (patch)
tree45d8290d7102bd44f7240a7e85be2ef9d2cf6b18 /markup/src
parentaad370012461f9b75e18d4306ecb68873a80c51e (diff)
implement breakdown
Diffstat (limited to 'markup/src')
-rw-r--r--markup/src/breakdown.rs462
-rw-r--r--markup/src/lib.rs1
-rw-r--r--markup/src/lineup/mod.rs4
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> {