use std::fmt;
use serde::Serialize;
use pest::{Parser, Span};
use pest_derive::Parser;
#[derive(Parser)]
#[grammar = "commit.pest"]
struct CommitParser;
#[derive(Debug, Serialize, Default)]
pub struct CommitSpan<'a> {
input: &'a str,
start: usize,
end: usize,
}
impl<'a> CommitSpan<'a> {
pub fn new(input: &'a str, start: usize, end: usize) -> Self {
CommitSpan { input, start, end }
}
fn from(span: Span<'a>) -> Self {
CommitSpan {
input: span.as_str(),
start: span.start(),
end: span.end(),
}
}
pub fn start(&self) -> usize {
self.start
}
pub fn end(&self) -> usize {
self.end
}
}
impl fmt::Display for CommitSpan<'_> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.input)
}
}
#[derive(Debug, Serialize)]
pub struct Commit<'a> {
pub header: CommitSpan<'a>,
pub body: Option<CommitSpan<'a>>,
pub footer: Option<CommitSpan<'a>>,
pub commit_type: CommitSpan<'a>,
pub scope: Option<CommitSpan<'a>>,
pub subject: CommitSpan<'a>,
pub raw: String,
}
impl Commit<'_> {
pub fn new() -> Self {
Commit {
header: CommitSpan::default(),
body: None,
footer: None,
commit_type: CommitSpan::default(),
scope: None,
subject: CommitSpan::default(),
raw: String::from(""),
}
}
}
impl Default for Commit<'_> {
fn default() -> Self {
Self::new()
}
}
pub fn parse_commit(commit_msg: &str) -> Commit {
let pairs = CommitParser::parse(Rule::commit, commit_msg).unwrap_or_else(|e| panic!("{}", e));
println!("{:#?}", pairs);
let mut commit = Commit {
header: CommitSpan::new("", 0, 0),
body: None,
footer: None,
commit_type: CommitSpan::new("", 0, 0),
scope: None,
subject: CommitSpan::new("", 0, 0),
raw: String::from(""),
};
for pair in pairs {
if let Rule::commit = pair.as_rule() {
commit.raw = pair.as_str().to_string();
for inner_pair in pair.into_inner() {
match inner_pair.as_rule() {
Rule::header => {
commit.header = CommitSpan::from(inner_pair.as_span());
for header_pair in inner_pair.into_inner() {
match header_pair.as_rule() {
Rule::commit_type => {
commit.commit_type = CommitSpan::from(header_pair.as_span())
}
Rule::scope => {
commit.scope = Some(CommitSpan::from(header_pair.as_span()))
}
Rule::subject => {
commit.subject = CommitSpan::from(header_pair.as_span())
}
_ => {}
}
}
}
Rule::body => commit.body = Some(CommitSpan::from(inner_pair.as_span())),
Rule::footer => commit.footer = Some(CommitSpan::from(inner_pair.as_span())),
Rule::commit_type => {
commit.commit_type = CommitSpan::from(inner_pair.as_span())
}
Rule::scope => commit.scope = Some(CommitSpan::from(inner_pair.as_span())),
Rule::subject => commit.subject = CommitSpan::from(inner_pair.as_span()),
_ => {}
}
}
}
}
commit
}
#[cfg(test)]
mod tests {
use super::*;
struct TestConfig {
name: String,
commit: String,
want_err: bool,
}
#[test]
fn commit_parse_tests() {
let test_configs = vec![
TestConfig {
name: String::from("complex"),
commit: String::from("feat(nice): add cool feature\n\nsome body\n\nsome footer"),
want_err: false,
},
TestConfig {
name: String::from("scope missing"),
commit: String::from("feat: add cool feature\n\nsome body\n\nsome footer"),
want_err: false,
},
TestConfig {
name: String::from("body and footer missing"),
commit: String::from("feat(nice): add cool feature"),
want_err: false,
},
TestConfig {
name: String::from(
"body and footer missing with newline at the end (stdio input adds a newline)",
),
commit: String::from("feat(nice): add cool feature\n"),
want_err: false,
},
TestConfig {
name: String::from("subject with whitespace at the end"),
commit: String::from("feat(nice): add cool feature \t "),
want_err: false,
},
TestConfig {
name: String::from("footer missing"),
commit: String::from("feat(nice): add cool feature\n\nsome body"),
want_err: false,
},
TestConfig {
name: String::from("multiple body lines"),
commit: String::from(
"feat(nice): add cool feature\n\nsome body\nnext body line\n\nthe real footer",
),
want_err: false,
},
TestConfig {
name: String::from("breaking change after type"),
commit: String::from("feat!: add cool feature\n\nsome body"),
want_err: false,
},
TestConfig {
name: String::from("breaking change after scope"),
commit: String::from("feat(nice)!: add cool feature\n\nsome body"),
want_err: false,
},
TestConfig {
name: String::from("only one newline after header"),
commit: String::from("feat(nice): add cool feature\nsome body"),
want_err: true,
},
TestConfig {
name: String::from("type missing"),
commit: String::from("add cool feature\n\nsome body\n\nsome footer"),
want_err: true,
},
TestConfig {
name: String::from("not enough newlines"),
commit: String::from("feat: add cool feature\nsome body"),
want_err: true,
},
TestConfig {
name: String::from("random text"),
commit: String::from("Added a cool new feature"),
want_err: true,
},
TestConfig {
name: String::from("no text"),
commit: String::new(),
want_err: true,
},
];
for test_config in test_configs {
let parse_result = CommitParser::parse(Rule::commit, &test_config.commit);
if test_config.want_err {
assert!(
parse_result.is_err(),
"{} | commit parse error = {:#?}",
test_config.name,
parse_result
);
} else {
let result = parse_commit(&test_config.commit);
assert!(
parse_result.is_ok(),
"{} | commit parse should be successfull",
test_config.name
);
insta::assert_yaml_snapshot!(test_config.name, result);
}
}
}
}