Skip to main content

diffler_core/
lens.rs

1//! The symbol lens: the names on one diff line and their uses on lines the
2//! diff shows. A local reaches its enclosing function; a function, method or
3//! type reaches every file of the diff.
4
5use std::collections::{HashMap, HashSet};
6use std::ops::{Range, RangeInclusive};
7
8use crate::model::{DiffModel, LineKind};
9use crate::syntax::registry::REGISTRY;
10use crate::syntax::{Ident, ScopeIndex};
11
12/// One per digit key.
13pub const MAX_SYMBOLS: usize = 9;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct LensOrigin {
17    pub path: String,
18    pub on_old_side: bool,
19    pub line: u32,
20}
21
22/// Both sides' text and the lines the diff shows on each. A context line
23/// counts on the new side only, so it counts once.
24#[derive(Debug, Clone)]
25pub struct LensFile {
26    pub path: String,
27    pub old_text: Option<String>,
28    pub new_text: Option<String>,
29    pub old_lines: HashSet<u32>,
30    pub new_lines: HashSet<u32>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Reach {
35    /// A local of the named function.
36    Function(String),
37    /// A top-level name of its file.
38    File,
39    /// A definition, call or type position.
40    Diff,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct LensSymbol {
45    pub name: String,
46    pub reach: Reach,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct LensUse {
51    pub symbol: usize,
52    pub path: String,
53    pub on_old_side: bool,
54    pub line: u32,
55    pub range: Range<usize>,
56}
57
58struct Side {
59    idents: Vec<Ident>,
60    scope: ScopeIndex,
61}
62
63impl Side {
64    fn read(path: &str, text: Option<&str>) -> Option<Self> {
65        let text = text?;
66        let (idents, scope) = REGISTRY.symbols(path, text);
67        Some(Self { idents, scope })
68    }
69}
70
71/// Parses both sides of every file, so we run it on a worker.
72pub fn compute(origin: &LensOrigin, files: &[LensFile]) -> LensData {
73    let sides: Vec<(Option<Side>, Option<Side>)> = files
74        .iter()
75        .map(|file| {
76            (
77                Side::read(&file.path, file.old_text.as_deref()),
78                Side::read(&file.path, file.new_text.as_deref()),
79            )
80        })
81        .collect();
82    let origin_side = files
83        .iter()
84        .position(|file| file.path == origin.path)
85        .and_then(|at| sides.get(at))
86        .and_then(|(old, new)| {
87            if origin.on_old_side {
88                old.as_ref()
89            } else {
90                new.as_ref()
91            }
92        });
93    let symbols = line_symbols(origin, origin_side);
94    let origin_span = origin_side
95        .and_then(|side| side.scope.enclosing(origin.line.saturating_sub(1) as usize))
96        .map(|(_, start, end)| start..=end);
97    let uses = symbol_uses(origin, origin_span.as_ref(), files, &sides, &symbols);
98    LensData {
99        origin: origin.clone(),
100        symbols,
101        uses,
102    }
103}
104
105fn line_symbols(origin: &LensOrigin, origin_side: Option<&Side>) -> Vec<LensSymbol> {
106    let origin_row = origin.line.saturating_sub(1) as usize;
107    let mut names: Vec<String> = Vec::new();
108    let mut items: HashSet<&str> = HashSet::new();
109    for ident in origin_side.map_or(&[][..], |side| side.idents.as_slice()) {
110        if ident.line != origin_row {
111            continue;
112        }
113        if ident.item {
114            items.insert(ident.name.as_str());
115        }
116        if !names.contains(&ident.name) && names.len() < MAX_SYMBOLS {
117            names.push(ident.name.clone());
118        }
119    }
120    let enclosing = origin_side.and_then(|side| side.scope.enclosing(origin_row));
121    names
122        .into_iter()
123        .map(|name| {
124            // a local sharing a name with a function elsewhere stays local
125            let reach = if items.contains(name.as_str()) {
126                Reach::Diff
127            } else if let Some((function, _, _)) = enclosing {
128                Reach::Function(function.to_owned())
129            } else {
130                Reach::File
131            };
132            LensSymbol { name, reach }
133        })
134        .collect()
135}
136
137fn symbol_uses(
138    origin: &LensOrigin,
139    origin_span: Option<&RangeInclusive<usize>>,
140    files: &[LensFile],
141    sides: &[(Option<Side>, Option<Side>)],
142    symbols: &[LensSymbol],
143) -> Vec<LensUse> {
144    let index: HashMap<&str, usize> = symbols
145        .iter()
146        .enumerate()
147        .map(|(at, symbol)| (symbol.name.as_str(), at))
148        .collect();
149    let mut uses = Vec::new();
150    for (file, (old, new)) in files.iter().zip(sides) {
151        for (on_old_side, side, shown) in
152            [(true, old, &file.old_lines), (false, new, &file.new_lines)]
153        {
154            let Some(side) = side else {
155                continue;
156            };
157            for ident in &side.idents {
158                let line = u32::try_from(ident.line + 1).unwrap_or(u32::MAX);
159                let Some(&symbol) = index.get(ident.name.as_str()) else {
160                    continue;
161                };
162                if !shown.contains(&line) {
163                    continue;
164                }
165                let reaches = match symbols.get(symbol).map(|s| &s.reach) {
166                    Some(Reach::Diff) => true,
167                    Some(Reach::File) => file.path == origin.path,
168                    Some(Reach::Function(function)) if file.path == origin.path => {
169                        // a file can define two functions of one name, so on the
170                        // origin's own side we hold to the one the line sits in
171                        let span = if on_old_side == origin.on_old_side {
172                            origin_span.cloned()
173                        } else {
174                            side.scope
175                                .def_span(function)
176                                .map(|(start, end)| start..=end)
177                        };
178                        span.is_some_and(|span| span.contains(&ident.line))
179                    }
180                    Some(Reach::Function(_)) | None => false,
181                };
182                if reaches {
183                    uses.push(LensUse {
184                        symbol,
185                        path: file.path.clone(),
186                        on_old_side,
187                        line,
188                        range: ident.range.clone(),
189                    });
190                }
191            }
192        }
193    }
194    uses
195}
196
197pub fn lens_files(model: &DiffModel) -> Vec<LensFile> {
198    model
199        .files
200        .iter()
201        .filter(|file| !file.binary)
202        .map(|file| {
203            let mut old_lines = HashSet::new();
204            let mut new_lines = HashSet::new();
205            for line in file.hunks.iter().flat_map(|hunk| &hunk.lines) {
206                match (line.kind, line.old_no, line.new_no) {
207                    (LineKind::Deleted, Some(old), _) => {
208                        old_lines.insert(old);
209                    }
210                    (LineKind::Added | LineKind::Context, _, Some(new)) => {
211                        new_lines.insert(new);
212                    }
213                    _ => {}
214                }
215            }
216            LensFile {
217                path: file.path.clone(),
218                old_text: file.old_text.clone(),
219                new_text: file.new_text.clone(),
220                old_lines,
221                new_lines,
222            }
223        })
224        .collect()
225}
226
227#[derive(Debug, Clone)]
228pub struct LensData {
229    pub origin: LensOrigin,
230    pub symbols: Vec<LensSymbol>,
231    pub uses: Vec<LensUse>,
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn a_local_stays_in_the_function_its_line_sits_in() {
240        let text = "fn f() {\n    let a = 1;\n}\n\nfn f() {\n    let a = 2;\n    a\n}\n";
241        let file = LensFile {
242            path: "a.rs".to_owned(),
243            old_text: None,
244            new_text: Some(text.to_owned()),
245            old_lines: HashSet::new(),
246            new_lines: (1..=8).collect(),
247        };
248        let origin = LensOrigin {
249            path: "a.rs".to_owned(),
250            on_old_side: false,
251            line: 6,
252        };
253        let lens = compute(&origin, &[file]);
254        let a = lens
255            .symbols
256            .iter()
257            .position(|symbol| symbol.name == "a")
258            .expect("a is named on the line");
259        let lines: Vec<u32> = lens
260            .uses
261            .iter()
262            .filter(|found| found.symbol == a)
263            .map(|found| found.line)
264            .collect();
265        assert_eq!(lines, [6, 7]);
266    }
267}