Skip to main content

objects/worktree/
worktree_diff.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Diff utilities for blobs.
3
4use crate::object::Blob;
5
6/// Compute a simple diff between two blobs.
7pub fn diff_blobs(old: &Blob, new: &Blob) -> Vec<DiffLine> {
8    let Some(old_text) = old.content_str() else {
9        return Vec::new();
10    };
11    let Some(new_text) = new.content_str() else {
12        return Vec::new();
13    };
14
15    let diff = similar::TextDiff::configure()
16        .algorithm(similar::Algorithm::Histogram)
17        .diff_lines(old_text, new_text)
18        .iter_all_changes()
19        .map(|change| {
20            let content = change.value().trim_end_matches('\n').to_string();
21            match change.tag() {
22                similar::ChangeTag::Delete => DiffLine::Removed(content),
23                similar::ChangeTag::Insert => DiffLine::Added(content),
24                similar::ChangeTag::Equal => DiffLine::Context(content),
25            }
26        })
27        .collect();
28    keep_annotations_with_inserted_items(diff)
29}
30
31fn keep_annotations_with_inserted_items(lines: Vec<DiffLine>) -> Vec<DiffLine> {
32    let mut output = Vec::with_capacity(lines.len());
33    let mut index = 0;
34
35    while index < lines.len() {
36        let Some(DiffLine::Context(annotation)) = lines.get(index) else {
37            output.push(lines[index].clone());
38            index += 1;
39            continue;
40        };
41
42        if !is_decoration_line(annotation) {
43            output.push(lines[index].clone());
44            index += 1;
45            continue;
46        }
47
48        let added_start = index + 1;
49        let mut added_end = added_start;
50        while matches!(lines.get(added_end), Some(DiffLine::Added(_))) {
51            added_end += 1;
52        }
53
54        let has_inserted_item = first_meaningful_added_line(&lines[added_start..added_end])
55            .is_some_and(is_item_declaration_line);
56        let decorates_next_context = matches!(
57            lines.get(added_end),
58            Some(DiffLine::Context(next)) if is_item_declaration_line(next)
59        );
60
61        if added_end > added_start && has_inserted_item && decorates_next_context {
62            output.push(DiffLine::Added(annotation.clone()));
63            output.extend(lines[added_start..added_end].iter().cloned());
64            output.push(lines[index].clone());
65            index = added_end;
66            continue;
67        }
68
69        output.push(lines[index].clone());
70        index += 1;
71    }
72
73    output
74}
75
76fn first_meaningful_added_line(lines: &[DiffLine]) -> Option<&str> {
77    lines.iter().find_map(|line| match line {
78        DiffLine::Added(content) if !content.trim().is_empty() => Some(content.as_str()),
79        _ => None,
80    })
81}
82
83fn is_attribute_line(line: &str) -> bool {
84    let trimmed = line.trim_start();
85    trimmed.starts_with("#[") || trimmed.starts_with("#![")
86}
87
88fn is_decoration_line(line: &str) -> bool {
89    let trimmed = line.trim_start();
90    is_attribute_line(line)
91        || trimmed.starts_with('@')
92        || trimmed.starts_with("///")
93        || trimmed.starts_with("//!")
94        || trimmed.starts_with("/**")
95        || trimmed.starts_with('*')
96        || trimmed.starts_with("\"\"\"")
97        || trimmed.starts_with("'''")
98}
99
100fn is_item_declaration_line(line: &str) -> bool {
101    let trimmed = line.trim_start();
102    matches!(
103        trimmed.split_whitespace().next(),
104        Some(
105            "fn" | "pub"
106                | "async"
107                | "const"
108                | "struct"
109                | "enum"
110                | "trait"
111                | "impl"
112                | "mod"
113                | "type"
114                | "def"
115                | "class"
116                | "function"
117                | "export"
118                | "let"
119                | "var"
120        )
121    )
122}
123
124/// A line in a diff.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum DiffLine {
127    /// Line present in both versions.
128    Context(String),
129    /// Line added in new version.
130    Added(String),
131    /// Line removed from old version.
132    Removed(String),
133}
134
135impl DiffLine {
136    /// Get the prefix for display.
137    pub fn prefix(&self) -> &'static str {
138        match self {
139            DiffLine::Context(_) => " ",
140            DiffLine::Added(_) => "+",
141            DiffLine::Removed(_) => "-",
142        }
143    }
144
145    /// Get the line content.
146    pub fn content(&self) -> &str {
147        match self {
148            DiffLine::Context(s) | DiffLine::Added(s) | DiffLine::Removed(s) => s,
149        }
150    }
151}