Skip to main content

heddle_object_model/object/
source_target.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Immutable source identities and shared position projections.
3//!
4//! A target's identity is independent of the Thread used to resolve it. Intern
5//! a reference against an existing binding before creating a new core: hashing
6//! each capture's current coordinates would create a different target.
7
8use serde::{Deserialize, Serialize};
9pub mod capture;
10
11use super::{
12    AnnotationSourceReference, CollaborationRevision, CollaborationScope,
13    CollaborationSourceAnchor, ContentHash,
14};
15
16/// Original file evidence. Renames update the file binding, never this core.
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct SourceFileCore {
20    pub scope: CollaborationScope,
21    pub revision: CollaborationRevision,
22    pub path: String,
23}
24
25impl SourceFileCore {
26    pub fn id(&self) -> Result<ContentHash, SourceTargetError> {
27        AnnotationSourceReference {
28            scope: self.scope.clone(),
29            source: CollaborationSourceAnchor {
30                revision: self.revision.clone(),
31                path: self.path.clone(),
32                symbol_id: String::new(),
33                start_line: None,
34                end_line: None,
35                target: None,
36            },
37        }
38        .validate()
39        .map_err(|error| SourceTargetError::Invalid(error.to_string()))?;
40        identity("heddle-source-file-v1", self)
41    }
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct SourceTargetCore {
47    pub file: ContentHash,
48    /// Exact original revision for the selector's symbol or positions.
49    pub revision: CollaborationRevision,
50    pub selector: SourceSelector,
51}
52
53impl SourceTargetCore {
54    pub fn id(&self) -> Result<ContentHash, SourceTargetError> {
55        // Use the same exact-revision validator as signed collaboration.
56        CollaborationSourceAnchor {
57            revision: self.revision.clone(),
58            path: String::new(),
59            symbol_id: String::new(),
60            start_line: None,
61            end_line: None,
62            target: None,
63        }
64        .validate()
65        .map_err(|error| SourceTargetError::Invalid(error.to_string()))?;
66        match &self.selector {
67            SourceSelector::File => {}
68            SourceSelector::Symbol { address } => {
69                if address.trim().is_empty()
70                    || address.len() > 4096
71                    || address.chars().any(char::is_control)
72                {
73                    return Err(invalid("invalid source symbol address"));
74                }
75            }
76            SourceSelector::Lines { range } => range.validate()?,
77        }
78        identity("heddle-source-target-v1", self)
79    }
80}
81
82#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
84pub enum SourceSelector {
85    File,
86    Symbol { address: String },
87    Lines { range: SourceLineRange },
88}
89
90/// Shared by primary anchors and reference tags. Scope selection is explicit;
91/// the authoring scope remains in the immutable core and signed operation.
92#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct SourceTargetReference {
95    pub target: ContentHash,
96    pub binding: SourceTargetBinding,
97}
98
99impl SourceTargetReference {
100    /// Intern exact original evidence once. A materialized anchor preserves its
101    /// existing identity even when a caller explicitly selects another binding.
102    pub fn from_source(
103        source: &AnnotationSourceReference,
104        binding: SourceTargetBinding,
105    ) -> Result<Self, SourceTargetError> {
106        source
107            .validate()
108            .map_err(|error| invalid(&error.to_string()))?;
109        if !source.source.symbol_id.is_empty() && source.source.symbol_id.trim().is_empty() {
110            return Err(invalid("invalid source symbol address"));
111        }
112        let target = if let Some(existing) = &source.source.target {
113            existing.target
114        } else {
115            let file = SourceFileCore {
116                scope: source.scope.clone(),
117                revision: source.source.revision.clone(),
118                path: source.source.path.clone(),
119            };
120            let selector = if !source.source.symbol_id.is_empty() {
121                SourceSelector::Symbol {
122                    address: source.source.symbol_id.clone(),
123                }
124            } else if let (Some(start), Some(end)) =
125                (source.source.start_line, source.source.end_line)
126            {
127                SourceSelector::Lines {
128                    range: SourceLineRange {
129                        start: start
130                            .checked_sub(1)
131                            .ok_or_else(|| invalid("source lines are one-based"))?,
132                        end,
133                        start_affinity: SourceAffinity::After,
134                        end_affinity: SourceAffinity::Before,
135                    },
136                }
137            } else {
138                SourceSelector::File
139            };
140            SourceTargetCore {
141                file: file.id()?,
142                revision: file.revision,
143                selector,
144            }
145            .id()?
146        };
147        let reference = Self { target, binding };
148        reference.validate()?;
149        Ok(reference)
150    }
151    pub fn validate(&self) -> Result<(), SourceTargetError> {
152        match &self.binding {
153            SourceTargetBinding::ViewedThread => Ok(()),
154            SourceTargetBinding::NamedThread { scope } => {
155                self.binding.scope(scope)?;
156                Ok(())
157            }
158            SourceTargetBinding::PinnedRevision { scope, revision } => {
159                self.binding.scope(scope)?;
160                CollaborationSourceAnchor {
161                    revision: revision.clone(),
162                    path: String::new(),
163                    symbol_id: String::new(),
164                    start_line: None,
165                    end_line: None,
166                    target: None,
167                }
168                .validate()
169                .map_err(|error| invalid(&error.to_string()))
170            }
171        }
172    }
173}
174
175#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case", tag = "kind", deny_unknown_fields)]
177pub enum SourceTargetBinding {
178    ViewedThread,
179    NamedThread {
180        scope: CollaborationScope,
181    },
182    PinnedRevision {
183        scope: CollaborationScope,
184        revision: CollaborationRevision,
185    },
186}
187
188impl SourceTargetBinding {
189    /// Selects identity only. The caller must authorize source access, verify
190    /// inheritance, and choose a revision/frontier in the returned Thread.
191    pub fn scope<'a>(
192        &'a self,
193        viewed_thread: &'a CollaborationScope,
194    ) -> Result<&'a CollaborationScope, SourceTargetError> {
195        let scope = match self {
196            Self::ViewedThread => viewed_thread,
197            Self::NamedThread { scope } | Self::PinnedRevision { scope, .. } => scope,
198        };
199        if scope.spool.is_nil()
200            || (scope.thread.is_none() && !matches!(self, Self::PinnedRevision { .. }))
201        {
202            return Err(invalid("tracking requires a concrete Thread and spool"));
203        }
204        Ok(scope)
205    }
206}
207
208/// Which side of an insertion at this exact boundary the endpoint follows.
209#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum SourceAffinity {
212    Before,
213    After,
214}
215
216/// Zero-based, half-open line boundaries. Inclusive display lines a..b become
217/// [a - 1, b). A default non-growing range uses After at start, Before at end.
218#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(deny_unknown_fields)]
220pub struct SourceLineRange {
221    pub start: u32,
222    pub end: u32,
223    pub start_affinity: SourceAffinity,
224    pub end_affinity: SourceAffinity,
225}
226
227impl SourceLineRange {
228    pub fn validate(&self) -> Result<(), SourceTargetError> {
229        if self.start >= self.end {
230            return Err(invalid("source range must contain at least one line"));
231        }
232        Ok(())
233    }
234}
235
236/// A single replacement in original/new line coordinates. Adjacent operations
237/// must be coalesced so each boundary has exactly one mapping decision.
238#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct SourceLineEdit {
241    pub old_start: u32,
242    pub old_end: u32,
243    pub new_start: u32,
244    pub new_end: u32,
245}
246
247/// Stored once per changed blob pair, never once per referring range. Validated
248/// on construction/decode, so each projection uses binary search over edits.
249#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
250#[serde(try_from = "LineEditMapWire")]
251pub struct SourceLineEditMap {
252    old_lines: u32,
253    new_lines: u32,
254    edits: Vec<SourceLineEdit>,
255}
256
257#[derive(Deserialize)]
258#[serde(deny_unknown_fields)]
259struct LineEditMapWire {
260    old_lines: u32,
261    new_lines: u32,
262    edits: Vec<SourceLineEdit>,
263}
264
265impl TryFrom<LineEditMapWire> for SourceLineEditMap {
266    type Error = SourceTargetError;
267    fn try_from(wire: LineEditMapWire) -> Result<Self, Self::Error> {
268        Self::new(wire.old_lines, wire.new_lines, wire.edits)
269    }
270}
271
272#[derive(Clone, Copy, Debug, PartialEq, Eq)]
273pub enum SourceRangeProjection {
274    Resolved {
275        range: SourceLineRange,
276        changed: bool,
277    },
278    Deleted,
279    /// An endpoint fell inside replaced material; there is no proven boundary.
280    Ambiguous,
281}
282
283impl SourceLineEditMap {
284    pub fn new(
285        old_lines: u32,
286        new_lines: u32,
287        edits: Vec<SourceLineEdit>,
288    ) -> Result<Self, SourceTargetError> {
289        if edits.len() > 65_536 {
290            return Err(invalid("line edit map exceeds its operation budget"));
291        }
292        let mut old_end = 0;
293        let mut new_end = 0;
294        for (index, edit) in edits.iter().enumerate() {
295            if edit.old_start > edit.old_end
296                || edit.new_start > edit.new_end
297                || edit.old_end > old_lines
298                || edit.new_end > new_lines
299                || edit.old_start < old_end
300                || edit.new_start < new_end
301                || edit.old_start - old_end != edit.new_start - new_end
302                || (index > 0 && edit.old_start == old_end)
303                || (edit.old_start == edit.old_end && edit.new_start == edit.new_end)
304            {
305                return Err(invalid("invalid, overlapping or uncoalesced line edits"));
306            }
307            old_end = edit.old_end;
308            new_end = edit.new_end;
309        }
310        if old_lines - old_end != new_lines - new_end {
311            return Err(invalid(
312                "line edit map does not cover the source transition",
313            ));
314        }
315        Ok(Self {
316            old_lines,
317            new_lines,
318            edits,
319        })
320    }
321
322    pub fn edits(&self) -> &[SourceLineEdit] {
323        &self.edits
324    }
325
326    /// O(log edits); this does not parse text, scan edits, or mutate references.
327    pub fn project(
328        &self,
329        range: SourceLineRange,
330    ) -> Result<SourceRangeProjection, SourceTargetError> {
331        range.validate()?;
332        if range.end > self.old_lines {
333            return Err(invalid("source range exceeds original file"));
334        }
335        let first = self
336            .edits
337            .partition_point(|edit| edit.old_end < range.start);
338        if self.edits.get(first).is_some_and(|edit| {
339            edit.old_start <= range.start
340                && edit.old_end >= range.end
341                && edit.new_start == edit.new_end
342        }) {
343            return Ok(SourceRangeProjection::Deleted);
344        }
345        let (Some(start), Some(end)) = (
346            self.boundary(range.start, range.start_affinity),
347            self.boundary(range.end, range.end_affinity),
348        ) else {
349            return Ok(SourceRangeProjection::Ambiguous);
350        };
351        if start >= end {
352            return Ok(SourceRangeProjection::Deleted);
353        }
354        // Find the first edit which affects selected content. Merely shifting
355        // both endpoints from an earlier insertion does not change its body.
356        let affecting = self.edits.partition_point(|edit| {
357            edit.old_end < range.start
358                || (edit.old_end == range.start
359                    && (edit.old_start < edit.old_end
360                        || range.start_affinity == SourceAffinity::After))
361        });
362        let changed = self.edits.get(affecting).is_some_and(|edit| {
363            edit.old_start < range.end
364                || (edit.old_start == range.end
365                    && edit.old_start == edit.old_end
366                    && range.end_affinity == SourceAffinity::After)
367        });
368        Ok(SourceRangeProjection::Resolved {
369            range: SourceLineRange {
370                start,
371                end,
372                ..range
373            },
374            changed,
375        })
376    }
377
378    fn boundary(&self, position: u32, affinity: SourceAffinity) -> Option<u32> {
379        let index = self.edits.partition_point(|edit| edit.old_end < position);
380        if let Some(edit) = self.edits.get(index)
381            && edit.old_start <= position
382        {
383            return if edit.old_start == edit.old_end {
384                Some(match affinity {
385                    SourceAffinity::Before => edit.new_start,
386                    SourceAffinity::After => edit.new_end,
387                })
388            } else if position == edit.old_start {
389                Some(edit.new_start)
390            } else if position == edit.old_end {
391                Some(edit.new_end)
392            } else {
393                None
394            };
395        }
396        match index.checked_sub(1).and_then(|prior| self.edits.get(prior)) {
397            Some(prior) => prior.new_end.checked_add(position - prior.old_end),
398            None => Some(position),
399        }
400    }
401}
402
403#[derive(Debug, thiserror::Error)]
404pub enum SourceTargetError {
405    #[error("{0}")]
406    Invalid(String),
407    #[error("source target encoding: {0}")]
408    Encoding(#[from] rmp_serde::encode::Error),
409}
410
411fn identity(domain: &str, value: &impl Serialize) -> Result<ContentHash, SourceTargetError> {
412    Ok(ContentHash::compute_typed(
413        domain,
414        &rmp_serde::to_vec_named(value)?,
415    ))
416}
417
418fn invalid(message: &str) -> SourceTargetError {
419    SourceTargetError::Invalid(message.into())
420}
421
422#[cfg(test)]
423#[path = "source_target_tests.rs"]
424mod tests;