1use serde::{Deserialize, Serialize};
9pub mod capture;
10
11use super::{
12 AnnotationSourceReference, CollaborationRevision, CollaborationScope,
13 CollaborationSourceAnchor, ContentHash,
14};
15
16#[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 pub revision: CollaborationRevision,
50 pub selector: SourceSelector,
51}
52
53impl SourceTargetCore {
54 pub fn id(&self) -> Result<ContentHash, SourceTargetError> {
55 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#[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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum SourceAffinity {
212 Before,
213 After,
214}
215
216#[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#[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#[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 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 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 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;