Skip to main content

ronin_core/
edit.rs

1//! Non-destructive CST edit primitives (TR-011, OBJ4).
2//!
3//! rowan green trees are **immutable / persistent**: an edit never mutates a
4//! shared node in place, it produces a *new* green tree that shares all
5//! untouched subtrees with the original (structural sharing). [`apply_edit`]
6//! exploits this to satisfy the edit-locality invariant (INV-8 / SC-007): every
7//! region the edit does not touch prints byte-identically, because those
8//! subtrees are the very same green nodes as before.
9//!
10//! # Model (AD-004)
11//!
12//! An [`EditOperation`] names:
13//!
14//! * an [`EditTarget`] — a whole [`SyntaxNode`] or a token *span*
15//!   `[first ..= last]` of adjacent sibling tokens/nodes;
16//! * an [`EditKind`] — `Insert` (before the target), `Replace`, or `Remove`;
17//! * a `payload` — replacement source text (parsed into a fresh subtree),
18//!   absent for `Remove`;
19//! * a [`TriviaPolicy`] — whether the adjacent leading / trailing trivia of the
20//!   target is kept or discarded (AD-004).
21//!
22//! # How a new tree is built (green-node splicing)
23//!
24//! Every edit reduces to *rebuilding one parent node's child list* and then
25//! re-rooting the tree with [`rowan::SyntaxNode::replace_with`], which rebuilds
26//! only the spine from that parent up to the root (cost ∝ tree depth) and reuses
27//! every other subtree verbatim. The payload text is lexed into raw green tokens
28//! (not re-parsed structurally) so the spliced bytes are preserved exactly and
29//! the surrounding tree keeps printing byte-for-byte.
30//!
31//! The result is wrapped back into a fresh [`CstDocument`]. Diagnostics from the
32//! original parse are **not** carried over — the edited tree is a new document
33//! whose diagnostics (if any) would come from re-parsing; for OBJ4 we expose the
34//! spliced tree with an empty diagnostics set (the tree is still fully
35//! printable, INV-8). Re-validation is a later-epic concern.
36
37use rowan::{GreenNode, GreenToken, NodeOrToken};
38
39use crate::lexer;
40use crate::parser::CstDocument;
41use crate::syntax::kind::RonLang;
42use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
43
44/// What an [`EditOperation`] acts on (AD-004).
45///
46/// Either a whole [`SyntaxNode`] subtree, or a contiguous *span* of sibling
47/// elements delimited by a first and last token (inclusive). A single-token
48/// edit is a span whose first and last token are the same.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum EditTarget {
51    /// A whole node subtree.
52    Node(SyntaxNode),
53    /// An inclusive span of adjacent siblings, addressed by its first and last
54    /// token. Both tokens MUST share the same parent node.
55    TokenSpan {
56        /// First token of the span (inclusive).
57        first: SyntaxToken,
58        /// Last token of the span (inclusive). May equal `first`.
59        last: SyntaxToken,
60    },
61}
62
63/// The kind of edit to perform (AD-004).
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65pub enum EditKind {
66    /// Insert the payload immediately *before* the target, keeping the target.
67    Insert,
68    /// Replace the target with the payload.
69    Replace,
70    /// Remove the target (payload is ignored / must be absent).
71    Remove,
72}
73
74/// Caller-chosen trivia handling for an edit (AD-004).
75///
76/// Controls whether the leading / trailing trivia *adjacent to the target* is
77/// kept or discarded when the target is removed or replaced. Leading trivia is
78/// the run of trivia tokens immediately preceding the target's first element;
79/// trailing trivia is the run immediately following the target's last element,
80/// within the same parent.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub struct TriviaPolicy {
83    /// Keep (`true`) or discard (`false`) the leading trivia adjacent to the
84    /// target.
85    pub keep_leading: bool,
86    /// Keep (`true`) or discard (`false`) the trailing trivia adjacent to the
87    /// target.
88    pub keep_trailing: bool,
89}
90
91impl TriviaPolicy {
92    /// Keep all adjacent trivia (the conservative, lossless-by-default policy).
93    pub const KEEP_ALL: Self = Self {
94        keep_leading: true,
95        keep_trailing: true,
96    };
97
98    /// Discard both adjacent leading and trailing trivia.
99    pub const DISCARD_ALL: Self = Self {
100        keep_leading: false,
101        keep_trailing: false,
102    };
103}
104
105impl Default for TriviaPolicy {
106    /// Defaults to [`TriviaPolicy::KEEP_ALL`] — never drop bytes unless asked.
107    #[inline]
108    fn default() -> Self {
109        Self::KEEP_ALL
110    }
111}
112
113/// A single non-destructive edit over a [`CstDocument`] (AD-004).
114///
115/// Construct via [`EditOperation::insert`], [`EditOperation::replace`], or
116/// [`EditOperation::remove`], then apply with [`apply_edit`].
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct EditOperation {
119    /// What the edit acts on.
120    pub target: EditTarget,
121    /// The kind of edit.
122    pub kind: EditKind,
123    /// Replacement / inserted source text (`None` for [`EditKind::Remove`]).
124    pub payload: Option<String>,
125    /// Trivia handling for the affected region.
126    pub trivia_policy: TriviaPolicy,
127}
128
129impl EditOperation {
130    /// Insert `text` immediately before `target`, keeping the target.
131    #[must_use]
132    pub fn insert(
133        target: EditTarget,
134        text: impl Into<String>,
135        trivia_policy: TriviaPolicy,
136    ) -> Self {
137        Self {
138            target,
139            kind: EditKind::Insert,
140            payload: Some(text.into()),
141            trivia_policy,
142        }
143    }
144
145    /// Replace `target` with `text`.
146    #[must_use]
147    pub fn replace(
148        target: EditTarget,
149        text: impl Into<String>,
150        trivia_policy: TriviaPolicy,
151    ) -> Self {
152        Self {
153            target,
154            kind: EditKind::Replace,
155            payload: Some(text.into()),
156            trivia_policy,
157        }
158    }
159
160    /// Remove `target`.
161    #[must_use]
162    pub fn remove(target: EditTarget, trivia_policy: TriviaPolicy) -> Self {
163        Self {
164            target,
165            kind: EditKind::Remove,
166            payload: None,
167            trivia_policy,
168        }
169    }
170}
171
172/// Why an [`apply_edit`] call could not produce a tree.
173///
174/// All variants are non-panicking: a malformed request returns an error rather
175/// than corrupting the tree (Principle I — never corrupt user data).
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum EditError {
178    /// The target node is the document root, which has no parent to splice into.
179    RootNotEditable,
180    /// A token-span's two endpoints do not share the same parent node.
181    SpanParentMismatch,
182    /// A token-span's `last` token precedes its `first` token.
183    SpanOutOfOrder,
184    /// The target element could not be located within its parent (e.g. it came
185    /// from a different tree).
186    TargetNotFound,
187}
188
189impl std::fmt::Display for EditError {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        let s = match self {
192            Self::RootNotEditable => "the document root cannot be edited",
193            Self::SpanParentMismatch => "token-span endpoints have different parents",
194            Self::SpanOutOfOrder => "token-span `last` precedes `first`",
195            Self::TargetNotFound => "edit target not found in its parent",
196        };
197        f.write_str(s)
198    }
199}
200
201impl std::error::Error for EditError {}
202
203/// Apply `edit` to `doc`, returning a **new** [`CstDocument`] (non-destructive).
204///
205/// The original `doc` is untouched. Unaffected regions print byte-identically
206/// (INV-8) because their green subtrees are reused unchanged; the new tree is
207/// always fully printable.
208///
209/// # Errors
210///
211/// Returns [`EditError`] if the target is the root, if a token-span is
212/// ill-formed (different parents or reversed), or if the target cannot be found
213/// in its parent. Never panics.
214pub fn apply_edit(doc: &CstDocument, edit: EditOperation) -> Result<CstDocument, EditError> {
215    // Resolve the target to (parent, child-index range [start..=end]).
216    let (parent, start, end) = resolve_target(&edit.target)?;
217
218    // Defensive: the target must belong to `doc` — splicing a node from a
219    // foreign tree would silently re-root that other tree (Principle I).
220    if !belongs_to(&parent, doc) {
221        return Err(EditError::TargetNotFound);
222    }
223
224    let parent_green = parent.raw().green().into_owned();
225    let child_count = parent_green.children().count();
226
227    // Expand the affected index range to absorb adjacent trivia per policy.
228    let (mut splice_start, mut splice_end) = (start, end);
229    if !edit.trivia_policy.keep_leading {
230        splice_start = absorb_leading_trivia(&parent_green, splice_start);
231    }
232    if !edit.trivia_policy.keep_trailing {
233        splice_end = absorb_trailing_trivia(&parent_green, splice_end, child_count);
234    }
235
236    // Build the replacement / inserted elements from the payload text.
237    let payload_elems: Vec<GreenElem> = edit
238        .payload
239        .as_deref()
240        .map(payload_to_green)
241        .unwrap_or_default();
242
243    // Rebuild the parent's child list.
244    let mut children: Vec<GreenElem> = parent_green
245        .children()
246        .map(|c| match c {
247            NodeOrToken::Node(n) => NodeOrToken::Node(n.to_owned()),
248            NodeOrToken::Token(t) => NodeOrToken::Token(t.to_owned()),
249        })
250        .collect();
251
252    match edit.kind {
253        EditKind::Insert => {
254            // Insert payload immediately before the (unexpanded) target start.
255            // Leading-trivia policy still applies to where "before" begins.
256            let at = if edit.trivia_policy.keep_leading {
257                start
258            } else {
259                splice_start
260            };
261            splice(&mut children, at..at, payload_elems);
262        }
263        EditKind::Replace => {
264            splice(&mut children, splice_start..splice_end + 1, payload_elems);
265        }
266        EditKind::Remove => {
267            splice(&mut children, splice_start..splice_end + 1, Vec::new());
268        }
269    }
270
271    let new_parent = GreenNode::new(rowan_kind(parent.kind()), children);
272
273    // Re-root: replace the parent subtree, rebuilding only the spine to the root.
274    let new_root_green = parent.raw().replace_with(new_parent);
275
276    Ok(CstDocument::from_green_for_edit(new_root_green))
277}
278
279/// A green child element. `GreenNode`/`GreenToken` both convert into the (crate-
280/// private to rowan) element type via public `From` impls, so we work with this
281/// `NodeOrToken` alias and rely on `.into()` at the splice boundary.
282type GreenElem = NodeOrToken<GreenNode, GreenToken>;
283
284/// Does `node` live in `doc`'s tree? Compares the green root reached by walking
285/// parents against `doc`'s root green node.
286fn belongs_to(node: &SyntaxNode, doc: &CstDocument) -> bool {
287    let mut top = node.clone();
288    while let Some(p) = top.parent() {
289        top = p;
290    }
291    top == doc.root()
292}
293
294/// Resolve an [`EditTarget`] to its parent node and the inclusive child-index
295/// range `[start, end]` it spans within that parent.
296fn resolve_target(target: &EditTarget) -> Result<(SyntaxNode, usize, usize), EditError> {
297    match target {
298        EditTarget::Node(node) => {
299            let parent = node.parent().ok_or(EditError::RootNotEditable)?;
300            let idx = child_index_of_node(&parent, node).ok_or(EditError::TargetNotFound)?;
301            Ok((parent, idx, idx))
302        }
303        EditTarget::TokenSpan { first, last } => {
304            let parent = first.parent().ok_or(EditError::RootNotEditable)?;
305            let last_parent = last.parent().ok_or(EditError::RootNotEditable)?;
306            if parent != last_parent {
307                return Err(EditError::SpanParentMismatch);
308            }
309            let start = child_index_of_token(&parent, first).ok_or(EditError::TargetNotFound)?;
310            let end = child_index_of_token(&parent, last).ok_or(EditError::TargetNotFound)?;
311            if end < start {
312                return Err(EditError::SpanOutOfOrder);
313            }
314            Ok((parent, start, end))
315        }
316    }
317}
318
319/// Index of `node` among its parent's children (nodes + tokens), if present.
320fn child_index_of_node(parent: &SyntaxNode, node: &SyntaxNode) -> Option<usize> {
321    parent
322        .children_with_tokens()
323        .position(|el| el.as_node() == Some(node))
324}
325
326/// Index of `token` among its parent's children (nodes + tokens), if present.
327fn child_index_of_token(parent: &SyntaxNode, token: &SyntaxToken) -> Option<usize> {
328    parent
329        .children_with_tokens()
330        .position(|el| el.as_token() == Some(token))
331}
332
333/// Move `start` left past any immediately-preceding trivia tokens.
334fn absorb_leading_trivia(parent: &rowan::GreenNodeData, start: usize) -> usize {
335    let kinds = child_kinds(parent);
336    let mut i = start;
337    while i > 0 && kinds[i - 1].is_trivia() {
338        i -= 1;
339    }
340    i
341}
342
343/// Move `end` right past any immediately-following trivia tokens.
344fn absorb_trailing_trivia(parent: &rowan::GreenNodeData, end: usize, count: usize) -> usize {
345    let kinds = child_kinds(parent);
346    let mut i = end;
347    while i + 1 < count && kinds[i + 1].is_trivia() {
348        i += 1;
349    }
350    i
351}
352
353/// The [`SyntaxKind`] of each direct child of a green node, in order.
354fn child_kinds(parent: &rowan::GreenNodeData) -> Vec<SyntaxKind> {
355    parent
356        .children()
357        .map(|c| {
358            let raw = match c {
359                NodeOrToken::Node(n) => n.kind(),
360                NodeOrToken::Token(t) => t.kind(),
361            };
362            SyntaxKind::from_raw(raw.0).unwrap_or(SyntaxKind::Error)
363        })
364        .collect()
365}
366
367/// Lex `text` into a flat run of raw green tokens (no structural parse).
368///
369/// The payload bytes are preserved exactly: each lexer token becomes a green
370/// token of the same kind and verbatim text, so a replace/insert splices the
371/// payload in byte-for-byte. Structural re-classification is intentionally
372/// avoided here so the edit never reflows surrounding bytes.
373fn payload_to_green(text: &str) -> Vec<GreenElem> {
374    lexer::tokenize(text)
375        .into_iter()
376        .map(|t| NodeOrToken::Token(GreenToken::new(rowan_kind(t.kind), t.text)))
377        .collect()
378}
379
380/// Splice `replacement` into `children` over the half-open index `range`.
381///
382/// Converts each element to rowan's green element type at the boundary via the
383/// public `From` impls.
384fn splice(
385    children: &mut Vec<GreenElem>,
386    range: std::ops::Range<usize>,
387    replacement: Vec<GreenElem>,
388) {
389    children.splice(range, replacement);
390}
391
392#[inline]
393fn rowan_kind(kind: SyntaxKind) -> rowan::SyntaxKind {
394    <RonLang as rowan::Language>::kind_to_raw(kind)
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::parser::parse;
401    use crate::printer::print;
402
403    /// Find the first descendant node of `kind` in the document.
404    fn first_node(doc: &CstDocument, kind: SyntaxKind) -> SyntaxNode {
405        fn walk(n: SyntaxNode, kind: SyntaxKind, out: &mut Option<SyntaxNode>) {
406            if out.is_some() {
407                return;
408            }
409            if n.kind() == kind {
410                *out = Some(n.clone());
411                return;
412            }
413            for c in n.children() {
414                walk(c, kind, out);
415            }
416        }
417        let mut out = None;
418        walk(doc.root(), kind, &mut out);
419        out.unwrap_or_else(|| panic!("no {kind:?} node found"))
420    }
421
422    #[test]
423    fn replace_node_keeps_unaffected_regions() {
424        let src = "Foo(x: 1, y: 2)";
425        let doc = parse(src);
426        // Replace the first struct field value `1` (its Literal node).
427        let field = first_node(&doc, SyntaxKind::StructField);
428        let lit = field
429            .children()
430            .find(|c| c.kind() == SyntaxKind::Literal)
431            .unwrap();
432        let edited = apply_edit(
433            &doc,
434            EditOperation::replace(EditTarget::Node(lit), "99", TriviaPolicy::KEEP_ALL),
435        )
436        .unwrap();
437        assert_eq!(print(&edited), "Foo(x: 99, y: 2)");
438        // Original is untouched (non-destructive).
439        assert_eq!(print(&doc), src);
440    }
441
442    #[test]
443    fn remove_node_keep_trivia() {
444        let src = "[1, 2, 3]";
445        let doc = parse(src);
446        let list = first_node(&doc, SyntaxKind::List);
447        // Remove the first Literal node (the `1`), keeping adjacent trivia.
448        let first_lit = list
449            .children()
450            .find(|c| c.kind() == SyntaxKind::Literal)
451            .unwrap();
452        let edited = apply_edit(
453            &doc,
454            EditOperation::remove(EditTarget::Node(first_lit), TriviaPolicy::KEEP_ALL),
455        )
456        .unwrap();
457        // `1` removed; the comma+space that followed remain (keep policy).
458        assert_eq!(print(&edited), "[, 2, 3]");
459    }
460
461    #[test]
462    fn insert_before_node() {
463        let src = "[1]";
464        let doc = parse(src);
465        let list = first_node(&doc, SyntaxKind::List);
466        let lit = list
467            .children()
468            .find(|c| c.kind() == SyntaxKind::Literal)
469            .unwrap();
470        let edited = apply_edit(
471            &doc,
472            EditOperation::insert(EditTarget::Node(lit), "0, ", TriviaPolicy::KEEP_ALL),
473        )
474        .unwrap();
475        assert_eq!(print(&edited), "[0, 1]");
476    }
477
478    #[test]
479    fn token_span_replace() {
480        let src = "Foo(x: 1)";
481        let doc = parse(src);
482        // Replace the name token `Foo` (a single-token span) with `Bar`.
483        let strukt = first_node(&doc, SyntaxKind::Struct);
484        let name = strukt.first_token_of(SyntaxKind::Ident).unwrap();
485        let edited = apply_edit(
486            &doc,
487            EditOperation::replace(
488                EditTarget::TokenSpan {
489                    first: name.clone(),
490                    last: name,
491                },
492                "Bar",
493                TriviaPolicy::KEEP_ALL,
494            ),
495        )
496        .unwrap();
497        assert_eq!(print(&edited), "Bar(x: 1)");
498    }
499
500    #[test]
501    fn root_is_not_editable() {
502        let doc = parse("1");
503        let err = apply_edit(
504            &doc,
505            EditOperation::remove(EditTarget::Node(doc.root()), TriviaPolicy::KEEP_ALL),
506        )
507        .unwrap_err();
508        assert_eq!(err, EditError::RootNotEditable);
509    }
510}