#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GemLine {
Heading { level: u8, text: String },
Text(String),
Link { url: String, label: String },
Item(String),
Quote(String),
Pre { alt: Option<String>, text: String },
Blank,
}
pub fn parse(body: &str) -> Vec<GemLine> {
let mut out = Vec::new();
let mut pre: Option<(Option<String>, String)> = None;
for line in body.lines() {
if let Some((_, text)) = pre.as_mut() {
if line.starts_with("```") {
let (alt, text) = pre.take().expect("inside a preformatted block");
out.push(GemLine::Pre { alt, text });
} else {
text.push_str(line);
text.push('\n');
}
continue;
}
if let Some(rest) = line.strip_prefix("```") {
pre = Some((trimmed_or_none(rest), String::new()));
} else if let Some(t) = line.strip_prefix("### ") {
out.push(GemLine::Heading { level: 3, text: t.trim().to_string() });
} else if let Some(t) = line.strip_prefix("## ") {
out.push(GemLine::Heading { level: 2, text: t.trim().to_string() });
} else if let Some(t) = line.strip_prefix("# ") {
out.push(GemLine::Heading { level: 1, text: t.trim().to_string() });
} else if let Some(rest) = line.strip_prefix("=>") {
if let Some(link) = parse_link(rest) {
out.push(link);
}
} else if let Some(t) = line.strip_prefix("* ") {
out.push(GemLine::Item(t.trim().to_string()));
} else if let Some(t) = line.strip_prefix("> ") {
out.push(GemLine::Quote(t.trim().to_string()));
} else if line.is_empty() {
out.push(GemLine::Blank);
} else {
out.push(GemLine::Text(line.to_string()));
}
}
if let Some((alt, text)) = pre.take() {
out.push(GemLine::Pre { alt, text });
}
out
}
fn parse_link(rest: &str) -> Option<GemLine> {
let rest = rest.trim_start();
if rest.is_empty() {
return None;
}
let (url, label) = match rest.find(char::is_whitespace) {
Some(idx) => (rest[..idx].to_string(), rest[idx..].trim().to_string()),
None => (rest.to_string(), String::new()),
};
Some(GemLine::Link { url, label })
}
fn trimmed_or_none(value: &str) -> Option<String> {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headings_at_three_levels() {
let lines = parse("# one\n## two\n### three\n");
assert_eq!(
lines,
vec![
GemLine::Heading { level: 1, text: "one".into() },
GemLine::Heading { level: 2, text: "two".into() },
GemLine::Heading { level: 3, text: "three".into() },
]
);
}
#[test]
fn link_with_and_without_label() {
let lines = parse("=> gemini://example.test/ Example capsule\n=> gemini://example.test/page\n");
assert_eq!(
lines,
vec![
GemLine::Link {
url: "gemini://example.test/".into(),
label: "Example capsule".into(),
},
GemLine::Link {
url: "gemini://example.test/page".into(),
label: String::new(),
},
]
);
}
#[test]
fn bare_arrow_is_dropped() {
assert_eq!(parse("=>\n"), vec![]);
assert_eq!(parse("=> \n"), vec![]);
}
#[test]
fn items_and_quotes_stay_line_level() {
let lines = parse("* one\n* two\n> q1\n> q2\n");
assert_eq!(
lines,
vec![
GemLine::Item("one".into()),
GemLine::Item("two".into()),
GemLine::Quote("q1".into()),
GemLine::Quote("q2".into()),
]
);
}
#[test]
fn preformatted_with_alt_captures_verbatim() {
let lines = parse("```rust\nfn main() {}\n```\n");
assert_eq!(
lines,
vec![GemLine::Pre {
alt: Some("rust".into()),
text: "fn main() {}\n".into(),
}]
);
}
#[test]
fn preformatted_swallows_other_prefixes() {
let lines = parse("```\n=> not-a-link\n# not-a-heading\n```\n");
let GemLine::Pre { alt, text } = &lines[0] else {
panic!("expected a preformatted block, got {:?}", lines);
};
assert_eq!(*alt, None);
assert!(text.contains("=> not-a-link"));
assert!(text.contains("# not-a-heading"));
}
#[test]
fn unterminated_fence_runs_to_end() {
let lines = parse("```\nstill open\n");
assert_eq!(
lines,
vec![GemLine::Pre { alt: None, text: "still open\n".into() }]
);
}
#[test]
fn blank_lines_and_text_preserved_in_order() {
let lines = parse("first\nsecond\n\nnext\n");
assert_eq!(
lines,
vec![
GemLine::Text("first".into()),
GemLine::Text("second".into()),
GemLine::Blank,
GemLine::Text("next".into()),
]
);
}
}