use super::{heading_to_key, section_path, section_shape, SECTION_PATHS};
use crate::{CoreError, LoopConfig};
use serde_yaml::{Mapping, Value};
fn slot_at<'a>(root: &'a mut Mapping, path: &str) -> Result<&'a mut Value, String> {
let mut parts = path.split('.').peekable();
let mut cursor = root;
loop {
let part = parts.next().expect("a path has at least one segment");
let key = Value::from(part);
if parts.peek().is_none() {
return Ok(cursor
.entry(key)
.or_insert(Value::Mapping(Mapping::new())));
}
let slot = cursor
.entry(key)
.or_insert_with(|| Value::Mapping(Mapping::new()));
let Value::Mapping(next) = slot else {
return Err(format!("`{path}` runs through a value that is not a section"));
};
cursor = next;
}
}
fn known_headings() -> String {
SECTION_PATHS
.iter()
.map(|(_, _, heading)| *heading)
.collect::<Vec<_>>()
.join(", ")
}
#[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> {
parse_md_reporting(text, origin).map(|(cfg, _)| cfg)
}
pub fn parse_md_reporting(
text: &str,
origin: &str,
) -> Result<(LoopConfig, Vec<crate::config::legacy::Moved>), 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(),
})?;
let (value, moved) = crate::config::legacy::migrate(&value);
serde_yaml::from_value::<LoopConfig>(value)
.map(|cfg| (cfg, moved))
.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)?;
let key = heading_to_key(heading);
let path = section_path(&key).ok_or_else(|| {
format!(
"`## {heading}` is not a config section. Known sections are: {}",
known_headings()
)
})?;
section = Some(path.to_string());
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();
super::nested_insert(&mut m, 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 = slot_at(&mut root, sec)?;
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 = slot_at(root, sec)?;
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 => {
let slot = slot_at(root, sec)?;
if slot.as_mapping().is_some_and(|m| m.is_empty()) {
*slot = Value::Sequence(vec![]);
}
slot
}
};
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 {
merge_key(target, k, v);
}
Ok(())
}
_ => Err("expected `- key: value` bullets here, found a bare list".into()),
}
}
fn merge_key(target: &mut Mapping, key: Value, value: Value) {
if let (Some(Value::Mapping(existing)), Value::Mapping(incoming)) =
(target.get_mut(&key), &value)
{
for (k, v) in incoming.clone() {
merge_key(existing, k, v);
}
return;
}
target.insert(key, value);
}
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()))
}