1use super::git::{gitconfig_excludes_path, resolve_git_directory};
2use super::overrides::OverrideRules;
3use super::repository_source::find_repository_root;
4use super::{
5 IgnoreRules, RepositoryMatch, RuleAction, SourceRank, match_prepared_rules, match_rules,
6 normalized_evidence_location,
7};
8use crate::config::{IgnorePolicy, ScanOptions};
9use crate::error::{Error, Result};
10use crate::hidden::is_hidden_with_hint;
11use crate::path::normalized_relative_path;
12use crate::report::{IgnoreSourceEvidence, IgnoreSourceKind, ScanWarning};
13use std::borrow::Cow;
14use std::collections::HashMap;
15use std::io;
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone)]
24pub struct RepositoryMatcher {
25 pub(super) scan_root: PathBuf,
26 pub(super) options: ScanOptions,
27 pub(super) match_root: PathBuf,
28 pub(super) scan_base: String,
29 pub(super) ignore_files: Vec<String>,
30 pub(super) case_insensitive: bool,
31 pub(super) error_policy: crate::walker::ErrorPolicy,
32 pub(super) overrides: OverrideRules,
33 pub(super) skip_hidden: bool,
34 pub(super) base_rules: IgnoreRules,
35 pub(super) directories: HashMap<PathBuf, IgnoreRules>,
36 pub(super) sources: Vec<IgnoreSourceEvidence>,
37 pub(super) warnings: Vec<ScanWarning>,
38 pub(super) portable: bool,
39}
40
41impl RepositoryMatcher {
42 pub fn new(root: impl AsRef<Path>) -> Result<Self> {
48 Self::with_options(root, &ScanOptions::default())
49 }
50
51 pub fn with_options(root: impl AsRef<Path>, options: &ScanOptions) -> Result<Self> {
58 let requested = root.as_ref();
59 let scan_root = requested
60 .canonicalize()
61 .map_err(|source| Error::io(requested, source))?;
62 if !scan_root.is_dir() {
63 return Err(Error::InvalidRoot(scan_root));
64 }
65 let repository_root = find_repository_root(&scan_root);
66 let git_sources_enabled = !options.ignore_policy.require_git || repository_root.is_some();
67 let match_root = if options.ignore_policy.parent_rules {
68 repository_root.clone().unwrap_or_else(|| scan_root.clone())
69 } else {
70 scan_root.clone()
71 };
72 let (overrides, override_errors, override_evidence) =
73 OverrideRules::new(&options.override_rules, options.ignore_case_insensitive);
74 let mut matcher = Self {
75 scan_base: scan_root
76 .strip_prefix(&match_root)
77 .map_or_else(|_| String::new(), normalized_relative_path),
78 scan_root,
79 options: options.clone(),
80 match_root,
81 ignore_files: options
82 .ignore_files
83 .iter()
84 .filter(|name| source_enabled(name, &options.ignore_policy, git_sources_enabled))
85 .cloned()
86 .collect(),
87 case_insensitive: options.ignore_case_insensitive,
88 error_policy: options.walk.error_policy,
89 overrides,
90 skip_hidden: options.skip_hidden,
91 base_rules: IgnoreRules::default(),
92 directories: HashMap::new(),
93 sources: override_evidence.into_iter().collect(),
94 warnings: Vec::new(),
95 portable: true,
96 };
97 matcher.handle_errors(override_errors)?;
98 matcher.load_configured_sources(
99 options,
100 repository_root.as_deref(),
101 git_sources_enabled,
102 )?;
103 let root = matcher.match_root.clone();
104 matcher.prepare_directory_inner(&root)?;
105 if matcher.scan_root != matcher.match_root {
106 let scan_root = matcher.scan_root.clone();
107 matcher.prepare_directory_inner(&scan_root)?;
108 }
109 Ok(matcher)
110 }
111
112 fn load_configured_sources(
113 &mut self,
114 options: &ScanOptions,
115 repository_root: Option<&Path>,
116 git_sources_enabled: bool,
117 ) -> Result<()> {
118 if git_sources_enabled
119 && options.ignore_policy.git_global
120 && let Some(path) = gitconfig_excludes_path(repository_root)
121 {
122 self.load_static_source(
123 &path,
124 "",
125 SourceRank::GitGlobal,
126 IgnoreSourceKind::GitGlobal,
127 "<git-global>",
128 )?;
129 self.portable &= !path.is_file();
130 }
131 if git_sources_enabled
132 && options.ignore_policy.git_exclude
133 && let Some(git_directory) = repository_root.and_then(resolve_git_directory)
134 {
135 let path = git_directory.join("info").join("exclude");
136 self.load_static_source(
137 &path,
138 "",
139 SourceRank::GitExclude,
140 IgnoreSourceKind::GitExclude,
141 ".git/info/exclude",
142 )?;
143 self.portable &= !path.is_file();
144 }
145 for path in &options.ignore_policy.explicit_files {
146 self.load_explicit_source(path)?;
147 }
148 Ok(())
149 }
150
151 fn load_explicit_source(&mut self, path: &Path) -> Result<()> {
152 let configured = if path.is_absolute() {
153 path.to_path_buf()
154 } else {
155 self.scan_root.join(path)
156 };
157 let absolute = configured.canonicalize().unwrap_or(configured);
158 let location = normalized_evidence_location(&absolute, &self.match_root);
159 let base = self
160 .scan_root
161 .strip_prefix(&self.match_root)
162 .map_or_else(|_| String::new(), normalized_relative_path);
163 self.load_static_source(
164 &absolute,
165 &base,
166 SourceRank::Explicit,
167 IgnoreSourceKind::Explicit,
168 &location,
169 )?;
170 if absolute.is_file() && !absolute.starts_with(&self.match_root) {
171 self.portable = false;
172 }
173 Ok(())
174 }
175
176 pub fn matched(
184 &mut self,
185 path: impl AsRef<Path>,
186 is_directory: bool,
187 ) -> Result<RepositoryMatch> {
188 let absolute = self.absolute_path(path.as_ref())?;
189 self.ensure_scan_scope(&absolute)?;
190 let parent = absolute.parent().unwrap_or(&self.match_root).to_path_buf();
191 self.prepare_directory_inner(&parent)?;
192 let scan_relative = absolute.strip_prefix(&self.scan_root).map_err(|_| {
193 Error::io(
194 &absolute,
195 io::Error::new(io::ErrorKind::InvalidInput, "path escapes matcher root"),
196 )
197 })?;
198 Ok(self.matched_prepared(
199 &normalized_relative_path(scan_relative),
200 &parent,
201 &absolute,
202 is_directory,
203 None,
204 false,
205 ))
206 }
207
208 pub fn is_ignored(&mut self, path: impl AsRef<Path>, is_directory: bool) -> Result<bool> {
214 self.matched(path, is_directory)
215 .map(RepositoryMatch::is_ignored)
216 }
217
218 pub fn normalize(&self, path: impl AsRef<Path>) -> Result<PathBuf> {
228 let absolute = self.absolute_path(path.as_ref())?;
229 self.ensure_scan_scope(&absolute)?;
230 absolute
231 .strip_prefix(&self.scan_root)
232 .map(Path::to_path_buf)
233 .map_err(|_| {
234 Error::io(
235 &absolute,
236 io::Error::new(io::ErrorKind::InvalidInput, "path escapes matcher root"),
237 )
238 })
239 }
240
241 pub fn prepare_directory(&mut self, directory: impl AsRef<Path>) -> Result<()> {
247 let absolute = self.absolute_path(directory.as_ref())?;
248 self.ensure_scan_scope(&absolute)?;
249 self.prepare_directory_inner(&absolute)
250 }
251
252 pub fn refresh(&mut self) -> Result<bool> {
260 let refreshed = Self::with_options(&self.scan_root, &self.options)?;
261 let changed = self.sources != refreshed.sources
262 || self.portable != refreshed.portable
263 || self.warnings != refreshed.warnings;
264 *self = refreshed;
265 Ok(changed)
266 }
267
268 #[must_use]
269 pub fn root(&self) -> &Path {
270 &self.scan_root
271 }
272
273 #[must_use]
274 pub fn sources(&self) -> &[IgnoreSourceEvidence] {
275 &self.sources
276 }
277
278 #[must_use]
279 pub fn warnings(&self) -> &[ScanWarning] {
280 &self.warnings
281 }
282
283 #[must_use]
284 pub const fn portable(&self) -> bool {
285 self.portable
286 }
287
288 pub(crate) fn matched_prepared(
289 &self,
290 scan_relative: &str,
291 parent: &Path,
292 absolute: &Path,
293 is_directory: bool,
294 hidden: Option<bool>,
295 ancestors_prepared: bool,
296 ) -> RepositoryMatch {
297 let rules = self.directories.get(parent).unwrap_or(&self.base_rules);
298 self.matched_with_rules(
299 scan_relative,
300 absolute,
301 is_directory,
302 hidden,
303 rules,
304 ancestors_prepared,
305 )
306 }
307
308 pub(crate) fn prepared_rules(&self, parent: &Path) -> &IgnoreRules {
309 self.directories.get(parent).unwrap_or(&self.base_rules)
310 }
311
312 pub(crate) fn matched_with_rules(
313 &self,
314 scan_relative: &str,
315 absolute: &Path,
316 is_directory: bool,
317 hidden: Option<bool>,
318 rules: &IgnoreRules,
319 ancestors_prepared: bool,
320 ) -> RepositoryMatch {
321 let override_match = self.overrides.matched(scan_relative, is_directory);
322 if override_match != RepositoryMatch::None {
323 return override_match;
324 }
325 let candidate = if self.scan_base.is_empty() {
326 Cow::Borrowed(scan_relative)
327 } else {
328 Cow::Owned(format!("{}/{scan_relative}", self.scan_base))
329 };
330 let matched = if ancestors_prepared {
331 match_prepared_rules(&candidate, is_directory, rules)
332 } else {
333 match_rules(&candidate, is_directory, rules)
334 };
335 match matched {
336 Some(RuleAction::Ignore) => RepositoryMatch::Ignore,
337 Some(RuleAction::Include) => RepositoryMatch::Include,
338 None if self.skip_hidden && is_hidden_with_hint(absolute, hidden) => {
339 RepositoryMatch::Hidden
340 }
341 None => RepositoryMatch::None,
342 }
343 }
344}
345
346fn source_enabled(name: &str, policy: &IgnorePolicy, git_sources_enabled: bool) -> bool {
347 match name {
348 ".gitignore" => policy.git_ignore && git_sources_enabled,
349 ".ignore" => policy.dot_ignore,
350 _ => policy.custom_ignore,
351 }
352}