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