git_sumi/
lint.rs

1pub mod constants;
2mod display;
3
4use crate::config::{self, count_active_rules, DescriptionCase};
5use crate::errors;
6use crate::errors::{pluralize, SumiError};
7use crate::parser::{handle_parsing, ParsedCommit};
8
9use config::Config;
10use constants::gitmoji::{STRING_EMOJIS, UNICODE_EMOJIS};
11use constants::non_imperative_verbs::NON_IMPERATIVE_VERBS;
12use display::display_parsed_commit;
13use log::{error, info};
14use regex::Regex;
15use std::sync::LazyLock;
16
17pub fn run_lint_on_each_line(
18    commit_message: &str,
19    config: &Config,
20) -> Result<Vec<ParsedCommit>, SumiError> {
21    let non_empty_lines = commit_message.lines().filter(|line| !line.is_empty());
22    let mut parsed_commits = Vec::new();
23    let mut errors = Vec::new();
24
25    for line in non_empty_lines.clone() {
26        match run_lint(line, config) {
27            Ok(parsed_commit) => parsed_commits.push(parsed_commit),
28            Err(error) => {
29                error!("{error}");
30                errors.push(error);
31            }
32        }
33    }
34
35    if errors.is_empty() {
36        Ok(parsed_commits)
37    } else {
38        let lines_with_errors = errors.len();
39        let line_plural_suffix = pluralize(lines_with_errors, "line", "lines");
40        Err(SumiError::SplitLinesErrors {
41            lines_with_errors,
42            total_lines: non_empty_lines.count(),
43            line_or_lines: line_plural_suffix.to_string(),
44        })
45    }
46}
47
48/// Lints and parses the given commit message.
49/// Returns a `ParsedCommit` struct if the commit is valid, or an error message if it is not.
50pub fn run_lint(raw_commit: &str, config: &Config) -> Result<ParsedCommit, SumiError> {
51    let commit = preprocess_commit_message(raw_commit);
52    info!("💬 Input: \"{commit}\"");
53    let mut non_fatal_errors: Vec<SumiError> = Vec::new();
54    let parsed_commit = handle_parsing(&commit, config, &mut non_fatal_errors)?;
55    let errors = validate_commit(&commit, &parsed_commit, config);
56    non_fatal_errors.extend(errors);
57    if non_fatal_errors.is_empty() {
58        handle_success(&parsed_commit, config)?;
59        return Ok(parsed_commit);
60    }
61    handle_failure(&non_fatal_errors)
62}
63
64fn preprocess_commit_message(commit: &str) -> String {
65    // Remove comments.
66    commit
67        .lines()
68        .filter(|line| !line.trim_start().starts_with('#'))
69        .collect::<Vec<&str>>()
70        .join("\n")
71}
72
73fn validate_commit(
74    commit: &String,
75    parsed_commit: &ParsedCommit,
76    config: &Config,
77) -> Vec<SumiError> {
78    let mut errors = validate_whitespace_and_length(commit.to_string(), config);
79    if let Some(validation_errors) = validate_parsed_commit(parsed_commit, config) {
80        errors.extend(validation_errors);
81    }
82    errors
83}
84
85fn validate_whitespace_and_length(commit: String, config: &Config) -> Vec<SumiError> {
86    let mut errors = Vec::new();
87    let mut lines = commit.lines();
88    let header_line = lines.next().unwrap_or("");
89    let validation_header = if should_strip_header_pattern(config) {
90        strip_header_pattern_from_line(header_line, &config.header_pattern)
91    } else {
92        header_line.to_string()
93    };
94    if let Err(err) = validate_whitespace(&validation_header, config) {
95        errors.push(err);
96    }
97    if let Err(actual_length) = validate_line_length(header_line, config.max_header_length) {
98        errors.push(SumiError::LineTooLong {
99            line_number: 1,
100            line_length: actual_length,
101            max_length: config.max_header_length,
102        });
103    }
104    errors.extend(validate_body_lines(lines, config));
105    errors
106}
107
108fn should_strip_header_pattern(config: &Config) -> bool {
109    config.strip_header_pattern && !config.header_pattern.is_empty()
110}
111
112fn strip_header_pattern_from_line(line: &str, pattern: &str) -> String {
113    if let Ok(regex) = Regex::new(pattern) {
114        regex.replace(line, "").to_string()
115    } else {
116        line.to_string()
117    }
118}
119
120fn validate_line_length(line: &str, max_length: usize) -> Result<(), usize> {
121    if max_length == 0 {
122        return Ok(());
123    }
124    let actual_length = line.chars().count();
125    if actual_length > max_length {
126        return Err(actual_length);
127    }
128    Ok(())
129}
130
131fn validate_body_lines(lines: std::str::Lines, config: &Config) -> Vec<SumiError> {
132    let mut errors = Vec::new();
133    for (line_number, line) in lines.enumerate() {
134        if line_number == 0 && !line.is_empty() {
135            errors.push(SumiError::SeparateHeaderFromBody);
136            continue;
137        }
138        if let Err(err) = validate_whitespace(line, config) {
139            errors.push(err);
140        }
141        if let Err(actual_length) = validate_line_length(line, config.max_body_length) {
142            errors.push(SumiError::LineTooLong {
143                line_number: line_number + 2,
144                line_length: actual_length,
145                max_length: config.max_body_length,
146            });
147        }
148    }
149    errors
150}
151
152fn validate_whitespace(line: &str, config: &Config) -> Result<(), SumiError> {
153    if !config.whitespace {
154        return Ok(());
155    }
156
157    let mut issues = Vec::new();
158    let highlighted_line = WHITESPACE_REGEX.replace_all(line, |caps: &regex::Captures| {
159        let len = caps[0].len();
160        let start = caps.get(0).unwrap().start();
161        let end = caps.get(0).unwrap().end();
162
163        if start == 0 {
164            issues.push("Leading space".to_owned());
165        } else if end == line.len() {
166            issues.push("Trailing space".to_owned());
167        } else {
168            issues.push(format!("{len} adjacent spaces"));
169        }
170
171        "🟥️".repeat(len)
172    });
173
174    if !issues.is_empty() {
175        let issue_count = issues.len();
176        let issues_list = issues
177            .iter()
178            .map(|issue| format!("  - {issue}: \"{highlighted_line}\""))
179            .collect::<Vec<String>>()
180            .join("\n");
181
182        return Err(SumiError::GeneralError {
183            details: format!(
184                "Whitespace {} detected:\n{}",
185                pluralize(issue_count, "issue", "issues"),
186                issues_list
187            ),
188        });
189    }
190
191    Ok(())
192}
193
194static WHITESPACE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
195    // This regex has three capturing groups:
196    // - ^\s+ captures leading spaces.
197    // - \s+$ captures trailing spaces.
198    // - \s{2,} captures adjacent spaces.
199    Regex::new(r"(^\s+|\s+$|\s{2,})").unwrap()
200});
201
202fn validate_parsed_commit(parsed_commit: &ParsedCommit, config: &Config) -> Option<Vec<SumiError>> {
203    let mut errors: Vec<SumiError> = Vec::new();
204    let validation_description = if should_strip_header_pattern(config) {
205        strip_header_pattern_from_line(&parsed_commit.description, &config.header_pattern)
206    } else {
207        parsed_commit.description.clone()
208    };
209
210    if config.gitmoji {
211        if let Err(err) = validate_gitmoji(&parsed_commit.gitmoji) {
212            errors.push(err);
213        }
214    }
215
216    if let Some(err) = validate_description_case_for_string(&validation_description, config) {
217        errors.push(err);
218    }
219
220    if config.imperative {
221        if let Err(err) = is_imperative(&validation_description) {
222            errors.push(err);
223        }
224    }
225
226    if config.no_period {
227        if let Err(err) = validate_no_period(&parsed_commit.header) {
228            errors.push(err);
229        }
230    }
231
232    if config.conventional {
233        if let Err(err) = validate_commit_type_and_scope(parsed_commit, config) {
234            errors.push(err);
235        }
236    }
237
238    if !config.header_pattern.is_empty() {
239        if let Err(err) = validate_header_pattern(&parsed_commit.header, &config.header_pattern) {
240            errors.push(err);
241        }
242    }
243
244    Some(errors)
245}
246
247/// Validates that the commit title contains exactly one gitmoji.
248/// Returns the gitmoji if it is valid, or an error message if it is not.
249/// Validates that the commit title contains exactly one gitmoji.
250/// Returns the normalised gitmoji if it is valid, or an error message if it is not.
251fn validate_gitmoji(emojis: &Option<Vec<String>>) -> Result<(), SumiError> {
252    match emojis {
253        Some(gitmojis) if gitmojis.len() != 1 => Err(SumiError::IncorrectEmojiCount {
254            found: gitmojis.len(),
255        }),
256        Some(gitmojis) => {
257            let gitmoji = &gitmojis[0];
258            let normalised_gitmoji = normalise_emoji(gitmoji);
259            if !UNICODE_EMOJIS.contains(normalised_gitmoji.as_str())
260                && !STRING_EMOJIS.contains(&gitmoji.as_str())
261            {
262                Err(SumiError::InvalidEmoji {
263                    emoji: gitmoji.clone(),
264                })
265            } else {
266                Ok(())
267            }
268        }
269        None => Err(SumiError::IncorrectEmojiCount { found: 0 }),
270    }
271}
272
273// Function to normalise emojis, removing variation selectors.
274fn normalise_emoji(emoji: &str) -> String {
275    emoji.replace('\u{fe0f}', "")
276}
277
278fn validate_description_case_for_string(description: &str, config: &Config) -> Option<SumiError> {
279    match config.description_case {
280        DescriptionCase::Lower => validate_lowercase(description).err(),
281        DescriptionCase::Upper => validate_upper_case(description).err(),
282        DescriptionCase::Any => None,
283    }
284}
285
286fn validate_lowercase(description: &str) -> Result<(), SumiError> {
287    let first_char = description.chars().next();
288    match first_char {
289        Some(c) if c.is_uppercase() => {
290            let corrected_description = c.to_lowercase().to_string() + &description[1..];
291            Err(SumiError::DescriptionNotLowercase {
292                lowercase_header: corrected_description,
293            })
294        }
295        Some(_) => Ok(()),
296        None => Err(SumiError::EmptyCommitHeader),
297    }
298}
299
300fn validate_upper_case(description: &str) -> Result<(), SumiError> {
301    let first_char = description.chars().next().unwrap();
302    if is_lowercase_letter(first_char) {
303        let capitalized_title = capitalize_title(first_char, &description[1..]);
304        return Err(SumiError::DescriptionNotTitleCase {
305            capitalized_description: capitalized_title,
306        });
307    }
308    Ok(())
309}
310
311// This is a best-effort heuristic, and will not catch all non-imperative messages.
312fn is_imperative(description: &str) -> Result<(), SumiError> {
313    let first_word = description
314        .split_whitespace()
315        .next()
316        .unwrap_or("")
317        .to_string();
318    let first_word_lower = first_word.to_lowercase();
319    if NON_IMPERATIVE_VERBS.contains(first_word_lower.as_str()) {
320        return Err(SumiError::NonImperativeVerb { verb: first_word });
321    }
322    Ok(())
323}
324
325fn is_lowercase_letter(character: char) -> bool {
326    character.is_alphabetic() && !character.is_uppercase()
327}
328
329fn capitalize_title(first_char: char, rest: &str) -> String {
330    let capitalized_first_char = first_char.to_uppercase().collect::<String>();
331    format!("{capitalized_first_char}{rest}")
332}
333
334fn validate_no_period(header: &str) -> Result<(), SumiError> {
335    if header.ends_with('.') {
336        return Err(SumiError::HeaderEndsWithPeriod);
337    }
338    Ok(())
339}
340
341fn validate_commit_type_and_scope(
342    parsed_commit: &ParsedCommit,
343    config: &Config,
344) -> Result<(), SumiError> {
345    let types_allowed = split_and_trim_list(&config.types_allowed);
346    let scopes_allowed = split_and_trim_list(&config.scopes_allowed);
347
348    // Empty lists mean all types/scopes are allowed.
349    if types_allowed.is_empty() && scopes_allowed.is_empty() {
350        return Ok(());
351    }
352
353    if let Some(commit_type) = &parsed_commit.commit_type {
354        if !types_allowed.is_empty() && !types_allowed.contains(commit_type) {
355            return Err(SumiError::InvalidCommitType {
356                type_found: commit_type.clone(),
357                allowed_types: types_allowed.join(", "),
358            });
359        }
360    }
361
362    if let Some(scope) = &parsed_commit.scope {
363        if !scopes_allowed.is_empty() && !scopes_allowed.contains(scope) {
364            return Err(SumiError::InvalidCommitScope {
365                scope_found: scope.clone(),
366                allowed_scopes: scopes_allowed.join(", "),
367            });
368        }
369    }
370
371    Ok(())
372}
373
374fn validate_header_pattern(header: &str, pattern: &str) -> Result<(), SumiError> {
375    let re = Regex::new(pattern).map_err(|_| SumiError::InvalidRegexPattern {
376        pattern: pattern.to_string(),
377    })?;
378    if !re.is_match(header) {
379        return Err(SumiError::HeaderPatternMismatch {
380            pattern: pattern.to_string(),
381        });
382    }
383    Ok(())
384}
385
386// Helper function to process allowed types and scopes.
387fn split_and_trim_list(list: &[String]) -> Vec<String> {
388    list.iter()
389        .flat_map(|s| s.split(',').map(|item| item.trim().to_string()))
390        .filter(|x| !x.is_empty())
391        .collect()
392}
393
394fn handle_success(parsed_commit: &ParsedCommit, config: &Config) -> Result<(), SumiError> {
395    if config.display {
396        display_parsed_commit(parsed_commit, &config.format)?;
397    }
398    if !config.quiet {
399        let active_rule_count = count_active_rules(config);
400        if !config.quiet && active_rule_count > 0 {
401            info!(
402                "✅ All {} {} passed.",
403                active_rule_count,
404                pluralize(active_rule_count, "check", "checks")
405            );
406        }
407    }
408    Ok(())
409}
410
411fn handle_failure(errors: &[SumiError]) -> Result<ParsedCommit, SumiError> {
412    display_errors(errors);
413    Err(SumiError::GeneralError {
414        details: format!(
415            "Found {} linting {}",
416            errors.len(),
417            pluralize(errors.len(), "error", "errors")
418        ),
419    })
420}
421
422fn display_errors(errors: &[SumiError]) {
423    for err in errors.iter() {
424        eprintln!("️❗ {err}");
425    }
426}