use conventional_commits_types::{
Commit, Footer, FooterSeparator, SEPARATOR_COLON, SEPARATOR_HASHTAG,
};
use nom::{
branch::alt,
bytes::complete::{tag, take, take_while1},
character::complete::{line_ending, not_line_ending},
combinator::{map, map_res, opt, peek},
error::{context, ParseError, VerboseError},
multi::many0,
sequence::{preceded, terminated, tuple},
IResult,
};
use nom_unicode::complete::alpha1;
use std::str::FromStr;
pub use conventional_commits_types;
pub const BREAKING_CHANGE_TOKEN: &str = "BREAKING CHANGE";
pub const BREAKING_CHANGE_WITH_HYPHEN_TOKEN: &str = "BREAKING-CHANGE";
fn r#type<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, &'a str, E> {
alpha1(i)
}
fn scope<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, &'a str, E> {
preceded(tag("("), terminated(alpha1, tag(")")))(i)
}
fn colon<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, &'a str, E> {
tag(":")(i)
}
fn exclamation_mark<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, &'a str, E> {
tag("!")(i)
}
fn colon_separator<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, &'a str, E> {
let (rest, _) = colon(i)?;
tag(" ")(rest)
}
fn description<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, &'a str, E> {
not_line_ending(i)
}
fn body<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, Option<&str>, E> {
if peek::<_, _, E, _>(footer_identifier)(i).is_ok() {
return Ok((i, None));
}
let mut found_newline = false;
let mut offset_to_split_off = 0usize;
for line in i.lines() {
if line.is_empty() {
found_newline = true;
} else if peek::<_, _, E, _>(footer_identifier)(line).is_ok() && found_newline {
break;
} else {
found_newline = false;
}
offset_to_split_off += line.chars().count() + 1;
}
let to_subtract = if found_newline { 2 } else { 1 };
let (rest, b) = map(take(offset_to_split_off - to_subtract), str::trim)(i)?;
Ok((rest, Some(b)))
}
fn is_breaking_change_token(i: &str) -> bool {
i == BREAKING_CHANGE_TOKEN || i == BREAKING_CHANGE_WITH_HYPHEN_TOKEN
}
fn breaking_change_footer_token<'a, E: ParseError<&'a str>>(
i: &'a str,
) -> IResult<&'a str, &'a str, E> {
alt((
tag(BREAKING_CHANGE_TOKEN),
tag(BREAKING_CHANGE_WITH_HYPHEN_TOKEN),
))(i)
}
fn is_footer_token_char(c: char) -> bool {
c.is_alphabetic() || c == '-'
}
fn footer_token_other<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, &'a str, E> {
take_while1(is_footer_token_char)(i)
}
fn footer_token<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, &'a str, E> {
alt((breaking_change_footer_token, footer_token_other))(i)
}
fn footer_separator<'a, E: ParseError<&'a str>>(
i: &'a str,
) -> IResult<&'a str, FooterSeparator, E> {
map_res(alt((tag(SEPARATOR_COLON), tag(SEPARATOR_HASHTAG))), |v| {
FooterSeparator::from_str(v)
})(i)
}
type FooterIdentifier<'a> = (&'a str, FooterSeparator);
fn footer_identifier<'a, E: ParseError<&'a str>>(
i: &'a str,
) -> IResult<&'a str, FooterIdentifier<'a>, E> {
tuple((footer_token, footer_separator))(i)
}
fn footer_value<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, &'a str, E> {
let mut offset_to_split_off = 0usize;
for line in i.lines() {
if peek::<_, _, E, _>(footer_identifier)(line).is_ok() {
offset_to_split_off += 1;
break;
}
offset_to_split_off += line.chars().count() + 1;
}
map(take(offset_to_split_off - 1), str::trim_end)(i)
}
type FooterType<'a> = (&'a str, FooterSeparator, &'a str);
fn footer<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, FooterType<'a>, E> {
tuple((footer_token, footer_separator, footer_value))(i)
}
type CommitFirstLine<'a> = (&'a str, Option<&'a str>, Option<&'a str>, &'a str);
fn commit<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, CommitFirstLine<'a>, E> {
map(
tuple((
r#type,
opt(scope),
opt(exclamation_mark),
colon_separator,
description,
)),
|(ty, scope, exclamation_mark, _, description)| (ty, scope, exclamation_mark, description),
)(i)
}
fn footers<'a, E: ParseError<&'a str>>(
i: &'a str,
) -> IResult<&'a str, Vec<(&'a str, FooterSeparator, &'a str)>, E> {
many0(footer)(i)
}
fn commit_complete<'a, E: ParseError<&'a str>>(i: &'a str) -> IResult<&'a str, Commit<'a>, E> {
map(
tuple((
context("First line", commit),
context("Optional body", |i| {
let (rest, line_end) = opt(line_ending::<_, E>)(i)?;
if line_end.is_none() {
return Ok((i, None));
}
let (rest, optional_body) = opt::<_, _, E, _>(preceded(line_ending, body))(rest)?;
match optional_body {
None => Ok((i, None)),
Some(inner_optional) => {
match inner_optional {
None => Ok((i, None)),
Some(b) => Ok((rest, Some(b))),
}
}
}
}),
context("Optional footer", |i| {
let (rest, line_end) = opt(line_ending::<_, E>)(i)?;
if line_end.is_none() {
return Ok((i, None));
}
opt(preceded(line_ending, footers))(rest)
}),
)),
|(first_line, body, footers)| {
let footers = footers.unwrap_or_else(|| vec![]);
let footers = footers
.iter()
.map(|f| Footer::from(f.0, f.1, f.2))
.collect::<Vec<_>>();
let is_breaking_change =
first_line.2.is_some() || footers.iter().any(|f| is_breaking_change_token(f.token));
Commit::from(
first_line.0,
first_line.1,
first_line.3,
body,
is_breaking_change,
footers,
)
},
)(i)
}
pub fn parse_commit_msg<'a>(i: &'a str) -> Result<Commit<'a>, VerboseError<&'a str>> {
let result = commit_complete::<VerboseError<_>>(i);
result
.map_err(|err| match err {
nom::Err::Error(err) | nom::Err::Failure(err) => {
err
}
_ => unreachable!(),
})
.map(|t| t.1)
}
#[cfg(test)]
mod tests {
use super::r#type;
use crate::parser::{body, description, footer, footer_token, footers, scope};
use conventional_commits_types::FooterSeparator;
use nom::{
error::{ErrorKind, VerboseError},
Err::Error,
IResult,
};
fn simple_ok(i: &str) -> IResult<&str, &str> {
Ok(("", i))
}
fn simple_rest<'a>(rest: &'a str, i: &'a str) -> IResult<&'a str, &'a str> {
Ok((rest, i))
}
#[test]
fn test_ty() {
let i = "type";
let res = simple_ok(i);
assert_eq!(res, r#type(i));
let i = "日本";
let res = simple_ok(i);
assert_eq!(res, r#type(i));
let i = "日本\n";
let res = simple_rest("\n", "日本");
assert_eq!(res, r#type(i));
}
#[test]
fn test_scope() {
let i = "(scope)";
let res = Ok(("", "scope"));
assert_eq!(res, scope::<VerboseError<&str>>(i));
let i = "(日本)";
let res = Ok(("", "日本"));
assert_eq!(res, scope::<VerboseError<&str>>(i));
let i = "(日本\n)";
let res = Err(Error(("\n)", ErrorKind::Tag)));
assert_eq!(res, scope(i));
let i = "(scope";
let res = Err(Error(("", ErrorKind::Tag)));
assert_eq!(res, scope(i));
let i = "scope)";
let res = Err(Error(("scope)", ErrorKind::Tag)));
assert_eq!(res, scope(i));
}
#[test]
fn test_description() {
let i = "a short description";
let res = simple_ok(i);
assert_eq!(res, description(i));
let i = "日本の本が好き";
let res = simple_ok(i);
assert_eq!(res, description(i));
let i = "a short description\n";
let res = simple_rest("\n", "a short description");
assert_eq!(res, description(i));
}
#[test]
fn test_body() {
let i = include_str!("../tests/body_no_footer.txt");
let res = Ok(("", Some(i)));
assert_eq!(res, body::<VerboseError<&str>>(i));
let b = include_str!("../tests/body_no_footer.txt");
let i = include_str!("../tests/body_no_footer2.txt");
let res = Ok(("\n\nFixes #123", Some(b)));
assert_eq!(res, body::<VerboseError<&str>>(i));
}
#[test]
fn test_footer_token() {
let i = "Fixes";
let res = simple_ok(i);
assert_eq!(res, footer_token(i));
let i = "PR-close";
let res = simple_ok(i);
assert_eq!(res, footer_token(i));
let i = "Signed-off-by";
let res = simple_ok(i);
assert_eq!(res, footer_token(i));
let i = "Signed-off-by-日本";
let res = simple_ok(i);
assert_eq!(res, footer_token(i));
}
#[test]
fn test_footer() {
let i = "Fixes #123";
let expected = Ok(("", ("Fixes", FooterSeparator::SpaceHashTag, "123")));
assert_eq!(expected, footer::<VerboseError<&str>>(&i));
let i = "\nFixes #123";
assert!(footer::<VerboseError<&str>>(&i).is_err());
let i = "Fixes: 123";
let expected = Ok(("", ("Fixes", FooterSeparator::ColonSpace, "123")));
assert_eq!(expected, footer::<VerboseError<&str>>(&i));
let i = "Signed-off-by: me";
let expected = Ok(("", ("Signed-off-by", FooterSeparator::ColonSpace, "me")));
assert_eq!(expected, footer::<VerboseError<&str>>(&i));
let i = "Check-日本: yes";
let expected = Ok(("", ("Check-日本", FooterSeparator::ColonSpace, "yes")));
assert_eq!(expected, footer::<VerboseError<&str>>(&i));
}
#[test]
fn test_footers() {
let i = "Fixes #123\nPR-Close #432";
let expected = Ok((
"",
vec![
("Fixes", FooterSeparator::SpaceHashTag, "123"),
("PR-Close", FooterSeparator::SpaceHashTag, "432"),
],
));
assert_eq!(expected, footers::<VerboseError<&str>>(i));
}
#[cfg(feature = "serde")]
#[test]
fn test_serialized_commit_messages() -> anyhow::Result<()> {
use super::parse_commit_msg;
use conventional_commits_types::Commit;
use std::path::Path;
use walkdir::{DirEntry, WalkDir};
let tests_folder_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/serialized");
let walker = WalkDir::new(&tests_folder_path).contents_first(true);
for entry in walker
.into_iter()
.filter_entry(|e: &DirEntry| {
println!("{}", e.path().display());
if let Some(extension) = e.path().extension() {
extension == "txt"
} else {
false
}
})
.filter_map(|e| e.ok())
{
let stem = entry.path().file_stem();
let folder_commit_msg_is_in =
entry.path().parent().expect("failed to get folder parent");
let result_ron_file =
folder_commit_msg_is_in.join(&format!("{}.ron", stem.unwrap().to_str().unwrap()));
let commit_content = std::fs::read_to_string(entry.path())?;
let commit_content_trimmed = commit_content.trim_end();
let ser_commit_content = std::fs::read_to_string(result_ron_file)?;
let commit = parse_commit_msg(commit_content_trimmed).expect("parse commit");
let ser_commit: Commit<'_> = ron::from_str(&ser_commit_content)?;
assert_eq!(ser_commit, commit, "failed at: {:?}", &stem);
println!("right assert");
}
Ok(())
}
}