pub mod constants;
mod display;
use crate::config::{self, count_active_rules, DescriptionCase};
use crate::errors;
use crate::errors::{pluralize, SumiError};
use crate::parser::{handle_parsing, ParsedCommit};
use config::Config;
use constants::gitmoji::{STRING_EMOJIS, UNICODE_EMOJIS};
use constants::non_imperative_verbs::NON_IMPERATIVE_VERBS;
use display::display_parsed_commit;
use log::{error, info};
use regex::Regex;
use std::sync::LazyLock;
pub fn run_lint_on_commit_range(
commits: Vec<(String, String)>,
config: &Config,
) -> Result<Vec<ParsedCommit>, SumiError> {
let total_commits = commits.len();
let mut parsed_commits = Vec::new();
let mut errors = Vec::new();
for (sha, message) in &commits {
let short_sha = &sha[..7.min(sha.len())];
let prefix = format!("[{short_sha}] ");
let result = if config.split_lines {
run_lint_on_each_line(message, config, Some(&prefix))
} else {
run_lint(message, config, Some(&prefix)).map(|pc| vec![pc])
};
match result {
Ok(pcs) => parsed_commits.extend(pcs),
Err(err) => {
error!("{prefix}{err}");
errors.push(err);
}
}
}
if errors.is_empty() {
Ok(parsed_commits)
} else {
let commits_with_errors = errors.len();
let commit_plural = pluralize(total_commits, "commit", "commits");
Err(SumiError::CommitRangeErrors {
commits_with_errors,
total_commits,
commit_or_commits: commit_plural.to_string(),
})
}
}
pub fn run_lint_on_each_line(
commit_message: &str,
config: &Config,
log_prefix: Option<&str>,
) -> Result<Vec<ParsedCommit>, SumiError> {
let non_empty_lines = commit_message.lines().filter(|line| !line.is_empty());
let prefix = log_prefix.unwrap_or("");
let mut parsed_commits = Vec::new();
let mut errors = Vec::new();
for line in non_empty_lines.clone() {
match run_lint(line, config, log_prefix) {
Ok(parsed_commit) => parsed_commits.push(parsed_commit),
Err(error) => {
error!("{prefix}{error}");
errors.push(error);
}
}
}
if errors.is_empty() {
Ok(parsed_commits)
} else {
let lines_with_errors = errors.len();
let total_lines = non_empty_lines.count();
let line_plural_suffix = pluralize(total_lines, "line", "lines");
Err(SumiError::SplitLinesErrors {
lines_with_errors,
total_lines,
line_or_lines: line_plural_suffix.to_string(),
})
}
}
pub fn run_lint(
raw_commit: &str,
config: &Config,
log_prefix: Option<&str>,
) -> Result<ParsedCommit, SumiError> {
let prefix = log_prefix.unwrap_or("");
let commit = preprocess_commit_message(raw_commit);
info!("{prefix}💬 Input: \"{commit}\"");
let mut non_fatal_errors: Vec<SumiError> = Vec::new();
let parsed_commit = handle_parsing(&commit, config, &mut non_fatal_errors)?;
let errors = validate_commit(&commit, &parsed_commit, config);
non_fatal_errors.extend(errors);
if non_fatal_errors.is_empty() {
handle_success(&parsed_commit, config, prefix)?;
return Ok(parsed_commit);
}
handle_failure(&non_fatal_errors, prefix)
}
fn preprocess_commit_message(commit: &str) -> String {
commit
.lines()
.filter(|line| !line.trim_start().starts_with('#'))
.collect::<Vec<&str>>()
.join("\n")
}
fn validate_commit(
commit: &String,
parsed_commit: &ParsedCommit,
config: &Config,
) -> Vec<SumiError> {
let mut errors = validate_whitespace_and_length(commit.to_string(), config);
if let Some(validation_errors) = validate_parsed_commit(parsed_commit, config) {
errors.extend(validation_errors);
}
errors
}
fn validate_whitespace_and_length(commit: String, config: &Config) -> Vec<SumiError> {
let mut errors = Vec::new();
let mut lines = commit.lines();
let header_line = lines.next().unwrap_or("");
let validation_header = if should_strip_header_pattern(config) {
strip_header_pattern_from_line(header_line, &config.header_pattern)
} else {
header_line.to_string()
};
if let Err(err) = validate_whitespace(&validation_header, config) {
errors.push(err);
}
if let Err(actual_length) = validate_line_length(header_line, config.max_header_length) {
errors.push(SumiError::LineTooLong {
line_number: 1,
line_length: actual_length,
max_length: config.max_header_length,
});
}
errors.extend(validate_body_lines(lines, config));
errors
}
fn should_strip_header_pattern(config: &Config) -> bool {
config.strip_header_pattern && !config.header_pattern.is_empty()
}
fn strip_header_pattern_from_line(line: &str, pattern: &str) -> String {
if let Ok(regex) = Regex::new(pattern) {
regex.replace(line, "").to_string()
} else {
line.to_string()
}
}
fn validate_line_length(line: &str, max_length: usize) -> Result<(), usize> {
if max_length == 0 {
return Ok(());
}
let actual_length = line.chars().count();
if actual_length > max_length {
return Err(actual_length);
}
Ok(())
}
fn validate_body_lines(lines: std::str::Lines, config: &Config) -> Vec<SumiError> {
let mut errors = Vec::new();
for (line_number, line) in lines.enumerate() {
if line_number == 0 && !line.is_empty() {
errors.push(SumiError::SeparateHeaderFromBody);
continue;
}
if let Err(err) = validate_whitespace(line, config) {
errors.push(err);
}
if let Err(actual_length) = validate_line_length(line, config.max_body_length) {
errors.push(SumiError::LineTooLong {
line_number: line_number + 2,
line_length: actual_length,
max_length: config.max_body_length,
});
}
}
errors
}
fn validate_whitespace(line: &str, config: &Config) -> Result<(), SumiError> {
if !config.whitespace {
return Ok(());
}
let mut issues = Vec::new();
let highlighted_line = WHITESPACE_REGEX.replace_all(line, |caps: ®ex::Captures| {
let len = caps[0].len();
let start = caps.get(0).unwrap().start();
let end = caps.get(0).unwrap().end();
if start == 0 {
issues.push("Leading space".to_owned());
} else if end == line.len() {
issues.push("Trailing space".to_owned());
} else {
issues.push(format!("{len} adjacent spaces"));
}
"🟥️".repeat(len)
});
if !issues.is_empty() {
let issue_count = issues.len();
let issues_list = issues
.iter()
.map(|issue| format!(" - {issue}: \"{highlighted_line}\""))
.collect::<Vec<String>>()
.join("\n");
return Err(SumiError::GeneralError {
details: format!(
"Whitespace {} detected:\n{}",
pluralize(issue_count, "issue", "issues"),
issues_list
),
});
}
Ok(())
}
static WHITESPACE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(^\s+|\s+$|\s{2,})").unwrap()
});
fn validate_parsed_commit(parsed_commit: &ParsedCommit, config: &Config) -> Option<Vec<SumiError>> {
let mut errors: Vec<SumiError> = Vec::new();
let validation_description = if should_strip_header_pattern(config) {
strip_header_pattern_from_line(&parsed_commit.description, &config.header_pattern)
} else {
parsed_commit.description.clone()
};
if config.gitmoji {
if let Err(err) = validate_gitmoji(&parsed_commit.gitmoji) {
errors.push(err);
}
}
if let Some(err) = validate_description_case_for_string(&validation_description, config) {
errors.push(err);
}
if config.imperative {
if let Err(err) = is_imperative(&validation_description) {
errors.push(err);
}
}
if config.no_period {
if let Err(err) = validate_no_period(&parsed_commit.header) {
errors.push(err);
}
}
if config.conventional {
if let Err(err) = validate_commit_type_and_scope(parsed_commit, config) {
errors.push(err);
}
}
if !config.header_pattern.is_empty() {
if let Err(err) = validate_header_pattern(&parsed_commit.header, &config.header_pattern) {
errors.push(err);
}
}
Some(errors)
}
fn validate_gitmoji(emojis: &Option<Vec<String>>) -> Result<(), SumiError> {
match emojis {
Some(gitmojis) if gitmojis.len() != 1 => Err(SumiError::IncorrectEmojiCount {
found: gitmojis.len(),
}),
Some(gitmojis) => {
let gitmoji = &gitmojis[0];
let normalised_gitmoji = normalise_emoji(gitmoji);
if !UNICODE_EMOJIS.contains(normalised_gitmoji.as_str())
&& !STRING_EMOJIS.contains(&gitmoji.as_str())
{
Err(SumiError::InvalidEmoji {
emoji: gitmoji.clone(),
})
} else {
Ok(())
}
}
None => Err(SumiError::IncorrectEmojiCount { found: 0 }),
}
}
fn normalise_emoji(emoji: &str) -> String {
emoji.replace('\u{fe0f}', "")
}
fn validate_description_case_for_string(description: &str, config: &Config) -> Option<SumiError> {
match config.description_case {
DescriptionCase::Lower => validate_lowercase(description).err(),
DescriptionCase::Upper => validate_upper_case(description).err(),
DescriptionCase::Any => None,
}
}
fn validate_lowercase(description: &str) -> Result<(), SumiError> {
let first_char = description.chars().next();
match first_char {
Some(c) if c.is_uppercase() => {
let corrected_description = c.to_lowercase().to_string() + &description[1..];
Err(SumiError::DescriptionNotLowercase {
lowercase_header: corrected_description,
})
}
Some(_) => Ok(()),
None => Err(SumiError::EmptyCommitHeader),
}
}
fn validate_upper_case(description: &str) -> Result<(), SumiError> {
let first_char = description.chars().next().unwrap();
if is_lowercase_letter(first_char) {
let capitalized_title = capitalize_title(first_char, &description[1..]);
return Err(SumiError::DescriptionNotTitleCase {
capitalized_description: capitalized_title,
});
}
Ok(())
}
fn is_imperative(description: &str) -> Result<(), SumiError> {
let first_word = description
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
let first_word_lower = first_word.to_lowercase();
if NON_IMPERATIVE_VERBS.contains(first_word_lower.as_str()) {
return Err(SumiError::NonImperativeVerb { verb: first_word });
}
Ok(())
}
fn is_lowercase_letter(character: char) -> bool {
character.is_alphabetic() && !character.is_uppercase()
}
fn capitalize_title(first_char: char, rest: &str) -> String {
let capitalized_first_char = first_char.to_uppercase().collect::<String>();
format!("{capitalized_first_char}{rest}")
}
fn validate_no_period(header: &str) -> Result<(), SumiError> {
if header.ends_with('.') {
return Err(SumiError::HeaderEndsWithPeriod);
}
Ok(())
}
fn validate_commit_type_and_scope(
parsed_commit: &ParsedCommit,
config: &Config,
) -> Result<(), SumiError> {
let types_allowed = split_and_trim_list(&config.types_allowed);
let scopes_allowed = split_and_trim_list(&config.scopes_allowed);
if types_allowed.is_empty() && scopes_allowed.is_empty() {
return Ok(());
}
if let Some(commit_type) = &parsed_commit.commit_type {
if !types_allowed.is_empty() && !types_allowed.contains(commit_type) {
return Err(SumiError::InvalidCommitType {
type_found: commit_type.clone(),
allowed_types: types_allowed.join(", "),
});
}
}
if let Some(scope) = &parsed_commit.scope {
if !scopes_allowed.is_empty() && !scopes_allowed.contains(scope) {
return Err(SumiError::InvalidCommitScope {
scope_found: scope.clone(),
allowed_scopes: scopes_allowed.join(", "),
});
}
}
Ok(())
}
fn validate_header_pattern(header: &str, pattern: &str) -> Result<(), SumiError> {
let re = Regex::new(pattern).map_err(|_| SumiError::InvalidRegexPattern {
pattern: pattern.to_string(),
})?;
if !re.is_match(header) {
return Err(SumiError::HeaderPatternMismatch {
pattern: pattern.to_string(),
});
}
Ok(())
}
fn split_and_trim_list(list: &[String]) -> Vec<String> {
list.iter()
.flat_map(|s| s.split(',').map(|item| item.trim().to_string()))
.filter(|x| !x.is_empty())
.collect()
}
fn handle_success(
parsed_commit: &ParsedCommit,
config: &Config,
log_prefix: &str,
) -> Result<(), SumiError> {
if config.display {
display_parsed_commit(parsed_commit, &config.format)?;
}
if !config.quiet {
let active_rule_count = count_active_rules(config);
if !config.quiet && active_rule_count > 0 {
info!(
"{log_prefix}✅ All {} {} passed.",
active_rule_count,
pluralize(active_rule_count, "check", "checks")
);
}
}
Ok(())
}
fn handle_failure(errors: &[SumiError], log_prefix: &str) -> Result<ParsedCommit, SumiError> {
display_errors(errors, log_prefix);
Err(SumiError::GeneralError {
details: format!(
"Found {} linting {}",
errors.len(),
pluralize(errors.len(), "error", "errors")
),
})
}
fn display_errors(errors: &[SumiError], log_prefix: &str) {
for err in errors.iter() {
eprintln!("{log_prefix}️❗ {err}");
}
}