Skip to main content

weavatrix_scan/ignore/
mod.rs

1use crate::hash::FingerprintHasher;
2use crate::path::normalized_relative_path;
3use crate::report::{IgnoreSourceEvidence, IgnoreSourceKind};
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/// Highest-precedence selection decision for a repository path.
34#[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, Clone, Default)]
66struct RuleSet {
67    rules: Vec<IgnoreRule>,
68    exact_anywhere: Option<Box<[Vec<usize>; 256]>>,
69    prefixes: Option<Box<[Vec<usize>; 256]>>,
70    suffixes: Option<Box<[Vec<usize>; 256]>>,
71    generic: Vec<usize>,
72}
73
74#[derive(Debug, Clone)]
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 bytes = match read_local_rule_file(&path) {
160            Ok(bytes) => bytes,
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        parser::parse_file_bytes(
173            &path,
174            &bytes,
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            &bytes,
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, &current.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 match_prepared_rules(path: &str, is_directory: bool, rules: &IgnoreRules) -> Option<RuleAction> {
222    for source in rules.layers.iter().rev() {
223        let mut layer = source.as_deref();
224        while let Some(current) = layer {
225            if let Some(candidate) = candidate_for_base(path, &current.base)
226                && let Some(action) = current.rules.matches_exact(candidate, is_directory)
227            {
228                return Some(action);
229            }
230            layer = current.parent.as_deref();
231        }
232    }
233    None
234}
235
236fn candidate_for_base<'a>(path: &'a str, base: &str) -> Option<&'a str> {
237    if base.is_empty() {
238        Some(path)
239    } else {
240        path.strip_prefix(base)?.strip_prefix('/')
241    }
242}
243
244fn source_for_name(name: &str) -> (SourceRank, IgnoreSourceKind) {
245    match name {
246        ".gitignore" => (SourceRank::GitIgnore, IgnoreSourceKind::GitIgnore),
247        ".ignore" => (SourceRank::DotIgnore, IgnoreSourceKind::DotIgnore),
248        _ => (SourceRank::Custom, IgnoreSourceKind::Custom),
249    }
250}
251
252fn source_evidence(
253    kind: IgnoreSourceKind,
254    location: String,
255    contents: &[u8],
256) -> IgnoreSourceEvidence {
257    let mut hash = FingerprintHasher::new();
258    hash.write(contents);
259    IgnoreSourceEvidence {
260        kind,
261        location,
262        content_hash: hash.finish(),
263    }
264}
265
266fn normalized_evidence_location(path: &Path, root: &Path) -> String {
267    path.strip_prefix(root).map_or_else(
268        |_| path.to_string_lossy().replace('\\', "/"),
269        normalized_relative_path,
270    )
271}
272
273fn read_local_rule_file(path: &Path) -> io::Result<Vec<u8>> {
274    let metadata = fs::symlink_metadata(path)?;
275    if metadata.file_type().is_symlink() {
276        return Err(io::Error::new(
277            io::ErrorKind::InvalidInput,
278            "ignore file is a symbolic link",
279        ));
280    }
281    fs::read(path)
282}