1use 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
19pub 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
34fn 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 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 last_bullet_indent = None;
109 after_blank = false;
110 }
111 out
112}
113
114fn build_document(toks: &[Tok]) -> Result<Value, String> {
116 let mut root = Mapping::new();
117 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, §ion, &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, §ion, &mut entry)?;
131 section = Some(heading_to_key(heading));
132 i += 1;
133 }
134 Tok::H3(heading) => {
135 flush_entry(&mut root, §ion, &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 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 (Some(m), _) => merge_into(m, value)?,
175 (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 (None, None) => merge_into(&mut root, value)?,
187 }
188 i = end;
189 }
190 }
191 }
192 flush_entry(&mut root, §ion, &mut entry)?;
193 Ok(Value::Mapping(root))
194}
195
196fn 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 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 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
247fn 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 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 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
302fn 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
318fn 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}