Skip to main content

clankerdiff_core/
content.rs

1//! Immutable complete-file source versions.
2
3use crate::{DiffSide, Fingerprint, RepoPath, SourceSequenceId};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::sync::Arc;
6
7/// Maximum UTF-8 bytes accepted for one complete source version.
8pub const MAX_SOURCE_FILE_BYTES: u64 = 8 * 1024 * 1024;
9/// Defensive maximum normalized lines accepted for one complete source version.
10pub const MAX_SOURCE_FILE_LINES: usize = 1_000_000;
11
12/// One side of a changed file: its complete source, or why it is unavailable.
13pub type SourceResult = Result<Arc<SourceDocument>, SourceUnavailable>;
14
15/// A one-based source coordinate independent of patch provenance.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct SourceLineRef {
18    pub side: DiffSide,
19    pub line_number: usize,
20}
21
22/// A durable source coordinate within the current review snapshot.
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct SourceLocation {
25    pub path: RepoPath,
26    pub side: DiffSide,
27    pub line_number: usize,
28}
29
30/// An immutable, contiguous source document with stable line coordinates.
31///
32/// The source text is retained as one allocation. `line_starts` makes line lookup
33/// and byte spans constant time without copying or normalizing the document.
34#[derive(Debug, Clone)]
35pub struct SourceDocument {
36    text: Arc<str>,
37    line_starts: Arc<[usize]>,
38    content_id: Fingerprint,
39    sequence_id: SourceSequenceId,
40    byte_len: u64,
41    trailing_newline: bool,
42}
43
44impl PartialEq for SourceDocument {
45    fn eq(&self, other: &Self) -> bool {
46        self.content_id == other.content_id
47    }
48}
49
50impl Eq for SourceDocument {}
51
52impl Serialize for SourceDocument {
53    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
54        serializer.serialize_str(&self.text)
55    }
56}
57
58impl<'de> Deserialize<'de> for SourceDocument {
59    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
60        let text = String::deserialize(deserializer)?;
61        Self::try_from_text(&text).map_err(serde::de::Error::custom)
62    }
63}
64
65impl SourceDocument {
66    /// Captures one immutable source document.
67    ///
68    /// # Errors
69    /// Returns a typed unavailable reason when the source exceeds the
70    /// complete-file byte or line limits.
71    pub fn new(text: impl AsRef<str>) -> Result<Self, SourceUnavailable> {
72        Self::try_from_text(text.as_ref())
73    }
74
75    /// Captures exact byte identity while normalizing rendered line endings like patch parsing.
76    ///
77    /// # Errors
78    /// Returns a typed unavailable reason before allocating normalized lines when the source
79    /// exceeds the complete-file byte or line limits.
80    pub fn try_from_text(text: &str) -> Result<Self, SourceUnavailable> {
81        let byte_len = u64::try_from(text.len()).unwrap_or(u64::MAX);
82        if byte_len > MAX_SOURCE_FILE_BYTES {
83            return Err(SourceUnavailable::TooLarge { bytes: byte_len });
84        }
85        let trailing_newline = text.ends_with('\n');
86        let mut line_starts = vec![0];
87        for (index, byte) in text.bytes().enumerate() {
88            if byte == b'\n' {
89                line_starts.push(index + 1);
90            }
91        }
92
93        if trailing_newline {
94            line_starts.pop();
95        }
96        if text.is_empty() {
97            line_starts.clear();
98        }
99
100        let line_count = line_starts.len();
101        if line_count > MAX_SOURCE_FILE_LINES {
102            return Err(SourceUnavailable::TooManyLines { lines: line_count });
103        }
104        let content_id = Fingerprint::of([b"diff-source-document-v1".as_slice(), text.as_bytes()]);
105        let text: Arc<str> = Arc::from(text);
106        let sequence_id = SourceSequenceId::from_lines((0..line_count).map(|line| {
107            let start = line_starts[line];
108            let end = text[start..]
109                .find('\n')
110                .map_or(text.len(), |offset| start + offset);
111            text[start..end]
112                .strip_suffix('\r')
113                .unwrap_or(&text[start..end])
114        }));
115        Ok(Self {
116            text,
117            line_starts: line_starts.into(),
118            content_id,
119            sequence_id,
120            byte_len,
121            trailing_newline,
122        })
123    }
124
125    /// Returns a one-based normalized source line.
126    #[must_use]
127    pub fn line(&self, number: usize) -> Option<&str> {
128        let span = self.line_span(number)?;
129        Some(&self.text[span])
130    }
131
132    /// Returns the zero-based byte span of a one-based source line.
133    #[must_use]
134    pub fn line_span(&self, number: usize) -> Option<std::ops::Range<usize>> {
135        let index = number.checked_sub(1)?;
136        let start = *self.line_starts.get(index)?;
137        let end = self
138            .line_starts
139            .get(index + 1)
140            .copied()
141            .unwrap_or(self.text.len());
142        let end = if end > start && self.text.as_bytes()[end - 1] == b'\n' {
143            end - 1
144        } else {
145            end
146        };
147        let end = if end > start && self.text.as_bytes()[end - 1] == b'\r' {
148            end - 1
149        } else {
150            end
151        };
152        Some(start..end)
153    }
154
155    #[must_use]
156    pub fn text(&self) -> &str {
157        &self.text
158    }
159
160    #[must_use]
161    pub fn line_starts(&self) -> &[usize] {
162        &self.line_starts
163    }
164
165    #[must_use]
166    pub const fn identity(&self) -> Fingerprint {
167        self.content_id
168    }
169
170    #[must_use]
171    pub fn line_count(&self) -> usize {
172        self.line_starts.len()
173    }
174
175    #[must_use]
176    pub const fn content_id(&self) -> Fingerprint {
177        self.content_id
178    }
179
180    #[must_use]
181    pub const fn sequence_id(&self) -> SourceSequenceId {
182        self.sequence_id
183    }
184
185    #[must_use]
186    pub const fn byte_len(&self) -> u64 {
187        self.byte_len
188    }
189
190    #[must_use]
191    pub const fn trailing_newline(&self) -> bool {
192        self.trailing_newline
193    }
194}
195
196/// Why a complete source version cannot be displayed.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
198pub enum SourceUnavailable {
199    #[error("source version is absent")]
200    Absent,
201    #[error("source version was not captured")]
202    NotCaptured,
203    #[error("source version is binary or not valid UTF-8")]
204    Binary,
205    #[error("source version is too large ({bytes} bytes)")]
206    TooLarge { bytes: u64 },
207    #[error("source version has too many lines ({lines})")]
208    TooManyLines { lines: usize },
209    #[error("source snapshot budget was exceeded")]
210    SnapshotBudgetExceeded,
211    #[error("repository changed while capturing the snapshot")]
212    UnstableSnapshot,
213    #[error("{0}")]
214    Error(String),
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn exact_identity_and_normalized_lines_are_independent() {
223        let lf = SourceDocument::try_from_text("a\nb\n").unwrap();
224        let crlf = SourceDocument::try_from_text("a\r\nb\r\n").unwrap();
225        assert_eq!(lf.line(1), crlf.line(1));
226        assert_eq!(lf.line(2), crlf.line(2));
227        assert_eq!(lf.sequence_id(), crlf.sequence_id());
228        assert_ne!(lf.content_id(), crlf.content_id());
229        assert_eq!(lf.line(1), Some("a"));
230        assert_eq!(lf.line(2), Some("b"));
231        assert_eq!(lf.line(3), None);
232        assert!(lf.trailing_newline());
233    }
234
235    #[test]
236    fn contiguous_documents_provide_stable_byte_coordinates() {
237        let document = SourceDocument::new("α\r\nb\n").unwrap();
238        assert_eq!(document.text(), "α\r\nb\n");
239        assert_eq!(document.line_starts(), &[0, 4]);
240        assert_eq!(document.line_span(1), Some(0..2));
241        assert_eq!(document.line_span(2), Some(4..5));
242        assert_eq!(document.line(1), Some("α"));
243        assert_eq!(document.identity(), document.content_id());
244    }
245
246    #[test]
247    fn equality_and_serialization_follow_the_text() {
248        let document = SourceDocument::new("a\r\nb").unwrap();
249        let json = serde_json::to_string(&document).unwrap();
250        assert_eq!(json, "\"a\\r\\nb\"");
251        let decoded = serde_json::from_str::<SourceDocument>(&json).unwrap();
252        assert_eq!(decoded, document);
253        assert_eq!(decoded.line_starts(), document.line_starts());
254        assert!(!decoded.trailing_newline());
255        assert_ne!(SourceDocument::new("a\nb").unwrap(), document);
256    }
257
258    #[test]
259    fn empty_and_blank_files_have_distinct_line_models() {
260        assert_eq!(SourceDocument::try_from_text("").unwrap().line_count(), 0);
261        let blank = SourceDocument::try_from_text("\n").unwrap();
262        assert_eq!(blank.line_count(), 1);
263        assert_eq!(blank.line(1), Some(""));
264    }
265}