Skip to main content

git_checks/
check_whitespace.rs

1// Copyright Kitware, Inc.
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use derive_builder::Builder;
10use git_checks_core::impl_prelude::*;
11use itertools::Itertools;
12
13/// The CR/LF line ending.
14const CR_LF_ENDING: &str = "\r\n";
15/// A symbol to replace `\r` characters so that they appear in the error message.
16const CARRIAGE_RETURN_SYMBOL: &str = "\u{23ce}";
17
18/// Checks for bad whitespace using Git's built-in checks.
19///
20/// This is attribute-driven, so any `gitattributes(5)` files may be used to suppress spirious
21/// errors from this check.
22#[derive(Builder, Debug, Default, Clone, Copy)]
23#[non_exhaustive]
24#[builder(field(private))]
25pub struct CheckWhitespace {}
26
27impl CheckWhitespace {
28    /// Create a new builder.
29    pub fn builder() -> CheckWhitespaceBuilder {
30        Default::default()
31    }
32
33    fn diff_tree(
34        ctx: &CheckGitContext,
35        args: &[&str],
36        content: &dyn Content,
37    ) -> Result<CheckResult, Box<dyn Error>> {
38        let mut result = CheckResult::new();
39
40        let diff_tree = ctx
41            .git()
42            .arg("diff-tree")
43            .arg("--no-commit-id")
44            .arg("--root")
45            .arg("-c")
46            .arg("--check")
47            .args(args)
48            .output()
49            .map_err(|err| GitError::subcommand("diff-tree", err))?;
50        if !diff_tree.status.success() {
51            Self::add_error(&mut result, &diff_tree.stdout, content);
52        }
53
54        Ok(result)
55    }
56
57    fn add_error(result: &mut CheckResult, output: &[u8], content: &dyn Content) {
58        // Check for CR/LF line endings. This is done because most editors will mask their
59        // existence making the "trailing whitespace" hard to find.
60        let output = String::from_utf8_lossy(output);
61        let crlf_msg = if output.contains(CR_LF_ENDING) {
62            " including CR/LF line endings"
63        } else {
64            ""
65        };
66        let formatted_output = output
67            .split('\n')
68            // Git seems to add a trailing newline to its output, so drop the last line.
69            .dropping_back(1)
70            .map(|line| format!("        {line}\n"))
71            .join("")
72            .replace('\r', CARRIAGE_RETURN_SYMBOL);
73
74        result.add_error(format!(
75            "{}adds bad whitespace{crlf_msg}:\n\n{formatted_output}",
76            commit_prefix(content),
77        ));
78    }
79}
80
81impl Check for CheckWhitespace {
82    fn name(&self) -> &str {
83        "check-whitespace"
84    }
85
86    fn check(&self, ctx: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
87        Self::diff_tree(ctx, &[commit.sha1.as_str()], commit)
88    }
89}
90
91impl TopicCheck for CheckWhitespace {
92    fn name(&self) -> &str {
93        "check-whitespace"
94    }
95
96    fn check(&self, ctx: &CheckGitContext, topic: &Topic) -> Result<CheckResult, Box<dyn Error>> {
97        Self::diff_tree(ctx, &[topic.base.as_str(), topic.sha1.as_str()], topic)
98    }
99}
100
101#[cfg(feature = "config")]
102pub(crate) mod config {
103    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
104    use serde::Deserialize;
105    #[cfg(test)]
106    use serde_json::json;
107
108    use crate::CheckWhitespace;
109
110    /// Configuration for the `CheckWhitespace` check.
111    ///
112    /// No configuration available.
113    ///
114    /// This check is registered as a commit check with the name `"check_whitespace"` and a topic
115    /// check with the name `"check_whitespace/topic"`.
116    #[derive(Deserialize, Debug)]
117    pub struct CheckWhitespaceConfig {}
118
119    impl IntoCheck for CheckWhitespaceConfig {
120        type Check = CheckWhitespace;
121
122        fn into_check(self) -> Self::Check {
123            Default::default()
124        }
125    }
126
127    register_checks! {
128        CheckWhitespaceConfig {
129            "check_whitespace" => CommitCheckConfig,
130            "check_whitespace/topic" => TopicCheckConfig,
131        },
132    }
133
134    #[test]
135    fn test_check_whitespace_config_empty() {
136        let json = json!({});
137        let check: CheckWhitespaceConfig = serde_json::from_value(json).unwrap();
138
139        let _ = check.into_check();
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use git_checks_core::{Check, TopicCheck};
146
147    use crate::test::*;
148    use crate::CheckWhitespace;
149
150    const DEFAULT_TOPIC: &str = "829cdf8cb069b8f8a634a034d3f85089271601cf";
151    const NOCR_TOPIC: &str = "5db0c24d032d972ba5bf50eca99016adbfdd3e87";
152    const ALL_IGNORED_TOPIC: &str = "3a87e0f3f7430bbb81ebbd8ae8764b7f26384f1c";
153    const ALL_IGNORED_BLANKET_TOPIC: &str = "92cac7579a26f7d8449512476bd64b3000688fd5";
154
155    #[test]
156    fn test_check_whitespace_builder_default() {
157        assert!(CheckWhitespace::builder().build().is_ok());
158    }
159
160    #[test]
161    fn test_check_whitespace_name_commit() {
162        let check = CheckWhitespace::default();
163        assert_eq!(Check::name(&check), "check-whitespace");
164    }
165
166    #[test]
167    fn test_check_whitespace_name_topic() {
168        let check = CheckWhitespace::default();
169        assert_eq!(TopicCheck::name(&check), "check-whitespace");
170    }
171
172    #[test]
173    fn test_check_whitespace_defaults() {
174        let check = CheckWhitespace::default();
175        let result = run_check("test_check_whitespace_defaults", DEFAULT_TOPIC, check);
176        test_result_errors(
177            result,
178            &[
179                "commit 829cdf8cb069b8f8a634a034d3f85089271601cf adds bad whitespace including \
180                 CR/LF line endings:\n\
181                 \n        \
182                 crlf-file:1: trailing whitespace.\n        \
183                 +This file contains CRLF lines.\u{23ce}\n        \
184                 crlf-file:2: trailing whitespace.\n        \
185                 +\u{23ce}\n        \
186                 crlf-file:3: trailing whitespace.\n        \
187                 +line1\u{23ce}\n        \
188                 crlf-file:4: trailing whitespace.\n        \
189                 +line2\u{23ce}\n        \
190                 crlf-mixed-file:3: trailing whitespace.\n        \
191                 +crlf\u{23ce}\n        \
192                 extra-newlines:2: new blank line at EOF.\n        \
193                 mixed-tabs-spaces:3: space before tab in indent.\n        \
194                 +   \tmixed indent\n        \
195                 trailing-spaces:3: trailing whitespace.\n        \
196                 +trailing \n        \
197                 trailing-tab:3: trailing whitespace.\n        \
198                 +trailing\t\n",
199            ],
200        );
201    }
202
203    #[test]
204    fn test_check_whitespace_nocr() {
205        let check = CheckWhitespace::default();
206        let result = run_check("test_check_whitespace_nocr", NOCR_TOPIC, check);
207        test_result_errors(
208            result,
209            &[
210                "commit 5db0c24d032d972ba5bf50eca99016adbfdd3e87 adds bad whitespace:\n\
211                 \n        \
212                 extra-newlines:2: new blank line at EOF.\n        \
213                 mixed-tabs-spaces:3: space before tab in indent.\n        \
214                 +   \tmixed indent\n        \
215                 trailing-spaces:3: trailing whitespace.\n        \
216                 +trailing \n        \
217                 trailing-tab:3: trailing whitespace.\n        \
218                 +trailing\t\n",
219            ],
220        );
221    }
222
223    #[test]
224    fn test_check_whitespace_all_ignored() {
225        let check = CheckWhitespace::default();
226        run_check_ok(
227            "test_check_whitespace_all_ignored",
228            ALL_IGNORED_TOPIC,
229            check,
230        );
231    }
232
233    #[test]
234    fn test_check_whitespace_all_ignored_blanket() {
235        let check = CheckWhitespace::default();
236        run_check_ok(
237            "test_check_whitespace_all_ignored_blanket",
238            ALL_IGNORED_BLANKET_TOPIC,
239            check,
240        );
241    }
242
243    #[test]
244    fn test_check_whitespace_defaults_topic() {
245        let check = CheckWhitespace::default();
246        let result = run_topic_check("test_check_whitespace_defaults_topic", DEFAULT_TOPIC, check);
247        test_result_errors(
248            result,
249            &["adds bad whitespace including CR/LF line endings:\n\
250               \n        \
251               crlf-file:1: trailing whitespace.\n        \
252               +This file contains CRLF lines.\u{23ce}\n        \
253               crlf-file:2: trailing whitespace.\n        \
254               +\u{23ce}\n        \
255               crlf-file:3: trailing whitespace.\n        \
256               +line1\u{23ce}\n        \
257               crlf-file:4: trailing whitespace.\n        \
258               +line2\u{23ce}\n        \
259               crlf-mixed-file:3: trailing whitespace.\n        \
260               +crlf\u{23ce}\n        \
261               extra-newlines:2: new blank line at EOF.\n        \
262               mixed-tabs-spaces:3: space before tab in indent.\n        \
263               +   \tmixed indent\n        \
264               trailing-spaces:3: trailing whitespace.\n        \
265               +trailing \n        \
266               trailing-tab:3: trailing whitespace.\n        \
267               +trailing\t\n"],
268        );
269    }
270
271    #[test]
272    fn test_check_whitespace_nocr_topic() {
273        let check = CheckWhitespace::default();
274        let result = run_topic_check("test_check_whitespace_nocr_topic", NOCR_TOPIC, check);
275        test_result_errors(
276            result,
277            &["adds bad whitespace:\n\
278               \n        \
279               extra-newlines:2: new blank line at EOF.\n        \
280               mixed-tabs-spaces:3: space before tab in indent.\n        \
281               +   \tmixed indent\n        \
282               trailing-spaces:3: trailing whitespace.\n        \
283               +trailing \n        \
284               trailing-tab:3: trailing whitespace.\n        \
285               +trailing\t\n"],
286        );
287    }
288
289    #[test]
290    fn test_check_whitespace_all_ignored_topic() {
291        let check = CheckWhitespace::default();
292        run_topic_check_ok(
293            "test_check_whitespace_all_ignored_topic",
294            ALL_IGNORED_TOPIC,
295            check,
296        );
297    }
298
299    #[test]
300    fn test_check_whitespace_all_ignored_blanket_topic() {
301        let check = CheckWhitespace::default();
302        run_topic_check_ok(
303            "test_check_whitespace_all_ignored_blanket_topic",
304            ALL_IGNORED_BLANKET_TOPIC,
305            check,
306        );
307    }
308}