Skip to main content

declutter/
project.rs

1use std::ops::Range;
2
3use crate::highlight::LineClasses;
4
5/// What the reviewer sees of a layer: all of it, none of it, or only it.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum LayerMode {
8    Shown,
9    Hidden,
10    Only,
11}
12
13impl LayerMode {
14    pub const ALL: [LayerMode; 3] = [LayerMode::Shown, LayerMode::Hidden, LayerMode::Only];
15
16    pub fn next(self) -> LayerMode {
17        match self {
18            LayerMode::Hidden => LayerMode::Only,
19            LayerMode::Only => LayerMode::Shown,
20            LayerMode::Shown => LayerMode::Hidden,
21        }
22    }
23
24    pub fn label(self) -> &'static str {
25        match self {
26            LayerMode::Shown => "shown",
27            LayerMode::Hidden => "hidden",
28            LayerMode::Only => "only",
29        }
30    }
31
32    pub fn parse(value: &str) -> Option<LayerMode> {
33        LayerMode::ALL
34            .into_iter()
35            .find(|mode| mode.label() == value)
36    }
37}
38
39/// A file as seen through a comment mode: the lines that remain, each tied back
40/// to the line it came from so line numbers always refer to the real file.
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
42pub struct Projection {
43    pub lines: Vec<String>,
44    /// Zero-based original line index of each entry in `lines`.
45    pub orig: Vec<usize>,
46    /// Syntax classes per line; empty until `highlight::annotate` runs.
47    pub syntax: Vec<LineClasses>,
48}
49
50pub fn project(src: &str, comments: &[Range<usize>], mode: LayerMode) -> Projection {
51    let mut projection = Projection::default();
52    let mut next_comment = 0;
53    let mut line_start = 0;
54
55    for (index, raw) in src.split_inclusive('\n').enumerate() {
56        let line_end = line_start + raw.len();
57        let line = raw.trim_end_matches('\n').trim_end_matches('\r');
58
59        while next_comment < comments.len() && comments[next_comment].end <= line_start {
60            next_comment += 1;
61        }
62        // An empty cut marks a blank line inside a multi-line comment: it belongs to the comment.
63        let cuts: Vec<Range<usize>> = comments[next_comment..]
64            .iter()
65            .take_while(|comment| comment.start < line_end)
66            .map(|comment| {
67                let end = comment.end.min(line_start + line.len()) - line_start;
68                (comment.start.max(line_start) - line_start).min(end)..end
69            })
70            .collect();
71
72        let kept = match mode {
73            LayerMode::Shown => Some(line.to_string()),
74            LayerMode::Hidden => without_cuts(line, &cuts),
75            LayerMode::Only => only_cuts(line, &cuts),
76        };
77        if let Some(text) = kept {
78            projection.lines.push(text);
79            projection.orig.push(index);
80        }
81        line_start = line_end;
82    }
83    projection
84}
85
86/// The line with its comments removed, or `None` if nothing but comments was on it.
87fn without_cuts(line: &str, cuts: &[Range<usize>]) -> Option<String> {
88    if cuts.is_empty() {
89        return Some(line.to_string());
90    }
91    let mut out = line[..cuts[0].start].to_string();
92    for (cut, next) in cuts.iter().zip(cuts.iter().skip(1)) {
93        push_joined(&mut out, &line[cut.end..next.start]);
94    }
95    push_joined(&mut out, &line[cuts[cuts.len() - 1].end..]);
96
97    let trimmed = out.trim_end();
98    (!trimmed.trim_start().is_empty()).then(|| trimmed.to_string())
99}
100
101/// Appends the text after a cut without doubling the whitespace around it. When the cut
102/// started the line, the line's own indentation went with it, so none is added back.
103fn push_joined(out: &mut String, segment: &str) {
104    if out.is_empty() || out.ends_with(char::is_whitespace) {
105        out.push_str(segment.trim_start());
106    } else {
107        out.push_str(segment);
108    }
109}
110
111/// Only the comment text of the line, keeping its indentation, or `None` if it has no comment.
112fn only_cuts(line: &str, cuts: &[Range<usize>]) -> Option<String> {
113    let first = cuts.first()?;
114    let indent = &line[..line.len() - line.trim_start().len()];
115    let mut out = if first.start == 0 {
116        String::new()
117    } else {
118        indent.to_string()
119    };
120    for (i, cut) in cuts.iter().enumerate() {
121        let text = &line[cut.clone()];
122        if i == 0 && cut.start == 0 {
123            out.push_str(text);
124        } else {
125            if i > 0 {
126                out.push(' ');
127            }
128            out.push_str(text.trim());
129        }
130    }
131    Some(out.trim_end().to_string())
132}