1use crate::path::{RevisionHasher, normalized_relative_path};
2use crate::report::{IgnoreSourceEvidence, IgnoreSourceKind};
3use std::collections::HashMap;
4use std::fmt;
5use std::fs;
6use std::io;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10mod git;
11mod matcher;
12mod overrides;
13mod parser;
14mod repository;
15mod repository_source;
16mod rules;
17#[cfg(test)]
18mod tests;
19
20#[cfg(test)]
21use git::{expand_home, read_excludes_setting, resolve_git_directory};
22use matcher::RuleMatcher;
23use parser::parse_file;
24pub use repository::RepositoryMatcher;
25#[cfg(test)]
26use repository_source::{add_rule_file, find_repository_root};
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct IgnoreFile {
30 pub name: String,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum RepositoryMatch {
36 None,
37 Ignore,
38 Include,
39 OverrideIgnore,
40 OverrideInclude,
41 Hidden,
42}
43
44impl RepositoryMatch {
45 #[must_use]
46 pub const fn is_ignored(self) -> bool {
47 matches!(self, Self::Ignore | Self::OverrideIgnore | Self::Hidden)
48 }
49}
50
51const SOURCE_COUNT: usize = 6;
52
53#[derive(Debug, Clone, Default)]
54pub(crate) struct IgnoreRules {
55 layers: [Option<Arc<IgnoreLayer>>; SOURCE_COUNT],
56}
57
58#[derive(Debug)]
59struct IgnoreLayer {
60 base: String,
61 rules: RuleSet,
62 parent: Option<Arc<IgnoreLayer>>,
63}
64
65#[derive(Debug, Default)]
66struct RuleSet {
67 rules: Vec<IgnoreRule>,
68 exact_anywhere: HashMap<String, Vec<usize>>,
69 prefixes: HashMap<u8, Vec<usize>>,
70 suffixes: HashMap<u8, Vec<usize>>,
71 generic: Vec<usize>,
72}
73
74#[derive(Debug)]
75struct IgnoreRule {
76 pattern: String,
77 action: RuleAction,
78 target: RuleTarget,
79 scope: RuleScope,
80 matcher: RuleMatcher,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84enum RuleAction {
85 Ignore,
86 Include,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90enum RuleTarget {
91 Any,
92 Directory,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96enum RuleScope {
97 Anywhere,
98 Path,
99 Anchored,
100}
101
102#[derive(Debug, Clone, Copy)]
103enum RuleMatch {
104 Exact(RuleAction),
105 Ancestor(RuleAction),
106}
107
108#[derive(Debug, Clone, Copy)]
109enum SourceRank {
110 GitGlobal = 0,
111 GitExclude = 1,
112 GitIgnore = 2,
113 DotIgnore = 3,
114 Custom = 4,
115 Explicit = 5,
116}
117
118impl SourceRank {
119 const fn index(self) -> usize {
120 self as usize
121 }
122}
123
124#[derive(Debug)]
125pub(crate) struct IgnoreError {
126 kind: io::ErrorKind,
127 path: PathBuf,
128 message: String,
129}
130
131impl IgnoreError {
132 pub(crate) const fn kind(&self) -> io::ErrorKind {
133 self.kind
134 }
135}
136
137impl fmt::Display for IgnoreError {
138 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139 write!(formatter, "{}: {}", self.path.display(), self.message)
140 }
141}
142
143impl std::error::Error for IgnoreError {}
144
145pub(crate) fn build_child_rules(
146 directory: &Path,
147 base: &str,
148 ignore_files: &[String],
149 case_insensitive: bool,
150 inherited: &IgnoreRules,
151 evidence_root: &Path,
152) -> (IgnoreRules, Vec<IgnoreError>, Vec<IgnoreSourceEvidence>) {
153 let mut result = inherited.clone();
154 let mut rules_by_source: [RuleSet; SOURCE_COUNT] = Default::default();
155 let mut errors = Vec::new();
156 let mut evidence = Vec::new();
157 for name in ignore_files {
158 let path = directory.join(name);
159 let text = match read_local_rule_file(&path) {
160 Ok(text) => text,
161 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
162 Err(error) => {
163 errors.push(IgnoreError {
164 kind: error.kind(),
165 path,
166 message: error.to_string(),
167 });
168 continue;
169 }
170 };
171 let (rank, kind) = source_for_name(name);
172 parse_file(
173 &path,
174 &text,
175 case_insensitive,
176 &mut rules_by_source[rank.index()],
177 &mut errors,
178 );
179 evidence.push(source_evidence(
180 kind,
181 normalized_evidence_location(&path, evidence_root),
182 &text,
183 ));
184 }
185 for (index, rules) in rules_by_source.into_iter().enumerate() {
186 if rules.rules.is_empty() {
187 continue;
188 }
189 result.layers[index] = Some(Arc::new(IgnoreLayer {
190 base: base.to_owned(),
191 rules,
192 parent: result.layers[index].clone(),
193 }));
194 }
195 (result, errors, evidence)
196}
197
198fn match_rules(path: &str, is_directory: bool, rules: &IgnoreRules) -> Option<RuleAction> {
199 let mut ancestor_included = false;
200 for source in rules.layers.iter().rev() {
201 let mut layer = source.as_deref();
202 while let Some(current) = layer {
203 if let Some(candidate) = candidate_for_base(path, ¤t.base)
204 && let Some(rule_match) = current.rules.matches(candidate, is_directory)
205 {
206 match rule_match {
207 RuleMatch::Exact(action) => return Some(action),
208 RuleMatch::Ancestor(RuleAction::Include) => ancestor_included = true,
209 RuleMatch::Ancestor(RuleAction::Ignore) if !ancestor_included => {
210 return Some(RuleAction::Ignore);
211 }
212 RuleMatch::Ancestor(RuleAction::Ignore) => {}
213 }
214 }
215 layer = current.parent.as_deref();
216 }
217 }
218 ancestor_included.then_some(RuleAction::Include)
219}
220
221fn candidate_for_base<'a>(path: &'a str, base: &str) -> Option<&'a str> {
222 if base.is_empty() {
223 Some(path)
224 } else {
225 path.strip_prefix(base)?.strip_prefix('/')
226 }
227}
228
229fn source_for_name(name: &str) -> (SourceRank, IgnoreSourceKind) {
230 match name {
231 ".gitignore" => (SourceRank::GitIgnore, IgnoreSourceKind::GitIgnore),
232 ".ignore" => (SourceRank::DotIgnore, IgnoreSourceKind::DotIgnore),
233 _ => (SourceRank::Custom, IgnoreSourceKind::Custom),
234 }
235}
236
237fn source_evidence(
238 kind: IgnoreSourceKind,
239 location: String,
240 contents: &str,
241) -> IgnoreSourceEvidence {
242 let mut hash = RevisionHasher::new();
243 hash.write(contents.as_bytes());
244 IgnoreSourceEvidence {
245 kind,
246 location,
247 content_hash: hash.finish(),
248 }
249}
250
251fn normalized_evidence_location(path: &Path, root: &Path) -> String {
252 path.strip_prefix(root).map_or_else(
253 |_| path.to_string_lossy().replace('\\', "/"),
254 normalized_relative_path,
255 )
256}
257
258fn read_local_rule_file(path: &Path) -> io::Result<String> {
259 let metadata = fs::symlink_metadata(path)?;
260 if metadata.file_type().is_symlink() {
261 return Err(io::Error::new(
262 io::ErrorKind::InvalidInput,
263 "ignore file is a symbolic link",
264 ));
265 }
266 fs::read_to_string(path)
267}