about summary refs log tree commit diff
path: root/markup/src
diff options
context:
space:
mode:
authorgennyble <gen@nyble.dev>2026-09-27 03:00:09 -0500
committergennyble <gen@nyble.dev>2026-09-27 03:00:09 -0500
commit34965266351952c13db1213be141b61514ec2b60 (patch)
treebf961a61d3414308938c56eb5d5cb7a02a866e5d /markup/src
parent8df3edb9ab13e7b7db58d0e605e0f6dd004325b5 (diff)
rework breakdown
Diffstat (limited to 'markup/src')
-rw-r--r--markup/src/breakdown.rs352
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>"),
 		];