use super::{heading_to_key, section_shape};
use crate::{CoreError, LoopConfig};
use serde_yaml::{Mapping, Value};
#[derive(Debug)]
enum Tok {
H1(String),
H2(String),
H3(String),
Bullet { indent: usize, text: String },
}
pub fn parse_md(text: &str, origin: &str) -> Result<LoopConfig, CoreError> {
let toks = tokenize(text);
let value = build_document(&toks).map_err(|e| CoreError::Parse {
path: origin.to_string(),
yaml: e,
json: "not attempted: the file was read as markdown".into(),
})?;
serde_yaml::from_value::<LoopConfig>(value).map_err(|e| CoreError::Parse {
path: origin.to_string(),
yaml: e.to_string(),
json: "not attempted: the file was read as markdown".into(),
})
}
fn tokenize(text: &str) -> Vec<Tok> {
let mut out: Vec<Tok> = Vec::new();
let mut fenced = false;
let mut last_bullet_indent: Option<usize> = None;
let mut after_blank = false;
for raw in text.lines() {
let trimmed = raw.trim_start();
let indent = raw.len() - trimmed.len();
if trimmed.starts_with("```") && indent == 0 {
fenced = !fenced;
last_bullet_indent = None;
continue;
}
if fenced {
continue;
}
if trimmed.is_empty() {
after_blank = true;
continue;
}
if indent == 0 {
if let Some(rest) = trimmed.strip_prefix("### ") {
out.push(Tok::H3(rest.trim().to_string()));
last_bullet_indent = None;
after_blank = false;
continue;
}
if let Some(rest) = trimmed.strip_prefix("## ") {
out.push(Tok::H2(rest.trim().to_string()));
last_bullet_indent = None;
after_blank = false;
continue;
}
if let Some(rest) = trimmed.strip_prefix("# ") {
out.push(Tok::H1(rest.trim().to_string()));
last_bullet_indent = None;
after_blank = false;
continue;
}
}
if let Some(rest) = trimmed.strip_prefix("- ") {
out.push(Tok::Bullet {
indent,
text: rest.trim_end().to_string(),
});
last_bullet_indent = Some(indent);
after_blank = false;
continue;
}
if let Some(bi) = last_bullet_indent {
if !after_blank && indent > bi {
if let Some(Tok::Bullet { text, .. }) = out.last_mut() {
text.push('\n');
text.push_str(trimmed.trim_end());
continue;
}
}
}
last_bullet_indent = None;
after_blank = false;
}
out
}
fn build_document(toks: &[Tok]) -> Result<Value, String> {
let mut root = Mapping::new();
let mut section: Option<String> = None;
let mut entry: Option<Mapping> = None;
let mut i = 0usize;
while i < toks.len() {
match &toks[i] {
Tok::H1(name) => {
flush_entry(&mut root, §ion, &mut entry)?;
root.insert(Value::from("name"), Value::from(name.clone()));
i += 1;
}
Tok::H2(heading) => {
flush_entry(&mut root, §ion, &mut entry)?;
section = Some(heading_to_key(heading));
i += 1;
}
Tok::H3(heading) => {
flush_entry(&mut root, §ion, &mut entry)?;
let Some(sec) = section.as_deref() else {
return Err(format!(
"`### {heading}` appears before any `##` section heading"
));
};
let shape = section_shape(sec).ok_or_else(|| {
format!("section `{sec}` does not take `###` entries; use bullets")
})?;
let mut m = Mapping::new();
m.insert(
Value::from(shape.key_field),
Value::from(heading.to_string()),
);
entry = Some(m);
i += 1;
}
Tok::Bullet { indent, .. } => {
let base = *indent;
let end = toks[i..]
.iter()
.position(|t| !matches!(t, Tok::Bullet { .. }))
.map(|p| i + p)
.unwrap_or(toks.len());
let block: Vec<(usize, &str)> = toks[i..end]
.iter()
.map(|t| match t {
Tok::Bullet { indent, text } => (*indent, text.as_str()),
_ => unreachable!("filtered above"),
})
.collect();
let (value, _) = build_block(&block, 0, base)?;
match (&mut entry, section.as_deref()) {
(Some(m), _) => merge_into(m, value)?,
(None, Some(sec)) => {
let slot = root
.entry(Value::from(sec.to_string()))
.or_insert(Value::Mapping(Mapping::new()));
match slot {
Value::Mapping(m) => merge_into(m, value)?,
_ => return Err(format!("section `{sec}` already holds a list")),
}
}
(None, None) => merge_into(&mut root, value)?,
}
i = end;
}
}
}
flush_entry(&mut root, §ion, &mut entry)?;
Ok(Value::Mapping(root))
}
fn flush_entry(
root: &mut Mapping,
section: &Option<String>,
entry: &mut Option<Mapping>,
) -> Result<(), String> {
let Some(m) = entry.take() else {
return Ok(());
};
let sec = section
.as_deref()
.ok_or_else(|| "an entry was written outside any section".to_string())?;
let shape = section_shape(sec).ok_or_else(|| format!("section `{sec}` takes no entries"))?;
let target = match shape.list_field {
Some(field) => {
let slot = root
.entry(Value::from(sec.to_string()))
.or_insert(Value::Mapping(Mapping::new()));
let Value::Mapping(section_map) = slot else {
return Err(format!("section `{sec}` should be a mapping"));
};
section_map
.entry(Value::from(field))
.or_insert(Value::Sequence(vec![]))
}
None => root
.entry(Value::from(sec.to_string()))
.or_insert(Value::Sequence(vec![])),
};
match target {
Value::Sequence(seq) => seq.push(Value::Mapping(m)),
_ => return Err(format!("section `{sec}` already holds a mapping")),
}
Ok(())
}
fn merge_into(target: &mut Mapping, value: Value) -> Result<(), String> {
match value {
Value::Mapping(m) => {
for (k, v) in m {
target.insert(k, v);
}
Ok(())
}
_ => Err("expected `- key: value` bullets here, found a bare list".into()),
}
}
fn build_block(
lines: &[(usize, &str)],
start: usize,
indent: usize,
) -> Result<(Value, usize), String> {
let mut map = Mapping::new();
let mut seq: Vec<Value> = Vec::new();
let mut i = start;
while i < lines.len() {
let (ind, text) = lines[i];
if ind < indent {
break;
}
if ind > indent {
return Err(format!("unexpected extra indentation before `- {text}`"));
}
match split_field(text) {
Some((key, "")) => {
let child_indent = lines.get(i + 1).map(|(n, _)| *n).unwrap_or(indent);
if child_indent > indent {
let (child, next) = build_block(lines, i + 1, child_indent)?;
map.insert(scalar(key), child);
i = next;
} else {
map.insert(scalar(key), Value::Null);
i += 1;
}
}
Some((key, rest)) => {
map.insert(scalar(key), scalar(rest));
i += 1;
}
None => {
seq.push(scalar(text));
i += 1;
}
}
}
if !map.is_empty() && !seq.is_empty() {
return Err("a bullet list mixes `key: value` entries with bare items".into());
}
if map.is_empty() && !seq.is_empty() {
return Ok((Value::Sequence(seq), i));
}
Ok((Value::Mapping(map), i))
}
fn split_field(text: &str) -> Option<(&str, &str)> {
let (key, rest) = match text.split_once(": ") {
Some((k, v)) => (k, v.trim()),
None => (text.strip_suffix(':')?, ""),
};
let key = key.trim();
if key.is_empty() || key.contains(char::is_whitespace) {
return None;
}
Some((key, rest))
}
fn scalar(text: &str) -> Value {
if text.contains('\n') {
return Value::from(text.to_string());
}
serde_yaml::from_str::<Value>(text).unwrap_or_else(|_| Value::from(text.to_string()))
}