Skip to main content

mezura_core/
lib.rs

1//! Counts the lines of a codebase: which language every file is written in, and how many of its
2//! lines are code, comments and neither.
3//!
4//! A run takes two things. [`EngineConfig`] says what to count, and [`Languages`] says what the
5//! symbols of each language are. The second is built against the first and refuses to be used with
6//! any other, since counting Rust with settings that name Python would give figures that look
7//! perfectly normal and describe something else.
8//!
9//! ```no_run
10//! use mezura_core::{CountingModel, EngineConfig, Languages, run};
11//!
12//! let config = EngineConfig::new(["./src", "./tests"]);
13//! let (languages, warnings) = Languages::shipped(&config);
14//! for warning in &warnings {
15//!     eprintln!("{}", warning.message);
16//! }
17//!
18//! let result = run(&config, languages)?;
19//! for (name, stats) in result.sort_languages_by(Default::default(), CountingModel::Content) {
20//!     println!("{name}: {} code", stats.calculate_code_lines(CountingModel::Content));
21//! }
22//! # Ok::<(), mezura_core::RunError>(())
23//! ```
24//!
25//! Every line is sorted into one of the nine [`LineClasses`], and a [`CountingModel`] folds those
26//! nine into the three columns of a report, so one run answers both models.
27//!
28//! [`run_watched`] is the same run for a caller that needs real time feedback while it happens, and
29//! [`explain_file`] reads a single file line by line and says why each line was counted the way it
30//! was.
31
32#![forbid(unsafe_code)]
33#![warn(missing_docs)]
34#![warn(unreachable_pub)]
35#![allow(non_snake_case)]
36
37#[cfg(test)]
38#[macro_use]
39mod test_support;
40
41mod domain;
42mod explain;
43mod phase_timing;
44mod progress;
45mod result;
46
47pub mod engine;
48pub mod language_file;
49pub mod languages;
50pub mod render;
51pub mod warnings;
52
53pub use domain::{Bucket, CountingModel, Keyword, Language, LeveledPair, LineClass, LineClasses,
54        LineContinuation, MultilineString, NestedLanguage, Span, SpanKind, Stats, StringRules};
55pub use engine::config::{EngineConfig, ForcedLanguages, LanguageNames, ScopedByModule, Target,
56        Threads, format_module_scope, split_off_module_scope};
57pub use engine::identity::{Claim, ClaimKind, SettledBy};
58pub use engine::targets::TargetError;
59pub use explain::{Carried, ExplainError, ExplainedLine, FileExplanation, explain_file};
60pub use languages::{LanguageClaims, Languages};
61pub use progress::ScanProgress;
62pub use result::{FaultyFileDetails, FileEntry, FilesPresent, ModuleResult, Performance, RunError,
63        RunResult, ScanSkip, SkippedFiles, SortCriterion, UnreadableDirDetails};
64pub use warnings::{Affects, Code, Warning};
65
66#[cfg(test)]
67pub(crate) use test_support::{languages_claiming, test_paths};
68
69use std::collections::HashMap;
70use std::path::{Path, PathBuf};
71use std::sync::{Arc, Mutex};
72use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
73use std::time::Instant;
74
75use crossbeam_deque::{Injector, Worker};
76
77use engine::modules::{ModuleId, Modules};
78
79/// The name of the file that decides which language gets an extension or a file name two of them
80/// claim.
81///
82/// Nothing in this crate reads or writes it: a caller keeps that file wherever it keeps the rest,
83/// parses it with [`language_file::parse_conflict_rules_file`] and hands the rules to
84/// [`Languages::resolve`]. The name is here because the warning about an unsettled extension is
85/// written here and points the reader at the file.
86pub const LANGUAGE_CONFLICTS_FILE_NAME : &str = "language_conflicts.txt";
87/// The name of the report row holding everything no target was given a name for.
88pub const UNNAMED_MODULE_NAME : &str = "(unnamed)";
89
90pub(crate) type FaultyFilesListMut = Arc<Mutex<Vec<FaultyFileDetails>>>;
91pub(crate) type SharedModuleLookups = Arc<engine::identity::ModuleLookups>;
92// One bucket per module. A run where the user named no modules at all has exactly one bucket, so
93// nothing further down has two shapes to handle.
94pub(crate) type StatsMapMut = Arc<Mutex<Vec<HashMap<String,Stats>>>>;
95pub(crate) type NestedLanguageMapMut = Arc<Mutex<Vec<HashMap<String,HashMap<String,Stats>>>>>;
96pub(crate) type FilesPerModuleMut = Arc<Mutex<Vec<HashMap<String, Vec<FileEntry>>>>>;
97
98/// Counts the directories and files the configuration names, and gives back the figures.
99///
100/// The languages must have been resolved against this same configuration, and the run refuses the
101/// pair otherwise: resolving is what applies the chosen and excluded languages and the forced
102/// extensions, so an ill-matched pair would count one set of languages while the settings describe
103/// another.
104///
105/// Blocks until everything has been counted. Failing to read some of the files is not an error, and
106/// comes back in [`RunResult::faulty_files`]; the cases that are one are [`RunError`].
107pub fn run(config: &EngineConfig, languages: Languages) -> Result<RunResult, RunError> {
108    run_watched(config, languages, None, |_| {})
109}
110
111/// The same run, for a caller that needs real time feedback while it happens.
112///
113/// The progress counters move as files are found and parsed, so a thread of the caller's can read
114/// them while this one blocks.
115///
116/// `on_traversal_done` is called once, as soon as the directories have been scanned, with the files
117/// that were found; the counting of those files is still going on at that point. It is called on
118/// every run that returns `Ok`, including one that found nothing, and never on a run whose scanning
119/// thread died, because the figures such a run leaves behind are lower than what is really on disk.
120pub fn run_watched(config: &EngineConfig, languages: Languages, progress: Option<Arc<ScanProgress>>,
121        on_traversal_done: impl FnOnce(FilesPresent)) -> Result<RunResult, RunError>
122{
123    let progress = progress.unwrap_or_default();
124    // Guarded rather than raised on each return: 'run' refuses in six places before the walk ever
125    // starts, and a watcher of the public flag must see it rise on every one of them.
126    let _walk_ends = WalkDoneGuard(progress.clone());
127    if config.targets.is_empty() {
128        return Err(RunError::NoTargets);
129    }
130    // Checked before anything is read from disk.
131    if !languages.describe_the_same_selection_as(config) {
132        return Err(RunError::LanguagesFromAnotherConfig);
133    }
134    // Idempotent, so a caller that resolved its own targets earlier loses nothing here.
135    let targets = engine::targets::resolve(&config.targets, ObeyedIgnoreFiles::of(config),
136            config.should_search_in_dotted).map_err(RunError::InvalidTargets)?;
137    let config = Arc::new(config.clone());
138    let faulty_files_ref : FaultyFilesListMut  = Arc::new(Mutex::new(Vec::with_capacity(10)));
139    let finish_condition_ref = Arc::new(AtomicBool::new(false));
140    let (by_name, lookups, nested_definitions) = languages.into_parts();
141    let language_map_ref = Arc::new(by_name);
142    let nested_definitions = Arc::new(nested_definitions);
143    let modules = Arc::new(Modules::of(&targets));
144    // Only here can the lookups be put in the order the walk wants them: which number a module was
145    // given is decided by the targets, and the languages were resolved before they were seen.
146    let language_lookups: SharedModuleLookups = Arc::new(lookups.into_lookups_per_module(&modules));
147    let stats_per_module : StatsMapMut =
148            Arc::new(Mutex::new(make_language_stats(&language_map_ref, modules.count())));
149    let nested_per_module : NestedLanguageMapMut =
150            Arc::new(Mutex::new(vec![HashMap::new(); modules.count()]));
151    let files_per_module : FilesPerModuleMut =
152            Arc::new(Mutex::new(vec![HashMap::new(); modules.count()]));
153
154    let mut files_present = FilesPresent::default();
155    let idle_producers = Arc::new(AtomicUsize::new(0));
156    let files_injector = Arc::new(Injector::<ParsableFile>::new());
157    let dirs_injector = Arc::new(Injector::<TraversedDir>::new());
158    let exclude_matcher = Arc::new(engine::targets::build_exclude_matcher(&config.exclude_dirs)
159            .map_err(|_| {
160                // The builder rewrites every pattern into a longer form before compiling it, and its
161                // error quotes that rewritten text, which the user never typed. Trying them one at a
162                // time finds the broken one, so the error can quote it as it was written.
163                let culprit = config.exclude_dirs.iter()
164                        .find(|x| engine::targets::build_exclude_matcher(std::slice::from_ref(x)).is_err())
165                        .cloned().unwrap_or_default();
166                RunError::InvalidExcludePattern(culprit)
167            })?);
168    queue_the_targets(&config, &targets, &dirs_injector, &files_injector, &mut files_present,
169            &language_lookups, &modules, &progress);
170
171    let files_stats = Arc::new(Mutex::new(files_present));
172    let unreadable_dirs = Arc::new(Mutex::new(Vec::new()));
173
174    let mut producer_handles = Vec::with_capacity(config.threads.producers());
175    let mut consumer_handles = Vec::with_capacity(config.threads.consumers());
176    // Producers stop when the idle count reaches this, so until every spawn is done it holds a value
177    // that count can never reach: against a total still growing, the first producer to go idle would
178    // see itself as the last one standing.
179    let producers_total = Arc::new(AtomicUsize::new(usize::MAX));
180    let worker_panics: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
181
182    // A thread the operating system refuses is a slower run and not a different answer, so the run
183    // carries on with what it was given. Zero of either side is the exception, below.
184    let parsing_started_instant = Instant::now();
185    let mut last_refusal = None;
186    // Each producer keeps the subdirectories it finds and every other one can take them off it, so
187    // the queues have to exist before any thread starts.
188    let workers = (0..config.threads.producers())
189            .map(|_| Worker::<TraversedDir>::new_lifo()).collect::<Vec<_>>();
190    let stealers = Arc::new(workers.iter().map(Worker::stealer).collect::<Vec<_>>());
191    for (i, worker) in workers.into_iter().enumerate() {
192        match engine::producer::start_producer_thread(i, files_injector.clone(), dirs_injector.clone(), worker,
193                stealers.clone(), idle_producers.clone(), language_lookups.clone(), exclude_matcher.clone(),
194                config.clone(), files_stats.clone(), modules.clone(), unreadable_dirs.clone(),
195                producers_total.clone(), worker_panics.clone(), progress.clone()) {
196            Ok(handle) => producer_handles.push(handle),
197            Err(x) => last_refusal = Some(x)
198        }
199    }
200    if producer_handles.is_empty() {
201        return Err(RunError::NoThreadsAvailable { side: "producer", error: last_refusal.unwrap() });
202    }
203    producers_total.store(producer_handles.len(), Ordering::SeqCst);
204
205    // Written by whichever consumer stops last, read once they have all been joined.
206    let counting_ended = Arc::new(AtomicU64::new(0));
207    // Decided by the counting and not by the walk, so they are read after the joins below
208    let skipped_files: Arc<Mutex<SkippedFiles>> = Arc::new(Mutex::new(SkippedFiles::default()));
209    for i in 0..config.threads.consumers() {
210        match engine::consumer::start_parser_thread(i, files_injector.clone(), faulty_files_ref.clone(), finish_condition_ref.clone(),
211                stats_per_module.clone(), nested_per_module.clone(), files_per_module.clone(),
212                language_map_ref.clone(), nested_definitions.clone(), language_lookups.clone(), config.clone(),
213                parsing_started_instant, counting_ended.clone(), skipped_files.clone(),
214                progress.clone()) {
215            Ok(handle) => consumer_handles.push(handle),
216            Err(x) => last_refusal = Some(x)
217        }
218    }
219    if consumer_handles.is_empty() {
220        // Joined so that no thread outlives the call that started it.
221        for handle in producer_handles {
222            let _ = handle.join();
223        }
224        return Err(RunError::NoThreadsAvailable { side: "consumer", error: last_refusal.unwrap() });
225    }
226
227    let threads_used = Threads::new(producer_handles.len(), consumer_handles.len());
228    for handle in producer_handles {
229        let _ = handle.join();
230    }
231    // After the join and not before it, which reads as the more accurate place: a watcher starts
232    // timing the counting the moment this flag rises, and only here is nothing else left competing
233    // for the cores. Raised earlier, the pace it measures comes out low.
234    progress.mark_walk_done();
235    let producers_done_millis = parsing_started_instant.elapsed().as_millis();
236
237    let queued_at_producer_exit = files_injector.len();
238
239    finish_condition_ref.store(true,Ordering::Relaxed);
240
241    // The callback goes below the flag above and never above it. It is the caller's code, it may
242    // panic, and a panic here unwinds past the joins with the consumers still running: they leave
243    // their loop only on that flag, so raising it first is what lets them finish instead of spinning
244    // forever.
245    //
246    // A producer that died merged none of its share, so these counters are short of what is on disk
247    // and the run is about to refuse them anyway. Announcing them would put a number on screen that
248    // the error two steps down contradicts.
249    //
250    // Poisoning is tolerated on both locks because this sits above the guard that turns a dead worker
251    // into an error: panicking here would report a mutex instead of what actually happened.
252    let walk_was_whole = worker_panics.lock().unwrap_or_else(std::sync::PoisonError::into_inner).is_empty();
253    let files_present = *files_stats.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
254    if walk_was_whole {
255        on_traversal_done(files_present);
256    }
257
258    for handle in consumer_handles {
259        if let Err(payload) = handle.join() {
260            worker_panics.lock().unwrap().push(panic_message(payload.as_ref()));
261        }
262    }
263    // From the consumers and not from the clock here, which cannot tell the counting apart from the
264    // callback: both run on their own side of this thread, and it holds whichever finished last.
265    //
266    // The floor matters for a run whose consumers all died and recorded nothing. That is an error
267    // two steps down and should stay one rather than becoming an underflow in the line below.
268    let parsing_duration_millis = u128::from(counting_ended.load(Ordering::Relaxed)).max(producers_done_millis);
269
270    if *phase_timing::ENABLED {
271        eprintln!("[phase] producers alive: {} ms | drain after producers: {} ms | queue size at producer exit: {}",
272            producers_done_millis, parsing_duration_millis - producers_done_millis, queued_at_producer_exit);
273        eprintln!("{}", phase_timing::report(threads_used.consumers(), parsing_duration_millis));
274    }
275
276    // Ahead of every lock below, so that a dead worker is reported as itself rather than as whichever
277    // mutex it poisoned. Nothing past this line runs unless every worker finished whole, which is
278    // what leaves those locks clean.
279    let worker_panics = std::mem::take(&mut *worker_panics.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
280    if !worker_panics.is_empty() {
281        return Err(RunError::IncompleteRun { worker_panic: worker_panics.join(" | ") });
282    }
283
284    // Sorted here and not by a presenter, because the threads append in whichever order they
285    // finish and two runs over one tree must print one list.
286    let mut skipped_files = std::mem::take(&mut *skipped_files.lock().unwrap());
287    for kind in ScanSkip::ALL {
288        skipped_files.get_of_kind_mut(kind).sort_unstable();
289    }
290    let relevant_files_num = files_present.relevant_files;
291    if relevant_files_num == 0 {
292        return Ok(RunResult::of_nothing(files_present,
293                Performance { duration_millis: parsing_duration_millis, threads: threads_used }, &modules,
294                targets.to_vec(), std::mem::take(&mut unreadable_dirs.lock().unwrap())));
295    }
296
297    let mut stats_guard = stats_per_module.lock();
298    let per_module = stats_guard.as_deref_mut().unwrap();
299    let mut nested_guard = nested_per_module.lock();
300    let nested_by_module = nested_guard.as_deref_mut().unwrap();
301    let mut files_guard = files_per_module.lock();
302    let files_by_module = files_guard.as_deref_mut().unwrap();
303
304    let mut per_language = merge_over_modules(per_module);
305    // Dropped before the total is summed, or the total's keyword map would name the keywords of
306    // every language the run selected, including the ones no file was written in. The figures are
307    // the same either way, since an empty language adds nothing.
308    remove_languages_with_0_files(&mut per_language);
309    let total = Stats::total_of(&per_language);
310
311    let nested_languages = merge_nested_over_modules(nested_by_module);
312
313    let modules_result = per_module.iter_mut().enumerate().map(|(id, bucket)| {
314        let mut of_this_module = std::mem::take(bucket);
315        remove_languages_with_0_files(&mut of_this_module);
316        // A module that found nothing still gets its row: it was asked for by name, and its absence
317        // would read as a mistake in the report.
318        ModuleResult {
319            name: modules.name_of(id as ModuleId).map(str::to_owned),
320            total: Stats::total_of(&of_this_module),
321            per_language: of_this_module,
322            nested_languages: std::mem::take(&mut nested_by_module[id]),
323            files: std::mem::take(&mut files_by_module[id])
324        }
325    }).collect::<Vec<_>>();
326
327    Ok(RunResult {
328        per_language,
329        total,
330        nested_languages,
331        modules: modules_result,
332        faulty_files: std::mem::take(&mut faulty_files_ref.lock().unwrap()),
333        skipped_files,
334        files_present,
335        performance: Performance { duration_millis: parsing_duration_millis, threads: threads_used },
336        targets: targets.to_vec(),
337        unreadable_dirs: std::mem::take(&mut unreadable_dirs.lock().unwrap())
338    })
339}
340
341/// Whether this run will print a report of where its time went to the error output, which the
342/// `MEZURA_PHASE_TIMING` environment variable asks for.
343///
344/// Worth asking before drawing live lines of your own on the error output, so the two do not land
345/// on top of each other.
346pub fn prints_phase_timing() -> bool {
347    *phase_timing::ENABLED
348}
349
350struct WalkDoneGuard(Arc<ScanProgress>);
351
352impl Drop for WalkDoneGuard {
353    fn drop(&mut self) {
354        self.0.mark_walk_done();
355    }
356}
357
358// Fills the two queues the threads work from: a target that is a single file goes straight onto the
359// file queue, a directory is put in the queue for a scanning thread to descend into.
360//
361// Only the outermost targets are queued. One that sits inside another is reached by the scan of the
362// one around it, and queueing both would count its files twice; the name it was given is not lost
363// with it, the module table still hands it back on the way down.
364pub(crate) fn queue_the_targets(config: &EngineConfig, targets: &engine::targets::Targets,
365        dirs_injector: &Arc<Injector<TraversedDir>>, files_injector: &Arc<Injector<ParsableFile>>,
366        files_present: &mut FilesPresent, language_lookups: &engine::identity::ModuleLookups, modules: &Modules,
367        progress: &ScanProgress)
368{
369    for target in crate::engine::targets::topmost_targets(targets) {
370        let dir_path = Path::new(&target.path);
371        let module = modules.of_target(&target);
372        if dir_path.is_file() {
373            let lookup = language_lookups.get_of_module(module);
374            let Some(lang_name) = lookup.of_path_or_shebang(dir_path) else {
375                continue;
376            };
377            let size = std::fs::metadata(dir_path).map_or(0, |m| m.len());
378            let queued = match targets.was_written_by_hand(dir_path) {
379                true => ParsableFile::written_by_hand(dir_path.to_path_buf(), lang_name, module, size),
380                false => ParsableFile::new(dir_path.to_path_buf(), lang_name, module, size)
381            };
382            files_injector.push(queued.with_extension_rules(lookup.find_extension_rules(dir_path)));
383            files_present.total_files += 1;
384            files_present.relevant_files += 1;
385            progress.record_file_found();
386        } else if dir_path.is_dir() {
387            let gitignore_stack = GitignoreStack::for_root_dir(dir_path, ObeyedIgnoreFiles::of(config));
388            dirs_injector.push(TraversedDir::new(dir_path.to_path_buf(), gitignore_stack, module));
389        }
390    }
391}
392
393// A language nobody wrote a file in would take a row in every report and add nothing to any figure.
394pub(crate) fn remove_languages_with_0_files(languages: &mut HashMap<String,Stats>) {
395    languages.retain(|_, stats| stats.files > 0);
396}
397
398// A bucket for every language in every module, built up front: the merge that ends a consumer
399// reaches into this map by name, and a pair with no slot would kill the thread rather than miscount.
400pub(crate) fn make_language_stats(languages: &HashMap<String,Language>, modules: usize) -> Vec<HashMap<String,Stats>> {
401    let of_one_module = languages.iter().map(|(name, language)| (name.to_owned(), Stats::from(language)))
402            .collect::<HashMap<_,_>>();
403    vec![of_one_module; modules]
404}
405
406#[derive(Debug,Clone)]
407pub(crate) struct ParsableFile {
408    pub path: PathBuf,
409    pub language_name: Arc<str>,
410    pub module: ModuleId,
411    // As the directory listing gave it. Zero where it could not, and the read then goes on until
412    // the file ends.
413    pub size: u64,
414    // Named as a target rather than found by the walk, which is what exempts it from every rule
415    // that skips a file. The ignore files, the dotted names and the head checks all pass it through.
416    pub written_by_hand: bool,
417    pub extension_rules: Option<Arc<engine::identity::ExtensionRules>>
418}
419
420impl ParsableFile {
421    pub(crate) fn new(path: PathBuf, language_name: Arc<str>, module: ModuleId, size: u64) -> Self {
422        ParsableFile {
423            path,
424            language_name,
425            module,
426            size,
427            written_by_hand: false,
428            extension_rules: None
429        }
430    }
431
432    pub(crate) fn written_by_hand(path: PathBuf, language_name: Arc<str>, module: ModuleId, size: u64) -> Self {
433        ParsableFile { written_by_hand: true, ..ParsableFile::new(path, language_name, module, size) }
434    }
435
436    pub(crate) fn with_extension_rules(mut self, extension_rules: Option<Arc<engine::identity::ExtensionRules>>) -> Self {
437        self.extension_rules = extension_rules;
438        self
439    }
440}
441
442#[derive(Debug,Clone)]
443pub(crate) struct TraversedDir {
444    pub path: PathBuf,
445    pub gitignore_stack: Option<Arc<GitignoreStack>>,
446    pub module: ModuleId
447}
448
449impl TraversedDir {
450    pub(crate) fn new(path: PathBuf, gitignore_stack: Option<Arc<GitignoreStack>>, module: ModuleId) -> Self {
451        TraversedDir {
452            path,
453            gitignore_stack,
454            module
455        }
456    }
457}
458
459// Which of the ignore files a walk obeys. Two answers rather than one, because a '.gitignore' is
460// the repository's decision and a '.ignore' is the decision of whoever set up their search tools,
461// and a vendored dependency is routinely kept by the first and hidden by the second.
462#[derive(Debug, Clone, Copy)]
463pub(crate) struct ObeyedIgnoreFiles {
464    pub gitignore: bool,
465    pub search_tools: bool
466}
467
468impl ObeyedIgnoreFiles {
469    pub(crate) fn of(config: &EngineConfig) -> ObeyedIgnoreFiles {
470        ObeyedIgnoreFiles { gitignore: !config.no_gitignore, search_tools: !config.no_ignore_files }
471    }
472
473    pub(crate) fn obeys_nothing(self) -> bool {
474        !self.gitignore && !self.search_tools
475    }
476
477    // In the order they overrule each other, which is the order they are read in: the last rule
478    // that matches is the one that answers, so a '!keep' in '.rgignore' stands against an entry in
479    // '.gitignore' however the two files are written. That is the order ripgrep reads them in.
480    pub(crate) fn get_file_names(self) -> impl Iterator<Item = &'static str> {
481        [(".gitignore", self.gitignore), (".ignore", self.search_tools), (".rgignore", self.search_tools)]
482                .into_iter().filter_map(|(name, obeyed)| obeyed.then_some(name))
483    }
484}
485
486// The ignore files that apply at one depth, innermost first, each linked to the one above it. The
487// walk extends the chain as it descends so no directory reparses its parents' rules. One matcher
488// per directory holds all of that directory's files together, which is what gives them their order.
489#[derive(Debug)]
490pub(crate) struct GitignoreStack {
491    matcher: ignore::gitignore::Gitignore,
492    parent: Option<Arc<GitignoreStack>>
493}
494
495impl GitignoreStack {
496    // The names must arrive in the order 'get_file_names' gives them, since the last rule that
497    // matches is the one that answers.
498    pub(crate) fn extend_with_ignore_files(dir: &Path, parent: Option<Arc<GitignoreStack>>, names: &[&str])
499    -> Option<Arc<GitignoreStack>>
500    {
501        if names.is_empty() {
502            return parent;
503        }
504
505        let mut builder = ignore::gitignore::GitignoreBuilder::new(dir);
506        for name in names {
507            // Ignored the way 'Gitignore::new' ignores it: a file that could not be read, or a
508            // pattern that does not parse, costs that one rule and not the whole walk
509            let _ = builder.add(dir.join(name));
510        }
511
512        match builder.build() {
513            Ok(matcher) if !matcher.is_empty() => Some(Arc::new(GitignoreStack { matcher, parent })),
514            _ => parent
515        }
516    }
517
518    pub(crate) fn extend_with_dir(dir: &Path, parent: Option<Arc<GitignoreStack>>, obeyed: ObeyedIgnoreFiles)
519    -> Option<Arc<GitignoreStack>>
520    {
521        let present = obeyed.get_file_names().filter(|name| dir.join(name).is_file()).collect::<Vec<_>>();
522        Self::extend_with_ignore_files(dir, parent, &present)
523    }
524
525    // The ignore files of every dir between the repository root and the given dir, excluding it
526    fn of_ancestors(dir: &Path, obeyed: ObeyedIgnoreFiles) -> Option<Arc<GitignoreStack>> {
527        if dir.join(".git").exists() {
528            return None;
529        }
530
531        let mut relevant_ancestors: Vec<&Path> = Vec::new();
532        for ancestor in dir.ancestors().skip(1) {
533            relevant_ancestors.push(ancestor);
534            if ancestor.join(".git").exists() {
535                break;
536            }
537        }
538
539        let mut stack = None;
540        for ancestor in relevant_ancestors.iter().rev() {
541            stack = Self::extend_with_dir(ancestor, stack, obeyed);
542        }
543        stack
544    }
545
546    // Explicitly given target dirs are traversed even if an ignore file of their ancestors ignores them
547    pub(crate) fn for_root_dir(dir: &Path, obeyed: ObeyedIgnoreFiles) -> Option<Arc<GitignoreStack>> {
548        if obeyed.obeys_nothing() {
549            return None;
550        }
551        let stack = Self::of_ancestors(dir, obeyed);
552        if let Some(s) = &stack && s.is_ignored(dir, true) {
553            return None;
554        }
555        stack
556    }
557
558    // Used for paths that the program discovered on its own, like the matches of a glob pattern
559    pub(crate) fn is_path_ignored(path: &Path, obeyed: ObeyedIgnoreFiles) -> bool {
560        if obeyed.obeys_nothing() {
561            return false;
562        }
563        let is_dir = path.is_dir();
564        let Some(parent) = path.parent() else { return false };
565
566        let stack = Self::extend_with_dir(parent, Self::of_ancestors(parent, obeyed), obeyed);
567        match stack {
568            Some(x) => x.is_ignored_with_ancestor_dirs(path, is_dir),
569            None => false
570        }
571    }
572
573    // Unlike the traversal, which prunes ignored dirs as it descends and therefore only has to
574    // check the entry itself, a standalone path has to be checked against its parent dirs too
575    fn is_ignored_with_ancestor_dirs(&self, path: &Path, is_dir: bool) -> bool {
576        let mut node = Some(self);
577        while let Some(stack) = node {
578            match stack.matcher.matched_path_or_any_parents(path, is_dir) {
579                ignore::Match::Ignore(_) => return true,
580                ignore::Match::Whitelist(_) => return false,
581                ignore::Match::None => {}
582            }
583            node = stack.parent.as_deref();
584        }
585
586        false
587    }
588
589    pub(crate) fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
590        let mut node = Some(self);
591        while let Some(stack) = node {
592            match stack.matcher.matched(path, is_dir) {
593                ignore::Match::Ignore(_) => return true,
594                ignore::Match::Whitelist(_) => return false,
595                ignore::Match::None => {}
596            }
597            node = stack.parent.as_deref();
598        }
599
600        false
601    }
602}
603
604fn merge_over_modules(per_module: &[HashMap<String,Stats>]) -> HashMap<String,Stats> {
605    let mut merged = per_module[0].clone();
606    for of_a_module in &per_module[1..] {
607        for (name, stats) in of_a_module {
608            merged.entry(name.clone()).or_default().add(stats);
609        }
610    }
611
612    merged
613}
614
615fn merge_nested_over_modules(nested_by_module: &[HashMap<String, HashMap<String, Stats>>])
616-> HashMap<String, HashMap<String, Stats>> {
617    let mut merged: HashMap<String, HashMap<String, Stats>> = HashMap::new();
618    for bucket in nested_by_module {
619        for (shell_name, sections) in bucket {
620            let shell_entry = merged.entry(shell_name.clone()).or_default();
621            for (inner_name, stats) in sections {
622                shell_entry.entry(inner_name.clone()).or_default().add(stats);
623            }
624        }
625    }
626
627    merged
628}
629
630// A panic's payload as text. 'panic!' with a literal carries a '&str' and everything formatted
631// carries a 'String'; anything else is somebody's own type and has no text to give.
632pub(crate) fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
633    if let Some(text) = payload.downcast_ref::<&'static str>() {
634        (*text).to_owned()
635    } else if let Some(text) = payload.downcast_ref::<String>() {
636        text.clone()
637    } else {
638        "a worker died with a panic payload that is not text".to_owned()
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    // Asserted here and not through a run, since a result has had the empty languages removed from
647    // it by then.
648    #[test]
649    fn every_language_gets_a_bucket_in_every_module() {
650        let languages = languages_claiming(&[("Rust", &["rs"]), ("Go", &["go"]), ("Zig", &["zig"])]);
651        let modules = Modules::of(&[crate::engine::config::Target::named("backend", "./api"),
652                crate::engine::config::Target::named("frontend", "./web"),
653                crate::engine::config::Target::of("./docs")]);
654        assert_eq!(3, modules.count());
655
656        let stats = make_language_stats(&languages, modules.count());
657        assert_eq!(modules.count(), stats.len());
658
659        for (id, of_a_module) in stats.iter().enumerate() {
660            for name in languages.keys() {
661                assert!(of_a_module.contains_key(name), "'{name}' has no bucket in module {id}");
662            }
663        }
664    }
665
666    #[test]
667    fn the_total_is_the_languages_added_together() {
668        let counted = |code, comments| LineClasses {
669                words_in_code: code, words_in_comment: comments, ..Default::default() };
670        let languages = hashmap![
671            "a".to_owned() => Stats::new(20, 100_000, 2000, counted(1400, 100), hashmap!["classes".to_owned() => 7]),
672            "b".to_owned() => Stats::new(10, 50_000, 1000, counted(800, 50), hashmap!["classes".to_owned() => 2]),
673            "c".to_owned() => Stats::new(10, 50_000, 1000, counted(800, 50), hashmap!["structs".to_owned() => 5])
674        ];
675        let total = Stats::total_of(&languages);
676
677        assert_eq!(40, total.files);
678        assert_eq!(200_000, total.bytes);
679        assert_eq!(4000, total.lines);
680        assert_eq!(3000, total.calculate_code_lines(CountingModel::Content));
681        assert_eq!(200, total.calculate_comment_lines(CountingModel::Content));
682        assert_eq!(800, total.calculate_extra_lines(CountingModel::Content));
683        // 'classes' is declared by two of the three, so its total is 7 + 2
684        assert_eq!(Some(&9), total.keyword_occurences.get("classes"));
685        assert_eq!(Some(&5), total.keyword_occurences.get("structs"));
686
687        // nothing to add up is a total of nothing; 'average_size' over no files is asserted in 'domain'
688        assert_eq!(0, Stats::total_of(&HashMap::new()).files);
689    }
690}
691
692// What 'run' owes its caller when a worker thread dies: an error, never a number it knows is short.
693// The two hooks that cause the deaths fire on the corpus names used here and on nothing else.
694#[cfg(test)]
695mod worker_death_tests {
696    use crate::{EngineConfig, Languages, RunError, run, run_watched};
697
698    fn corpus(name: &str) -> (std::path::PathBuf, EngineConfig) {
699        let root = std::env::temp_dir().join(name);
700        let _ = std::fs::remove_dir_all(&root);
701        std::fs::create_dir_all(&root).unwrap();
702        std::fs::write(root.join("a.rs"), "fn a() { let x = 1; }\n").unwrap();
703        let config = EngineConfig {
704            threads: crate::Threads::new(2, 2),
705            ..EngineConfig::new([root.to_string_lossy().replace('\\', "/")])
706        };
707        (root, config)
708    }
709
710    fn languages_for(config: &EngineConfig) -> Languages {
711        let languages = crate::language_file::parse_languages_in_dir(crate::test_paths::LANGUAGES_DIR).unwrap().0;
712        Languages::resolve(config, languages, &Default::default()).0
713    }
714
715    #[test]
716    fn a_dead_consumer_is_an_error_and_not_a_short_count() {
717        let (root, config) = corpus("mezura-dead-consumer");
718
719        let err = run(&config, languages_for(&config));
720        std::fs::remove_dir_all(&root).unwrap();
721        let (clean_root, clean_config) = corpus("mezura-alive-consumer");
722        let clean = run(&clean_config, languages_for(&clean_config));
723        std::fs::remove_dir_all(&clean_root).unwrap();
724
725        let err = err.expect_err("a consumer died and run returned a result anyway");
726        assert!(matches!(&err, RunError::IncompleteRun { worker_panic } if worker_panic.contains("test-induced consumer panic")),
727                "got: {err:?}");
728        // and the hook answers to that corpus name alone, so an ordinary run is untouched
729        assert_eq!(1, clean.unwrap().total.files);
730    }
731
732    #[test]
733    fn a_dead_producer_is_an_error_and_the_run_still_terminates() {
734        let (root, config) = corpus("mezura-dead-producer");
735
736        let err = run(&config, languages_for(&config));
737        std::fs::remove_dir_all(&root).unwrap();
738        let (clean_root, clean_config) = corpus("mezura-alive-producer");
739        let clean = run(&clean_config, languages_for(&clean_config));
740        std::fs::remove_dir_all(&clean_root).unwrap();
741
742        let err = err.expect_err("a producer died and run returned a result anyway");
743        assert!(matches!(&err, RunError::IncompleteRun { worker_panic } if worker_panic.contains("test-induced producer panic")),
744                "got: {err:?}");
745        assert_eq!(1, clean.unwrap().total.files);
746    }
747
748    // A dead producer takes its share of the walk with it and merges nothing, so the counters left
749    // behind are short. The announcement fires before the guard that turns the death into an error,
750    // so it is the one thing that could put a wrong number on the screen a moment before the run
751    // refuses it.
752    #[test]
753    fn a_walk_whose_own_thread_died_is_never_announced() {
754        let (root, config) = corpus("mezura-dead-producer-announce");
755        let mut announced = Vec::new();
756        let outcome = run_watched(&config, languages_for(&config), None, |scan| announced.push(scan));
757        std::fs::remove_dir_all(&root).unwrap();
758
759        // the hook answers to 'mezura-dead-producer' as a prefix, so this corpus dies the same way
760        assert!(matches!(&outcome, Err(RunError::IncompleteRun { .. })), "got: {outcome:?}");
761        assert!(announced.is_empty(), "a walk that lost a thread was announced anyway: {announced:?}");
762
763        // the same run with every thread intact does announce, so the guard is the difference
764        let (clean_root, clean_config) = corpus("mezura-alive-producer-announce");
765        let mut announced = Vec::new();
766        let clean = run_watched(&clean_config, languages_for(&clean_config), None, |scan| announced.push(scan));
767        std::fs::remove_dir_all(&clean_root).unwrap();
768        assert_eq!(1, clean.unwrap().total.files);
769        assert_eq!(1, announced.len(), "an intact walk was not announced");
770    }
771
772    // The integration test beside this one only reaches the case where the callback is the last
773    // thing running. Here the counting outlasts it, which is what every run over a real tree looks
774    // like, so nothing should come off the figure: ten files at forty milliseconds through one
775    // consumer is four hundred milliseconds of counting under a callback that sleeps a hundred and
776    // fifty.
777    #[test]
778    fn a_callback_that_finishes_before_the_counting_takes_nothing_off_the_duration() {
779        const SLEPT_PER_FILE : u128 = 40;
780        const FILES : u128 = 10;
781        let callback_holds = std::time::Duration::from_millis(150);
782
783        let root = std::env::temp_dir().join("mezura-slow-consumer-clock");
784        let _ = std::fs::remove_dir_all(&root);
785        std::fs::create_dir_all(&root).unwrap();
786        for i in 0..FILES {
787            std::fs::write(root.join(format!("f{i}.rs")), "fn a() { let x = 1; }\n").unwrap();
788        }
789        // One consumer, so the sleeps add up instead of overlapping and the expected floor is
790        // arithmetic rather than a guess about how many cores are free
791        let config = EngineConfig {
792            threads: crate::Threads::new(1, 1),
793            ..EngineConfig::new([root.to_string_lossy().replace('\\', "/")])
794        };
795
796        let counted = run_watched(&config, languages_for(&config), None,
797                |_| std::thread::sleep(callback_holds)).unwrap();
798        std::fs::remove_dir_all(&root).unwrap();
799
800        assert_eq!(FILES as usize, counted.total.files);
801        let counting_took = SLEPT_PER_FILE * FILES;
802        // Only the sleeps are asserted on, never the parsing around them, so a slow machine can only
803        // push the figure up and the floor holds wherever this runs
804        assert!(counted.performance.duration_millis >= counting_took,
805                "{} ms of counting under a callback that held {} ms was reported as {} ms, so the \
806                 callback was taken off a run it never delayed", counting_took,
807                callback_holds.as_millis(), counted.performance.duration_millis);
808    }
809}