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}