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}