Skip to main content

use_git_ignore/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Error returned while parsing ignore vocabulary.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub enum GitIgnoreParseError {
10    /// A pattern-bearing rule had no pattern text.
11    EmptyPattern,
12    /// The supplied scope label was not recognized.
13    UnknownScope,
14}
15
16impl fmt::Display for GitIgnoreParseError {
17    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::EmptyPattern => formatter.write_str("Git ignore pattern cannot be empty"),
20            Self::UnknownScope => formatter.write_str("unknown Git ignore scope"),
21        }
22    }
23}
24
25impl Error for GitIgnoreParseError {}
26
27/// Pattern negation vocabulary.
28#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
29pub enum GitIgnoreNegation {
30    /// A normal ignore pattern.
31    Normal,
32    /// A negated ignore pattern prefixed by `!`.
33    Negated,
34}
35
36impl GitIgnoreNegation {
37    /// Returns true when the pattern is negated.
38    #[must_use]
39    pub const fn is_negated(self) -> bool {
40        matches!(self, Self::Negated)
41    }
42}
43
44/// Ignore rule classification.
45#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
46pub enum GitIgnoreScope {
47    /// A blank line.
48    Blank,
49    /// A comment line.
50    Comment,
51    /// A file or path pattern.
52    Pattern,
53    /// A directory-only pattern.
54    Directory,
55}
56
57impl GitIgnoreScope {
58    /// Returns the stable scope label.
59    #[must_use]
60    pub const fn as_str(self) -> &'static str {
61        match self {
62            Self::Blank => "blank",
63            Self::Comment => "comment",
64            Self::Pattern => "pattern",
65            Self::Directory => "directory",
66        }
67    }
68}
69
70impl fmt::Display for GitIgnoreScope {
71    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
72        formatter.write_str(self.as_str())
73    }
74}
75
76impl FromStr for GitIgnoreScope {
77    type Err = GitIgnoreParseError;
78
79    fn from_str(value: &str) -> Result<Self, Self::Err> {
80        match value.trim().to_ascii_lowercase().as_str() {
81            "blank" => Ok(Self::Blank),
82            "comment" => Ok(Self::Comment),
83            "pattern" => Ok(Self::Pattern),
84            "directory" | "dir" => Ok(Self::Directory),
85            _ => Err(GitIgnoreParseError::UnknownScope),
86        }
87    }
88}
89
90/// A non-empty ignore pattern.
91#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
92pub struct GitIgnorePattern(String);
93
94impl GitIgnorePattern {
95    /// Creates an ignore pattern from text.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`GitIgnoreParseError::EmptyPattern`] when the pattern is empty.
100    pub fn new(value: impl AsRef<str>) -> Result<Self, GitIgnoreParseError> {
101        let trimmed = value.as_ref().trim();
102        if trimmed.is_empty() {
103            Err(GitIgnoreParseError::EmptyPattern)
104        } else {
105            Ok(Self(trimmed.to_string()))
106        }
107    }
108
109    /// Returns the pattern text.
110    #[must_use]
111    pub fn as_str(&self) -> &str {
112        &self.0
113    }
114}
115
116impl AsRef<str> for GitIgnorePattern {
117    fn as_ref(&self) -> &str {
118        self.as_str()
119    }
120}
121
122impl fmt::Display for GitIgnorePattern {
123    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124        formatter.write_str(self.as_str())
125    }
126}
127
128/// A classified ignore rule.
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct GitIgnoreRule {
131    original: String,
132    pattern: Option<GitIgnorePattern>,
133    negation: GitIgnoreNegation,
134    scope: GitIgnoreScope,
135}
136
137impl GitIgnoreRule {
138    /// Parses a `gitignore`-style line.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`GitIgnoreParseError::EmptyPattern`] when a negated line has no pattern.
143    pub fn parse(value: impl AsRef<str>) -> Result<Self, GitIgnoreParseError> {
144        let original = value.as_ref().to_string();
145        let trimmed = value.as_ref().trim();
146
147        if trimmed.is_empty() {
148            return Ok(Self {
149                original,
150                pattern: None,
151                negation: GitIgnoreNegation::Normal,
152                scope: GitIgnoreScope::Blank,
153            });
154        }
155
156        if trimmed.starts_with('#') {
157            return Ok(Self {
158                original,
159                pattern: None,
160                negation: GitIgnoreNegation::Normal,
161                scope: GitIgnoreScope::Comment,
162            });
163        }
164
165        let (negation, pattern_text) = trimmed
166            .strip_prefix('!')
167            .map_or((GitIgnoreNegation::Normal, trimmed), |rest| {
168                (GitIgnoreNegation::Negated, rest)
169            });
170        let pattern = GitIgnorePattern::new(pattern_text)?;
171        let scope = if pattern.as_str().ends_with('/') {
172            GitIgnoreScope::Directory
173        } else {
174            GitIgnoreScope::Pattern
175        };
176
177        Ok(Self {
178            original,
179            pattern: Some(pattern),
180            negation,
181            scope,
182        })
183    }
184
185    /// Returns the original line text.
186    #[must_use]
187    pub fn original(&self) -> &str {
188        &self.original
189    }
190
191    /// Returns the pattern when this is a pattern-bearing rule.
192    #[must_use]
193    pub const fn pattern(&self) -> Option<&GitIgnorePattern> {
194        self.pattern.as_ref()
195    }
196
197    /// Returns the negation marker.
198    #[must_use]
199    pub const fn negation(&self) -> GitIgnoreNegation {
200        self.negation
201    }
202
203    /// Returns the rule scope.
204    #[must_use]
205    pub const fn scope(&self) -> GitIgnoreScope {
206        self.scope
207    }
208
209    /// Returns true when this is a comment line.
210    #[must_use]
211    pub const fn is_comment(&self) -> bool {
212        matches!(self.scope, GitIgnoreScope::Comment)
213    }
214
215    /// Returns true when this is a blank line.
216    #[must_use]
217    pub const fn is_blank(&self) -> bool {
218        matches!(self.scope, GitIgnoreScope::Blank)
219    }
220
221    /// Returns true when this rule is directory-only vocabulary.
222    #[must_use]
223    pub const fn is_directory_only(&self) -> bool {
224        matches!(self.scope, GitIgnoreScope::Directory)
225    }
226}
227
228impl FromStr for GitIgnoreRule {
229    type Err = GitIgnoreParseError;
230
231    fn from_str(value: &str) -> Result<Self, Self::Err> {
232        Self::parse(value)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::{GitIgnoreNegation, GitIgnoreParseError, GitIgnoreRule, GitIgnoreScope};
239
240    #[test]
241    fn classifies_ignore_rules() -> Result<(), GitIgnoreParseError> {
242        let comment = GitIgnoreRule::parse("# target files")?;
243        let blank = GitIgnoreRule::parse("  ")?;
244        let rule = GitIgnoreRule::parse("!target/")?;
245
246        assert!(comment.is_comment());
247        assert!(blank.is_blank());
248        assert_eq!(rule.negation(), GitIgnoreNegation::Negated);
249        assert_eq!(rule.scope(), GitIgnoreScope::Directory);
250        assert!(rule.is_directory_only());
251        Ok(())
252    }
253
254    #[test]
255    fn rejects_empty_negated_pattern() {
256        assert_eq!(
257            GitIgnoreRule::parse("!"),
258            Err(GitIgnoreParseError::EmptyPattern)
259        );
260    }
261}