Skip to main content

opys_engine/mdprism/
edit.rs

1//! In-place editing: replace the text content of a named node identified by
2//! a dot-separated alias path, leaving the rest of the document byte-for-byte
3//! identical.
4
5use super::error::EditError;
6use super::schema::*;
7use comrak::nodes::{AstNode, ListType, NodeValue};
8use comrak::{parse_document, Arena, Options};
9
10impl Schema {
11    /// Replace the content of the node at `target` (a `.`-separated alias
12    /// path, e.g. `"plan.cases.2"`) with `new_value` and return the modified
13    /// markdown.
14    ///
15    /// `target` mirrors the key path of [`Schema::extract`]'s output; a
16    /// trailing numeric segment (`.2`) picks the N-th element of a list.
17    /// Only leaf string nodes (list items and prose paragraphs) are editable;
18    /// for list items the bullet/marker and any checkbox prefix are preserved.
19    ///
20    /// Returns [`EditError::TargetNotFound`] if the path does not resolve, or
21    /// [`EditError::IndexOutOfRange`] if a numeric index exceeds the list.
22    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// ---- source-position-aware block tree ----------------------------------------
46
47/// Byte range within the body string (`start` inclusive, `end` exclusive).
48#[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    /// Byte span of the editable text (after the list marker and any checkbox).
78    text_span: Span,
79    checked: Option<bool>,
80    children: Vec<SpBlock>,
81}
82
83// Two-stage build: first flatten into a mixed heading/block sequence, then nest.
84
85enum 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
216/// Byte span of the first `Text` child's content, relative to the body string.
217fn 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            // Keep HTML-like text (`<uuid>`) that comrak parses as raw inline HTML.
256            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
289// ---- path navigation ---------------------------------------------------------
290
291/// Walk the schema + SpBlock tree following `path` and return the byte span of
292/// the target leaf's editable text.
293fn 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    // Walk schema nodes in order; for each, advance into the corresponding doc block.
301    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        // Skip doc blocks that don't match the current schema node type.
312        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 // Can't edit a whole section heading (not yet supported)
337                } 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                    // Nested list navigation: recurse with item's children.
358                    // Build a synthetic schema for item children — use the
359                    // schema list's children as the per-item schema.
360                    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
388/// Find the doc block at or after `block_idx` that structurally matches the
389/// given schema node (heading↔section, list↔list, prose↔para), advance
390/// `block_idx` to point at it, and return a reference. Returns `None` if no
391/// matching block is found within the remaining blocks.
392fn 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}