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
48pub 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 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: ®ex::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 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
247fn 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
273fn 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
311fn 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 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
386fn 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}