1use std::cell::RefCell;
2use std::collections::{HashMap, HashSet};
3use std::ops::Range;
4use std::rc::Rc;
5
6use crate::classify::{Classified, classify};
7use crate::diff::{Hunk, RowKind, diff, filter_formatting};
8use crate::highlight::annotate;
9use crate::lang::Lang;
10use crate::project::{LayerMode, project};
11use crate::store::fingerprint;
12use crate::test_files::is_test_file;
13
14pub const CONTEXT: usize = 3;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ChangeStatus {
19 Added,
20 Deleted,
21 Modified,
22 Renamed,
23}
24
25impl ChangeStatus {
26 pub fn letter(self) -> char {
27 match self {
28 ChangeStatus::Added => 'A',
29 ChangeStatus::Deleted => 'D',
30 ChangeStatus::Modified => 'M',
31 ChangeStatus::Renamed => 'R',
32 }
33 }
34}
35
36#[derive(Debug, Clone)]
38pub struct FileChange {
39 pub path: String,
40 pub old_path: Option<String>,
41 pub status: ChangeStatus,
42 pub old: Option<String>,
43 pub new: Option<String>,
44 pub binary: bool,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Detection {
50 Parsed(Lang),
51 Partial(Lang, usize),
54 Unsupported,
56 Binary,
57}
58
59impl Detection {
60 pub fn label(self) -> String {
61 match self {
62 Detection::Parsed(lang) => lang.name().to_string(),
63 Detection::Partial(lang, line) => {
64 format!("{}, partial parse near line {line}", lang.name())
65 }
66 Detection::Unsupported => "no grammar".to_string(),
67 Detection::Binary => "binary".to_string(),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum SpanLayer {
75 Comments,
76 Imports,
77 Logging,
78 Tests,
81}
82
83impl SpanLayer {
84 pub const ALL: [SpanLayer; 4] = [
85 SpanLayer::Comments,
86 SpanLayer::Imports,
87 SpanLayer::Logging,
88 SpanLayer::Tests,
89 ];
90
91 pub fn adjective(self) -> &'static str {
93 match self {
94 SpanLayer::Comments => "comment",
95 SpanLayer::Imports => "import",
96 SpanLayer::Logging => "logging",
97 SpanLayer::Tests => "test",
98 }
99 }
100
101 fn spans(self, classified: &Classified) -> &[Range<usize>] {
102 match self {
103 SpanLayer::Comments => &classified.comments,
104 SpanLayer::Imports => &classified.imports,
105 SpanLayer::Logging => &classified.logging,
106 SpanLayer::Tests => &classified.tests,
107 }
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct DiffModes {
115 pub comments: LayerMode,
116 pub imports: LayerMode,
117 pub logging: LayerMode,
118 pub formatting: LayerMode,
119 pub tests: LayerMode,
120}
121
122impl DiffModes {
123 pub const SHOWN: DiffModes = DiffModes {
124 comments: LayerMode::Shown,
125 imports: LayerMode::Shown,
126 logging: LayerMode::Shown,
127 formatting: LayerMode::Shown,
128 tests: LayerMode::Shown,
129 };
130
131 pub fn mode(self, layer: SpanLayer) -> LayerMode {
132 match layer {
133 SpanLayer::Comments => self.comments,
134 SpanLayer::Imports => self.imports,
135 SpanLayer::Logging => self.logging,
136 SpanLayer::Tests => self.tests,
137 }
138 }
139
140 pub fn effective(self) -> (LayerMode, Vec<SpanLayer>) {
143 for mode in [LayerMode::Only, LayerMode::Hidden] {
144 let layers: Vec<SpanLayer> = SpanLayer::ALL
145 .into_iter()
146 .filter(|&layer| self.mode(layer) == mode)
147 .collect();
148 if !layers.is_empty() {
149 return (mode, layers);
150 }
151 }
152 (LayerMode::Shown, Vec::new())
153 }
154
155 pub fn outcome(self) -> (LayerMode, String) {
158 for mode in [LayerMode::Only, LayerMode::Hidden] {
159 let mut names: Vec<&str> = SpanLayer::ALL
160 .into_iter()
161 .filter(|&layer| self.mode(layer) == mode)
162 .map(SpanLayer::adjective)
163 .collect();
164 if self.formatting == mode {
165 names.push("formatting");
166 }
167 if !names.is_empty() {
168 return (mode, names.join("/"));
169 }
170 }
171 (LayerMode::Shown, String::new())
172 }
173
174 pub fn adjective(self) -> String {
175 self.outcome().1
176 }
177}
178
179impl From<LayerMode> for DiffModes {
181 fn from(comments: LayerMode) -> DiffModes {
182 DiffModes {
183 comments,
184 ..DiffModes::SHOWN
185 }
186 }
187}
188
189impl From<Layers> for DiffModes {
190 fn from(layers: Layers) -> DiffModes {
191 DiffModes {
192 comments: layers.comments,
193 imports: layers.imports,
194 logging: layers.logging,
195 formatting: layers.formatting,
196 tests: layers.tests,
197 }
198 }
199}
200
201#[derive(Debug, Clone, Default)]
202pub struct ModeView {
203 pub hunks: Vec<Hunk>,
204 pub hidden_hunks: usize,
206}
207
208#[derive(Debug, Clone)]
210pub struct FileReview {
211 pub path: String,
212 pub old_path: Option<String>,
213 pub status: ChangeStatus,
214 pub detection: Detection,
215 pub is_test: bool,
217 pub fingerprint: u64,
219 old: String,
220 new: String,
221 old_layers: Classified,
222 new_layers: Classified,
223 views: RefCell<HashMap<DiffModes, Rc<ModeView>>>,
224}
225
226impl FileReview {
227 pub fn new(change: FileChange) -> FileReview {
228 let old = change.old.unwrap_or_default();
229 let new = change.new.unwrap_or_default();
230
231 let (detection, old_layers, new_layers) = if change.binary {
232 (
233 Detection::Binary,
234 Classified::default(),
235 Classified::default(),
236 )
237 } else {
238 match Lang::from_path(&change.path)
239 .and_then(|lang| Some((lang, classify(lang, &old)?, classify(lang, &new)?)))
240 {
241 Some((lang, old_c, new_c)) => {
242 let detection = match new_c.error_line.or(old_c.error_line) {
243 Some(line) => Detection::Partial(lang, line),
244 None => Detection::Parsed(lang),
245 };
246 (detection, old_c, new_c)
247 }
248 None => (
249 Detection::Unsupported,
250 Classified::default(),
251 Classified::default(),
252 ),
253 }
254 };
255
256 let is_test = is_test_file(&change.path, if new.is_empty() { &old } else { &new });
257 let fingerprint = fingerprint(&[&change.path, &old, &new]);
258 FileReview {
259 path: change.path,
260 old_path: change.old_path,
261 status: change.status,
262 detection,
263 is_test,
264 fingerprint,
265 old,
266 new,
267 old_layers,
268 new_layers,
269 views: RefCell::new(HashMap::new()),
270 }
271 }
272
273 pub fn view(&self, modes: impl Into<DiffModes>) -> Rc<ModeView> {
274 let modes = modes.into();
275 if let Some(view) = self.views.borrow().get(&modes) {
276 return Rc::clone(view);
277 }
278 let hunks = self.hunks_for(modes);
279 let hidden_hunks = if modes == DiffModes::SHOWN {
280 0
281 } else {
282 count_hidden(&self.view(DiffModes::SHOWN).hunks, &hunks)
283 };
284 let view = Rc::new(ModeView {
285 hunks,
286 hidden_hunks,
287 });
288 self.views.borrow_mut().insert(modes, Rc::clone(&view));
289 view
290 }
291
292 fn hunks_for(&self, modes: DiffModes) -> Vec<Hunk> {
293 let modes = if self.is_test {
296 DiffModes {
297 tests: LayerMode::Shown,
298 ..modes
299 }
300 } else {
301 modes
302 };
303 let (mode, layers) = modes.effective();
304 let lang = match self.detection {
305 Detection::Parsed(lang) | Detection::Partial(lang, _) => Some(lang),
306 _ => None,
307 };
308 let side = |src: &str, classified: &Classified| {
309 let mut projection = project(src, &union(classified, &layers), mode);
310 if let Some(lang) = lang {
311 annotate(&mut projection, lang);
312 }
313 projection
314 };
315 let mut hunks = diff(
316 &side(&self.old, &self.old_layers),
317 &side(&self.new, &self.new_layers),
318 CONTEXT,
319 );
320 let indentation_matters = !matches!(
322 lang,
323 Some(
324 Lang::Swift
325 | Lang::TypeScript
326 | Lang::Tsx
327 | Lang::JavaScript
328 | Lang::Go
329 | Lang::Rust
330 | Lang::Kotlin
331 )
332 );
333 filter_formatting(&mut hunks, modes.formatting, indentation_matters);
334 if mode == LayerMode::Hidden {
335 for hunk in &mut hunks {
338 hunk.rows
339 .retain(|row| row.kind == RowKind::Context || !row.text.trim().is_empty());
340 }
341 hunks.retain(|hunk| hunk.changed().next().is_some());
342 }
343 hunks
344 }
345
346 pub fn total_hunks(&self) -> usize {
347 self.view(DiffModes::SHOWN).hunks.len()
348 }
349
350 pub fn is_visible(&self, tests: LayerMode) -> bool {
354 match tests {
355 LayerMode::Shown => true,
356 LayerMode::Hidden => !self.is_test,
357 LayerMode::Only => self.is_test || self.has_test_blocks(),
358 }
359 }
360
361 pub fn has_test_blocks(&self) -> bool {
363 !self.old_layers.tests.is_empty() || !self.new_layers.tests.is_empty()
364 }
365
366 pub fn tag(&self) -> String {
368 let detection = self.detection.label();
369 if self.is_test {
370 format!("{detection}, test")
371 } else {
372 detection
373 }
374 }
375}
376
377fn union(classified: &Classified, layers: &[SpanLayer]) -> Vec<Range<usize>> {
379 let mut spans: Vec<Range<usize>> = layers
380 .iter()
381 .flat_map(|layer| layer.spans(classified).iter().cloned())
382 .collect();
383 spans.sort_by_key(|span| span.start);
384 let mut merged: Vec<Range<usize>> = Vec::with_capacity(spans.len());
385 for span in spans {
386 match merged.last_mut() {
387 Some(last) if span.start <= last.end => last.end = last.end.max(span.end),
388 _ => merged.push(span),
389 }
390 }
391 merged
392}
393
394fn count_hidden(full: &[Hunk], visible: &[Hunk]) -> usize {
396 let key = |kind: RowKind, old: Option<usize>, new: Option<usize>| match kind {
397 RowKind::Removed => old.map(|line| (false, line)),
398 _ => new.map(|line| (true, line)),
399 };
400 let still_changed: HashSet<(bool, usize)> = visible
401 .iter()
402 .flat_map(Hunk::changed)
403 .filter_map(|row| key(row.kind, row.old_line, row.new_line))
404 .collect();
405 full.iter()
406 .filter(|hunk| {
407 !hunk.changed().any(|row| {
408 key(row.kind, row.old_line, row.new_line)
409 .is_some_and(|k| still_changed.contains(&k))
410 })
411 })
412 .count()
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417pub struct Layers {
418 pub comments: LayerMode,
419 pub tests: LayerMode,
420 pub imports: LayerMode,
421 pub logging: LayerMode,
422 pub formatting: LayerMode,
423}
424
425impl Default for Layers {
426 fn default() -> Layers {
427 Layers {
428 comments: LayerMode::Hidden,
429 tests: LayerMode::Shown,
430 imports: LayerMode::Shown,
431 logging: LayerMode::Shown,
432 formatting: LayerMode::Shown,
433 }
434 }
435}
436
437#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
439pub struct Summary {
440 pub files: usize,
441 pub total_hunks: usize,
442 pub visible_hunks: usize,
443 pub hidden_hunks: usize,
444 pub fully_hidden_files: usize,
446 pub filtered_files: usize,
448 pub unsupported_files: usize,
449 pub partial_files: usize,
450 pub test_block_files: usize,
452}
453
454impl Summary {
455 pub fn new(files: &[FileReview], layers: Layers) -> Summary {
456 let mut summary = Summary::default();
457 for file in files {
458 if !file.is_visible(layers.tests) {
459 summary.filtered_files += 1;
460 continue;
461 }
462 let view = file.view(layers);
463 summary.files += 1;
464 summary.total_hunks += file.total_hunks();
465 summary.visible_hunks += view.hunks.len();
466 summary.hidden_hunks += view.hidden_hunks;
467 if file.total_hunks() > 0 && view.hunks.is_empty() {
468 summary.fully_hidden_files += 1;
469 }
470 if file.has_test_blocks() {
471 summary.test_block_files += 1;
472 }
473 match file.detection {
474 Detection::Unsupported => summary.unsupported_files += 1,
475 Detection::Partial(..) => summary.partial_files += 1,
476 _ => {}
477 }
478 }
479 summary
480 }
481
482 pub fn status_line(&self, layers: Layers) -> String {
483 let states: Vec<String> = [
484 ("comments", layers.comments),
485 ("tests", layers.tests),
486 ("imports", layers.imports),
487 ("logging", layers.logging),
488 ("formatting", layers.formatting),
489 ]
490 .into_iter()
491 .filter(|(_, mode)| *mode != LayerMode::Shown)
492 .map(|(name, mode)| format!("{name}: {}", mode.label()))
493 .collect();
494 let mut parts = if states.is_empty() {
495 vec!["all layers shown".to_string()]
496 } else {
497 states
498 };
499
500 let mut modes = DiffModes::from(layers);
501 if self.test_block_files == 0 {
504 modes.tests = LayerMode::Shown;
505 }
506 let adjective = modes.adjective();
507 match modes.outcome().0 {
508 LayerMode::Shown => parts.push(format!(
509 "{} {} in {} {}",
510 self.total_hunks,
511 plural(self.total_hunks, "hunk"),
512 self.files,
513 plural(self.files, "file")
514 )),
515 LayerMode::Hidden => {
516 parts.push(format!(
517 "showing {} of {} hunks",
518 self.visible_hunks, self.total_hunks
519 ));
520 parts.push(format!(
521 "{} {adjective}-only {} hidden",
522 self.hidden_hunks,
523 plural(self.hidden_hunks, "hunk")
524 ));
525 if self.fully_hidden_files > 0 {
526 parts.push(format!(
527 "{} {adjective}-only {}",
528 self.fully_hidden_files,
529 plural(self.fully_hidden_files, "file")
530 ));
531 }
532 }
533 LayerMode::Only => {
534 parts.push(format!(
535 "{} {} with {adjective} changes",
536 self.visible_hunks,
537 plural(self.visible_hunks, "hunk")
538 ));
539 parts.push(format!(
540 "{} other {} hidden",
541 self.hidden_hunks,
542 plural(self.hidden_hunks, "hunk")
543 ));
544 }
545 }
546 match layers.tests {
547 LayerMode::Shown => {}
548 LayerMode::Hidden => parts.push(format!(
549 "{} test {} hidden",
550 self.filtered_files,
551 plural(self.filtered_files, "file")
552 )),
553 LayerMode::Only => parts.push(format!(
554 "{} non-test {} hidden",
555 self.filtered_files,
556 plural(self.filtered_files, "file")
557 )),
558 }
559 if self.unsupported_files > 0 {
560 parts.push(format!(
561 "no grammar for {} {}",
562 self.unsupported_files,
563 plural(self.unsupported_files, "file")
564 ));
565 }
566 if self.partial_files > 0 {
567 parts.push(format!(
568 "{} {} partially parsed",
569 self.partial_files,
570 plural(self.partial_files, "file")
571 ));
572 }
573 parts.join(" · ")
574 }
575}
576
577fn plural(count: usize, word: &str) -> String {
578 if count == 1 {
579 word.to_string()
580 } else {
581 format!("{word}s")
582 }
583}