1use crate::check::Verdict;
26use crate::commit_style::{self, Style};
27use crate::ui::{error_sign, highlight, valid_sign};
28
29use crate::vocabulary::{self, COMMIT_TYPES};
30
31pub struct Subject {
32 pub prefix: String,
33 pub scope: String,
34 pub breaking: String,
35 pub description: String,
36}
37
38pub fn strip_comments(msg: &str) -> String {
40 let mut out: Vec<&str> = Vec::new();
41 for line in msg.split('\n') {
42 if !line.starts_with('#') {
43 out.push(line);
44 }
45 }
46 out.join("\n")
47}
48
49pub fn split_leading_emoji(subject: &str) -> &str {
62 subject.trim_start_matches(|c: char| !c.is_ascii() || c == ' ' || c == '\t')
63}
64
65pub fn parse_subject(subject_line: &str) -> Option<Subject> {
71 let rest = split_leading_emoji(subject_line);
72 let (prefix, rest) = COMMIT_TYPES
73 .iter()
74 .map(|t| t.name)
75 .find(|t| rest.starts_with(t))
76 .map(|t| (t.to_string(), &rest[t.len()..]))?;
77 let (scope, breaking, description) = parse_tail(rest)?;
78 Some(Subject {
79 prefix,
80 scope,
81 breaking,
82 description,
83 })
84}
85
86fn parse_tail(rest: &str) -> Option<(String, String, String)> {
94 let (scope, rest) = if let Some(after) = rest.strip_prefix('(') {
95 let end = after.find(')')?;
96 let inner = &after[..end];
97 if inner.is_empty()
98 || !inner
99 .chars()
100 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
101 {
102 return None;
103 }
104 (format!("({inner})"), &after[end + 1..])
105 } else {
106 (String::new(), rest)
107 };
108
109 let (breaking, rest) = match rest.strip_prefix('!') {
110 Some(r) => ("!".to_string(), r),
111 None => (String::new(), rest),
112 };
113
114 let description = rest.strip_prefix(':')?.trim_start_matches(' ').to_string();
115 Some((scope, breaking, description))
116}
117
118pub struct Undecorated<'a> {
120 pub recovered_type: Option<&'static str>,
126 pub text: &'a str,
128}
129
130pub fn undecorate(subject_line: &str) -> Undecorated<'_> {
138 let trimmed = subject_line.trim_start();
139 for t in COMMIT_TYPES {
140 if let Some(rest) = trimmed.strip_prefix(t.emoji) {
141 return Undecorated {
142 recovered_type: Some(t.name),
143 text: rest.trim_start(),
144 };
145 }
146 }
147 Undecorated {
148 recovered_type: None,
149 text: subject_line,
150 }
151}
152
153fn undecorate_tail<'a>(description: &'a str, emoji: &str) -> &'a str {
159 if emoji.is_empty() {
160 return description;
161 }
162 match description.trim_end().strip_suffix(emoji) {
163 Some(rest) => rest.trim_end(),
164 None => description,
165 }
166}
167
168fn recovered_subject(prefix: &'static str, text: &str) -> Subject {
171 let (scope, breaking, description) = parse_tail(text).unwrap_or_else(|| {
172 (String::new(), String::new(), text.to_string())
175 });
176 Subject {
177 prefix: prefix.to_string(),
178 scope,
179 breaking,
180 description,
181 }
182}
183
184pub fn wrap(text: &str, width: usize) -> String {
188 let mut out: Vec<String> = Vec::new();
189 for line in text.split('\n') {
190 if line.chars().count() <= width {
191 out.push(line.to_string());
192 continue;
193 }
194 let mut current = String::new();
195 for word in line.split(' ') {
196 if current.is_empty() {
197 current.push_str(word);
198 } else if current.chars().count() + 1 + word.chars().count() <= width {
199 current.push(' ');
200 current.push_str(word);
201 } else {
202 out.push(std::mem::take(&mut current));
203 current.push_str(word);
204 }
205 }
206 if !current.is_empty() {
207 out.push(current);
208 }
209 }
210 out.join("\n")
211}
212
213pub fn is_footer(line: &str) -> bool {
216 if line.is_empty() {
217 return true;
218 }
219 if let Some(rest) = line
220 .strip_prefix("BREAKING CHANGE:")
221 .or_else(|| line.strip_prefix("BREAKING-CHANGE:"))
222 {
223 return rest.starts_with(' ')
224 && rest
225 .trim_start()
226 .starts_with(|c: char| c.is_alphanumeric() || c == '_');
227 }
228 if let Some(rest) = line.strip_prefix("Refs:").or_else(|| {
233 line.strip_prefix("Refs")
234 .filter(|rest| rest.starts_with(' ') || rest.starts_with('#'))
235 }) {
236 let r = rest.trim_start_matches(' ');
237 let r = r.strip_prefix('#').unwrap_or(r);
238 if r.starts_with(|c: char| c.is_ascii_digit()) {
239 return true;
240 }
241 }
242 is_hyphenated_key(line)
243}
244
245fn is_hyphenated_key(line: &str) -> bool {
268 let key: String = line
269 .chars()
270 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
271 .collect();
272 if !key.starts_with(|c: char| c.is_alphanumeric() || c == '_')
275 || !key.ends_with(|c: char| c.is_alphanumeric() || c == '_')
276 || !key.contains('-')
277 {
278 return false;
279 }
280 let Some(rest) = line[key.len()..].strip_prefix(": ") else {
281 return false;
282 };
283 rest.starts_with(|c: char| c.is_alphanumeric() || c == '_')
284}
285
286pub fn group_footer(text: &str) -> String {
304 let trimmed = text.trim_end_matches('\n');
305 let lines: Vec<&str> = trimmed.split('\n').collect();
306 let mut footer_size = 0;
307 for line in lines[1..].iter().rev() {
308 if is_footer(line) {
309 footer_size += 1;
310 } else {
311 break;
312 }
313 }
314 let split_at = lines.len() - footer_size;
315 let body = &lines[..split_at];
316 let footer: Vec<&str> = lines[split_at..]
317 .iter()
318 .copied()
319 .filter(|l| !l.is_empty())
320 .collect();
321
322 let mut out: Vec<&str> = body.to_vec();
323 out.push("");
324 out.extend(footer);
325 format!("{}\n", out.join("\n"))
326}
327
328fn valid(msg: &str) {
329 println!(" {} {msg}", valid_sign().trim());
330}
331fn error(msg: &str) {
332 eprintln!(" {} {msg}", error_sign().trim());
333}
334fn orange(s: &str) -> String {
335 highlight(s)
336}
337
338pub fn run(args: &[std::ffi::OsString]) -> Verdict {
339 let Some(filename) = args.first().and_then(|a| a.to_str()) else {
340 println!("Usage:\n\n./commit-msg <filename>");
341 return Verdict::Block;
342 };
343 let Ok(raw) = std::fs::read_to_string(filename) else {
344 return Verdict::Block;
345 };
346 let style = Style::resolve();
347 let cleaned = strip_comments(&raw);
348 let mut parts = cleaned.splitn(2, '\n');
349 let subject_line = parts.next().unwrap_or("");
350 let body = parts.next().unwrap_or("").trim_start_matches('\n');
360
361 let undecorated = undecorate(subject_line);
365 let written = undecorated.text;
366
367 if written.is_empty() || written.chars().count() > style.subject_max {
368 error(&format!(
369 "Commit's first line should exist and be at most {} characters.",
370 orange(&style.subject_max.to_string())
371 ));
372 return Verdict::Block;
373 }
374 valid(&format!(
375 "Summary size is at most {} characters",
376 orange(&style.subject_max.to_string())
377 ));
378
379 let types: Vec<String> = COMMIT_TYPES.iter().map(|t| orange(t.name)).collect();
380 let subject = match parse_subject(written) {
381 Some(s) => s,
382 None => match undecorated.recovered_type {
386 Some(t) => recovered_subject(t, written),
387 None => {
388 error(&format!(
389 "Commits MUST be prefixed with a type, which consists of a noun:
390 {}
391 The prefix must be followed by the OPTIONAL scope, OPTIONAL !,
392 and REQUIRED terminal colon and space.
393 A scope MAY be provided after a type. A scope MUST consist of a noun describing
394 a section of the codebase surrounded by parenthesis, e.g., fix(parser)",
395 types.join(", ")
396 ));
397 return Verdict::Block;
398 }
399 },
400 };
401 valid("A prefix is defined");
402
403 let description = undecorate_tail(&subject.description, vocabulary::emoji_for(&subject.prefix));
405
406 if description.is_empty() {
407 error(&format!(
408 "A description MUST immediately follow the {} and {} after the type/scope prefix.
409 The description is a short summary of the code changes, e.g., fix: array parsing issue when multiple spaces were contained in string.",
410 orange("colon"), orange("space")
411 ));
412 return Verdict::Block;
413 }
414 valid("A description is present in the summary");
415
416 if description.chars().count() > style.description_max {
417 error(&format!(
418 "The description after the {} should be at most {} characters.",
419 orange("colon"),
420 orange(&style.description_max.to_string())
421 ));
422 return Verdict::Block;
423 }
424 valid(&format!(
425 "Description size is at most {} characters",
426 orange(&style.description_max.to_string())
427 ));
428
429 let formatted = format!(
430 "{}\n\n{}\n",
431 commit_style::render_subject(
432 style.gitmoji,
433 &subject.prefix,
434 &subject.scope,
435 &subject.breaking,
436 description,
437 ),
438 wrap_body(&strip_comments(body), style.body_wrap)
439 );
440 if std::fs::write(filename, group_footer(&formatted)).is_err() {
441 return Verdict::Block;
442 }
443 Verdict::Proceed
444}
445
446fn wrap_body(body: &str, column: usize) -> String {
452 if column == 0 {
453 body.to_string()
454 } else {
455 wrap(body, column)
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn parses_the_conventional_shapes() {
465 let s = parse_subject("feat: add a thing").unwrap();
466 assert_eq!(
467 (s.prefix.as_str(), s.description.as_str()),
468 ("feat", "add a thing")
469 );
470
471 let s = parse_subject("fix(parser): trim").unwrap();
472 assert_eq!(s.scope, "(parser)");
473
474 let s = parse_subject("fix(my-scope): trim").unwrap();
475 assert_eq!(s.scope, "(my-scope)");
476
477 let s = parse_subject("feat!: breaking").unwrap();
478 assert_eq!(s.breaking, "!");
479 }
480
481 #[test]
485 fn accepts_emoji_prefixes_including_multi_codepoint_ones() {
486 for subject in [
487 "✨ feat: x",
488 "⬆️ chore: x", "♻️ refactor: x", "🔧 chore: x",
491 "👨💻 feat: x", ] {
493 assert!(parse_subject(subject).is_some(), "failed: {subject}");
494 }
495 }
496
497 #[test]
498 fn rejects_what_is_not_a_conventional_subject() {
499 assert!(parse_subject("just a message").is_none());
500 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()); }
504
505 #[test]
506 fn description_may_be_empty_and_is_caught_by_the_caller() {
507 assert_eq!(parse_subject("feat:").unwrap().description, "");
508 }
509
510 #[test]
511 fn wraps_on_spaces_without_splitting_long_words() {
512 let wrapped = wrap("aaa bbb ccc ddd", 7);
513 assert_eq!(wrapped, "aaa bbb\nccc ddd");
514 let long = "x".repeat(20);
515 assert_eq!(wrap(&long, 7), long); }
517
518 #[test]
519 fn recognises_footers() {
520 assert!(is_footer("Co-Authored-By: someone"));
521 assert!(is_footer("BREAKING CHANGE: it broke"));
522 assert!(is_footer("Refs: #123"));
523 assert!(is_footer(""));
524 assert!(!is_footer("just prose"));
525 assert!(!is_footer("a sentence with - a dash"));
526 }
527
528 #[test]
533 fn a_bare_refs_needs_a_separator_not_just_a_leading_digit() {
534 assert!(is_footer("Refs #123"));
535 assert!(is_footer("Refs 123"));
536 assert!(
537 !is_footer("Refs42 was the original ticket."),
538 "prose starting with Refs+digit must not read as a footer"
539 );
540 }
541
542 #[test]
548 fn a_key_must_start_the_line_to_be_a_footer() {
549 assert!(is_footer("Co-Authored-By: someone"));
551 assert!(is_footer("Signed-off-by: someone"));
552 assert!(is_footer("Reviewed-by: a"));
553 assert!(!is_footer("fix: pre-commit: stop hanging"));
555 assert!(!is_footer("🐛 fix: pre-commit: stop hanging"));
556 assert!(!is_footer("see the pre-commit: docs above"));
558 assert!(!is_footer(" Co-Authored-By: indented is not a trailer"));
559 assert!(!is_footer("-foo: bar"));
562 assert!(!is_footer("A-: bar"));
563 assert!(is_footer("BREAKING CHANGE: it broke"));
565 assert!(is_footer("Refs: #123"));
566 }
567
568 #[test]
569 fn groups_the_trailing_footer_with_one_blank_line() {
570 let out = group_footer("subject\n\nbody text\n\nCo-Authored-By: x\n\n");
571 assert_eq!(out, "subject\n\nbody text\n\nCo-Authored-By: x\n");
572 }
573
574 const SHAPES: &[&str] = &[
580 "fix: pre-commit: stop hanging",
582 "fix: pre-commit: stop hanging\n\n\n",
583 "fix: pre-commit: stop hanging\n\nthe worker thread blocked on a tty\n",
585 "fix: pre-commit: stop hanging\n\nCo-Authored-By: a <a@x>\n",
587 "fix: pre-commit: stop hanging\n\nthe worker thread blocked\n\nCo-Authored-By: a <a@x>\n",
589 "fix: pre-commit: stop hanging\n\nthe worker thread blocked\n\nCo-Authored-By: a <a@x>\n\n\n",
591 "feat: x\n\nbody\n\nBREAKING CHANGE: it broke\n\nCo-Authored-By: a <a@x>\n",
593 "feat: add a thing\n\nbody\n\nCo-Authored-By: a <a@x>\n",
595 ];
596
597 #[test]
605 fn group_footer_never_loses_a_line() {
606 for shape in SHAPES {
607 let out = group_footer(shape);
608
609 let mut before: Vec<&str> = shape
610 .trim_end_matches('\n')
611 .split('\n')
612 .filter(|l| !l.is_empty())
613 .collect();
614 let mut after: Vec<&str> = out
615 .trim_end_matches('\n')
616 .split('\n')
617 .filter(|l| !l.is_empty())
618 .collect();
619 before.sort_unstable();
620 after.sort_unstable();
621 assert_eq!(before, after, "lines changed for {shape:?} -> {out:?}");
622
623 assert_eq!(
624 out.split('\n').next(),
625 shape.split('\n').next(),
626 "the subject left line 0 for {shape:?} -> {out:?}"
627 );
628 }
629 }
630
631 #[test]
635 fn group_footer_is_idempotent() {
636 for shape in SHAPES {
637 let once = group_footer(shape);
638 let twice = group_footer(&once);
639 assert_eq!(once, twice, "not idempotent for {shape:?}");
640 }
641 }
642
643 #[test]
648 fn a_subject_and_its_trailers_stay_separated() {
649 let out = group_footer("fix: pre-commit: stop hanging\n\nCo-Authored-By: a <a@x>\n");
650 assert_eq!(
651 out, "fix: pre-commit: stop hanging\n\nCo-Authored-By: a <a@x>\n",
652 "got: {out:?}"
653 );
654 assert!(
655 !out.starts_with('\n'),
656 "the message must not begin with a blank line: {out:?}"
657 );
658 }
659
660 #[test]
661 fn strips_comment_lines() {
662 assert_eq!(strip_comments("keep\n# drop\nkeep2"), "keep\nkeep2");
663 }
664
665 use crate::commit_style::{render_subject, Gitmoji};
666
667 fn store(placement: Gitmoji, typed: &str) -> String {
670 let s = parse_subject(typed).expect("test subjects parse");
671 render_subject(
672 placement,
673 &s.prefix,
674 &s.scope,
675 &s.breaking,
676 undecorate_tail(&s.description, vocabulary::emoji_for(&s.prefix)),
677 )
678 }
679
680 fn restore(placement: Gitmoji, stored: &str) -> String {
682 let u = undecorate(stored);
683 let s = match parse_subject(u.text) {
684 Some(s) => s,
685 None => recovered_subject(u.recovered_type.expect("a type to recover"), u.text),
686 };
687 render_subject(
688 placement,
689 &s.prefix,
690 &s.scope,
691 &s.breaking,
692 undecorate_tail(&s.description, vocabulary::emoji_for(&s.prefix)),
693 )
694 }
695
696 #[test]
703 fn decorating_a_subject_is_idempotent() {
704 for typed in [
705 "feat: add a cart",
706 "fix(parser): trim",
707 "feat(api)!: drop v1",
708 "docs: explain the trust model",
709 ] {
710 for placement in Gitmoji::ALL {
711 let once = store(placement, typed);
712 let twice = restore(placement, &once);
713 assert_eq!(
714 once,
715 twice,
716 "{} is not idempotent for {typed:?}",
717 placement.as_str()
718 );
719 assert_eq!(twice, restore(placement, &twice));
721 }
722 }
723 }
724
725 #[test]
728 fn each_placement_puts_the_emoji_where_it_says() {
729 assert_eq!(store(Gitmoji::None, "feat: add a cart"), "feat: add a cart");
730 assert_eq!(
731 store(Gitmoji::Prefix, "feat: add a cart"),
732 "✨ feat: add a cart"
733 );
734 assert_eq!(
735 store(Gitmoji::Suffix, "feat: add a cart"),
736 "feat: add a cart ✨"
737 );
738 assert_eq!(
739 store(Gitmoji::Replace, "feat: add a cart"),
740 "✨ add a cart"
741 );
742 }
743
744 #[test]
748 fn suffix_leaves_the_type_where_tooling_looks_for_it() {
749 assert!(store(Gitmoji::Suffix, "fix: a bug").starts_with("fix:"));
750 assert!(!store(Gitmoji::Replace, "fix: a bug").starts_with("fix:"));
751 }
752
753 #[test]
757 fn replace_keeps_a_scope_and_a_breaking_marker() {
758 let stored = store(Gitmoji::Replace, "feat(api)!: drop v1");
759 assert_eq!(stored, "✨ (api)!: drop v1");
760 let u = undecorate(&stored);
761 let s = recovered_subject(u.recovered_type.unwrap(), u.text);
762 assert_eq!(
763 (s.prefix.as_str(), s.scope.as_str(), s.breaking.as_str()),
764 ("feat", "(api)", "!")
765 );
766 assert_eq!(s.description, "drop v1");
767 }
768
769 #[test]
772 fn only_our_own_emoji_recovers_a_type() {
773 assert_eq!(undecorate("✨ add a cart").recovered_type, Some("feat"));
774 assert_eq!(undecorate("🐛 fix: x").recovered_type, Some("fix"));
775 assert_eq!(undecorate("🚀 ship it").recovered_type, None);
776 assert_eq!(undecorate("feat: x").recovered_type, None);
777 assert_eq!(undecorate("✨ add a cart").text, "add a cart");
779 assert_eq!(undecorate("🚀 ship it").text, "🚀 ship it");
780 }
781
782 #[test]
784 fn a_trailing_emoji_is_only_stripped_when_we_wrote_it() {
785 assert_eq!(undecorate_tail("add a cart ✨", "✨"), "add a cart");
786 assert_eq!(undecorate_tail("ship it 🚀", "✨"), "ship it 🚀");
787 assert_eq!(undecorate_tail("plain", "✨"), "plain");
788 assert_eq!(undecorate_tail("nothing to strip", ""), "nothing to strip");
789 }
790
791 #[test]
797 fn decoration_never_counts_against_the_limit() {
798 let typed = format!("feat: {}", "x".repeat(60));
799 assert_eq!(typed.chars().count(), 66);
800 for placement in Gitmoji::ALL {
801 let stored = store(placement, &typed);
802 let remeasured = undecorate(&stored);
803 let s = match parse_subject(remeasured.text) {
804 Some(s) => s,
805 None => recovered_subject(remeasured.recovered_type.unwrap(), remeasured.text),
806 };
807 let description = undecorate_tail(&s.description, vocabulary::emoji_for(&s.prefix));
808 assert_eq!(
809 description.chars().count(),
810 60,
811 "{} changed the measured description: {stored:?}",
812 placement.as_str()
813 );
814 }
815 }
816
817 #[test]
818 fn a_zero_wrap_column_leaves_the_body_alone() {
819 let long = "x ".repeat(100);
820 assert_eq!(wrap_body(&long, 0), long);
821 assert!(wrap_body(&long, 72).contains('\n'));
822 }
823}