Skip to main content

grit_lib/diff/
whitespace_mode.rs

1//! Line comparison rules for `git diff -w`, `-b`, `--ignore-space-at-eol`, and `--ignore-blank-lines`.
2
3use std::borrow::Cow;
4
5use crate::ws::is_space_git;
6
7/// Whitespace handling for line diffs ([`super::DiffOptions::whitespace`]).
8#[non_exhaustive]
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
10pub struct WhitespaceMode {
11    /// `-w` / `--ignore-all-space`: drop every whitespace character when comparing lines.
12    pub ignore_all_space: bool,
13    /// `-b` / `--ignore-space-change`: collapse runs of whitespace to one space; trim trailing spaces.
14    pub ignore_space_change: bool,
15    /// `--ignore-space-at-eol`: trim trailing whitespace before the line terminator.
16    pub ignore_space_at_eol: bool,
17    /// `--ignore-blank-lines`: treat blank-line-only change records as ignorable (Git `xch->ignore`).
18    pub ignore_blank_lines: bool,
19}
20
21impl WhitespaceMode {
22    /// `-w` / `--ignore-all-space`.
23    #[must_use]
24    pub fn ignore_all_space() -> Self {
25        Self {
26            ignore_all_space: true,
27            ..Self::default()
28        }
29    }
30
31    /// `-b` / `--ignore-space-change`.
32    #[must_use]
33    pub fn ignore_space_change() -> Self {
34        Self {
35            ignore_space_change: true,
36            ..Self::default()
37        }
38    }
39
40    /// `--ignore-blank-lines`.
41    #[must_use]
42    pub fn ignore_blank_lines() -> Self {
43        Self {
44            ignore_blank_lines: true,
45            ..Self::default()
46        }
47    }
48
49    /// Whether any whitespace rule is enabled.
50    #[must_use]
51    pub fn is_active(self) -> bool {
52        self.ignore_all_space
53            || self.ignore_space_change
54            || self.ignore_space_at_eol
55            || self.ignore_blank_lines
56    }
57
58    /// Whether line text should be normalized before xdiff line hashing (`-w`, `-b`, `--ignore-space-at-eol`).
59    #[must_use]
60    pub fn normalizes_line_compare(self) -> bool {
61        self.ignore_all_space || self.ignore_space_change || self.ignore_space_at_eol
62    }
63
64    /// Normalize one line body (without its trailing `\n`) for line-interner comparison.
65    #[must_use]
66    pub fn normalize_line_for_compare<'a>(&self, line: &'a str) -> Cow<'a, str> {
67        if !self.normalizes_line_compare() {
68            return Cow::Borrowed(line);
69        }
70        if self.ignore_all_space {
71            return Cow::Owned(strip_git_whitespace_bytes(line));
72        }
73        let mut work = line.to_string();
74        if self.ignore_space_at_eol {
75            work = trim_trailing_git_whitespace(&work);
76        }
77        if self.ignore_space_change {
78            work = normalize_ignore_space_change_line(&work);
79        }
80        Cow::Owned(work)
81    }
82}
83
84/// Normalise one line like Git's `-b` / `--ignore-space-change` (ASCII ws only).
85#[must_use]
86pub fn normalize_ignore_space_change_line(line: &str) -> String {
87    let bytes = line.as_bytes();
88    let mut result = String::with_capacity(line.len());
89    let mut in_space = false;
90    for &ch in bytes {
91        if is_space_git(ch) {
92            if !in_space {
93                result.push(' ');
94                in_space = true;
95            }
96        } else {
97            result.push(ch as char);
98            in_space = false;
99        }
100    }
101    while result.ends_with(' ') {
102        result.pop();
103    }
104    result
105}
106
107/// Strip Git whitespace bytes for `-w` (NBSP and other non-Git space bytes are kept).
108#[must_use]
109pub fn strip_git_whitespace_bytes(line: &str) -> String {
110    let filtered: Vec<u8> = line.bytes().filter(|b| !is_space_git(*b)).collect();
111    String::from_utf8(filtered).unwrap_or_else(|_| line.to_string())
112}
113
114fn trim_trailing_git_whitespace(line: &str) -> String {
115    let bytes = line.as_bytes();
116    let mut end = bytes.len();
117    while end > 0 && is_space_git(bytes[end - 1]) {
118        end -= 1;
119    }
120    if end == bytes.len() {
121        return line.to_string();
122    }
123    String::from_utf8(bytes[..end].to_vec()).unwrap_or_else(|_| line.to_string())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn ignore_space_change_collapses_runs() {
132        assert_eq!(normalize_ignore_space_change_line("f o o"), "f o o");
133        assert_eq!(normalize_ignore_space_change_line("a  b\tc  "), "a b c");
134    }
135
136    #[test]
137    fn ignore_all_space_strips_git_whitespace_only() {
138        let ws = WhitespaceMode::ignore_all_space();
139        assert_eq!(ws.normalize_line_for_compare("f o o").as_ref(), "foo");
140        let nbsp_line = "a\u{00a0}b";
141        assert_eq!(
142            ws.normalize_line_for_compare(nbsp_line).as_ref(),
143            nbsp_line,
144            "NBSP is not Git whitespace and must not be stripped by -w"
145        );
146    }
147
148    #[test]
149    fn nbsp_change_not_ignored_by_ignore_all_space_compare_key() {
150        let ws = WhitespaceMode::ignore_all_space();
151        let old_key = ws.normalize_line_for_compare("a\u{00a0}b");
152        let new_key = ws.normalize_line_for_compare("ab");
153        assert_ne!(old_key, new_key);
154    }
155}