use std::path::PathBuf;
use std::process::{ExitCode, Termination};
use anyhow::Result;
use ariadne::{ColorGenerator, Label, Report, ReportKind, Source};
use atypical_commit::config::{self, CommitConfig};
use clap::Parser;
use clap_stdin::FileOrStdin;
#[repr(u8)]
enum Exit {
Success = 0,
Invalid = 1,
Usage = 2,
}
impl Termination for Exit {
fn report(self) -> ExitCode {
ExitCode::from(self as u8)
}
}
#[derive(Debug)]
#[derive(Parser)]
#[command(
version,
about = "Lint commit messages with atypical.",
long_about = indoc::indoc! {r#"
Lint commit messages with atypical.
Check out the documentation for more details: https://github.com/kekkon-nexus/atypical
"#},
after_help = indoc::indoc! {r#"
Exit codes:
0 the commit message is valid
1 the commit message failed linting, or input could not be read
2 usage error, or no commit message to lint
"#}
)]
struct Args {
input: Option<FileOrStdin>,
#[arg(short, long, value_name = "FILE")]
config: Option<PathBuf>,
}
fn commit_config(path: Option<PathBuf>) -> Result<CommitConfig> {
let path = match path {
Some(path) => Some(path),
None => atypical_config::find(std::env::current_dir()?),
};
let config = match path {
Some(path) => atypical_config::load(path, config::SECTION)?,
None => None,
};
Ok(config.unwrap_or_default())
}
fn message_header(input: &str) -> Option<(usize, &str)> {
let mut offset = 0;
for line in input.split_inclusive('\n') {
let content = line.trim_end_matches(['\n', '\r']);
if !content.trim().is_empty() && !content.trim_start().starts_with('#')
{
return Some((offset, content));
}
offset += line.len();
}
None
}
fn header_parser<'i>(
tokens: &'i atypical_commit::Tokens<'i>,
) -> impl chumsky::Parser<
'i,
&'i str,
atypical_commit::Header<'i>,
atypical_commit::Extra<'i>,
> {
use chumsky::Parser;
atypical_commit::header()
.with_ctx(atypical_commit::ExtraContext::new(tokens))
}
fn main() -> Result<Exit> {
let args = Args::parse();
let Some(input) = args.input else {
eprintln!("No input provided.");
return Ok(Exit::Usage);
};
let filename = input.filename().to_owned();
let input = input.contents()?;
let Some((offset, header)) = message_header(&input) else {
eprintln!("No commit message to lint.");
return Ok(Exit::Usage);
};
use chumsky::Parser;
let config = commit_config(args.config)?;
if config.default_ignores && atypical_commit::ignore::is_ignored(header) {
return Ok(Exit::Success);
}
let tokens = atypical_commit::Tokens::from(&config);
let result = header_parser(&tokens).parse(header);
if !result.has_errors() {
return Ok(Exit::Success);
}
let mut report = Report::build(
ReportKind::Error,
(&filename, offset..offset + header.len()),
)
.with_message("Failed to parse commit message")
.with_code(3);
let mut colors = ColorGenerator::new();
for error in result.errors() {
let range = error.span().into_range();
report = report.with_label(
Label::new((&filename, offset + range.start..offset + range.end))
.with_message(error.to_string())
.with_color(colors.next()),
);
}
report.finish().eprint((&filename, Source::from(&input)))?;
Ok(Exit::Invalid)
}