Skip to main content

lang_check/
selection.rs

1//! Which files a config selects for checking, and why the rest were skipped.
2//!
3//! `include`, `exclude` and `file_types` decide what a project checks, and
4//! until the answer is visible the only way to find out is to run a check and
5//! count. A pattern that matches nothing and one that swallows a directory
6//! nobody meant to drop both look exactly like a checker that is working.
7//! The CLI's `config files` and the editor's Inspector both answer from here,
8//! so they cannot disagree.
9
10use std::path::{Path, PathBuf};
11
12use glob::glob;
13
14use crate::config::Config;
15
16/// The config list that turned a file away.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum RejectedBy {
19    /// Its extension is not in `file_types`.
20    FileTypes,
21    /// `include` is set and does not match it.
22    Include,
23    /// `exclude` matches it.
24    Exclude,
25}
26
27impl RejectedBy {
28    /// The config key, as a user writes it.
29    #[must_use]
30    pub const fn key(self) -> &'static str {
31        match self {
32            Self::FileTypes => "file_types",
33            Self::Include => "include",
34            Self::Exclude => "exclude",
35        }
36    }
37}
38
39/// What a config selects, and what it turned away.
40#[derive(Debug, Default)]
41pub struct Selection {
42    pub selected: Vec<PathBuf>,
43    /// Each rejected path with the list that rejected it. Empty unless asked
44    /// for, since collecting it walks every candidate twice over.
45    pub rejected: Vec<(PathBuf, RejectedBy)>,
46}
47
48/// Walk the same patterns the indexer walks and sort the results by verdict.
49///
50/// The grammars decide which extensions are candidates, so this answers for
51/// what the editor and CI will visit and not for every file on disk. Paths
52/// come back as the glob found them, under `search_from`.
53#[must_use]
54pub fn select_files(
55    config: &Config,
56    root: &Path,
57    search_from: &Path,
58    with_rejected: bool,
59) -> Selection {
60    let mut patterns = crate::languages::all_file_patterns(config);
61    patterns.sort();
62    patterns.dedup();
63
64    let mut selection = Selection::default();
65    for (suffix, _lang) in &patterns {
66        let Ok(entries) = glob(&format!("{}/{}", search_from.to_string_lossy(), suffix)) else {
67            continue;
68        };
69        for found in entries.flatten() {
70            if config.checks(&found, root) {
71                selection.selected.push(found);
72            } else if with_rejected {
73                let by = if !config.admits_type(&found) {
74                    RejectedBy::FileTypes
75                } else if config.includes(&found, root) {
76                    RejectedBy::Exclude
77                } else {
78                    RejectedBy::Include
79                };
80                selection.rejected.push((found, by));
81            }
82        }
83    }
84    selection.selected.sort();
85    selection.selected.dedup();
86    selection.rejected.sort();
87    selection.rejected.dedup();
88    selection
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn workspace(files: &[&str], config: &str) -> tempfile::TempDir {
96        let dir = tempfile::tempdir().unwrap();
97        for file in files {
98            let path = dir.path().join(file);
99            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
100            std::fs::write(path, "Some prose.\n").unwrap();
101        }
102        std::fs::write(dir.path().join(".languagecheck.yaml"), config).unwrap();
103        dir
104    }
105
106    fn relative(root: &Path, paths: impl IntoIterator<Item = PathBuf>) -> Vec<String> {
107        paths
108            .into_iter()
109            .map(|p| {
110                p.strip_prefix(root)
111                    .unwrap()
112                    .to_string_lossy()
113                    .replace('\\', "/")
114            })
115            .collect()
116    }
117
118    #[test]
119    fn include_selects_and_exclude_subtracts() {
120        let dir = workspace(
121            &["docs/a.md", "docs/drafts/b.md", "notes.md", "page.html"],
122            "include: [\"docs/**\"]\nexclude: [\"docs/drafts/**\"]\n",
123        );
124        let config = Config::load(dir.path()).unwrap();
125        let selection = select_files(&config, dir.path(), dir.path(), true);
126
127        assert_eq!(relative(dir.path(), selection.selected), ["docs/a.md"]);
128        let rejected: Vec<(String, RejectedBy)> = selection
129            .rejected
130            .into_iter()
131            .map(|(p, by)| (relative(dir.path(), [p]).remove(0), by))
132            .collect();
133        assert!(rejected.contains(&("docs/drafts/b.md".into(), RejectedBy::Exclude)));
134        assert!(rejected.contains(&("notes.md".into(), RejectedBy::Include)));
135        assert!(rejected.contains(&("page.html".into(), RejectedBy::Include)));
136    }
137
138    #[test]
139    fn file_types_is_named_before_include() {
140        let dir = workspace(&["a.md", "b.html"], "file_types: [md]\n");
141        let config = Config::load(dir.path()).unwrap();
142        let selection = select_files(&config, dir.path(), dir.path(), true);
143
144        assert_eq!(relative(dir.path(), selection.selected), ["a.md"]);
145        assert_eq!(selection.rejected.len(), 1);
146        assert_eq!(selection.rejected[0].1, RejectedBy::FileTypes);
147    }
148
149    #[test]
150    fn rejected_is_left_empty_unless_asked_for() {
151        let dir = workspace(&["a.md", "b.md"], "exclude: [\"b.md\"]\n");
152        let config = Config::load(dir.path()).unwrap();
153        let selection = select_files(&config, dir.path(), dir.path(), false);
154
155        assert_eq!(relative(dir.path(), selection.selected), ["a.md"]);
156        assert_eq!(selection.rejected, Vec::<(PathBuf, RejectedBy)>::new());
157    }
158}