Skip to main content

loopsmith_core/md/
parse.rs

1//! Markdown document → `serde_yaml::Value` → `LoopConfig`.
2//!
3//! The parser is deliberately ignorant of the config model. It produces a
4//! generic value tree and lets serde do the typing, so a new section needs an
5//! entry in [`super::section_shape`] at most, and usually nothing at all.
6
7use super::{heading_to_key, section_shape};
8use crate::{CoreError, LoopConfig};
9use serde_yaml::{Mapping, Value};
10
11#[derive(Debug)]
12enum Tok {
13    H1(String),
14    H2(String),
15    H3(String),
16    Bullet { indent: usize, text: String },
17}
18
19/// Parse a markdown config.
20pub fn parse_md(text: &str, origin: &str) -> Result<LoopConfig, CoreError> {
21    let toks = tokenize(text);
22    let value = build_document(&toks).map_err(|e| CoreError::Parse {
23        path: origin.to_string(),
24        yaml: e,
25        json: "not attempted: the file was read as markdown".into(),
26    })?;
27    serde_yaml::from_value::<LoopConfig>(value).map_err(|e| CoreError::Parse {
28        path: origin.to_string(),
29        yaml: e.to_string(),
30        json: "not attempted: the file was read as markdown".into(),
31    })
32}
33
34/// Split the document into headings and bullets, folding indented
35/// continuation lines into the bullet above them.
36///
37/// Anything else — paragraphs, tables, fenced blocks at the left margin — is
38/// documentation and is dropped. That is the feature: the config explains
39/// itself in the same file.
40fn tokenize(text: &str) -> Vec<Tok> {
41    let mut out: Vec<Tok> = Vec::new();
42    let mut fenced = false;
43    let mut last_bullet_indent: Option<usize> = None;
44    let mut after_blank = false;
45
46    for raw in text.lines() {
47        let trimmed = raw.trim_start();
48        let indent = raw.len() - trimmed.len();
49
50        if trimmed.starts_with("```") && indent == 0 {
51            fenced = !fenced;
52            last_bullet_indent = None;
53            continue;
54        }
55        if fenced {
56            continue;
57        }
58        if trimmed.is_empty() {
59            after_blank = true;
60            continue;
61        }
62
63        if indent == 0 {
64            if let Some(rest) = trimmed.strip_prefix("### ") {
65                out.push(Tok::H3(rest.trim().to_string()));
66                last_bullet_indent = None;
67                after_blank = false;
68                continue;
69            }
70            if let Some(rest) = trimmed.strip_prefix("## ") {
71                out.push(Tok::H2(rest.trim().to_string()));
72                last_bullet_indent = None;
73                after_blank = false;
74                continue;
75            }
76            if let Some(rest) = trimmed.strip_prefix("# ") {
77                out.push(Tok::H1(rest.trim().to_string()));
78                last_bullet_indent = None;
79                after_blank = false;
80                continue;
81            }
82        }
83
84        if let Some(rest) = trimmed.strip_prefix("- ") {
85            out.push(Tok::Bullet {
86                indent,
87                text: rest.trim_end().to_string(),
88            });
89            last_bullet_indent = Some(indent);
90            after_blank = false;
91            continue;
92        }
93
94        // A non-bullet line indented past the bullet above it, with no blank
95        // line in between, continues that bullet's value. This is how a long
96        // `instruction` spans several lines without becoming prose.
97        if let Some(bi) = last_bullet_indent {
98            if !after_blank && indent > bi {
99                if let Some(Tok::Bullet { text, .. }) = out.last_mut() {
100                    text.push('\n');
101                    text.push_str(trimmed.trim_end());
102                    continue;
103                }
104            }
105        }
106
107        // Anything else is prose.
108        last_bullet_indent = None;
109        after_blank = false;
110    }
111    out
112}
113
114/// Assemble the token stream into the config mapping.
115fn build_document(toks: &[Tok]) -> Result<Value, String> {
116    let mut root = Mapping::new();
117    // The section currently open, and the entry currently open inside it.
118    let mut section: Option<String> = None;
119    let mut entry: Option<Mapping> = None;
120
121    let mut i = 0usize;
122    while i < toks.len() {
123        match &toks[i] {
124            Tok::H1(name) => {
125                flush_entry(&mut root, &section, &mut entry)?;
126                root.insert(Value::from("name"), Value::from(name.clone()));
127                i += 1;
128            }
129            Tok::H2(heading) => {
130                flush_entry(&mut root, &section, &mut entry)?;
131                section = Some(heading_to_key(heading));
132                i += 1;
133            }
134            Tok::H3(heading) => {
135                flush_entry(&mut root, &section, &mut entry)?;
136                let Some(sec) = section.as_deref() else {
137                    return Err(format!(
138                        "`### {heading}` appears before any `##` section heading"
139                    ));
140                };
141                let shape = section_shape(sec).ok_or_else(|| {
142                    format!("section `{sec}` does not take `###` entries; use bullets")
143                })?;
144                let mut m = Mapping::new();
145                // A heading is always a string, never re-interpreted as YAML.
146                // `### Recorded the baseline: test count, coverage` would
147                // otherwise parse as a one-entry mapping and land on a field
148                // that wanted text.
149                m.insert(
150                    Value::from(shape.key_field),
151                    Value::from(heading.to_string()),
152                );
153                entry = Some(m);
154                i += 1;
155            }
156            Tok::Bullet { indent, .. } => {
157                let base = *indent;
158                let end = toks[i..]
159                    .iter()
160                    .position(|t| !matches!(t, Tok::Bullet { .. }))
161                    .map(|p| i + p)
162                    .unwrap_or(toks.len());
163                let block: Vec<(usize, &str)> = toks[i..end]
164                    .iter()
165                    .map(|t| match t {
166                        Tok::Bullet { indent, text } => (*indent, text.as_str()),
167                        _ => unreachable!("filtered above"),
168                    })
169                    .collect();
170                let (value, _) = build_block(&block, 0, base)?;
171
172                match (&mut entry, section.as_deref()) {
173                    // Bullets inside a `###` entry are that entry's fields.
174                    (Some(m), _) => merge_into(m, value)?,
175                    // Bullets directly under a `##` section are the section.
176                    (None, Some(sec)) => {
177                        let slot = root
178                            .entry(Value::from(sec.to_string()))
179                            .or_insert(Value::Mapping(Mapping::new()));
180                        match slot {
181                            Value::Mapping(m) => merge_into(m, value)?,
182                            _ => return Err(format!("section `{sec}` already holds a list")),
183                        }
184                    }
185                    // Bullets before any section are top-level fields.
186                    (None, None) => merge_into(&mut root, value)?,
187                }
188                i = end;
189            }
190        }
191    }
192    flush_entry(&mut root, &section, &mut entry)?;
193    Ok(Value::Mapping(root))
194}
195
196/// Append a finished `###` entry to its section's list.
197fn flush_entry(
198    root: &mut Mapping,
199    section: &Option<String>,
200    entry: &mut Option<Mapping>,
201) -> Result<(), String> {
202    let Some(m) = entry.take() else {
203        return Ok(());
204    };
205    let sec = section
206        .as_deref()
207        .ok_or_else(|| "an entry was written outside any section".to_string())?;
208    let shape = section_shape(sec).ok_or_else(|| format!("section `{sec}` takes no entries"))?;
209
210    let target = match shape.list_field {
211        // e.g. `graph` holds its entries under `graph.nodes`.
212        Some(field) => {
213            let slot = root
214                .entry(Value::from(sec.to_string()))
215                .or_insert(Value::Mapping(Mapping::new()));
216            let Value::Mapping(section_map) = slot else {
217                return Err(format!("section `{sec}` should be a mapping"));
218            };
219            section_map
220                .entry(Value::from(field))
221                .or_insert(Value::Sequence(vec![]))
222        }
223        // e.g. `goals` *is* the list.
224        None => root
225            .entry(Value::from(sec.to_string()))
226            .or_insert(Value::Sequence(vec![])),
227    };
228    match target {
229        Value::Sequence(seq) => seq.push(Value::Mapping(m)),
230        _ => return Err(format!("section `{sec}` already holds a mapping")),
231    }
232    Ok(())
233}
234
235fn merge_into(target: &mut Mapping, value: Value) -> Result<(), String> {
236    match value {
237        Value::Mapping(m) => {
238            for (k, v) in m {
239                target.insert(k, v);
240            }
241            Ok(())
242        }
243        _ => Err("expected `- key: value` bullets here, found a bare list".into()),
244    }
245}
246
247/// Turn one indentation level of bullets into a mapping or a sequence.
248///
249/// Returns the built value and the index just past the block it consumed.
250fn build_block(
251    lines: &[(usize, &str)],
252    start: usize,
253    indent: usize,
254) -> Result<(Value, usize), String> {
255    let mut map = Mapping::new();
256    let mut seq: Vec<Value> = Vec::new();
257    let mut i = start;
258
259    while i < lines.len() {
260        let (ind, text) = lines[i];
261        if ind < indent {
262            break;
263        }
264        if ind > indent {
265            return Err(format!("unexpected extra indentation before `- {text}`"));
266        }
267
268        match split_field(text) {
269            Some((key, "")) => {
270                // A key with no value owns the deeper bullets below it.
271                let child_indent = lines.get(i + 1).map(|(n, _)| *n).unwrap_or(indent);
272                if child_indent > indent {
273                    let (child, next) = build_block(lines, i + 1, child_indent)?;
274                    map.insert(scalar(key), child);
275                    i = next;
276                } else {
277                    // Nothing below: an explicitly empty value.
278                    map.insert(scalar(key), Value::Null);
279                    i += 1;
280                }
281            }
282            Some((key, rest)) => {
283                map.insert(scalar(key), scalar(rest));
284                i += 1;
285            }
286            None => {
287                seq.push(scalar(text));
288                i += 1;
289            }
290        }
291    }
292
293    if !map.is_empty() && !seq.is_empty() {
294        return Err("a bullet list mixes `key: value` entries with bare items".into());
295    }
296    if map.is_empty() && !seq.is_empty() {
297        return Ok((Value::Sequence(seq), i));
298    }
299    Ok((Value::Mapping(map), i))
300}
301
302/// Split `key: value` when the text really is a field rather than prose.
303///
304/// The guard on spaces is what keeps `- Never git stash. Never git reset.`
305/// from being read as a field named `Never git stash. Never git reset.`.
306fn split_field(text: &str) -> Option<(&str, &str)> {
307    let (key, rest) = match text.split_once(": ") {
308        Some((k, v)) => (k, v.trim()),
309        None => (text.strip_suffix(':')?, ""),
310    };
311    let key = key.trim();
312    if key.is_empty() || key.contains(char::is_whitespace) {
313        return None;
314    }
315    Some((key, rest))
316}
317
318/// Interpret a scalar the way YAML would, so `12`, `true`, and `[a, b]` arrive
319/// as the types they look like. Multi-line values stay verbatim strings —
320/// prose with a colon in it is not a mapping.
321fn scalar(text: &str) -> Value {
322    if text.contains('\n') {
323        return Value::from(text.to_string());
324    }
325    serde_yaml::from_str::<Value>(text).unwrap_or_else(|_| Value::from(text.to_string()))
326}