1use std::{borrow::Cow, convert::Infallible, path::Path, str::FromStr};
2
3use wildmatch::WildMatch;
4
5pub 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}