#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitIgnoreParseError {
EmptyPattern,
UnknownScope,
}
impl fmt::Display for GitIgnoreParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyPattern => formatter.write_str("Git ignore pattern cannot be empty"),
Self::UnknownScope => formatter.write_str("unknown Git ignore scope"),
}
}
}
impl Error for GitIgnoreParseError {}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitIgnoreNegation {
Normal,
Negated,
}
impl GitIgnoreNegation {
#[must_use]
pub const fn is_negated(self) -> bool {
matches!(self, Self::Negated)
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GitIgnoreScope {
Blank,
Comment,
Pattern,
Directory,
}
impl GitIgnoreScope {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Blank => "blank",
Self::Comment => "comment",
Self::Pattern => "pattern",
Self::Directory => "directory",
}
}
}
impl fmt::Display for GitIgnoreScope {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for GitIgnoreScope {
type Err = GitIgnoreParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"blank" => Ok(Self::Blank),
"comment" => Ok(Self::Comment),
"pattern" => Ok(Self::Pattern),
"directory" | "dir" => Ok(Self::Directory),
_ => Err(GitIgnoreParseError::UnknownScope),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GitIgnorePattern(String);
impl GitIgnorePattern {
pub fn new(value: impl AsRef<str>) -> Result<Self, GitIgnoreParseError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
Err(GitIgnoreParseError::EmptyPattern)
} else {
Ok(Self(trimmed.to_string()))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for GitIgnorePattern {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for GitIgnorePattern {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitIgnoreRule {
original: String,
pattern: Option<GitIgnorePattern>,
negation: GitIgnoreNegation,
scope: GitIgnoreScope,
}
impl GitIgnoreRule {
pub fn parse(value: impl AsRef<str>) -> Result<Self, GitIgnoreParseError> {
let original = value.as_ref().to_string();
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
return Ok(Self {
original,
pattern: None,
negation: GitIgnoreNegation::Normal,
scope: GitIgnoreScope::Blank,
});
}
if trimmed.starts_with('#') {
return Ok(Self {
original,
pattern: None,
negation: GitIgnoreNegation::Normal,
scope: GitIgnoreScope::Comment,
});
}
let (negation, pattern_text) = trimmed
.strip_prefix('!')
.map_or((GitIgnoreNegation::Normal, trimmed), |rest| {
(GitIgnoreNegation::Negated, rest)
});
let pattern = GitIgnorePattern::new(pattern_text)?;
let scope = if pattern.as_str().ends_with('/') {
GitIgnoreScope::Directory
} else {
GitIgnoreScope::Pattern
};
Ok(Self {
original,
pattern: Some(pattern),
negation,
scope,
})
}
#[must_use]
pub fn original(&self) -> &str {
&self.original
}
#[must_use]
pub const fn pattern(&self) -> Option<&GitIgnorePattern> {
self.pattern.as_ref()
}
#[must_use]
pub const fn negation(&self) -> GitIgnoreNegation {
self.negation
}
#[must_use]
pub const fn scope(&self) -> GitIgnoreScope {
self.scope
}
#[must_use]
pub const fn is_comment(&self) -> bool {
matches!(self.scope, GitIgnoreScope::Comment)
}
#[must_use]
pub const fn is_blank(&self) -> bool {
matches!(self.scope, GitIgnoreScope::Blank)
}
#[must_use]
pub const fn is_directory_only(&self) -> bool {
matches!(self.scope, GitIgnoreScope::Directory)
}
}
impl FromStr for GitIgnoreRule {
type Err = GitIgnoreParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value)
}
}
#[cfg(test)]
mod tests {
use super::{GitIgnoreNegation, GitIgnoreParseError, GitIgnoreRule, GitIgnoreScope};
#[test]
fn classifies_ignore_rules() -> Result<(), GitIgnoreParseError> {
let comment = GitIgnoreRule::parse("# target files")?;
let blank = GitIgnoreRule::parse(" ")?;
let rule = GitIgnoreRule::parse("!target/")?;
assert!(comment.is_comment());
assert!(blank.is_blank());
assert_eq!(rule.negation(), GitIgnoreNegation::Negated);
assert_eq!(rule.scope(), GitIgnoreScope::Directory);
assert!(rule.is_directory_only());
Ok(())
}
#[test]
fn rejects_empty_negated_pattern() {
assert_eq!(
GitIgnoreRule::parse("!"),
Err(GitIgnoreParseError::EmptyPattern)
);
}
}