Skip to main content

declutter/
review.rs

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
14/// Unchanged lines shown around each change.
15pub 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/// One changed file, both versions in full.
37#[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/// How well declutter could find the layers in a file.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Detection {
50    Parsed(Lang),
51    /// Parsed with syntax errors; layers near the error may be missed. Holds the line of
52    /// the first error, in the new version if it has one.
53    Partial(Lang, usize),
54    /// No grammar for this file type, so nothing but whole test files is hidden.
55    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/// A layer made of spans inside a file, as opposed to the file-level test layer.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum SpanLayer {
75    Comments,
76    Imports,
77    Logging,
78    /// Test code inside a source file (Rust's `#[cfg(test)]` modules); whole test files
79    /// are the file-level side of the same layer.
80    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    /// The adjective used in messages: "comment-only hunks".
92    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/// The part of `Layers` that changes a file's diff: the span layers, and whether
112/// formatting-only changes are shown.
113#[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    /// How the file is projected: any layer set to *only* wins, and shows the union of
141    /// those layers; otherwise the hidden layers are cut out.
142    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    /// What the reviewer sees, for messages: only some layers, some hidden, or all
156    /// shown, with the adjective naming those layers ("comment", "comment/formatting").
157    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
179/// Just the comment layer set; the rest shown.
180impl 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    /// Hunks of the full diff that have no visible change in this mode.
205    pub hidden_hunks: usize,
206}
207
208/// A changed file. Its diff under each choice of layers is computed on first use.
209#[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    /// The whole file is test code; the test layer is file-granular.
216    pub is_test: bool,
217    /// Identifies this exact change (path and both versions) for review marks.
218    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        // A test file is test code from top to bottom: the test layer decides whether it
294        // is listed at all, never which of its lines are shown.
295        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        // Indentation is syntax in Python, and in files without a grammar it may be too.
321        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            // Added or removed blank lines are layout: they mostly travel with whatever
336            // was hidden, and they never change what the code does.
337            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    /// Whether the file is listed at all under this test-layer mode. A source file with
351    /// test blocks in it stays listed either way: hiding tests cuts the blocks out, and
352    /// showing only tests keeps just them.
353    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    /// Test code inside the file, in either version — Rust's `#[cfg(test)]` modules.
362    pub fn has_test_blocks(&self) -> bool {
363        !self.old_layers.tests.is_empty() || !self.new_layers.tests.is_empty()
364    }
365
366    /// The bracketed tag after the file name: language or detection problem, and `test`.
367    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
377/// The spans of `layers`, merged into one sorted, non-overlapping list.
378fn 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
394/// Counts hunks of the full diff none of whose changed lines are still changed in `visible`.
395fn 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/// What the reviewer has chosen to see of each layer.
416#[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/// Totals across the files visible under one choice of layers.
438#[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    /// Files with changes, none of them visible under these layers.
445    pub fully_hidden_files: usize,
446    /// Files left out by the test layer: test files when hidden, the rest when only.
447    pub filtered_files: usize,
448    pub unsupported_files: usize,
449    pub partial_files: usize,
450    /// Listed files with test blocks inside them (Rust's `#[cfg(test)]` modules).
451    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        // With no test blocks inside the listed files, the test layer only filters files,
502        // which is reported on its own below; it hides no hunks.
503        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}