Skip to main content

file_specifier/
lib.rs

1use std::{borrow::Cow, convert::Infallible, path::Path, str::FromStr};
2
3use wildmatch::WildMatch;
4
5/// Gitignore形式のファイル指定子
6pub struct FileSpecifier(Vec<FileSpecifierPattern>, String);
7
8#[derive(Debug)]
9struct FileSpecifierPattern {
10    matcher: WildMatch,
11    matcher_for_any_depth: Option<WildMatch>,
12    path_only: bool,
13    negated: bool,
14}
15
16impl std::fmt::Debug for FileSpecifier {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        f.debug_tuple("FileSpecifier").field(&self.1).finish()
19    }
20}
21
22impl FileSpecifier {
23    pub fn matched(&self, filepath: impl AsRef<Path>) -> bool {
24        let path = filepath.as_ref().to_string_lossy();
25        let path = if path.contains('\\') {
26            Cow::Owned(path.replace('\\', "/"))
27        } else {
28            path
29        };
30        let mut ignored = false;
31
32        for pat in &self.0 {
33            let matches = if pat.path_only {
34                pat.matcher.matches(&path)
35                    || pat
36                        .matcher_for_any_depth
37                        .as_ref()
38                        .is_some_and(|m| m.matches(&path))
39            } else {
40                path.split('/').any(|seg| pat.matcher.matches(seg))
41            };
42
43            if matches {
44                ignored = !pat.negated;
45            }
46        }
47
48        ignored
49    }
50}
51
52impl FromStr for FileSpecifier {
53    type Err = Infallible;
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        let mut patterns = Vec::new();
56        for line in s.lines() {
57            let mut line = line.trim();
58            if line.is_empty() || line.starts_with('#') {
59                continue;
60            }
61
62            let negated = line.starts_with('!');
63            if negated {
64                line = line.strip_prefix('!').unwrap_or(line);
65            }
66            if let Some(rest) = line.strip_prefix(r"\!") {
67                line = rest;
68            } else if let Some(rest) = line.strip_prefix(r"\#") {
69                line = rest;
70            }
71            if line.is_empty() {
72                continue;
73            }
74
75            let anchored_to_root = line.starts_with('/');
76            let line = line.trim_start_matches('/');
77            let had_slash = line.contains('/');
78            let directory_only = line.ends_with('/');
79            let line = line.trim_end_matches('/');
80            if line.is_empty() {
81                continue;
82            }
83
84            let path_only = had_slash || directory_only;
85            let body = if directory_only {
86                format!("{line}/**")
87            } else {
88                line.to_string()
89            };
90            let matcher_for_any_depth = if path_only && !anchored_to_root {
91                Some(WildMatch::new(&format!("**/{body}")))
92            } else {
93                None
94            };
95
96            patterns.push(FileSpecifierPattern {
97                matcher: WildMatch::new(&body),
98                matcher_for_any_depth,
99                path_only,
100                negated,
101            });
102        }
103        Ok(FileSpecifier(patterns, s.to_string()))
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::FileSpecifier;
110    use std::{path::Path, str::FromStr};
111
112    #[test]
113    fn file_specifier_matches_by_segment() {
114        let spec = FileSpecifier::from_str("README.md\nLICENSE*").expect("must parse");
115
116        assert!(spec.matched(Path::new("plugin/README.md")));
117        assert!(spec.matched(Path::new("foo/LICENSE.txt")));
118        assert!(!spec.matched(Path::new("plugin/main.lua")));
119    }
120
121    #[test]
122    fn file_specifier_supports_directory_pattern() {
123        let spec = FileSpecifier::from_str("tests/").expect("must parse");
124
125        assert!(spec.matched(Path::new("tests/a.lua")));
126        assert!(spec.matched(Path::new("foo/tests/b.lua")));
127        assert!(!spec.matched(Path::new("test/a.lua")));
128    }
129
130    #[test]
131    fn file_specifier_supports_negation() {
132        let spec = FileSpecifier::from_str("*.md\n!README.md").expect("must parse");
133
134        assert!(spec.matched(Path::new("docs/guide.md")));
135        assert!(!spec.matched(Path::new("README.md")));
136    }
137
138    #[test]
139    fn file_specifier_supports_root_anchored_path() {
140        let spec = FileSpecifier::from_str("/doc/*.txt").expect("must parse");
141
142        assert!(spec.matched(Path::new("doc/help.txt")));
143        assert!(!spec.matched(Path::new("plugin/doc/help.txt")));
144    }
145}