litcheck_lit/suite/registry/
default.rs

1use std::{collections::BTreeMap, path::Path, sync::Arc};
2
3use litcheck::{
4    diagnostics::{DiagResult, IntoDiagnostic, Report, WrapErr},
5    fs::{self, PathPrefixTree},
6};
7
8use crate::{
9    format::TestFormat,
10    suite::{TestSuite, TestSuiteKey, TestSuiteSet},
11    test::{Test, TestConfig, TestList},
12    Config, LitError,
13};
14
15use super::TestSuiteRegistry;
16
17#[derive(Default)]
18pub struct DefaultTestSuiteRegistry {
19    /// The set of suites under management
20    suites: TestSuiteSet,
21    /// The set of tests for each suite under management
22    tests_by_suite: BTreeMap<TestSuiteKey, TestList>,
23    /// A path prefix tree for all directories visited during config discovery
24    ///
25    /// Using this, we can easily find the nearest test suite for a given path
26    config_cache: PathPrefixTree<TestSuiteKey>,
27    local_config_cache: PathPrefixTree<Arc<TestConfig>>,
28}
29impl TestSuiteRegistry for DefaultTestSuiteRegistry {
30    fn load(&mut self, config: &Config) -> DiagResult<()> {
31        let cwd = std::env::current_dir().expect("unable to access current working directory");
32
33        self.clear();
34
35        // Load all test suites corresponding to the provided input paths,
36        // and add the input path as a filter for the tests in that suite,
37        // if applicable.
38        for input in config.tests.iter().map(Path::new) {
39            let input_path = input
40                .canonicalize()
41                .into_diagnostic()
42                .wrap_err("test input does not exist")?;
43            if !input_path.starts_with(&cwd) {
44                return Err(Report::new(LitError::TestPath(input_path)));
45            }
46            let search_root = if input_path.starts_with(&cwd) {
47                &cwd
48            } else {
49                &input_path
50            };
51
52            if let Some(suite) = self.find_nearest_suite(&input_path, search_root, config)? {
53                log::debug!("resolved input {} to {}", input.display(), &suite.id());
54                let suite_source_dir = suite.source_dir();
55                let filter_path = input_path.strip_prefix(suite_source_dir).expect(
56                    "test suite source directory should have been an ancestor of the input path",
57                );
58                if filter_path != Path::new("") {
59                    log::debug!(
60                        "filtering suite '{}' by path: '{}'",
61                        &suite.id(),
62                        filter_path.display()
63                    );
64                    suite.filter_by_path(filter_path);
65                }
66            } else {
67                log::warn!("unable to locate test suite for {}", input.display());
68            }
69        }
70
71        if self.suites.is_empty() {
72            return Ok(());
73        }
74
75        self.load_tests(config)?;
76
77        Ok(())
78    }
79
80    fn is_empty(&self) -> bool {
81        !self.tests_by_suite.values().any(|suite| !suite.is_empty())
82    }
83
84    fn num_suites(&self) -> usize {
85        self.tests_by_suite.len()
86    }
87
88    fn num_tests(&self) -> usize {
89        self.tests_by_suite.values().map(|suite| suite.len()).sum()
90    }
91
92    fn size_of_suite(&self, id: &TestSuiteKey) -> usize {
93        self.tests_by_suite[id].len()
94    }
95
96    #[inline(always)]
97    fn get(&self, id: &TestSuiteKey) -> Arc<TestSuite> {
98        self.suites.get(id).unwrap()
99    }
100
101    /// Get a [TestSuite] given the path at which it's configuration resides
102    fn get_by_path(&self, path: &Path) -> Option<Arc<TestSuite>> {
103        self.config_cache
104            .get(path)
105            .and_then(|key| self.suites.get(key))
106    }
107
108    fn tests(&self) -> impl Iterator<Item = Arc<Test>> + '_ {
109        self.tests_by_suite.values().flat_map(|suite| suite.iter())
110    }
111
112    fn suites(&self) -> impl Iterator<Item = Arc<TestSuite>> + '_ {
113        self.suites.iter()
114    }
115}
116impl IntoIterator for DefaultTestSuiteRegistry {
117    type Item = Arc<Test>;
118    type IntoIter = <TestList as IntoIterator>::IntoIter;
119
120    fn into_iter(mut self) -> Self::IntoIter {
121        let mut tests = self
122            .tests_by_suite
123            .pop_first()
124            .map(|(_, tests)| tests)
125            .unwrap_or_default();
126        while let Some((_, ts)) = self.tests_by_suite.pop_first() {
127            tests.append(ts);
128        }
129        tests.into_iter()
130    }
131}
132impl rayon::iter::IntoParallelIterator for DefaultTestSuiteRegistry {
133    type Item = Arc<Test>;
134    type Iter = <TestList as rayon::iter::IntoParallelIterator>::Iter;
135
136    #[inline]
137    fn into_par_iter(self) -> Self::Iter {
138        Self::into_iter(self)
139    }
140}
141impl DefaultTestSuiteRegistry {
142    fn clear(&mut self) {
143        self.local_config_cache.clear();
144        self.config_cache.clear();
145        self.tests_by_suite.clear();
146        self.suites.clear();
147    }
148
149    /// Search for the nearest `lit.suite.toml` to which `path` belongs.
150    ///
151    /// If `path` is a file path, the directory containing that file is the first place
152    /// searched, followed by each ancestor in the directory tree until a config is found.
153    ///
154    /// If `path` is a directory, the directory itself is searched, followed by each
155    /// ancestor in the directory tree until a config is found.
156    pub fn find_nearest_suite<P: AsRef<Path>>(
157        &mut self,
158        path: P,
159        cwd: &Path,
160        config: &Config,
161    ) -> DiagResult<Option<Arc<TestSuite>>> {
162        let path = path.as_ref();
163        // If the path is a directory, we need to check the cache for an exact
164        // match, and failing that, check the directory itself for a lit.suite.toml.
165        // If successful, we're done; otherwise, we fallback to the same search
166        // used for file paths
167        if path.is_dir() {
168            if let Some(cache_key) = self.config_cache.get(path) {
169                return Ok(Some(self.get(cache_key)));
170            }
171
172            let mut found =
173                fs::search_directory(path, false, |entry| entry.file_name() == "lit.suite.toml");
174
175            if let Some(entry) = found.next() {
176                let entry = entry
177                    .into_diagnostic()
178                    .wrap_err("found test suite config, but it could not be read")?;
179                return self.load_without_cache(entry.path(), config).map(Some);
180            }
181        }
182
183        self.find_nearest_suite_containing(path, cwd, config)
184    }
185
186    fn find_nearest_suite_containing(
187        &mut self,
188        path: &Path,
189        cwd: &Path,
190        config: &Config,
191    ) -> DiagResult<Option<Arc<TestSuite>>> {
192        // If `dir` is not explicitly represented in `config_paths`, then it hasn't
193        // been visited to check for a `lit.suite.toml` file yet, and we can't rely
194        // on the nearest ancestor to be correct. Otherwise, the nearest ancestor
195        // refers to the nearest path in `config_paths`
196        if let Some(cache_key) = self.config_cache.nearest_ancestor(path) {
197            return Ok(Some(self.get(cache_key.into_value())));
198        }
199
200        let dir = if path.is_dir() {
201            path
202        } else {
203            path.parent().expect("expected canonical file path")
204        };
205
206        // Search the ancestors of `path` until we find a `lit.suite.toml` whose source
207        // directory contains `path`.
208        for ancestor in dir.ancestors() {
209            let is_cwd = ancestor == cwd;
210            let mut found = fs::search_directory(ancestor, false, |entry| {
211                entry.file_name() == "lit.suite.toml"
212            });
213            if let Some(entry) = found.next() {
214                let entry = entry.into_diagnostic()?;
215                let suite = self.load_without_cache(entry.path(), config)?;
216                // If the test suite source directory contains `dir`, we've found a match;
217                // otherwise, we must continue searching upwards.
218                if dir.starts_with(suite.source_dir()) {
219                    return Ok(Some(suite));
220                }
221            }
222
223            // If we've reached the current working directory, ascend no further
224            if is_cwd {
225                break;
226            }
227        }
228
229        Ok(None)
230    }
231
232    fn load_without_cache(&mut self, path: &Path, config: &Config) -> DiagResult<Arc<TestSuite>> {
233        let suite = TestSuite::parse(path, config)?;
234        self.suites.insert(suite.clone());
235        self.tests_by_suite.insert(suite.id(), TestList::default());
236        // Make lookups for the config path easy
237        self.config_cache.insert(path, suite.id());
238        // Ensure we can look up test suites from test sources
239        self.config_cache.insert(suite.source_dir(), suite.id());
240
241        Ok(suite)
242    }
243
244    /// Visits each test suite under management, and runs test discovery for that suite
245    /// using the provided configuration.
246    ///
247    /// This is safe to call multiple times, each subsequent load will clear all previously
248    /// loaded tests, and run discovery on an empty test set for each suite.
249    fn load_tests(&mut self, config: &Config) -> DiagResult<()> {
250        log::debug!("loading tests into registry..");
251
252        for suite in self.suites.iter() {
253            log::debug!("loading tests for '{}'", &suite.id());
254            // Now that all specified test suites are loaded and have filters applied,
255            // perform test discovery for each suite taking those filters into account.
256            let tests = self
257                .tests_by_suite
258                .get_mut(&suite.id())
259                .expect("invalid test suite key");
260            tests.clear();
261
262            // If we have no specific selections, select everything
263            let search_paths = {
264                let mut lock = suite.search_paths();
265                core::mem::take(&mut *lock)
266            };
267            if search_paths.is_empty() {
268                log::debug!(
269                    "no search paths given, searching entire suite source directory for tests"
270                );
271                let found = suite.config.format.registry().all(suite.clone())?;
272                log::debug!("found {} tests", found.len());
273                tests.append(found);
274            } else {
275                log::debug!(
276                    "{} search paths were given, searching just those paths for tests",
277                    search_paths.len()
278                );
279                for path in search_paths.iter() {
280                    log::debug!("searching {} for tests..", path.display());
281                    // Look for local configuration that applies to these tests,
282                    // using the suite config if no local config is present
283                    let local_config =
284                        get_local_config(&suite, path, config, &mut self.local_config_cache)?;
285                    let found = local_config.format.registry().find(
286                        path,
287                        suite.clone(),
288                        local_config.clone(),
289                    )?;
290                    log::debug!("found {} tests", found.len());
291                    tests.append(found);
292                }
293            }
294
295            *suite.search_paths() = search_paths;
296        }
297
298        log::debug!("loading complete!");
299
300        Ok(())
301    }
302}
303
304fn get_local_config(
305    suite: &TestSuite,
306    path_in_suite: &Path,
307    lit: &Config,
308    local_config_cache: &mut PathPrefixTree<Arc<TestConfig>>,
309) -> DiagResult<Arc<TestConfig>> {
310    let source_dir = suite.source_dir();
311    let path = source_dir.join(path_in_suite);
312    let path = if path.is_dir() {
313        path
314    } else {
315        path.parent().unwrap().to_path_buf()
316    };
317
318    // If we already have a cache entry for the given path, we're done
319    if let Some(config) = local_config_cache.get(&path) {
320        return Ok(config.clone());
321    }
322
323    // Otherwise, we are going to reify a config for each level in the directory tree
324    // between the nearest ancestor for which we have already computed a configuration -
325    // using the suite config and source directory as a fallback - and `path_in_suite`.
326    // We start at that point and descend to `path_in_suite`, computing the effective
327    // configuration at each step if we encounter a `lit.local.toml` at that level.
328    let (mut config, search_root_in_suite) =
329        if let Some(entry) = local_config_cache.nearest_ancestor(&path) {
330            (
331                entry.data.clone(),
332                entry.path.strip_prefix(source_dir).unwrap().to_path_buf(),
333            )
334        } else {
335            (suite.config.clone(), path_in_suite.to_path_buf())
336        };
337
338    let mut path = source_dir.to_path_buf();
339    for component in search_root_in_suite.components() {
340        path.push(AsRef::<Path>::as_ref(&component));
341
342        let mut found =
343            fs::search_directory(&path, false, |entry| entry.file_name() == "lit.local.toml");
344        if let Some(entry) = found.next() {
345            let entry = entry.into_diagnostic()?;
346            let mut cfg = TestConfig::parse(entry.path())?;
347            cfg.inherit(&config);
348            cfg.set_default_substitutions(lit, suite, &path);
349            cfg.set_default_features(lit);
350            let cfg = Arc::<TestConfig>::from(cfg);
351            local_config_cache.insert(&path, cfg.clone());
352            config = cfg;
353        }
354    }
355
356    // Return the constructed configuration, or fallback to cloning the suite config
357    local_config_cache.insert(&path, config.clone());
358    Ok(config)
359}