Skip to main content

documents/
edit.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! One save of a document: edits against the version they were made on
7//! (`crates/documents/spec/Edit.lean`, D8 and D9).
8//!
9//! A transaction names the version it was made on, so a second writer
10//! that started from the same version learns that the document moved
11//! instead of writing over the first writer's change. Its edits are
12//! byte spans of that version, in order and apart, so every byte outside
13//! them is copied through untouched, and applying one hands back the
14//! transaction that undoes it. A page sends text rather than bytes, and
15//! [`save`] writes that text in the version's own encoding (D11, D12).
16
17use kernel::{AxCode, AxError, B3Hash};
18use serde::{Deserialize, Serialize};
19
20use crate::encoding::{Encoding, Reading};
21use crate::span::{Span, offset};
22
23/// One edit as a page sends it: a stretch of the baseline's bytes, and
24/// the text that takes its place (D11).
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub struct TextEdit {
28    pub span: Span,
29    pub text: String,
30}
31
32/// Applies a page's text edits to `source`, which must be the version
33/// `baseline`, writing each text in that version's encoding (D11, D12).
34///
35/// # Errors
36/// `E_VERSION_CONFLICT` when `source` is not `baseline`, judged before
37/// anything else. `E_INVALID_ARGS` when the version is not text, when an
38/// edit starts or ends inside a character, when the edits are out of
39/// order, overlap or reach past the end, and when the result would not
40/// read as text in the same encoding. Nothing was applied in any case.
41pub fn save(source: &[u8], baseline: B3Hash, edits: &[TextEdit]) -> Result<Applied, AxError> {
42    if B3Hash::digest(source) != baseline {
43        return Err(moved(baseline));
44    }
45    saved_on(source, baseline, edits)
46}
47
48/// [`save`] once the source is known to be `baseline`, so a caller that
49/// already compared the digest does not hash the version twice.
50pub(crate) fn saved_on(
51    source: &[u8],
52    baseline: B3Hash,
53    edits: &[TextEdit],
54) -> Result<Applied, AxError> {
55    let Reading::Text(encoding) = Reading::of(source) else {
56        return Err(not_text(
57            "the version is not text in any encoding this city reads",
58        ));
59    };
60    let bytes = edits
61        .iter()
62        .map(|edit| {
63            on_characters(encoding, source, edit.span).map(|()| Edit {
64                span: edit.span,
65                bytes: encoding.encode(&edit.text),
66            })
67        })
68        .collect::<Result<Vec<Edit>, AxError>>()?;
69    let applied = Transaction::new(baseline, bytes)?.splice(source)?;
70    if Reading::of(&applied.bytes) != Reading::Text(encoding) {
71        return Err(not_text(
72            "the saved bytes would not read as text in the version's own encoding",
73        ));
74    }
75    Ok(applied)
76}
77
78/// Whether both ends of `span` fall on a character of `source`.
79fn on_characters(encoding: Encoding, source: &[u8], span: Span) -> Result<(), AxError> {
80    let starts = |at: u64| {
81        usize::try_from(at)
82            .is_ok_and(|index| index == source.len() || encoding.begins_at(source, index, 0))
83    };
84    if starts(span.start()) && starts(span.end()) {
85        Ok(())
86    } else {
87        Err(not_text(&format!(
88            "the edit at {}..{} starts or ends inside a character",
89            span.start(),
90            span.end()
91        )))
92    }
93}
94
95fn not_text(why: &str) -> AxError {
96    AxError::failure(AxCode::InvalidArgs, "save a document", why.to_owned())
97        .with_recovery("read the document again: its answer says whether and how it reads as text")
98}
99
100fn moved(baseline: B3Hash) -> AxError {
101    AxError::failure(
102        AxCode::VersionConflict,
103        "save a document",
104        format!("the document is no longer version {baseline}"),
105    )
106    .with_recovery("read the document again and make the change on what it says now")
107}
108
109/// One replacement: the bytes of `span` in the baseline become `bytes`.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct Edit {
112    pub span: Span,
113    pub bytes: Vec<u8>,
114}
115
116/// Edits against one version, in order, none overlapping another.
117///
118/// [`Transaction::new`] is the one construction point, so edits out of
119/// order are refused once rather than read two ways.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct Transaction {
122    baseline: B3Hash,
123    edits: Vec<Edit>,
124}
125
126/// What applying a transaction left: the new version's bytes and
127/// identity, and the transaction that takes it back.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Applied {
130    baseline: B3Hash,
131    bytes: Vec<u8>,
132    version: B3Hash,
133    undo: Transaction,
134}
135
136impl Transaction {
137    /// Edits made on the version `baseline`.
138    ///
139    /// Two insertions at one offset are kept in the order given; any
140    /// other pair must not share a byte.
141    ///
142    /// # Errors
143    /// `E_INVALID_ARGS` naming the first edit that starts before the one
144    /// ahead of it ends.
145    pub fn new(baseline: B3Hash, edits: Vec<Edit>) -> Result<Transaction, AxError> {
146        let tangled = edits
147            .iter()
148            .zip(edits.iter().skip(1))
149            .find_map(|(earlier, later)| {
150                (later.span.start() < earlier.span.end()).then_some(later.span)
151            });
152        if let Some(span) = tangled {
153            return Err(AxError::failure(
154                AxCode::InvalidArgs,
155                "save a document",
156                format!(
157                    "the edit at {}..{} starts before the edit ahead of it ends",
158                    span.start(),
159                    span.end()
160                ),
161            )
162            .with_recovery("send the edits in document order, each apart from the next"));
163        }
164        Ok(Transaction { baseline, edits })
165    }
166
167    /// The version these edits were made on.
168    pub fn baseline(&self) -> B3Hash {
169        self.baseline
170    }
171
172    pub fn edits(&self) -> &[Edit] {
173        &self.edits
174    }
175
176    /// Applies the edits to `source`, which must be the baseline version.
177    ///
178    /// # Errors
179    /// `E_VERSION_CONFLICT` when `source` is not the baseline: somebody
180    /// saved since these edits were made, and their change stands.
181    /// `E_INVALID_ARGS` when an edit reaches past the end of `source`.
182    /// Either way nothing was applied.
183    pub fn apply(&self, source: &[u8]) -> Result<Applied, AxError> {
184        if B3Hash::digest(source) != self.baseline {
185            return Err(moved(self.baseline));
186        }
187        self.splice(source)
188    }
189
190    /// Applies the edits to `source`, already known to be the baseline.
191    fn splice(&self, source: &[u8]) -> Result<Applied, AxError> {
192        let mut bytes = Vec::with_capacity(source.len());
193        let mut undo = Vec::with_capacity(self.edits.len());
194        let mut cursor = 0_usize;
195        for edit in &self.edits {
196            let outside = || past_the_end(edit.span, source.len());
197            let (start, end) = edit.span.within(source.len()).ok_or_else(outside)?;
198            bytes.extend_from_slice(source.get(cursor..start).ok_or_else(outside)?);
199            let replaced = source.get(start..end).ok_or_else(outside)?;
200            let from = offset(bytes.len());
201            bytes.extend_from_slice(&edit.bytes);
202            undo.push(Edit {
203                span: Span::ordered(from, offset(bytes.len())),
204                bytes: replaced.to_vec(),
205            });
206            cursor = end;
207        }
208        bytes.extend_from_slice(
209            source
210                .get(cursor..)
211                .ok_or_else(|| past_the_end(Span::at(offset(cursor)), source.len()))?,
212        );
213        let version = B3Hash::digest(&bytes);
214        Ok(Applied {
215            baseline: self.baseline,
216            bytes,
217            version,
218            undo: Transaction {
219                baseline: version,
220                edits: undo,
221            },
222        })
223    }
224}
225
226impl Applied {
227    /// The version the transaction was applied on.
228    pub fn baseline(&self) -> B3Hash {
229        self.baseline
230    }
231
232    pub fn bytes(&self) -> &[u8] {
233        &self.bytes
234    }
235
236    /// The identity of the version the transaction made.
237    pub fn version(&self) -> B3Hash {
238        self.version
239    }
240
241    /// The transaction that takes this version back to the baseline,
242    /// made on this version (D9).
243    pub fn undo(&self) -> &Transaction {
244        &self.undo
245    }
246}
247
248fn past_the_end(span: Span, len: usize) -> AxError {
249    AxError::failure(
250        AxCode::InvalidArgs,
251        "save a document",
252        format!(
253            "the edit at {}..{} reaches past the end of {len} bytes",
254            span.start(),
255            span.end()
256        ),
257    )
258    .with_recovery("read the document again: the edit was made on a longer version")
259}
260
261#[cfg(test)]
262#[allow(
263    clippy::unwrap_used,
264    clippy::expect_used,
265    clippy::panic,
266    clippy::indexing_slicing,
267    reason = "test code"
268)]
269mod tests;