use crate::check::Verdict;
use crate::commit_style::{self, Style};
use crate::ui::{error_sign, highlight, valid_sign};
use crate::vocabulary::{self, COMMIT_TYPES};
pub struct Subject {
pub prefix: String,
pub scope: String,
pub breaking: String,
pub description: String,
}
pub fn strip_comments(msg: &str) -> String {
let mut out: Vec<&str> = Vec::new();
for line in msg.split('\n') {
if !line.starts_with('#') {
out.push(line);
}
}
out.join("\n")
}
pub fn split_leading_emoji(subject: &str) -> &str {
subject.trim_start_matches(|c: char| !c.is_ascii() || c == ' ' || c == '\t')
}
pub fn parse_subject(subject_line: &str) -> Option<Subject> {
let rest = split_leading_emoji(subject_line);
let (prefix, rest) = COMMIT_TYPES
.iter()
.map(|t| t.name)
.find(|t| rest.starts_with(t))
.map(|t| (t.to_string(), &rest[t.len()..]))?;
let (scope, breaking, description) = parse_tail(rest)?;
Some(Subject {
prefix,
scope,
breaking,
description,
})
}
fn parse_tail(rest: &str) -> Option<(String, String, String)> {
let (scope, rest) = if let Some(after) = rest.strip_prefix('(') {
let end = after.find(')')?;
let inner = &after[..end];
if inner.is_empty()
|| !inner
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return None;
}
(format!("({inner})"), &after[end + 1..])
} else {
(String::new(), rest)
};
let (breaking, rest) = match rest.strip_prefix('!') {
Some(r) => ("!".to_string(), r),
None => (String::new(), rest),
};
let description = rest.strip_prefix(':')?.trim_start_matches(' ').to_string();
Some((scope, breaking, description))
}
pub struct Undecorated<'a> {
pub recovered_type: Option<&'static str>,
pub text: &'a str,
}
pub fn undecorate(subject_line: &str) -> Undecorated<'_> {
let trimmed = subject_line.trim_start();
for t in COMMIT_TYPES {
if let Some(rest) = trimmed.strip_prefix(t.emoji) {
return Undecorated {
recovered_type: Some(t.name),
text: rest.trim_start(),
};
}
}
Undecorated {
recovered_type: None,
text: subject_line,
}
}
fn undecorate_tail<'a>(description: &'a str, emoji: &str) -> &'a str {
if emoji.is_empty() {
return description;
}
match description.trim_end().strip_suffix(emoji) {
Some(rest) => rest.trim_end(),
None => description,
}
}
fn recovered_subject(prefix: &'static str, text: &str) -> Subject {
let (scope, breaking, description) = parse_tail(text).unwrap_or_else(|| {
(String::new(), String::new(), text.to_string())
});
Subject {
prefix: prefix.to_string(),
scope,
breaking,
description,
}
}
pub fn wrap(text: &str, width: usize) -> String {
let mut out: Vec<String> = Vec::new();
for line in text.split('\n') {
if line.chars().count() <= width {
out.push(line.to_string());
continue;
}
let mut current = String::new();
for word in line.split(' ') {
if current.is_empty() {
current.push_str(word);
} else if current.chars().count() + 1 + word.chars().count() <= width {
current.push(' ');
current.push_str(word);
} else {
out.push(std::mem::take(&mut current));
current.push_str(word);
}
}
if !current.is_empty() {
out.push(current);
}
}
out.join("\n")
}
pub fn is_footer(line: &str) -> bool {
if line.is_empty() {
return true;
}
if let Some(rest) = line
.strip_prefix("BREAKING CHANGE:")
.or_else(|| line.strip_prefix("BREAKING-CHANGE:"))
{
return rest.starts_with(' ')
&& rest
.trim_start()
.starts_with(|c: char| c.is_alphanumeric() || c == '_');
}
if let Some(rest) = line.strip_prefix("Refs:").or_else(|| {
line.strip_prefix("Refs")
.filter(|rest| rest.starts_with(' ') || rest.starts_with('#'))
}) {
let r = rest.trim_start_matches(' ');
let r = r.strip_prefix('#').unwrap_or(r);
if r.starts_with(|c: char| c.is_ascii_digit()) {
return true;
}
}
is_hyphenated_key(line)
}
fn is_hyphenated_key(line: &str) -> bool {
let key: String = line
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
.collect();
if !key.starts_with(|c: char| c.is_alphanumeric() || c == '_')
|| !key.ends_with(|c: char| c.is_alphanumeric() || c == '_')
|| !key.contains('-')
{
return false;
}
let Some(rest) = line[key.len()..].strip_prefix(": ") else {
return false;
};
rest.starts_with(|c: char| c.is_alphanumeric() || c == '_')
}
pub fn group_footer(text: &str) -> String {
let trimmed = text.trim_end_matches('\n');
let lines: Vec<&str> = trimmed.split('\n').collect();
let mut footer_size = 0;
for line in lines[1..].iter().rev() {
if is_footer(line) {
footer_size += 1;
} else {
break;
}
}
let split_at = lines.len() - footer_size;
let body = &lines[..split_at];
let footer: Vec<&str> = lines[split_at..]
.iter()
.copied()
.filter(|l| !l.is_empty())
.collect();
let mut out: Vec<&str> = body.to_vec();
out.push("");
out.extend(footer);
format!("{}\n", out.join("\n"))
}
fn valid(msg: &str) {
println!(" {} {msg}", valid_sign().trim());
}
fn error(msg: &str) {
eprintln!(" {} {msg}", error_sign().trim());
}
fn orange(s: &str) -> String {
highlight(s)
}
pub fn run(args: &[std::ffi::OsString]) -> Verdict {
let Some(filename) = args.first().and_then(|a| a.to_str()) else {
println!("Usage:\n\n./commit-msg <filename>");
return Verdict::Block;
};
let Ok(raw) = std::fs::read_to_string(filename) else {
return Verdict::Block;
};
let style = Style::resolve();
let cleaned = strip_comments(&raw);
let mut parts = cleaned.splitn(2, '\n');
let subject_line = parts.next().unwrap_or("");
let body = parts.next().unwrap_or("").trim_start_matches('\n');
let undecorated = undecorate(subject_line);
let written = undecorated.text;
if written.is_empty() || written.chars().count() > style.subject_max {
error(&format!(
"Commit's first line should exist and be at most {} characters.",
orange(&style.subject_max.to_string())
));
return Verdict::Block;
}
valid(&format!(
"Summary size is at most {} characters",
orange(&style.subject_max.to_string())
));
let types: Vec<String> = COMMIT_TYPES.iter().map(|t| orange(t.name)).collect();
let subject = match parse_subject(written) {
Some(s) => s,
None => match undecorated.recovered_type {
Some(t) => recovered_subject(t, written),
None => {
error(&format!(
"Commits MUST be prefixed with a type, which consists of a noun:
{}
The prefix must be followed by the OPTIONAL scope, OPTIONAL !,
and REQUIRED terminal colon and space.
A scope MAY be provided after a type. A scope MUST consist of a noun describing
a section of the codebase surrounded by parenthesis, e.g., fix(parser)",
types.join(", ")
));
return Verdict::Block;
}
},
};
valid("A prefix is defined");
let description = undecorate_tail(&subject.description, vocabulary::emoji_for(&subject.prefix));
if description.is_empty() {
error(&format!(
"A description MUST immediately follow the {} and {} after the type/scope prefix.
The description is a short summary of the code changes, e.g., fix: array parsing issue when multiple spaces were contained in string.",
orange("colon"), orange("space")
));
return Verdict::Block;
}
valid("A description is present in the summary");
if description.chars().count() > style.description_max {
error(&format!(
"The description after the {} should be at most {} characters.",
orange("colon"),
orange(&style.description_max.to_string())
));
return Verdict::Block;
}
valid(&format!(
"Description size is at most {} characters",
orange(&style.description_max.to_string())
));
let formatted = format!(
"{}\n\n{}\n",
commit_style::render_subject(
style.gitmoji,
&subject.prefix,
&subject.scope,
&subject.breaking,
description,
),
wrap_body(&strip_comments(body), style.body_wrap)
);
if std::fs::write(filename, group_footer(&formatted)).is_err() {
return Verdict::Block;
}
Verdict::Proceed
}
fn wrap_body(body: &str, column: usize) -> String {
if column == 0 {
body.to_string()
} else {
wrap(body, column)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_conventional_shapes() {
let s = parse_subject("feat: add a thing").unwrap();
assert_eq!(
(s.prefix.as_str(), s.description.as_str()),
("feat", "add a thing")
);
let s = parse_subject("fix(parser): trim").unwrap();
assert_eq!(s.scope, "(parser)");
let s = parse_subject("fix(my-scope): trim").unwrap();
assert_eq!(s.scope, "(my-scope)");
let s = parse_subject("feat!: breaking").unwrap();
assert_eq!(s.breaking, "!");
}
#[test]
fn accepts_emoji_prefixes_including_multi_codepoint_ones() {
for subject in [
"✨ feat: x",
"⬆️ chore: x", "♻️ refactor: x", "🔧 chore: x",
"👨💻 feat: x", ] {
assert!(parse_subject(subject).is_some(), "failed: {subject}");
}
}
#[test]
fn rejects_what_is_not_a_conventional_subject() {
assert!(parse_subject("just a message").is_none());
assert!(parse_subject("feat add a thing").is_none()); assert!(parse_subject("feature: x").is_none()); assert!(parse_subject("fix(bad scope): x").is_none()); }
#[test]
fn description_may_be_empty_and_is_caught_by_the_caller() {
assert_eq!(parse_subject("feat:").unwrap().description, "");
}
#[test]
fn wraps_on_spaces_without_splitting_long_words() {
let wrapped = wrap("aaa bbb ccc ddd", 7);
assert_eq!(wrapped, "aaa bbb\nccc ddd");
let long = "x".repeat(20);
assert_eq!(wrap(&long, 7), long); }
#[test]
fn recognises_footers() {
assert!(is_footer("Co-Authored-By: someone"));
assert!(is_footer("BREAKING CHANGE: it broke"));
assert!(is_footer("Refs: #123"));
assert!(is_footer(""));
assert!(!is_footer("just prose"));
assert!(!is_footer("a sentence with - a dash"));
}
#[test]
fn a_bare_refs_needs_a_separator_not_just_a_leading_digit() {
assert!(is_footer("Refs #123"));
assert!(is_footer("Refs 123"));
assert!(
!is_footer("Refs42 was the original ticket."),
"prose starting with Refs+digit must not read as a footer"
);
}
#[test]
fn a_key_must_start_the_line_to_be_a_footer() {
assert!(is_footer("Co-Authored-By: someone"));
assert!(is_footer("Signed-off-by: someone"));
assert!(is_footer("Reviewed-by: a"));
assert!(!is_footer("fix: pre-commit: stop hanging"));
assert!(!is_footer("🐛 fix: pre-commit: stop hanging"));
assert!(!is_footer("see the pre-commit: docs above"));
assert!(!is_footer(" Co-Authored-By: indented is not a trailer"));
assert!(!is_footer("-foo: bar"));
assert!(!is_footer("A-: bar"));
assert!(is_footer("BREAKING CHANGE: it broke"));
assert!(is_footer("Refs: #123"));
}
#[test]
fn groups_the_trailing_footer_with_one_blank_line() {
let out = group_footer("subject\n\nbody text\n\nCo-Authored-By: x\n\n");
assert_eq!(out, "subject\n\nbody text\n\nCo-Authored-By: x\n");
}
const SHAPES: &[&str] = &[
"fix: pre-commit: stop hanging",
"fix: pre-commit: stop hanging\n\n\n",
"fix: pre-commit: stop hanging\n\nthe worker thread blocked on a tty\n",
"fix: pre-commit: stop hanging\n\nCo-Authored-By: a <a@x>\n",
"fix: pre-commit: stop hanging\n\nthe worker thread blocked\n\nCo-Authored-By: a <a@x>\n",
"fix: pre-commit: stop hanging\n\nthe worker thread blocked\n\nCo-Authored-By: a <a@x>\n\n\n",
"feat: x\n\nbody\n\nBREAKING CHANGE: it broke\n\nCo-Authored-By: a <a@x>\n",
"feat: add a thing\n\nbody\n\nCo-Authored-By: a <a@x>\n",
];
#[test]
fn group_footer_never_loses_a_line() {
for shape in SHAPES {
let out = group_footer(shape);
let mut before: Vec<&str> = shape
.trim_end_matches('\n')
.split('\n')
.filter(|l| !l.is_empty())
.collect();
let mut after: Vec<&str> = out
.trim_end_matches('\n')
.split('\n')
.filter(|l| !l.is_empty())
.collect();
before.sort_unstable();
after.sort_unstable();
assert_eq!(before, after, "lines changed for {shape:?} -> {out:?}");
assert_eq!(
out.split('\n').next(),
shape.split('\n').next(),
"the subject left line 0 for {shape:?} -> {out:?}"
);
}
}
#[test]
fn group_footer_is_idempotent() {
for shape in SHAPES {
let once = group_footer(shape);
let twice = group_footer(&once);
assert_eq!(once, twice, "not idempotent for {shape:?}");
}
}
#[test]
fn a_subject_and_its_trailers_stay_separated() {
let out = group_footer("fix: pre-commit: stop hanging\n\nCo-Authored-By: a <a@x>\n");
assert_eq!(
out, "fix: pre-commit: stop hanging\n\nCo-Authored-By: a <a@x>\n",
"got: {out:?}"
);
assert!(
!out.starts_with('\n'),
"the message must not begin with a blank line: {out:?}"
);
}
#[test]
fn strips_comment_lines() {
assert_eq!(strip_comments("keep\n# drop\nkeep2"), "keep\nkeep2");
}
use crate::commit_style::{render_subject, Gitmoji};
fn store(placement: Gitmoji, typed: &str) -> String {
let s = parse_subject(typed).expect("test subjects parse");
render_subject(
placement,
&s.prefix,
&s.scope,
&s.breaking,
undecorate_tail(&s.description, vocabulary::emoji_for(&s.prefix)),
)
}
fn restore(placement: Gitmoji, stored: &str) -> String {
let u = undecorate(stored);
let s = match parse_subject(u.text) {
Some(s) => s,
None => recovered_subject(u.recovered_type.expect("a type to recover"), u.text),
};
render_subject(
placement,
&s.prefix,
&s.scope,
&s.breaking,
undecorate_tail(&s.description, vocabulary::emoji_for(&s.prefix)),
)
}
#[test]
fn decorating_a_subject_is_idempotent() {
for typed in [
"feat: add a cart",
"fix(parser): trim",
"feat(api)!: drop v1",
"docs: explain the trust model",
] {
for placement in Gitmoji::ALL {
let once = store(placement, typed);
let twice = restore(placement, &once);
assert_eq!(
once,
twice,
"{} is not idempotent for {typed:?}",
placement.as_str()
);
assert_eq!(twice, restore(placement, &twice));
}
}
}
#[test]
fn each_placement_puts_the_emoji_where_it_says() {
assert_eq!(store(Gitmoji::None, "feat: add a cart"), "feat: add a cart");
assert_eq!(
store(Gitmoji::Prefix, "feat: add a cart"),
"✨ feat: add a cart"
);
assert_eq!(
store(Gitmoji::Suffix, "feat: add a cart"),
"feat: add a cart ✨"
);
assert_eq!(
store(Gitmoji::Replace, "feat: add a cart"),
"✨ add a cart"
);
}
#[test]
fn suffix_leaves_the_type_where_tooling_looks_for_it() {
assert!(store(Gitmoji::Suffix, "fix: a bug").starts_with("fix:"));
assert!(!store(Gitmoji::Replace, "fix: a bug").starts_with("fix:"));
}
#[test]
fn replace_keeps_a_scope_and_a_breaking_marker() {
let stored = store(Gitmoji::Replace, "feat(api)!: drop v1");
assert_eq!(stored, "✨ (api)!: drop v1");
let u = undecorate(&stored);
let s = recovered_subject(u.recovered_type.unwrap(), u.text);
assert_eq!(
(s.prefix.as_str(), s.scope.as_str(), s.breaking.as_str()),
("feat", "(api)", "!")
);
assert_eq!(s.description, "drop v1");
}
#[test]
fn only_our_own_emoji_recovers_a_type() {
assert_eq!(undecorate("✨ add a cart").recovered_type, Some("feat"));
assert_eq!(undecorate("🐛 fix: x").recovered_type, Some("fix"));
assert_eq!(undecorate("🚀 ship it").recovered_type, None);
assert_eq!(undecorate("feat: x").recovered_type, None);
assert_eq!(undecorate("✨ add a cart").text, "add a cart");
assert_eq!(undecorate("🚀 ship it").text, "🚀 ship it");
}
#[test]
fn a_trailing_emoji_is_only_stripped_when_we_wrote_it() {
assert_eq!(undecorate_tail("add a cart ✨", "✨"), "add a cart");
assert_eq!(undecorate_tail("ship it 🚀", "✨"), "ship it 🚀");
assert_eq!(undecorate_tail("plain", "✨"), "plain");
assert_eq!(undecorate_tail("nothing to strip", ""), "nothing to strip");
}
#[test]
fn decoration_never_counts_against_the_limit() {
let typed = format!("feat: {}", "x".repeat(60));
assert_eq!(typed.chars().count(), 66);
for placement in Gitmoji::ALL {
let stored = store(placement, &typed);
let remeasured = undecorate(&stored);
let s = match parse_subject(remeasured.text) {
Some(s) => s,
None => recovered_subject(remeasured.recovered_type.unwrap(), remeasured.text),
};
let description = undecorate_tail(&s.description, vocabulary::emoji_for(&s.prefix));
assert_eq!(
description.chars().count(),
60,
"{} changed the measured description: {stored:?}",
placement.as_str()
);
}
}
#[test]
fn a_zero_wrap_column_leaves_the_body_alone() {
let long = "x ".repeat(100);
assert_eq!(wrap_body(&long, 0), long);
assert!(wrap_body(&long, 72).contains('\n'));
}
}