Skip to main content

crap4rust/
file_walker.rs

1// Copyright 2025 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License or Apache License, Version 2.0
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::fs;
6use std::path::Path;
7
8use anyhow::{Context, Result};
9use syn::spanned::Spanned;
10use syn::{File, Item, ItemEnum, ItemFn, ItemMod, ItemStruct};
11use walkdir::{DirEntry, WalkDir};
12
13use crate::complexity::cognitive_complexity;
14use crate::impl_collector::{end_line, is_test_attrs, qualified_name, start_line, visit_impl};
15use crate::model::{PackageContext, SourceFunction};
16
17pub(crate) struct FileWalker<'a> {
18    package: &'a PackageContext,
19    include_test_targets: bool,
20    exclude_paths: &'a [String],
21}
22
23impl<'a> FileWalker<'a> {
24    pub(crate) fn new(package: &'a PackageContext) -> Self {
25        Self {
26            package,
27            include_test_targets: package.include_test_targets,
28            exclude_paths: &package.exclude_paths,
29        }
30    }
31
32    pub(crate) fn process_source_root(&self, source_root: &Path) -> Result<Vec<SourceFunction>> {
33        if !source_root.exists() {
34            return Ok(Vec::new());
35        }
36
37        let mut functions = Vec::new();
38        for entry in WalkDir::new(source_root)
39            .into_iter()
40            .filter_map(|entry| entry.ok())
41            .filter(|entry| entry.file_type().is_file())
42            .filter(|entry| {
43                entry
44                    .path()
45                    .extension()
46                    .is_some_and(|extension| extension == "rs")
47            })
48        {
49            functions.extend(self.process_entry(source_root, &entry)?);
50        }
51        Ok(functions)
52    }
53
54    fn process_entry(&self, source_root: &Path, entry: &DirEntry) -> Result<Vec<SourceFunction>> {
55        let file_path = entry.path();
56        let relative_file = relative_file(&self.package.manifest_dir, file_path);
57        if !is_selected_relative_file(&relative_file, self.include_test_targets)
58            || !is_selected_source_file(
59                &self.package.manifest_dir,
60                file_path,
61                self.include_test_targets,
62            )
63            || is_excluded_relative_file(&relative_file, self.exclude_paths)
64        {
65            return Ok(Vec::new());
66        }
67        let module_prefix = module_prefix(source_root, file_path);
68        let source = fs::read_to_string(file_path)
69            .with_context(|| format!("failed to read source file {}", file_path.display()))?;
70        let syntax = syn::parse_file(&source)
71            .with_context(|| format!("failed to parse source file {}", file_path.display()))?;
72
73        let mut functions = Vec::new();
74        visit_items(
75            self.package,
76            &syntax,
77            &normalize_path(file_path),
78            &relative_file,
79            &module_prefix,
80            &mut Vec::new(),
81            &mut functions,
82        );
83        Ok(functions)
84    }
85}
86
87pub fn normalize_path(path: &Path) -> String {
88    let normalized = path
89        .canonicalize()
90        .unwrap_or_else(|_| path.to_path_buf())
91        .to_string_lossy()
92        .replace('\\', "/");
93    if cfg!(windows) {
94        normalized.to_lowercase()
95    } else {
96        normalized
97    }
98}
99
100pub fn relative_file(base_dir: &Path, file_path: &Path) -> String {
101    file_path
102        .strip_prefix(base_dir)
103        .unwrap_or(file_path)
104        .to_string_lossy()
105        .replace('\\', "/")
106}
107
108pub(crate) fn is_selected_source_file(
109    base_dir: &Path,
110    file_path: &Path,
111    include_test_targets: bool,
112) -> bool {
113    let base_dir = normalize_path(base_dir);
114    let file_path = normalize_path(file_path);
115    let Some(relative) = file_path.strip_prefix(&base_dir) else {
116        return true;
117    };
118    let relative = relative.strip_prefix('/').unwrap_or(relative);
119
120    let mut components = relative.split('/');
121    let Some(first) = components.next() else {
122        return true;
123    };
124
125    if matches!(first, "examples" | "benches") {
126        return false;
127    }
128
129    if first == "tests" {
130        return include_test_targets;
131    }
132
133    !relative.ends_with("/build.rs") && relative != "build.rs"
134}
135
136pub fn is_excluded_relative_file(relative_file: &str, exclude_paths: &[String]) -> bool {
137    exclude_paths.iter().any(|prefix| {
138        let normalised = prefix.replace('\\', "/");
139        let prefix_with_slash = if normalised.ends_with('/') {
140            normalised.clone()
141        } else {
142            format!("{}/", normalised)
143        };
144        relative_file.starts_with(&prefix_with_slash) || relative_file == normalised
145    })
146}
147
148pub fn is_selected_relative_file(relative_file: &str, include_test_targets: bool) -> bool {
149    !relative_file.starts_with("examples/")
150        && !relative_file.starts_with("benches/")
151        && relative_file != "build.rs"
152        && (include_test_targets || !relative_file.starts_with("tests/"))
153}
154
155pub(crate) fn module_prefix(source_root: &Path, file_path: &Path) -> Vec<String> {
156    let relative = file_path.strip_prefix(source_root).unwrap_or(file_path);
157    let mut prefix = relative
158        .parent()
159        .map(|parent| {
160            parent
161                .components()
162                .map(|component| component.as_os_str().to_string_lossy().to_string())
163                .collect::<Vec<_>>()
164        })
165        .unwrap_or_default();
166    let file_stem = file_path
167        .file_stem()
168        .and_then(|stem| stem.to_str())
169        .unwrap_or_default();
170    if !matches!(file_stem, "lib" | "main" | "mod") {
171        prefix.push(file_stem.to_string());
172    }
173    prefix
174}
175
176pub(crate) fn visit_items(
177    package: &PackageContext,
178    syntax: &File,
179    path_key: &str,
180    relative_file: &str,
181    module_prefix: &[String],
182    inline_modules: &mut Vec<String>,
183    functions: &mut Vec<SourceFunction>,
184) {
185    for item in &syntax.items {
186        visit_item(
187            package,
188            item,
189            path_key,
190            relative_file,
191            module_prefix,
192            inline_modules,
193            functions,
194        );
195    }
196}
197
198pub(crate) fn visit_item(
199    package: &PackageContext,
200    item: &Item,
201    path_key: &str,
202    relative_file: &str,
203    module_prefix: &[String],
204    inline_modules: &mut Vec<String>,
205    functions: &mut Vec<SourceFunction>,
206) {
207    match item {
208        Item::Fn(item_fn) => {
209            if let Some(function) = record_function(
210                package,
211                item_fn,
212                None,
213                path_key,
214                relative_file,
215                module_prefix,
216                inline_modules,
217            ) {
218                functions.push(function);
219            }
220        }
221        Item::Impl(item_impl) if !is_test_attrs(&item_impl.attrs) => visit_impl(
222            package,
223            item_impl,
224            path_key,
225            relative_file,
226            module_prefix,
227            inline_modules,
228            functions,
229        ),
230        Item::Mod(item_mod) if !is_test_attrs(&item_mod.attrs) => visit_module(
231            package,
232            item_mod,
233            path_key,
234            relative_file,
235            module_prefix,
236            inline_modules,
237            functions,
238        ),
239        Item::Enum(ItemEnum { .. }) | Item::Struct(ItemStruct { .. }) => {}
240        _ => {}
241    }
242}
243
244pub(crate) fn visit_module(
245    package: &PackageContext,
246    item_mod: &ItemMod,
247    path_key: &str,
248    relative_file: &str,
249    module_prefix: &[String],
250    inline_modules: &mut Vec<String>,
251    functions: &mut Vec<SourceFunction>,
252) {
253    let Some((_, items)) = &item_mod.content else {
254        return;
255    };
256
257    inline_modules.push(item_mod.ident.to_string());
258    for item in items {
259        visit_item(
260            package,
261            item,
262            path_key,
263            relative_file,
264            module_prefix,
265            inline_modules,
266            functions,
267        );
268    }
269    inline_modules.pop();
270}
271
272pub(crate) fn record_function(
273    package: &PackageContext,
274    item_fn: &ItemFn,
275    receiver: Option<&str>,
276    path_key: &str,
277    relative_file: &str,
278    module_prefix: &[String],
279    inline_modules: &[String],
280) -> Option<SourceFunction> {
281    if is_test_attrs(&item_fn.attrs) {
282        return None;
283    }
284
285    let name = qualified_name(
286        module_prefix,
287        inline_modules,
288        receiver,
289        &item_fn.sig.ident.to_string(),
290    );
291    Some(SourceFunction {
292        package_name: package.name.clone(),
293        name,
294        path_key: path_key.to_string(),
295        relative_file: relative_file.to_string(),
296        line: start_line(item_fn.sig.ident.span()),
297        end_line: end_line(item_fn.span()),
298        complexity: cognitive_complexity(&item_fn.block),
299    })
300}