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}
79
80impl SpanLayer {
81 pub const ALL: [SpanLayer; 3] = [SpanLayer::Comments, SpanLayer::Imports, SpanLayer::Logging];
82
83 pub fn adjective(self) -> &'static str {
85 match self {
86 SpanLayer::Comments => "comment",
87 SpanLayer::Imports => "import",
88 SpanLayer::Logging => "logging",
89 }
90 }
91
92 fn spans(self, classified: &Classified) -> &[Range<usize>] {
93 match self {
94 SpanLayer::Comments => &classified.comments,
95 SpanLayer::Imports => &classified.imports,
96 SpanLayer::Logging => &classified.logging,
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104pub struct DiffModes {
105 pub comments: LayerMode,
106 pub imports: LayerMode,
107 pub logging: LayerMode,
108 pub formatting: LayerMode,
109}
110
111impl DiffModes {
112 pub const SHOWN: DiffModes = DiffModes {
113 comments: LayerMode::Shown,
114 imports: LayerMode::Shown,
115 logging: LayerMode::Shown,
116 formatting: LayerMode::Shown,
117 };
118
119 pub fn mode(self, layer: SpanLayer) -> LayerMode {
120 match layer {
121 SpanLayer::Comments => self.comments,
122 SpanLayer::Imports => self.imports,
123 SpanLayer::Logging => self.logging,
124 }
125 }
126
127 pub fn effective(self) -> (LayerMode, Vec<SpanLayer>) {
130 for mode in [LayerMode::Only, LayerMode::Hidden] {
131 let layers: Vec<SpanLayer> = SpanLayer::ALL
132 .into_iter()
133 .filter(|&layer| self.mode(layer) == mode)
134 .collect();
135 if !layers.is_empty() {
136 return (mode, layers);
137 }
138 }
139 (LayerMode::Shown, Vec::new())
140 }
141
142 pub fn outcome(self) -> (LayerMode, String) {
145 for mode in [LayerMode::Only, LayerMode::Hidden] {
146 let mut names: Vec<&str> = SpanLayer::ALL
147 .into_iter()
148 .filter(|&layer| self.mode(layer) == mode)
149 .map(SpanLayer::adjective)
150 .collect();
151 if self.formatting == mode {
152 names.push("formatting");
153 }
154 if !names.is_empty() {
155 return (mode, names.join("/"));
156 }
157 }
158 (LayerMode::Shown, String::new())
159 }
160
161 pub fn adjective(self) -> String {
162 self.outcome().1
163 }
164}
165
166impl From<LayerMode> for DiffModes {
168 fn from(comments: LayerMode) -> DiffModes {
169 DiffModes {
170 comments,
171 ..DiffModes::SHOWN
172 }
173 }
174}
175
176impl From<Layers> for DiffModes {
177 fn from(layers: Layers) -> DiffModes {
178 DiffModes {
179 comments: layers.comments,
180 imports: layers.imports,
181 logging: layers.logging,
182 formatting: layers.formatting,
183 }
184 }
185}
186
187#[derive(Debug, Clone, Default)]
188pub struct ModeView {
189 pub hunks: Vec<Hunk>,
190 pub hidden_hunks: usize,
192}
193
194#[derive(Debug, Clone)]
196pub struct FileReview {
197 pub path: String,
198 pub old_path: Option<String>,
199 pub status: ChangeStatus,
200 pub detection: Detection,
201 pub is_test: bool,
203 pub fingerprint: u64,
205 old: String,
206 new: String,
207 old_layers: Classified,
208 new_layers: Classified,
209 views: RefCell<HashMap<DiffModes, Rc<ModeView>>>,
210}
211
212impl FileReview {
213 pub fn new(change: FileChange) -> FileReview {
214 let old = change.old.unwrap_or_default();
215 let new = change.new.unwrap_or_default();
216
217 let (detection, old_layers, new_layers) = if change.binary {
218 (
219 Detection::Binary,
220 Classified::default(),
221 Classified::default(),
222 )
223 } else {
224 match Lang::from_path(&change.path)
225 .and_then(|lang| Some((lang, classify(lang, &old)?, classify(lang, &new)?)))
226 {
227 Some((lang, old_c, new_c)) => {
228 let detection = match new_c.error_line.or(old_c.error_line) {
229 Some(line) => Detection::Partial(lang, line),
230 None => Detection::Parsed(lang),
231 };
232 (detection, old_c, new_c)
233 }
234 None => (
235 Detection::Unsupported,
236 Classified::default(),
237 Classified::default(),
238 ),
239 }
240 };
241
242 let is_test = is_test_file(&change.path, if new.is_empty() { &old } else { &new });
243 let fingerprint = fingerprint(&[&change.path, &old, &new]);
244 FileReview {
245 path: change.path,
246 old_path: change.old_path,
247 status: change.status,
248 detection,
249 is_test,
250 fingerprint,
251 old,
252 new,
253 old_layers,
254 new_layers,
255 views: RefCell::new(HashMap::new()),
256 }
257 }
258
259 pub fn view(&self, modes: impl Into<DiffModes>) -> Rc<ModeView> {
260 let modes = modes.into();
261 if let Some(view) = self.views.borrow().get(&modes) {
262 return Rc::clone(view);
263 }
264 let hunks = self.hunks_for(modes);
265 let hidden_hunks = if modes == DiffModes::SHOWN {
266 0
267 } else {
268 count_hidden(&self.view(DiffModes::SHOWN).hunks, &hunks)
269 };
270 let view = Rc::new(ModeView {
271 hunks,
272 hidden_hunks,
273 });
274 self.views.borrow_mut().insert(modes, Rc::clone(&view));
275 view
276 }
277
278 fn hunks_for(&self, modes: DiffModes) -> Vec<Hunk> {
279 let (mode, layers) = modes.effective();
280 let lang = match self.detection {
281 Detection::Parsed(lang) | Detection::Partial(lang, _) => Some(lang),
282 _ => None,
283 };
284 let side = |src: &str, classified: &Classified| {
285 let mut projection = project(src, &union(classified, &layers), mode);
286 if let Some(lang) = lang {
287 annotate(&mut projection, lang);
288 }
289 projection
290 };
291 let mut hunks = diff(
292 &side(&self.old, &self.old_layers),
293 &side(&self.new, &self.new_layers),
294 CONTEXT,
295 );
296 let indentation_matters = !matches!(
298 lang,
299 Some(Lang::Swift | Lang::TypeScript | Lang::Tsx | Lang::JavaScript)
300 );
301 filter_formatting(&mut hunks, modes.formatting, indentation_matters);
302 if mode == LayerMode::Hidden {
303 for hunk in &mut hunks {
306 hunk.rows
307 .retain(|row| row.kind == RowKind::Context || !row.text.trim().is_empty());
308 }
309 hunks.retain(|hunk| hunk.changed().next().is_some());
310 }
311 hunks
312 }
313
314 pub fn total_hunks(&self) -> usize {
315 self.view(DiffModes::SHOWN).hunks.len()
316 }
317
318 pub fn is_visible(&self, tests: LayerMode) -> bool {
320 match tests {
321 LayerMode::Shown => true,
322 LayerMode::Hidden => !self.is_test,
323 LayerMode::Only => self.is_test,
324 }
325 }
326
327 pub fn tag(&self) -> String {
329 let detection = self.detection.label();
330 if self.is_test {
331 format!("{detection}, test")
332 } else {
333 detection
334 }
335 }
336}
337
338fn union(classified: &Classified, layers: &[SpanLayer]) -> Vec<Range<usize>> {
340 let mut spans: Vec<Range<usize>> = layers
341 .iter()
342 .flat_map(|layer| layer.spans(classified).iter().cloned())
343 .collect();
344 spans.sort_by_key(|span| span.start);
345 let mut merged: Vec<Range<usize>> = Vec::with_capacity(spans.len());
346 for span in spans {
347 match merged.last_mut() {
348 Some(last) if span.start <= last.end => last.end = last.end.max(span.end),
349 _ => merged.push(span),
350 }
351 }
352 merged
353}
354
355fn count_hidden(full: &[Hunk], visible: &[Hunk]) -> usize {
357 let key = |kind: RowKind, old: Option<usize>, new: Option<usize>| match kind {
358 RowKind::Removed => old.map(|line| (false, line)),
359 _ => new.map(|line| (true, line)),
360 };
361 let still_changed: HashSet<(bool, usize)> = visible
362 .iter()
363 .flat_map(Hunk::changed)
364 .filter_map(|row| key(row.kind, row.old_line, row.new_line))
365 .collect();
366 full.iter()
367 .filter(|hunk| {
368 !hunk.changed().any(|row| {
369 key(row.kind, row.old_line, row.new_line)
370 .is_some_and(|k| still_changed.contains(&k))
371 })
372 })
373 .count()
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub struct Layers {
379 pub comments: LayerMode,
380 pub tests: LayerMode,
381 pub imports: LayerMode,
382 pub logging: LayerMode,
383 pub formatting: LayerMode,
384}
385
386impl Default for Layers {
387 fn default() -> Layers {
388 Layers {
389 comments: LayerMode::Hidden,
390 tests: LayerMode::Shown,
391 imports: LayerMode::Shown,
392 logging: LayerMode::Shown,
393 formatting: LayerMode::Shown,
394 }
395 }
396}
397
398#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
400pub struct Summary {
401 pub files: usize,
402 pub total_hunks: usize,
403 pub visible_hunks: usize,
404 pub hidden_hunks: usize,
405 pub fully_hidden_files: usize,
407 pub filtered_files: usize,
409 pub unsupported_files: usize,
410 pub partial_files: usize,
411}
412
413impl Summary {
414 pub fn new(files: &[FileReview], layers: Layers) -> Summary {
415 let mut summary = Summary::default();
416 for file in files {
417 if !file.is_visible(layers.tests) {
418 summary.filtered_files += 1;
419 continue;
420 }
421 let view = file.view(layers);
422 summary.files += 1;
423 summary.total_hunks += file.total_hunks();
424 summary.visible_hunks += view.hunks.len();
425 summary.hidden_hunks += view.hidden_hunks;
426 if file.total_hunks() > 0 && view.hunks.is_empty() {
427 summary.fully_hidden_files += 1;
428 }
429 match file.detection {
430 Detection::Unsupported => summary.unsupported_files += 1,
431 Detection::Partial(..) => summary.partial_files += 1,
432 _ => {}
433 }
434 }
435 summary
436 }
437
438 pub fn status_line(&self, layers: Layers) -> String {
439 let states: Vec<String> = [
440 ("comments", layers.comments),
441 ("tests", layers.tests),
442 ("imports", layers.imports),
443 ("logging", layers.logging),
444 ("formatting", layers.formatting),
445 ]
446 .into_iter()
447 .filter(|(_, mode)| *mode != LayerMode::Shown)
448 .map(|(name, mode)| format!("{name}: {}", mode.label()))
449 .collect();
450 let mut parts = if states.is_empty() {
451 vec!["all layers shown".to_string()]
452 } else {
453 states
454 };
455
456 let modes = DiffModes::from(layers);
457 let adjective = modes.adjective();
458 match modes.outcome().0 {
459 LayerMode::Shown => parts.push(format!(
460 "{} {} in {} {}",
461 self.total_hunks,
462 plural(self.total_hunks, "hunk"),
463 self.files,
464 plural(self.files, "file")
465 )),
466 LayerMode::Hidden => {
467 parts.push(format!(
468 "showing {} of {} hunks",
469 self.visible_hunks, self.total_hunks
470 ));
471 parts.push(format!(
472 "{} {adjective}-only {} hidden",
473 self.hidden_hunks,
474 plural(self.hidden_hunks, "hunk")
475 ));
476 if self.fully_hidden_files > 0 {
477 parts.push(format!(
478 "{} {adjective}-only {}",
479 self.fully_hidden_files,
480 plural(self.fully_hidden_files, "file")
481 ));
482 }
483 }
484 LayerMode::Only => {
485 parts.push(format!(
486 "{} {} with {adjective} changes",
487 self.visible_hunks,
488 plural(self.visible_hunks, "hunk")
489 ));
490 parts.push(format!(
491 "{} other {} hidden",
492 self.hidden_hunks,
493 plural(self.hidden_hunks, "hunk")
494 ));
495 }
496 }
497 match layers.tests {
498 LayerMode::Shown => {}
499 LayerMode::Hidden => parts.push(format!(
500 "{} test {} hidden",
501 self.filtered_files,
502 plural(self.filtered_files, "file")
503 )),
504 LayerMode::Only => parts.push(format!(
505 "{} non-test {} hidden",
506 self.filtered_files,
507 plural(self.filtered_files, "file")
508 )),
509 }
510 if self.unsupported_files > 0 {
511 parts.push(format!(
512 "no grammar for {} {}",
513 self.unsupported_files,
514 plural(self.unsupported_files, "file")
515 ));
516 }
517 if self.partial_files > 0 {
518 parts.push(format!(
519 "{} {} partially parsed",
520 self.partial_files,
521 plural(self.partial_files, "file")
522 ));
523 }
524 parts.join(" ยท ")
525 }
526}
527
528fn plural(count: usize, word: &str) -> String {
529 if count == 1 {
530 word.to_string()
531 } else {
532 format!("{word}s")
533 }
534}