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}
79
80impl SpanLayer {
81    pub const ALL: [SpanLayer; 3] = [SpanLayer::Comments, SpanLayer::Imports, SpanLayer::Logging];
82
83    /// The adjective used in messages: "comment-only hunks".
84    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/// The part of `Layers` that changes a file's diff: the span layers, and whether
102/// formatting-only changes are shown.
103#[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    /// How the file is projected: any layer set to *only* wins, and shows the union of
128    /// those layers; otherwise the hidden layers are cut out.
129    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    /// What the reviewer sees, for messages: only some layers, some hidden, or all
143    /// shown, with the adjective naming those layers ("comment", "comment/formatting").
144    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
166/// Just the comment layer set; the rest shown.
167impl 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    /// Hunks of the full diff that have no visible change in this mode.
191    pub hidden_hunks: usize,
192}
193
194/// A changed file. Its diff under each choice of layers is computed on first use.
195#[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    /// The whole file is test code; the test layer is file-granular.
202    pub is_test: bool,
203    /// Identifies this exact change (path and both versions) for review marks.
204    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        // Indentation is syntax in Python, and in files without a grammar it may be too.
297        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            // Added or removed blank lines are layout: they mostly travel with whatever
304            // was hidden, and they never change what the code does.
305            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    /// Whether the file is listed at all under this test-layer mode.
319    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    /// The bracketed tag after the file name: language or detection problem, and `test`.
328    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
338/// The spans of `layers`, merged into one sorted, non-overlapping list.
339fn 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
355/// Counts hunks of the full diff none of whose changed lines are still changed in `visible`.
356fn 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/// What the reviewer has chosen to see of each layer.
377#[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/// Totals across the files visible under one choice of layers.
399#[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    /// Files with changes, none of them visible under these layers.
406    pub fully_hidden_files: usize,
407    /// Files left out by the test layer: test files when hidden, the rest when only.
408    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}