Skip to main content

noyalib/cst/document/
transaction.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Atomic batches of byte-range CST edits.
5
6use super::{Document, RepairScope};
7use crate::error::{Error, Result};
8use crate::prelude::*;
9
10#[derive(Debug)]
11struct PlannedEdit {
12    start: usize,
13    end: usize,
14    replacement: String,
15}
16
17/// A deferred, atomic batch of byte-range edits to a [`Document`].
18///
19/// Ranges refer to the unchanged source exposed by [`Self::source`]. Edits
20/// may be queued in any order, but their ranges must not overlap and two
21/// edits cannot begin at the same byte. The target document is borrowed but
22/// not modified until [`Self::commit`] validates the assembled source as one
23/// YAML document.
24///
25/// The session intentionally exposes no typed reads. Resolve every required
26/// span before starting the session, queue the replacements, and commit once.
27/// This avoids the repeated complete-document validation required by
28/// independent [`Document::replace_span`] calls.
29#[derive(Debug)]
30#[must_use = "dropping an EditSession aborts its queued edits"]
31pub struct EditSession<'a> {
32    document: &'a mut Document,
33    edits: Vec<PlannedEdit>,
34}
35
36impl<'a> EditSession<'a> {
37    pub(super) fn new(document: &'a mut Document) -> Self {
38        Self {
39            document,
40            edits: Vec::new(),
41        }
42    }
43
44    /// Borrow the unchanged source whose byte offsets this session uses.
45    #[must_use]
46    pub fn source(&self) -> &str {
47        self.document.source()
48    }
49
50    /// Return the number of replacements queued for commit.
51    #[must_use]
52    pub fn len(&self) -> usize {
53        self.edits.len()
54    }
55
56    /// Return `true` when no replacements are queued.
57    #[must_use]
58    pub fn is_empty(&self) -> bool {
59        self.edits.is_empty()
60    }
61
62    /// Queue a replacement measured against [`Self::source`].
63    ///
64    /// Insertions use an empty range (`start == end`). Adjacent ranges are
65    /// accepted, while overlapping ranges and multiple edits beginning at the
66    /// same byte are rejected because their application order would be
67    /// ambiguous.
68    ///
69    /// # Errors
70    ///
71    /// Returns [`Error::Parse`] when the range is out of bounds, is not on
72    /// UTF-8 character boundaries, or conflicts with an already queued edit.
73    /// YAML syntax is validated once, by [`Self::commit`].
74    pub fn replace_span(
75        &mut self,
76        start: usize,
77        end: usize,
78        replacement: impl Into<String>,
79    ) -> Result<()> {
80        let source = self.source();
81        if start > end || end > source.len() {
82            return Err(Error::Parse(format!(
83                "edit-session range {start}..{end} out of bounds (source length {})",
84                source.len()
85            )));
86        }
87        if !source.is_char_boundary(start) || !source.is_char_boundary(end) {
88            return Err(Error::Parse(format!(
89                "edit-session range {start}..{end} is not a character boundary"
90            )));
91        }
92        if self
93            .edits
94            .iter()
95            .any(|queued| queued.start == start || (start < queued.end && queued.start < end))
96        {
97            return Err(Error::Parse(format!(
98                "edit-session range {start}..{end} conflicts with a queued edit"
99            )));
100        }
101        self.edits.push(PlannedEdit {
102            start,
103            end,
104            replacement: replacement.into(),
105        });
106        Ok(())
107    }
108
109    /// Validate and atomically commit every queued replacement.
110    ///
111    /// The candidate is assembled without mutating the target document, then
112    /// validated and rebuilt as coordinated source, green-tree, value, and
113    /// span views. A parse or budget error leaves the target unchanged.
114    ///
115    /// # Errors
116    ///
117    /// Returns the parser error produced by the complete candidate document,
118    /// including an error when the edits introduce a second YAML document.
119    pub fn commit(mut self) -> Result<()> {
120        if self.edits.is_empty() {
121            return Ok(());
122        }
123
124        self.edits.sort_unstable_by_key(|edit| edit.start);
125        let source = self.document.source();
126        let final_len = self.edits.iter().try_fold(source.len(), |length, edit| {
127            length
128                .checked_sub(edit.end - edit.start)
129                .and_then(|length| length.checked_add(edit.replacement.len()))
130        });
131        let Some(final_len) = final_len else {
132            return Err(Error::Parse(
133                "edit-session candidate length exceeds addressable memory".into(),
134            ));
135        };
136
137        let mut candidate = String::with_capacity(final_len);
138        let mut cursor = 0;
139        for edit in &self.edits {
140            candidate.push_str(&source[cursor..edit.start]);
141            candidate.push_str(&edit.replacement);
142            cursor = edit.end;
143        }
144        candidate.push_str(&source[cursor..]);
145
146        self.document
147            .commit_source(&candidate, RepairScope::Document)
148    }
149
150    /// Abort the session and leave the target document unchanged.
151    pub fn abort(self) {}
152}