1use termesh_core::ProposalId;
14
15use crate::change::{Assoc, ChangeSet, RangeEffect};
16use crate::{ConflictReason, HunkState};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Severity {
22 Error,
23 Warning,
24 Info,
25 Hint,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum SyntaxKind {
31 Keyword,
32 StringLit,
33 Comment,
34 Number,
35 Type,
36 Function,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum HunkSide {
42 Removed,
45 Added,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum DecorationClass {
57 Syntax(SyntaxKind),
58 Diagnostic(Severity),
59 Hunk {
60 proposal: ProposalId,
61 side: HunkSide,
62 state: HunkState,
63 },
64 Match {
66 current: bool,
67 },
68}
69
70impl DecorationClass {
71 fn is_derived(&self) -> bool {
77 matches!(
78 self,
79 DecorationClass::Syntax(_)
80 | DecorationClass::Diagnostic(_)
81 | DecorationClass::Match { .. }
82 )
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct Decoration {
89 pub start: usize,
90 pub end: usize,
91 pub class: DecorationClass,
92}
93
94impl Decoration {
95 pub fn new(start: usize, end: usize, class: DecorationClass) -> Self {
96 Self { start, end, class }
97 }
98
99 pub fn is_empty(&self) -> bool {
100 self.start == self.end
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct LineDecoration {
107 pub start: usize,
108 pub end: usize,
109 pub class: DecorationClass,
110}
111
112#[derive(Debug, Default, Clone)]
114pub struct DecorationSet {
115 items: Vec<Decoration>,
116}
117
118impl DecorationSet {
119 pub fn new() -> Self {
120 Self::default()
121 }
122
123 pub fn push(&mut self, decoration: Decoration) {
124 self.items.push(decoration);
125 }
126
127 pub fn iter(&self) -> impl Iterator<Item = &Decoration> {
128 self.items.iter()
129 }
130
131 pub fn len(&self) -> usize {
132 self.items.len()
133 }
134
135 pub fn is_empty(&self) -> bool {
136 self.items.is_empty()
137 }
138
139 pub fn clear_syntax(&mut self) {
144 self.items.retain(|d| !matches!(d.class, DecorationClass::Syntax(_)));
145 }
146
147 pub fn clear_matches(&mut self) {
148 self.items.retain(|d| !matches!(d.class, DecorationClass::Match { .. }));
149 }
150
151 pub fn clear_diagnostics(&mut self) {
152 self.items.retain(|d| !matches!(d.class, DecorationClass::Diagnostic(_)));
153 }
154
155 pub fn clear_derived(&mut self) {
157 self.items.retain(|d| !d.class.is_derived());
158 }
159
160 pub fn remove_proposal(&mut self, proposal: ProposalId) {
162 self.items.retain(
163 |d| !matches!(d.class, DecorationClass::Hunk { proposal: p, .. } if p == proposal),
164 );
165 }
166
167 pub fn map(&mut self, changes: &ChangeSet) {
179 self.items.retain_mut(|d| {
180 let effect = changes.touches(d.start, d.end);
181
182 if let DecorationClass::Hunk { state, .. } = &mut d.class {
183 if let Some(reason) = ConflictReason::from_effect(effect) {
184 *state = HunkState::Conflicted(reason);
185 }
186 } else if effect != RangeEffect::Untouched {
187 return false;
188 }
189
190 d.start = changes.map_pos(d.start, Assoc::After);
194 d.end = changes.map_pos(d.end, Assoc::After);
195 true
196 });
197 }
198
199 pub fn for_line(&self, line_start: usize, line_end: usize) -> Vec<LineDecoration> {
205 let mut out: Vec<LineDecoration> = self
206 .items
207 .iter()
208 .filter(|d| overlaps(d, line_start, line_end))
209 .map(|d| LineDecoration {
210 start: d.start.clamp(line_start, line_end) - line_start,
211 end: d.end.clamp(line_start, line_end) - line_start,
212 class: d.class,
213 })
214 .collect();
215 out.sort_by_key(|d| (d.start, d.end));
216 out
217 }
218}
219
220fn overlaps(d: &Decoration, line_start: usize, line_end: usize) -> bool {
225 if d.is_empty() {
226 return d.start >= line_start && d.start <= line_end;
227 }
228 d.start < line_end && d.end > line_start
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn hunk(start: usize, end: usize, side: HunkSide) -> Decoration {
236 Decoration::new(
237 start,
238 end,
239 DecorationClass::Hunk { proposal: ProposalId::new(1), side, state: HunkState::Clean },
240 )
241 }
242
243 fn syntax(start: usize, end: usize) -> Decoration {
244 Decoration::new(start, end, DecorationClass::Syntax(SyntaxKind::Keyword))
245 }
246
247 fn state_of(set: &DecorationSet) -> Option<HunkState> {
248 set.iter().find_map(|d| match d.class {
249 DecorationClass::Hunk { state, .. } => Some(state),
250 _ => None,
251 })
252 }
253
254 #[test]
257 fn an_edit_before_a_decoration_shifts_it() {
258 let mut set = DecorationSet::new();
259 set.push(syntax(10, 20));
260 set.map(&ChangeSet::replace(40, 0, 0, "abc"));
261
262 let d = set.iter().next().unwrap();
263 assert_eq!((d.start, d.end), (13, 23));
264 }
265
266 #[test]
267 fn an_edit_after_a_decoration_leaves_it_alone() {
268 let mut set = DecorationSet::new();
269 set.push(syntax(10, 20));
270 set.map(&ChangeSet::replace(40, 30, 30, "abc"));
271
272 let d = set.iter().next().unwrap();
273 assert_eq!((d.start, d.end), (10, 20));
274 }
275
276 #[test]
279 fn a_disturbed_syntax_span_is_dropped() {
280 let mut set = DecorationSet::new();
281 set.push(syntax(10, 20));
282 set.map(&ChangeSet::replace(40, 12, 18, "x"));
283 assert!(set.is_empty(), "stale highlighting is worse than none");
284 }
285
286 #[test]
288 fn a_disturbed_hunk_is_kept_and_marked_conflicted() {
289 let mut set = DecorationSet::new();
290 set.push(hunk(10, 20, HunkSide::Removed));
291 set.map(&ChangeSet::replace(40, 12, 18, "mine"));
292
293 assert_eq!(set.len(), 1, "the human is mid-review; it must not vanish");
294 assert_eq!(state_of(&set), Some(HunkState::Conflicted(ConflictReason::AnchorDeleted)));
295 }
296
297 #[test]
298 fn typing_inside_a_hunk_conflicts_it_by_the_right_reason() {
299 let mut set = DecorationSet::new();
300 set.push(hunk(10, 20, HunkSide::Removed));
301 set.map(&ChangeSet::replace(40, 15, 15, "mine"));
302
303 assert_eq!(state_of(&set), Some(HunkState::Conflicted(ConflictReason::EditedInsideRange)));
304 }
305
306 #[test]
307 fn an_untouched_hunk_stays_clean_and_rides_forward() {
308 let mut set = DecorationSet::new();
309 set.push(hunk(10, 20, HunkSide::Removed));
310 set.map(&ChangeSet::replace(40, 0, 0, "xx"));
311
312 assert_eq!(state_of(&set), Some(HunkState::Clean));
313 let d = set.iter().next().unwrap();
314 assert_eq!((d.start, d.end), (12, 22));
315 }
316
317 #[test]
318 fn a_zero_width_insertion_anchor_rides_forward_too() {
319 let mut set = DecorationSet::new();
320 set.push(hunk(10, 10, HunkSide::Added));
321 set.map(&ChangeSet::replace(40, 0, 0, "abc"));
322
323 let d = set.iter().next().unwrap();
324 assert_eq!((d.start, d.end), (13, 13), "still zero-width, just moved");
325 }
326
327 #[test]
328 fn a_conflicted_hunk_does_not_silently_go_clean_again() {
329 let mut set = DecorationSet::new();
330 set.push(hunk(10, 20, HunkSide::Removed));
331 set.map(&ChangeSet::replace(40, 15, 15, "mine")); set.map(&ChangeSet::replace(44, 0, 0, "x")); assert!(matches!(state_of(&set), Some(HunkState::Conflicted(_))));
335 }
336
337 #[test]
340 fn each_producer_clears_only_its_own_class() {
341 let mut set = DecorationSet::new();
344 set.push(syntax(0, 5));
345 set.push(Decoration::new(6, 8, DecorationClass::Match { current: true }));
346 set.push(hunk(10, 20, HunkSide::Removed));
347
348 set.clear_syntax();
349 assert_eq!(set.len(), 2, "the match and the hunk survive a re-parse");
350
351 set.clear_matches();
352 assert_eq!(set.len(), 1, "the hunk survives a new search");
353 assert!(matches!(set.iter().next().unwrap().class, DecorationClass::Hunk { .. }));
354 }
355
356 #[test]
357 fn clearing_derived_decorations_leaves_pending_hunks_alone() {
358 let mut set = DecorationSet::new();
359 set.push(syntax(0, 5));
360 set.push(Decoration::new(6, 8, DecorationClass::Diagnostic(Severity::Error)));
361 set.push(hunk(10, 20, HunkSide::Removed));
362
363 set.clear_derived();
364 assert_eq!(set.len(), 1);
365 assert!(matches!(set.iter().next().unwrap().class, DecorationClass::Hunk { .. }));
366 }
367
368 #[test]
369 fn a_resolved_proposal_takes_only_its_own_hunks() {
370 let mut set = DecorationSet::new();
371 set.push(hunk(0, 5, HunkSide::Removed));
372 set.push(Decoration::new(
373 10,
374 15,
375 DecorationClass::Hunk {
376 proposal: ProposalId::new(2),
377 side: HunkSide::Removed,
378 state: HunkState::Clean,
379 },
380 ));
381
382 set.remove_proposal(ProposalId::new(1));
383 assert_eq!(set.len(), 1, "the other proposal's review is untouched");
384 }
385
386 #[test]
389 fn decorations_are_clipped_and_rebased_onto_their_line() {
390 let mut set = DecorationSet::new();
392 set.push(syntax(5, 14)); set.push(syntax(16, 30)); let spans = set.for_line(10, 20);
396 assert_eq!(spans.len(), 2);
397 assert_eq!((spans[0].start, spans[0].end), (0, 4));
398 assert_eq!((spans[1].start, spans[1].end), (6, 10));
399 }
400
401 #[test]
402 fn decorations_on_other_lines_are_excluded() {
403 let mut set = DecorationSet::new();
404 set.push(syntax(0, 5));
405 set.push(syntax(30, 35));
406 assert!(set.for_line(10, 20).is_empty());
407 }
408
409 #[test]
410 fn spans_come_back_in_order_so_the_renderer_can_walk_them() {
411 let mut set = DecorationSet::new();
412 set.push(syntax(18, 20));
413 set.push(syntax(10, 12));
414 set.push(syntax(14, 16));
415
416 let starts: Vec<usize> = set.for_line(10, 20).iter().map(|d| d.start).collect();
417 assert_eq!(starts, [0, 4, 8]);
418 }
419
420 #[test]
421 fn an_insertion_anchor_at_the_end_of_a_line_is_drawn_on_that_line() {
422 let mut set = DecorationSet::new();
424 set.push(hunk(20, 20, HunkSide::Added));
425
426 assert_eq!(set.for_line(10, 20).len(), 1, "belongs to the line it ends");
427 }
428
429 #[test]
430 fn a_decoration_touching_only_a_boundary_does_not_bleed_onto_the_next_line() {
431 let mut set = DecorationSet::new();
432 set.push(syntax(5, 10)); assert!(set.for_line(10, 20).is_empty());
434 }
435}