Skip to main content

crap4rust/
file_walker.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use syn::File;
10use walkdir::WalkDir;
11
12use crate::item_visitor::ItemVisitor;
13use crate::normalize_path::normalize_path;
14use crate::package_context::PackageContext;
15use crate::source_function::SourceFunction;
16use crate::test_module_registry::TestModuleRegistry;
17
18type ParsedFile = (PathBuf, File);
19
20pub struct FileWalker<'a> {
21    package: &'a PackageContext,
22    include_test_targets: bool,
23    exclude_paths: &'a [String],
24}
25
26impl<'a> FileWalker<'a> {
27    pub fn new(package: &'a PackageContext) -> Self {
28        Self {
29            package,
30            include_test_targets: package.include_test_targets,
31            exclude_paths: &package.exclude_paths,
32        }
33    }
34
35    pub fn process_source_root(&self, source_root: &Path) -> Result<Vec<SourceFunction>> {
36        if !source_root.exists() {
37            return Ok(Vec::new());
38        }
39
40        let parsed_files = self.collect_parsed_files(source_root)?;
41        let test_modules = TestModuleRegistry::build(&parsed_files);
42
43        let mut functions = Vec::new();
44        for (file_path, syntax) in &parsed_files {
45            if test_modules.is_excluded(file_path) {
46                continue;
47            }
48            functions.extend(self.visit_parsed_file(source_root, file_path, syntax));
49        }
50        Ok(functions)
51    }
52
53    fn collect_parsed_files(&self, source_root: &Path) -> Result<Vec<ParsedFile>> {
54        let mut parsed_files = Vec::new();
55        for entry in WalkDir::new(source_root)
56            .into_iter()
57            .filter_map(|entry| entry.ok())
58            .filter(|entry| entry.file_type().is_file())
59            .filter(|entry| {
60                entry
61                    .path()
62                    .extension()
63                    .is_some_and(|extension| extension == "rs")
64            })
65        {
66            let file_path = entry.path();
67            if !self.is_selected(file_path) {
68                continue;
69            }
70            let source = fs::read_to_string(file_path)
71                .with_context(|| format!("failed to read source file {}", file_path.display()))?;
72            let syntax = syn::parse_file(&source)
73                .with_context(|| format!("failed to parse source file {}", file_path.display()))?;
74            parsed_files.push((file_path.to_path_buf(), syntax));
75        }
76        Ok(parsed_files)
77    }
78
79    fn is_selected(&self, file_path: &Path) -> bool {
80        let relative_file = Self::relative_file(&self.package.manifest_dir, file_path);
81        Self::is_selected_relative_file(&relative_file, self.include_test_targets)
82            && Self::is_selected_source_file(
83                &self.package.manifest_dir,
84                file_path,
85                self.include_test_targets,
86            )
87            && !Self::is_excluded_relative_file(&relative_file, self.exclude_paths)
88    }
89
90    fn visit_parsed_file(
91        &self,
92        source_root: &Path,
93        file_path: &Path,
94        syntax: &File,
95    ) -> Vec<SourceFunction> {
96        let relative_file = Self::relative_file(&self.package.manifest_dir, file_path);
97        let module_prefix = Self::module_prefix(source_root, file_path);
98        let path_key = normalize_path(file_path);
99        let visitor = ItemVisitor::new(self.package, &path_key, &relative_file, &module_prefix);
100        visitor.visit_items(syntax, &[])
101    }
102
103    pub fn relative_file(base_dir: &Path, file_path: &Path) -> String {
104        file_path
105            .strip_prefix(base_dir)
106            .unwrap_or(file_path)
107            .to_string_lossy()
108            .replace('\\', "/")
109    }
110
111    pub fn is_selected_source_file(
112        base_dir: &Path,
113        file_path: &Path,
114        include_test_targets: bool,
115    ) -> bool {
116        let base_dir = normalize_path(base_dir);
117        let file_path = normalize_path(file_path);
118        let Some(relative) = file_path.strip_prefix(&base_dir) else {
119            return true;
120        };
121        let relative = relative.strip_prefix('/').unwrap_or(relative);
122
123        let mut components = relative.split('/');
124        let Some(first) = components.next() else {
125            return true;
126        };
127
128        if matches!(first, "examples" | "benches") {
129            return false;
130        }
131
132        if first == "tests" {
133            return include_test_targets;
134        }
135
136        !relative.ends_with("/build.rs") && relative != "build.rs"
137    }
138
139    pub fn is_excluded_relative_file(relative_file: &str, exclude_paths: &[String]) -> bool {
140        exclude_paths.iter().any(|prefix| {
141            let normalised = prefix.replace('\\', "/");
142            let prefix_with_slash = if normalised.ends_with('/') {
143                normalised.clone()
144            } else {
145                format!("{}/", normalised)
146            };
147            relative_file.starts_with(&prefix_with_slash) || relative_file == normalised
148        })
149    }
150
151    pub fn is_selected_relative_file(relative_file: &str, include_test_targets: bool) -> bool {
152        !relative_file.starts_with("examples/")
153            && !relative_file.starts_with("benches/")
154            && relative_file != "build.rs"
155            && (include_test_targets || !relative_file.starts_with("tests/"))
156    }
157
158    pub fn module_prefix(source_root: &Path, file_path: &Path) -> Vec<String> {
159        let relative = file_path.strip_prefix(source_root).unwrap_or(file_path);
160        let mut prefix = relative
161            .parent()
162            .map(|parent| {
163                parent
164                    .components()
165                    .map(|component| component.as_os_str().to_string_lossy().to_string())
166                    .collect::<Vec<_>>()
167            })
168            .unwrap_or_default();
169        let file_stem = file_path
170            .file_stem()
171            .and_then(|stem| stem.to_str())
172            .unwrap_or_default();
173        if !matches!(file_stem, "lib" | "main" | "mod") {
174            prefix.push(file_stem.to_string());
175        }
176        prefix
177    }
178}