qlty_analysis/workspace_entries/
matchers.rs

1use super::workspace_entry::{WorkspaceEntry, WorkspaceEntryKind};
2use crate::{code::language_detector::get_language_from_shebang, utils::fs::path_to_string};
3use anyhow::{Context, Result};
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use qlty_config::FileType;
6use std::{collections::HashMap, path::PathBuf};
7
8pub trait WorkspaceEntryMatcher: core::fmt::Debug {
9    fn matches(&self, workspace_entry: WorkspaceEntry) -> Option<WorkspaceEntry>;
10}
11
12#[derive(Debug)]
13pub struct AnyMatcher;
14
15impl WorkspaceEntryMatcher for AnyMatcher {
16    fn matches(&self, workspace_entry: WorkspaceEntry) -> Option<WorkspaceEntry> {
17        Some(workspace_entry)
18    }
19}
20
21/// Matches workspace entries that are of type `WorkspaceEntryKind::File`
22#[derive(Debug)]
23pub struct FileMatcher;
24
25impl WorkspaceEntryMatcher for FileMatcher {
26    fn matches(&self, workspace_entry: WorkspaceEntry) -> Option<WorkspaceEntry> {
27        if workspace_entry.kind == WorkspaceEntryKind::File {
28            Some(workspace_entry)
29        } else {
30            None
31        }
32    }
33}
34
35/// Matches workspace entries that are within a path prefix (a directory)
36#[derive(Debug)]
37pub struct PrefixMatcher {
38    path_prefix: String,
39    root: PathBuf,
40}
41
42impl PrefixMatcher {
43    pub fn new(path_prefix: String, root: PathBuf) -> Self {
44        Self { path_prefix, root }
45    }
46}
47
48impl WorkspaceEntryMatcher for PrefixMatcher {
49    fn matches(&self, workspace_entry: WorkspaceEntry) -> Option<WorkspaceEntry> {
50        match workspace_entry.full_path(&self.root) {
51            Ok(full_path) => {
52                if full_path.starts_with(path_to_string(&self.path_prefix)) {
53                    Some(workspace_entry)
54                } else {
55                    None
56                }
57            }
58            Err(_e) => None,
59        }
60    }
61}
62
63/// Compares workspace entries against a `GlobSet`. Either includes or excludes.
64#[derive(Debug)]
65pub struct GlobsMatcher {
66    glob_set: GlobSet,
67    include: bool,
68}
69
70impl GlobsMatcher {
71    pub fn new(glob_set: GlobSet, include: bool) -> Self {
72        Self { glob_set, include }
73    }
74
75    pub fn new_for_globs(globs: &[String], include: bool) -> Result<Self> {
76        let glob_set = globs_to_globset(globs)?;
77        Ok(Self { glob_set, include })
78    }
79
80    pub fn new_for_file_types(file_types: &[FileType]) -> Result<Self> {
81        let globs = file_types
82            .iter()
83            .flat_map(|file_type| file_type.globs.to_owned())
84            .collect::<Vec<String>>();
85        Self::new_for_globs(&globs, true)
86    }
87}
88
89impl WorkspaceEntryMatcher for GlobsMatcher {
90    fn matches(&self, workspace_entry: WorkspaceEntry) -> Option<WorkspaceEntry> {
91        let matches = self.glob_set.is_match(workspace_entry.path_string());
92
93        if matches == self.include {
94            Some(workspace_entry)
95        } else {
96            None
97        }
98    }
99}
100
101/// Matches workspace entries that are of a single specific language and
102/// amends the `WorkspaceEntry` with the `Language`
103///
104/// Uses GlobSetWorkspaceEntryMatcher under the hood.
105#[derive(Debug)]
106pub struct LanguageGlobsMatcher {
107    language_name: String,
108    matcher: GlobsMatcher,
109}
110
111impl LanguageGlobsMatcher {
112    pub fn new(language_name: &str, globs: &[String]) -> Result<Self> {
113        let matcher = GlobsMatcher::new_for_globs(globs, true)?;
114
115        Ok(Self {
116            language_name: language_name.to_string(),
117            matcher,
118        })
119    }
120}
121
122impl WorkspaceEntryMatcher for LanguageGlobsMatcher {
123    fn matches(&self, workspace_entry: WorkspaceEntry) -> Option<WorkspaceEntry> {
124        match self.matcher.matches(workspace_entry) {
125            Some(mut workspace_entry) => {
126                workspace_entry.language_name = Some(self.language_name.clone());
127                Some(workspace_entry)
128            }
129            None => None,
130        }
131    }
132}
133
134/// Matches workspace entries that have a sheband line that includes an interpretter
135/// This requires reading the contents of the file.
136#[derive(Debug)]
137pub struct LanguagesShebangMatcher {
138    interpreters: HashMap<String, Vec<String>>,
139}
140
141impl LanguagesShebangMatcher {
142    pub fn new(interpreters: HashMap<String, Vec<String>>) -> Self {
143        Self { interpreters }
144    }
145}
146
147impl WorkspaceEntryMatcher for LanguagesShebangMatcher {
148    fn matches(&self, workspace_entry: WorkspaceEntry) -> Option<WorkspaceEntry> {
149        let file = std::fs::File::open(&workspace_entry.path);
150
151        if let Ok(file) = file {
152            let mut reader = std::io::BufReader::new(file);
153            if let Ok(language_name) = get_language_from_shebang(&mut reader, &self.interpreters) {
154                if !language_name.is_empty() {
155                    return Some(WorkspaceEntry {
156                        language_name: Some(language_name),
157                        ..workspace_entry
158                    });
159                }
160            }
161        }
162        None
163    }
164}
165
166/// Matches workspace entries that match ANY of the provided matchers
167#[derive(Default, Debug)]
168pub struct OrMatcher {
169    matchers: Vec<Box<dyn WorkspaceEntryMatcher>>,
170}
171
172impl OrMatcher {
173    pub fn new(matchers: Vec<Box<dyn WorkspaceEntryMatcher>>) -> Self {
174        Self { matchers }
175    }
176
177    pub fn push(&mut self, matcher: Box<dyn WorkspaceEntryMatcher>) {
178        self.matchers.push(matcher);
179    }
180}
181
182impl WorkspaceEntryMatcher for OrMatcher {
183    fn matches(&self, workspace_entry: WorkspaceEntry) -> Option<WorkspaceEntry> {
184        for matcher in &self.matchers {
185            if let Some(matched_workspace_entry) = matcher.matches(workspace_entry.clone()) {
186                return Some(matched_workspace_entry);
187            }
188        }
189
190        None
191    }
192}
193
194/// Matches workspace entries that match ALL of the provided matchers
195#[derive(Default, Debug)]
196pub struct AndMatcher {
197    matchers: Vec<Box<dyn WorkspaceEntryMatcher>>,
198}
199
200impl AndMatcher {
201    pub fn new(matchers: Vec<Box<dyn WorkspaceEntryMatcher>>) -> Self {
202        Self { matchers }
203    }
204
205    pub fn push(&mut self, matcher: Box<dyn WorkspaceEntryMatcher>) {
206        self.matchers.push(matcher);
207    }
208}
209
210impl WorkspaceEntryMatcher for AndMatcher {
211    fn matches(&self, workspace_entry: WorkspaceEntry) -> Option<WorkspaceEntry> {
212        let mut matched_workspace_entry = workspace_entry.clone();
213
214        for matcher in &self.matchers {
215            if let Some(matched) = matcher.matches(matched_workspace_entry) {
216                matched_workspace_entry = matched;
217            } else {
218                return None;
219            }
220        }
221
222        Some(matched_workspace_entry)
223    }
224}
225
226fn globs_to_globset(globs: &[String]) -> Result<GlobSet> {
227    let mut builder = GlobSetBuilder::new();
228
229    for glob in globs {
230        builder
231            .add(Glob::new(glob).context("Failed to create a new Glob from the provided pattern")?);
232    }
233
234    Ok(builder.build()?)
235}
236
237#[cfg(test)]
238mod test {
239    use super::*;
240    use qlty_config::config::Builder;
241    use std::path::PathBuf;
242    use std::time::SystemTime;
243
244    // Tests for FileWorkspaceEntryMatcher
245    #[test]
246    fn test_file_workspace_entry_matcher_matches_file() {
247        let workspace_entry = WorkspaceEntry {
248            path: PathBuf::from("/path/to/file.txt"),
249            kind: WorkspaceEntryKind::File,
250            content_modified: SystemTime::now(),
251            contents_size: 100,
252            language_name: None,
253        };
254
255        let matcher = FileMatcher;
256        assert!(
257            matcher.matches(workspace_entry).is_some(),
258            "Expected workspace_entry to match as it is of type File"
259        );
260    }
261
262    #[test]
263    fn test_file_workspace_entry_matcher_does_not_match_directory() {
264        let workspace_entry = WorkspaceEntry {
265            path: PathBuf::from("/path/to/directory/"),
266            kind: WorkspaceEntryKind::Directory,
267            content_modified: SystemTime::now(),
268            contents_size: 0, // Assuming directories have a size of 0
269            language_name: None,
270        };
271
272        let matcher = FileMatcher;
273        assert!(
274            matcher.matches(workspace_entry).is_none(),
275            "Expected workspace_entry not to match as it is of type Directory"
276        );
277    }
278
279    // Tests for FileWorkspaceEntryMatcher
280    #[test]
281    fn test_glob_set_workspace_entry_matcher_matches_file() {
282        let workspace_entry = WorkspaceEntry {
283            path: PathBuf::from("/path/to/file.rs"),
284            kind: WorkspaceEntryKind::File,
285            content_modified: SystemTime::now(),
286            contents_size: 100,
287            language_name: None,
288        };
289        let config = Builder::default_config().unwrap().to_owned();
290        let all_file_types = config.file_types.to_owned();
291        let file_types_names = vec!["rust".to_owned()];
292        let file_types = all_file_types
293            .iter()
294            .filter_map(|(name, file_type)| {
295                if file_types_names.contains(&name) {
296                    Some(file_type.clone())
297                } else {
298                    None
299                }
300            })
301            .collect::<Vec<_>>();
302        let matcher = GlobsMatcher::new_for_file_types(&file_types).unwrap();
303
304        assert!(
305            matcher.matches(workspace_entry).is_some(),
306            "Expected workspace_entry to match as it is of type rust"
307        );
308    }
309
310    #[test]
311    fn test_glob_set_workspace_entry_matcher_does_not_match_file() {
312        let workspace_entry = WorkspaceEntry {
313            path: PathBuf::from("/path/to/file.rb"),
314            kind: WorkspaceEntryKind::File,
315            content_modified: SystemTime::now(),
316            contents_size: 100,
317            language_name: None,
318        };
319        let config = Builder::default_config().unwrap().to_owned();
320        let all_file_types = config.file_types.to_owned();
321        let file_types_names = vec!["rust".to_owned()];
322        let file_types = all_file_types
323            .iter()
324            .filter_map(|(name, file_type)| {
325                if file_types_names.contains(&name) {
326                    Some(file_type.clone())
327                } else {
328                    None
329                }
330            })
331            .collect::<Vec<_>>();
332        let matcher = GlobsMatcher::new_for_file_types(&file_types).unwrap();
333
334        assert!(
335            matcher.matches(workspace_entry).is_none(),
336            "Expected workspace_entry not to match as it is of type ruby"
337        );
338    }
339}