Skip to main content

crap4rust/analysis/
file_walker.rs

1// Copyright 2026 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::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}