Skip to main content

sim_codec_doc/
edit.rs

1//! Reversible document-domain edits for the shared markup IR.
2
3use sim_kernel::{Error, Expr, NumberLiteral, Result};
4use sim_value::build::{entry, list, sym, text, uint};
5
6use crate::{Inline, MarkupBlock, MarkupDoc, MarkupError, SpanState};
7
8/// A reversible edit over a semantic markup document.
9#[derive(Clone, Debug, PartialEq)]
10pub enum MarkupEdit {
11    /// Replace the document title.
12    SetTitle {
13        /// Title value expected before the edit.
14        old: Option<String>,
15        /// Title value written by the edit.
16        new: Option<String>,
17    },
18    /// Insert a block at an index.
19    InsertBlock {
20        /// Insertion index.
21        index: usize,
22        /// Block to insert.
23        block: MarkupBlock,
24    },
25    /// Replace the block at an index.
26    ReplaceBlock {
27        /// Replacement index.
28        index: usize,
29        /// Block expected before the edit.
30        old: MarkupBlock,
31        /// Block written by the edit.
32        new: MarkupBlock,
33    },
34    /// Delete the block at an index.
35    DeleteBlock {
36        /// Deletion index.
37        index: usize,
38        /// Block expected before deletion.
39        old: MarkupBlock,
40    },
41    /// Replace a text inline inside a block.
42    SetInlineText {
43        /// Block index containing the inline.
44        block: usize,
45        /// Index path from the block's editable inline list to a text inline.
46        path: Vec<usize>,
47        /// Inline text expected before the edit.
48        old: String,
49        /// Inline text written by the edit.
50        new: String,
51    },
52}
53
54impl MarkupEdit {
55    /// Project this edit into ordinary SIM data.
56    pub fn as_expr(&self) -> Expr {
57        match self {
58            Self::SetTitle { old, new } => {
59                let mut entries = edit_entries("set-title");
60                push_optional_string(&mut entries, "old", old);
61                push_optional_string(&mut entries, "new", new);
62                Expr::Map(entries)
63            }
64            Self::InsertBlock { index, block } => Expr::Map(vec![
65                entry("kind", sym("markup-edit")),
66                entry("op", sym("insert-block")),
67                entry("index", uint(*index as u64)),
68                entry("block", block.as_expr()),
69            ]),
70            Self::ReplaceBlock { index, old, new } => Expr::Map(vec![
71                entry("kind", sym("markup-edit")),
72                entry("op", sym("replace-block")),
73                entry("index", uint(*index as u64)),
74                entry("old", old.as_expr()),
75                entry("new", new.as_expr()),
76            ]),
77            Self::DeleteBlock { index, old } => Expr::Map(vec![
78                entry("kind", sym("markup-edit")),
79                entry("op", sym("delete-block")),
80                entry("index", uint(*index as u64)),
81                entry("old", old.as_expr()),
82            ]),
83            Self::SetInlineText {
84                block,
85                path,
86                old,
87                new,
88            } => Expr::Map(vec![
89                entry("kind", sym("markup-edit")),
90                entry("op", sym("set-inline-text")),
91                entry("block", uint(*block as u64)),
92                entry(
93                    "path",
94                    list(path.iter().map(|index| uint(*index as u64)).collect()),
95                ),
96                entry("old", text(old)),
97                entry("new", text(new)),
98            ]),
99        }
100    }
101
102    /// Reconstruct an edit from ordinary SIM data.
103    pub fn from_expr(expr: &Expr) -> Result<Self> {
104        let entries = map_entries(expr, "markup edit")?;
105        require_kind(entries)?;
106        match required_symbol(entries, "op", "markup edit")?.as_str() {
107            "set-title" => Ok(Self::SetTitle {
108                old: optional_string(entries, "old")?.map(str::to_owned),
109                new: optional_string(entries, "new")?.map(str::to_owned),
110            }),
111            "insert-block" => Ok(Self::InsertBlock {
112                index: required_usize(entries, "index", "insert block")?,
113                block: MarkupBlock::from_expr(required_field(entries, "block", "insert block")?)?,
114            }),
115            "replace-block" => Ok(Self::ReplaceBlock {
116                index: required_usize(entries, "index", "replace block")?,
117                old: MarkupBlock::from_expr(required_field(entries, "old", "replace block")?)?,
118                new: MarkupBlock::from_expr(required_field(entries, "new", "replace block")?)?,
119            }),
120            "delete-block" => Ok(Self::DeleteBlock {
121                index: required_usize(entries, "index", "delete block")?,
122                old: MarkupBlock::from_expr(required_field(entries, "old", "delete block")?)?,
123            }),
124            "set-inline-text" => Ok(Self::SetInlineText {
125                block: required_usize(entries, "block", "set inline text")?,
126                path: required_path(entries, "path", "set inline text")?,
127                old: required_string(entries, "old", "set inline text")?.to_owned(),
128                new: required_string(entries, "new", "set inline text")?.to_owned(),
129            }),
130            other => Err(Error::Eval(format!("unknown markup edit op {other}"))),
131        }
132    }
133}
134
135/// Apply a reversible markup edit to a document.
136pub fn apply_edit(doc: &mut MarkupDoc, edit: &MarkupEdit) -> std::result::Result<(), MarkupError> {
137    match edit {
138        MarkupEdit::SetTitle { old, new } => {
139            if &doc.title != old {
140                return invalid_edit("title precondition failed");
141            }
142            doc.title = new.clone();
143        }
144        MarkupEdit::InsertBlock { index, block } => {
145            if *index > doc.blocks.len() {
146                return invalid_edit("insert block index out of range");
147            }
148            doc.blocks.insert(*index, block.clone());
149        }
150        MarkupEdit::ReplaceBlock { index, old, new } => {
151            let current = doc.blocks.get_mut(*index).ok_or_else(|| {
152                MarkupError::InvalidDocument("replace block index out of range".to_owned())
153            })?;
154            if current != old {
155                return invalid_edit("replace block precondition failed");
156            }
157            *current = new.clone();
158        }
159        MarkupEdit::DeleteBlock { index, old } => {
160            let current = doc.blocks.get(*index).ok_or_else(|| {
161                MarkupError::InvalidDocument("delete block index out of range".to_owned())
162            })?;
163            if current != old {
164                return invalid_edit("delete block precondition failed");
165            }
166            doc.blocks.remove(*index);
167        }
168        MarkupEdit::SetInlineText {
169            block,
170            path,
171            old,
172            new,
173        } => {
174            let block = doc.blocks.get_mut(*block).ok_or_else(|| {
175                MarkupError::InvalidDocument("inline block index out of range".to_owned())
176            })?;
177            match block {
178                MarkupBlock::Heading { text, .. } => apply_inline_text(text, path, old, new)?,
179                MarkupBlock::Paragraph { content, .. } => {
180                    apply_inline_text(content, path, old, new)?
181                }
182                MarkupBlock::Figure { caption, .. } => apply_inline_text(caption, path, old, new)?,
183                _ => return invalid_edit("block has no editable inline text"),
184            }
185            mark_block_dirty(block);
186        }
187    }
188    doc.source = None;
189    Ok(())
190}
191
192/// Return the inverse edit for `edit`.
193pub fn invert_edit(edit: &MarkupEdit) -> MarkupEdit {
194    match edit {
195        MarkupEdit::SetTitle { old, new } => MarkupEdit::SetTitle {
196            old: new.clone(),
197            new: old.clone(),
198        },
199        MarkupEdit::InsertBlock { index, block } => MarkupEdit::DeleteBlock {
200            index: *index,
201            old: block.clone(),
202        },
203        MarkupEdit::ReplaceBlock { index, old, new } => MarkupEdit::ReplaceBlock {
204            index: *index,
205            old: new.clone(),
206            new: old.clone(),
207        },
208        MarkupEdit::DeleteBlock { index, old } => MarkupEdit::InsertBlock {
209            index: *index,
210            block: old.clone(),
211        },
212        MarkupEdit::SetInlineText {
213            block,
214            path,
215            old,
216            new,
217        } => MarkupEdit::SetInlineText {
218            block: *block,
219            path: path.clone(),
220            old: new.clone(),
221            new: old.clone(),
222        },
223    }
224}
225
226fn edit_entries(op: &str) -> Vec<(Expr, Expr)> {
227    vec![entry("kind", sym("markup-edit")), entry("op", sym(op))]
228}
229
230fn push_optional_string(entries: &mut Vec<(Expr, Expr)>, name: &str, value: &Option<String>) {
231    if let Some(value) = value {
232        entries.push(entry(name, text(value)));
233    }
234}
235
236fn apply_inline_text(
237    items: &mut [Inline],
238    path: &[usize],
239    old: &str,
240    new: &str,
241) -> std::result::Result<(), MarkupError> {
242    let inline = inline_at_path_mut(items, path)?;
243    match inline {
244        Inline::Text(value) if value == old => {
245            *value = new.to_owned();
246            Ok(())
247        }
248        Inline::Text(_) => invalid_edit("inline text precondition failed"),
249        _ => invalid_edit("inline path does not point at text"),
250    }
251}
252
253fn inline_at_path_mut<'a>(
254    items: &'a mut [Inline],
255    path: &[usize],
256) -> std::result::Result<&'a mut Inline, MarkupError> {
257    let (index, rest) = path
258        .split_first()
259        .ok_or_else(|| MarkupError::InvalidDocument("inline path must not be empty".to_owned()))?;
260    let inline = items
261        .get_mut(*index)
262        .ok_or_else(|| MarkupError::InvalidDocument("inline path index out of range".to_owned()))?;
263    if rest.is_empty() {
264        return Ok(inline);
265    }
266    match inline {
267        Inline::Emph(children) | Inline::Strong(children) => inline_at_path_mut(children, rest),
268        Inline::Link { label, .. } => inline_at_path_mut(label, rest),
269        _ => invalid_edit("inline path cannot descend through this node"),
270    }
271}
272
273fn mark_block_dirty(block: &mut MarkupBlock) {
274    match block {
275        MarkupBlock::Heading { span, .. }
276        | MarkupBlock::Paragraph { span, .. }
277        | MarkupBlock::CodeBlock { span, .. }
278        | MarkupBlock::MathBlock { span, .. }
279        | MarkupBlock::Table { span, .. }
280        | MarkupBlock::Figure { span, .. }
281        | MarkupBlock::Raw { span, .. } => mark_span_dirty(span),
282        MarkupBlock::Quote { blocks, span } => {
283            mark_span_dirty(span);
284            for block in blocks {
285                mark_block_dirty(block);
286            }
287        }
288        MarkupBlock::List { items, span, .. } => {
289            mark_span_dirty(span);
290            for item in items {
291                for block in item {
292                    mark_block_dirty(block);
293                }
294            }
295        }
296    }
297}
298
299fn mark_span_dirty(span: &mut Option<crate::Span>) {
300    if let Some(span) = span {
301        span.state = SpanState::Dirty;
302    }
303}
304
305fn invalid_edit<T>(message: &str) -> std::result::Result<T, MarkupError> {
306    Err(MarkupError::InvalidDocument(message.to_owned()))
307}
308
309fn map_entries<'a>(expr: &'a Expr, expected: &str) -> Result<&'a [(Expr, Expr)]> {
310    match expr {
311        Expr::Map(entries) => Ok(entries),
312        _ => Err(Error::Eval(format!("{expected} must be a map"))),
313    }
314}
315
316fn require_kind(entries: &[(Expr, Expr)]) -> Result<()> {
317    let actual = required_symbol(entries, "kind", "markup edit")?;
318    if actual == "markup-edit" {
319        Ok(())
320    } else {
321        Err(Error::Eval(
322            "markup edit kind must be markup-edit".to_owned(),
323        ))
324    }
325}
326
327fn required_field<'a>(entries: &'a [(Expr, Expr)], name: &str, context: &str) -> Result<&'a Expr> {
328    field(entries, name).ok_or_else(|| Error::Eval(format!("{context} requires {name} field")))
329}
330
331fn required_symbol(entries: &[(Expr, Expr)], name: &str, context: &str) -> Result<String> {
332    match required_field(entries, name, context)? {
333        Expr::Symbol(symbol) if symbol.namespace.is_none() => Ok(symbol.name.to_string()),
334        Expr::String(value) => Ok(value.clone()),
335        _ => Err(Error::Eval(format!(
336            "{context} field {name} must be a symbol"
337        ))),
338    }
339}
340
341fn required_string<'a>(entries: &'a [(Expr, Expr)], name: &str, context: &str) -> Result<&'a str> {
342    match required_field(entries, name, context)? {
343        Expr::String(value) => Ok(value),
344        _ => Err(Error::Eval(format!(
345            "{context} field {name} must be a string"
346        ))),
347    }
348}
349
350fn optional_string<'a>(entries: &'a [(Expr, Expr)], name: &str) -> Result<Option<&'a str>> {
351    match field(entries, name) {
352        Some(Expr::String(value)) => Ok(Some(value)),
353        Some(_) => Err(Error::Eval(format!("{name} field must be a string"))),
354        None => Ok(None),
355    }
356}
357
358fn required_usize(entries: &[(Expr, Expr)], name: &str, context: &str) -> Result<usize> {
359    match required_field(entries, name, context)? {
360        Expr::Number(NumberLiteral { canonical, .. }) => canonical
361            .parse()
362            .map_err(|_| Error::Eval(format!("{context} field {name} must be an integer"))),
363        _ => Err(Error::Eval(format!(
364            "{context} field {name} must be a number"
365        ))),
366    }
367}
368
369fn required_path(entries: &[(Expr, Expr)], name: &str, context: &str) -> Result<Vec<usize>> {
370    match required_field(entries, name, context)? {
371        Expr::List(items) => items
372            .iter()
373            .map(|item| match item {
374                Expr::Number(NumberLiteral { canonical, .. }) => canonical.parse().map_err(|_| {
375                    Error::Eval(format!("{context} field {name} must contain integers"))
376                }),
377                _ => Err(Error::Eval(format!(
378                    "{context} field {name} must contain numbers"
379                ))),
380            })
381            .collect(),
382        _ => Err(Error::Eval(format!(
383            "{context} field {name} must be a list"
384        ))),
385    }
386}
387
388fn field<'a>(entries: &'a [(Expr, Expr)], name: &str) -> Option<&'a Expr> {
389    entries
390        .iter()
391        .find_map(|(key, value)| (key_name(key).as_deref() == Some(name)).then_some(value))
392}
393
394fn key_name(key: &Expr) -> Option<String> {
395    match key {
396        Expr::Symbol(symbol) if symbol.namespace.is_none() => Some(symbol.name.to_string()),
397        Expr::String(value) => Some(value.clone()),
398        _ => None,
399    }
400}