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