Skip to main content

noyalib/cst/document/
edit.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Atomic source splicing and local green-tree repair.
5
6use super::{Document, RepairScope, entry_indent_column, walk_tokens};
7use crate::cst::builder::{
8    SubtreeContext, check_document_length, parse_subtree, rebuild_with_splice,
9};
10use crate::cst::green::{GreenChild, GreenNode};
11use crate::cst::syntax::SyntaxKind;
12use crate::error::{Error, Result};
13use crate::prelude::*;
14
15impl Document {
16    /// Replace the bytes in `start..end` with `replacement` and
17    /// re-parse. The caller is responsible for `replacement` being a
18    /// syntactically valid fragment in that position; if the spliced
19    /// source fails to parse, the original document is left
20    /// unchanged and the parse error is returned.
21    ///
22    /// # Errors
23    ///
24    /// - `Error::Parse` if the resulting source is not valid YAML.
25    /// - `Error::Parse` if `start..end` is out of bounds or not a
26    ///   character boundary.
27    ///
28    /// # Examples
29    ///
30    /// ```
31    /// use noyalib::cst::parse_document;
32    ///
33    /// let mut doc = parse_document("a: 1\n").unwrap();
34    /// let (s, e) = doc.span_at("a").unwrap();
35    /// doc.replace_span(s, e, "42").unwrap();
36    /// assert_eq!(doc.to_string(), "a: 42\n");
37    /// ```
38    pub fn replace_span(&mut self, start: usize, end: usize, replacement: &str) -> Result<()> {
39        #[cfg(test)]
40        if super::fault::splice_should_fail() {
41            return Err(Error::Parse("injected splice failure".into()));
42        }
43        if start > end || end > self.source.len() {
44            return Err(Error::Parse(format!(
45                "replace_span range {start}..{end} out of bounds (source length {})",
46                self.source.len()
47            )));
48        }
49        if !self.source.is_char_boundary(start) || !self.source.is_char_boundary(end) {
50            return Err(Error::Parse(format!(
51                "replace_span range {start}..{end} is not a character boundary"
52            )));
53        }
54        let mut new_source =
55            String::with_capacity(self.source.len() - (end - start) + replacement.len());
56        new_source.push_str(&self.source[..start]);
57        new_source.push_str(replacement);
58        new_source.push_str(&self.source[end..]);
59        check_document_length(&new_source, &self.config)?;
60
61        // A local green-tree repair avoids rebuilding unchanged CST
62        // nodes, but it is not a document-level validity proof. Parse
63        // the complete source before committing so `Ok(())` can never
64        // leave a `Document` whose next typed read panics.
65        let new_arc: Arc<str> = Arc::from(new_source.as_str());
66        if let Some((new_green, scope)) =
67            self.try_local_repair_green(start, end, replacement, &new_source)
68        {
69            let parsed = crate::parser::parse_exactly_one(&new_source, &self.config)?;
70            crate::policy::check_document(&self.config.policies, &parsed.0)?;
71            self.last_repair_scope.set(Some(scope));
72            self.source = new_arc;
73            self.green = new_green;
74            let _ = self.cache.replace(Some(parsed));
75            return Ok(());
76        }
77
78        // Safety net — full re-parse. Validates the new source and
79        // populates everything eagerly.
80        self.commit_source(&new_source, RepairScope::Document)
81    }
82
83    /// Attempt to repair the green tree locally for the edit
84    /// `[start, end) → replacement`. Returns the new tree and the
85    /// scope that was successfully repaired, or `None` if escalation
86    /// to a full re-parse is required. Pure — does not mutate
87    /// `self`.
88    fn try_local_repair_green(
89        &self,
90        start: usize,
91        end: usize,
92        replacement: &str,
93        new_source: &str,
94    ) -> Option<(GreenNode, RepairScope)> {
95        // Shape guard: any anchor / alias / tag in the affected
96        // region forces a Document-scope re-parse so we do not have
97        // to reason about cross-document name resolution.
98        if region_has_anchor_alias_or_tag(&self.green, start, end)
99            || replacement_introduces_anchor_alias_or_tag(replacement)
100        {
101            return None;
102        }
103
104        let delta = replacement.len() as isize - (end as isize - start as isize);
105        let candidates = ancestor_candidates(&self.green, start, end);
106
107        // Flow content is kept flat in the green tree, so re-parsing a
108        // block ancestor does not validate the structure of a flow
109        // collection the edit landed in: `{a: x {y} z, b: 2}` passed the
110        // sub-parse and was committed as the document's source (#332).
111        // Only the full parse checks flow structure, so escalate to it.
112        if candidates
113            .iter()
114            .any(|c| matches!(c.kind, SyntaxKind::FlowMapping | SyntaxKind::FlowSequence))
115        {
116            return None;
117        }
118
119        for cand in &candidates {
120            // Phase A only owns block-collection and block-entry
121            // re-parses. Other kinds (scalars, flow collections)
122            // are handled by climbing to an ancestor that this
123            // ladder rung does support.
124            if !is_phase_a_repairable(cand.kind) {
125                continue;
126            }
127
128            let n_old_start = cand.start;
129            let n_old_end = cand.end;
130            let n_new_start = n_old_start; // pre-edit start, by construction
131            let n_new_end_signed = n_old_end as isize + delta;
132            if n_new_end_signed < n_new_start as isize {
133                continue;
134            }
135            let n_new_end = n_new_end_signed as usize;
136            // Defensive: make sure the slice is in bounds.
137            if n_new_end > new_source.len() {
138                continue;
139            }
140            let fragment = &new_source[n_new_start..n_new_end];
141            let indent = entry_indent_column(&self.source, n_old_start);
142            let ctx = SubtreeContext::block_at(indent);
143
144            match parse_subtree(fragment, ctx, cand.kind, self.config.max_depth) {
145                Ok(new_sub)
146                    if new_sub.kind() == cand.kind && new_sub.text_len() == fragment.len() =>
147                {
148                    let new_root =
149                        rebuild_with_splice(&self.green, n_old_start, n_old_end, new_sub)?;
150                    return Some((new_root, scope_for_kind(cand.kind)));
151                }
152                Ok(_) | Err(_) => {
153                    // Shape inversion (kind mismatch), partial
154                    // coverage (text_len mismatch — the fragment
155                    // spans into sibling territory), or a sub-parse
156                    // error. Either way: climb the ladder.
157                    continue;
158                }
159            }
160        }
161        None
162    }
163}
164
165fn scope_for_kind(kind: SyntaxKind) -> RepairScope {
166    match kind {
167        SyntaxKind::MappingEntry | SyntaxKind::SequenceItem => RepairScope::Entry,
168        SyntaxKind::BlockMapping
169        | SyntaxKind::BlockSequence
170        | SyntaxKind::FlowMapping
171        | SyntaxKind::FlowSequence => RepairScope::Collection,
172        _ => RepairScope::Document,
173    }
174}
175
176fn is_phase_a_repairable(kind: SyntaxKind) -> bool {
177    matches!(
178        kind,
179        SyntaxKind::BlockMapping
180            | SyntaxKind::BlockSequence
181            | SyntaxKind::MappingEntry
182            | SyntaxKind::SequenceItem
183    )
184}
185
186/// One candidate ancestor for the smallest-scope repair walk.
187struct Candidate {
188    kind: SyntaxKind,
189    start: usize,
190    end: usize,
191}
192
193/// Walk the green tree once and collect every node ancestor of the
194/// edit span `[start, end)`, smallest-first. The Document root is
195/// implicitly the last entry — left out here because it always
196/// triggers escalation.
197fn ancestor_candidates(root: &GreenNode, start: usize, end: usize) -> Vec<Candidate> {
198    let mut out = Vec::new();
199    collect_ancestors(root, start, end, 0, &mut out);
200    // `collect_ancestors` pushes outermost-first; reverse so the
201    // smallest scope is tried first.
202    out.reverse();
203    out
204}
205
206fn collect_ancestors(
207    node: &GreenNode,
208    start: usize,
209    end: usize,
210    base: usize,
211    out: &mut Vec<Candidate>,
212) {
213    let node_end = base + node.text_len();
214    if start >= base && end <= node_end {
215        // This node fully contains the edit; record it.
216        out.push(Candidate {
217            kind: node.kind(),
218            start: base,
219            end: node_end,
220        });
221        // Recurse into the containing child.
222        let mut pos = base;
223        for child in node.children() {
224            let len = child.text_len();
225            let child_end = pos + len;
226            if start >= pos && end <= child_end {
227                if let GreenChild::Node(inner) = child {
228                    collect_ancestors(inner, start, end, pos, out);
229                }
230                break;
231            }
232            pos += len;
233        }
234    }
235}
236
237/// `true` when source bytes in `[start, end)` contain an anchor
238/// (`&`), alias (`*`), or tag (`!`) lexeme. Edits overlapping
239/// these are escalated to a full re-parse — we do not reason about
240/// cross-document name resolution after a localised splice.
241fn region_has_anchor_alias_or_tag(root: &GreenNode, start: usize, end: usize) -> bool {
242    let mut found = false;
243    walk_tokens(root, 0, &mut |kind, range| {
244        if range.start >= end || range.end <= start {
245            return; // disjoint
246        }
247        if matches!(
248            kind,
249            SyntaxKind::AnchorMark | SyntaxKind::AliasMark | SyntaxKind::TagMark
250        ) {
251            found = true;
252        }
253    });
254    found
255}
256
257/// Cheap textual screen for anchor / alias / tag introduction in
258/// the replacement bytes. Conservative by design — any whiff of
259/// these in `replacement` forces escalation to a full re-parse.
260fn replacement_introduces_anchor_alias_or_tag(replacement: &str) -> bool {
261    replacement.bytes().any(|b| matches!(b, b'&' | b'*' | b'!'))
262}