1use super::error::EditError;
6use super::schema::*;
7use comrak::nodes::{AstNode, ListType, NodeValue};
8use comrak::{parse_document, Arena, Options};
9
10impl Schema {
11 pub fn edit(&self, md: &str, target: &str, new_value: &str) -> Result<String, EditError> {
23 let body = strip_frontmatter(md);
24 let body_offset = md.len() - body.len();
25
26 let ls = line_start_offsets(body);
27 let arena = Arena::new();
28 let root = parse_document(&arena, body, &Options::default());
29 let blocks = build_sp_blocks(root.children(), &ls);
30
31 let path: Vec<&str> = target.split('.').collect();
32 let span = find_span(&path, &self.body, &blocks).ok_or(EditError::TargetNotFound)?;
33
34 let abs_start = body_offset + span.start;
35 let abs_end = body_offset + span.end;
36
37 let mut out = String::with_capacity(md.len());
38 out.push_str(&md[..abs_start]);
39 out.push_str(new_value);
40 out.push_str(&md[abs_end..]);
41 Ok(out)
42 }
43}
44
45#[derive(Clone, Copy)]
49struct Span {
50 start: usize,
51 end: usize,
52}
53
54struct SpBlock {
55 kind: SpBlockKind,
56}
57
58#[allow(dead_code)]
59enum SpBlockKind {
60 Section {
61 level: u8,
62 title: String,
63 children: Vec<SpBlock>,
64 },
65 List {
66 ordered: bool,
67 items: Vec<SpItem>,
68 },
69 Para {
70 span: Span,
71 },
72}
73
74#[allow(dead_code)]
75struct SpItem {
76 text: String,
77 text_span: Span,
79 checked: Option<bool>,
80 children: Vec<SpBlock>,
81}
82
83enum FlatItem {
86 Heading { level: u8, title: String },
87 Block { idx: usize },
88}
89
90fn build_sp_blocks<'a>(nodes: impl Iterator<Item = &'a AstNode<'a>>, ls: &[usize]) -> Vec<SpBlock> {
91 let mut flat: Vec<FlatItem> = Vec::new();
92 let mut store: Vec<Option<SpBlock>> = Vec::new();
93
94 for node in nodes {
95 let val = node.data.borrow().value.clone();
96 match val {
97 NodeValue::Heading(h) => {
98 flat.push(FlatItem::Heading {
99 level: h.level,
100 title: text_of(node),
101 });
102 }
103 NodeValue::List(nl) => {
104 let ordered = matches!(nl.list_type, ListType::Ordered);
105 let items = build_sp_items(node, ls);
106 let idx = store.len();
107 store.push(Some(SpBlock {
108 kind: SpBlockKind::List { ordered, items },
109 }));
110 flat.push(FlatItem::Block { idx });
111 }
112 NodeValue::Paragraph => {
113 if let Some(sp) = first_text_span(node, ls) {
114 let idx = store.len();
115 store.push(Some(SpBlock {
116 kind: SpBlockKind::Para { span: sp },
117 }));
118 flat.push(FlatItem::Block { idx });
119 }
120 }
121 _ => {}
122 }
123 }
124
125 let mut pos = 0;
126 sp_nest(&flat, &mut store, &mut pos, 0)
127}
128
129fn sp_nest(
130 flat: &[FlatItem],
131 store: &mut Vec<Option<SpBlock>>,
132 pos: &mut usize,
133 parent_level: usize,
134) -> Vec<SpBlock> {
135 let mut out: Vec<SpBlock> = Vec::new();
136 while *pos < flat.len() {
137 match &flat[*pos] {
138 FlatItem::Heading { level, title } => {
139 if (*level as usize) <= parent_level {
140 break;
141 }
142 let (level, title) = (*level, title.clone());
143 *pos += 1;
144 let children = sp_nest(flat, store, pos, level as usize);
145 out.push(SpBlock {
146 kind: SpBlockKind::Section {
147 level,
148 title,
149 children,
150 },
151 });
152 }
153 FlatItem::Block { idx } => {
154 let idx = *idx;
155 *pos += 1;
156 if let Some(block) = store.get_mut(idx).and_then(|b| b.take()) {
157 out.push(block);
158 }
159 }
160 }
161 }
162 out
163}
164
165fn build_sp_items<'a>(list_node: &'a AstNode<'a>, ls: &[usize]) -> Vec<SpItem> {
166 let mut items = Vec::new();
167 for item in list_node.children() {
168 let mut raw_text = String::new();
169 let mut raw_span = Span { start: 0, end: 0 };
170 let mut child_list_nodes: Vec<&AstNode<'a>> = Vec::new();
171
172 for ch in item.children() {
173 let val = ch.data.borrow().value.clone();
174 match val {
175 NodeValue::Paragraph if raw_text.is_empty() => {
176 raw_text = text_of(ch);
177 if let Some(sp) = first_text_span(ch, ls) {
178 raw_span = sp;
179 }
180 }
181 NodeValue::List(_) => child_list_nodes.push(ch),
182 _ => {}
183 }
184 }
185
186 let (checked, content, checkbox_bytes) = split_checkbox(&raw_text);
187 raw_span.start += checkbox_bytes;
188
189 let children = child_list_nodes
190 .into_iter()
191 .map(|n| {
192 let val = n.data.borrow().value.clone();
193 let ordered = matches!(
194 val,
195 NodeValue::List(ref nl) if matches!(nl.list_type, ListType::Ordered)
196 );
197 SpBlock {
198 kind: SpBlockKind::List {
199 ordered,
200 items: build_sp_items(n, ls),
201 },
202 }
203 })
204 .collect();
205
206 items.push(SpItem {
207 text: content,
208 text_span: raw_span,
209 checked,
210 children,
211 });
212 }
213 items
214}
215
216fn first_text_span<'a>(node: &'a AstNode<'a>, ls: &[usize]) -> Option<Span> {
218 for ch in node.children() {
219 let data = ch.data.borrow();
220 if let NodeValue::Text(ref t) = data.value {
221 let sp = data.sourcepos;
222 let start = linecol_to_offset(sp.start.line, sp.start.column, ls);
223 let end = start + t.len();
224 return Some(Span { start, end });
225 }
226 }
227 None
228}
229
230fn linecol_to_offset(line: usize, col: usize, ls: &[usize]) -> usize {
231 ls.get(line.saturating_sub(1)).copied().unwrap_or(0) + col.saturating_sub(1)
232}
233
234fn line_start_offsets(s: &str) -> Vec<usize> {
235 let mut starts = vec![0usize];
236 for (i, &b) in s.as_bytes().iter().enumerate() {
237 if b == b'\n' {
238 starts.push(i + 1);
239 }
240 }
241 starts
242}
243
244fn text_of<'a>(node: &'a AstNode<'a>) -> String {
245 let mut s = String::new();
246 collect_text(node, &mut s);
247 s.trim().to_string()
248}
249
250fn collect_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
251 for c in node.children() {
252 match &c.data.borrow().value {
253 NodeValue::Text(t) => out.push_str(t),
254 NodeValue::Code(code) => out.push_str(&code.literal),
255 NodeValue::HtmlInline(html) => out.push_str(html),
257 NodeValue::SoftBreak | NodeValue::LineBreak => out.push(' '),
258 _ => collect_text(c, out),
259 }
260 }
261}
262
263fn split_checkbox(text: &str) -> (Option<bool>, String, usize) {
264 if let Some(rest) = text.strip_prefix("[ ] ") {
265 (Some(false), rest.to_string(), 4)
266 } else if let Some(rest) = text
267 .strip_prefix("[x] ")
268 .or_else(|| text.strip_prefix("[X] "))
269 {
270 (Some(true), rest.to_string(), 4)
271 } else {
272 (None, text.to_string(), 0)
273 }
274}
275
276fn strip_frontmatter(md: &str) -> &str {
277 let Some(rest) = md.strip_prefix("---\n") else {
278 return md;
279 };
280 match rest.find("\n---") {
281 Some(end) => {
282 let after = &rest[end + 4..];
283 after.strip_prefix('\n').unwrap_or(after)
284 }
285 None => md,
286 }
287}
288
289fn find_span(path: &[&str], schema: &[Node], blocks: &[SpBlock]) -> Option<Span> {
294 if path.is_empty() {
295 return None;
296 }
297 let key = path[0];
298 let rest = &path[1..];
299
300 let mut block_idx = 0usize;
302 for snode in schema {
303 let alias = match node_alias(snode) {
304 Some(a) => a,
305 None => {
306 block_idx += 1;
307 continue;
308 }
309 };
310
311 let block = match advance_to_matching(snode, blocks, &mut block_idx) {
313 Some(b) => b,
314 None => continue,
315 };
316
317 if alias != key {
318 block_idx += 1;
319 continue;
320 }
321
322 return match (snode, block) {
323 (
324 Node::Heading {
325 children: schema_ch,
326 ..
327 },
328 SpBlock {
329 kind:
330 SpBlockKind::Section {
331 children: doc_ch, ..
332 },
333 },
334 ) => {
335 if rest.is_empty() {
336 None } else {
338 find_span(rest, schema_ch, doc_ch)
339 }
340 }
341
342 (
343 Node::List { head, .. },
344 SpBlock {
345 kind: SpBlockKind::List { items, .. },
346 },
347 ) => {
348 if rest.is_empty() {
349 return None;
350 }
351 let idx: usize = rest[0].parse().ok()?;
352 let remaining = &rest[1..];
353 let item = items.get(idx)?;
354 if remaining.is_empty() {
355 Some(item.text_span)
356 } else {
357 let _ = head;
361 let item_schema: &[Node] = match snode {
362 Node::List { children, .. } => children,
363 _ => &[],
364 };
365 find_span(remaining, item_schema, &item.children)
366 }
367 }
368
369 (
370 Node::Prose { .. },
371 SpBlock {
372 kind: SpBlockKind::Para { span },
373 },
374 ) => {
375 if rest.is_empty() {
376 Some(*span)
377 } else {
378 None
379 }
380 }
381
382 _ => None,
383 };
384 }
385 None
386}
387
388fn advance_to_matching<'a>(
393 snode: &Node,
394 blocks: &'a [SpBlock],
395 block_idx: &mut usize,
396) -> Option<&'a SpBlock> {
397 while *block_idx < blocks.len() {
398 let b = &blocks[*block_idx];
399 let is_match = matches!(
400 (snode, &b.kind),
401 (Node::Heading { .. }, SpBlockKind::Section { .. })
402 | (Node::List { .. }, SpBlockKind::List { .. })
403 | (Node::Prose { .. }, SpBlockKind::Para { .. })
404 );
405 if is_match {
406 return Some(b);
407 }
408 *block_idx += 1;
409 }
410 None
411}
412
413fn node_alias(node: &Node) -> Option<String> {
414 match node {
415 Node::Heading { head, title, .. } => head.name.clone().or_else(|| match title {
416 Match::Literal(t) => Some(slug(t)),
417 Match::Regex(_) => None,
418 }),
419 Node::List { head, .. } | Node::Prose { head, .. } => head.name.clone(),
420 }
421}
422
423fn slug(s: &str) -> String {
424 s.chars()
425 .map(|c| {
426 if c.is_ascii_alphanumeric() {
427 c.to_ascii_lowercase()
428 } else {
429 '_'
430 }
431 })
432 .collect::<String>()
433 .trim_matches('_')
434 .to_string()
435}