Skip to main content

dry4rust/rust/
parser.rs

1// Copyright (c) 2026 Matjaz Domen Pecan
2// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
3// Licensed under the MIT License
4// SPDX-License-Identifier: MIT
5
6use std::path::{Path, PathBuf};
7
8use syn::spanned::Spanned;
9use syn::visit::Visit;
10
11use crate::fingerprint::Fingerprint;
12use crate::node::NormalizedNode;
13
14use crate::node;
15use crate::rust::normalizer;
16use normalizer::normalize;
17use std::fs;
18use syn::Attribute;
19use syn::ExprClosure;
20use syn::Ident;
21use syn::ItemFn;
22use syn::ItemImpl;
23use syn::ItemMod;
24use syn::Type;
25use syn::parse_file as parse_syn_file;
26use syn::visit;
27
28pub use crate::code_unit::{CodeUnit, CodeUnitKind};
29
30/// Check if attributes contain `#[test]`.
31#[must_use]
32pub fn has_test_attr(attrs: &[Attribute]) -> bool {
33    attrs.iter().any(|attr| attr.path().is_ident("test"))
34}
35
36/// Check if attributes contain `#[cfg(test)]`.
37#[must_use]
38pub fn has_cfg_test_attr(attrs: &[Attribute]) -> bool {
39    attrs.iter().any(|attr| {
40        attr.path().is_ident("cfg")
41            && attr
42                .parse_args::<Ident>()
43                .is_ok_and(|ident| ident == "test")
44    })
45}
46
47/// Extracts code units from a syn file by visiting the AST.
48struct CodeUnitExtractor {
49    file: PathBuf,
50    min_node_count: usize,
51    min_line_count: usize,
52    units: Vec<CodeUnit>,
53    /// Track current impl block context for method naming.
54    current_impl: Option<String>,
55    /// Track if we're in a trait impl
56    in_trait_impl: bool,
57    /// Track if we're inside test code (`#[cfg(test)]` module/impl).
58    in_test_context: bool,
59}
60
61impl CodeUnitExtractor {
62    const fn new(file: PathBuf, min_node_count: usize, min_line_count: usize) -> Self {
63        Self {
64            file,
65            min_node_count,
66            min_line_count,
67            units: Vec::new(),
68            current_impl: None,
69            in_trait_impl: false,
70            in_test_context: false,
71        }
72    }
73
74    #[allow(clippy::too_many_arguments)]
75    fn add_unit(
76        &mut self,
77        kind: CodeUnitKind,
78        name: String,
79        line_start: usize,
80        line_end: usize,
81        sig: NormalizedNode,
82        body: NormalizedNode,
83        is_test: bool,
84    ) {
85        let node_count = node::count_nodes(&sig) + node::count_nodes(&body);
86        if node_count < self.min_node_count {
87            return;
88        }
89        let line_count = line_end.saturating_sub(line_start) + 1;
90        if self.min_line_count > 0 && line_count < self.min_line_count {
91            return;
92        }
93        let fingerprint = Fingerprint::from_sig_and_body(&sig, &body);
94        self.units.push(CodeUnit {
95            kind,
96            name,
97            file: self.file.clone(),
98            line_start,
99            line_end,
100            signature: sig,
101            body,
102            fingerprint,
103            node_count,
104            parent_name: None,
105            is_test,
106        });
107    }
108}
109
110impl<'ast> Visit<'ast> for CodeUnitExtractor {
111    fn visit_item_fn(&mut self, node: &'ast ItemFn) {
112        let is_test =
113            self.in_test_context || has_test_attr(&node.attrs) || has_cfg_test_attr(&node.attrs);
114
115        let name = node.sig.ident.to_string();
116        let line_start = node.sig.ident.span().start().line;
117        let line_end = node.block.brace_token.span.close().end().line;
118        let (sig, body) = normalize::normalize_item_fn(node);
119        self.add_unit(
120            CodeUnitKind::Function,
121            name,
122            line_start,
123            line_end,
124            sig,
125            body,
126            is_test,
127        );
128
129        // Continue visiting nested items (propagate test context)
130        let prev = self.in_test_context;
131        self.in_test_context = is_test;
132        visit::visit_item_fn(self, node);
133        self.in_test_context = prev;
134    }
135
136    fn visit_item_mod(&mut self, node: &'ast ItemMod) {
137        let prev = self.in_test_context;
138        if has_cfg_test_attr(&node.attrs) {
139            self.in_test_context = true;
140        }
141        visit::visit_item_mod(self, node);
142        self.in_test_context = prev;
143    }
144
145    fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
146        let prev_test = self.in_test_context;
147        if has_cfg_test_attr(&node.attrs) {
148            self.in_test_context = true;
149        }
150
151        let type_name = quote_type(&node.self_ty);
152        let is_trait_impl = node.trait_.is_some();
153        let trait_name = node
154            .trait_
155            .as_ref()
156            .map(|(_, path, _)| {
157                path.segments
158                    .iter()
159                    .map(|s| s.ident.to_string())
160                    .collect::<Vec<_>>()
161                    .join("::")
162            })
163            .unwrap_or_default();
164
165        let prev_impl = self.current_impl.take();
166        let prev_trait = self.in_trait_impl;
167
168        self.current_impl = Some(type_name.clone());
169        self.in_trait_impl = is_trait_impl;
170
171        // Visit each method in the impl block
172        for item in &node.items {
173            if let syn::ImplItem::Fn(method) = item {
174                let method_name = method.sig.ident.to_string();
175                let full_name = if is_trait_impl {
176                    format!("<{type_name} as {trait_name}>::{method_name}")
177                } else {
178                    format!("{type_name}::{method_name}")
179                };
180
181                let line_start = method.sig.ident.span().start().line;
182                let line_end = method.block.brace_token.span.close().end().line;
183
184                let (sig, body) = normalize::normalize_impl_item_fn(method);
185                let kind = if is_trait_impl {
186                    CodeUnitKind::TraitImplBlock
187                } else {
188                    CodeUnitKind::Method
189                };
190
191                self.add_unit(
192                    kind,
193                    full_name,
194                    line_start,
195                    line_end,
196                    sig,
197                    body,
198                    self.in_test_context,
199                );
200            }
201        }
202
203        self.current_impl = prev_impl;
204        self.in_trait_impl = prev_trait;
205        self.in_test_context = prev_test;
206    }
207
208    fn visit_expr_closure(&mut self, node: &'ast ExprClosure) {
209        let line_start = node.or1_token.span.start().line;
210        let line_end = match &*node.body {
211            syn::Expr::Block(eb) => eb.block.brace_token.span.close().end().line,
212            other => {
213                let end = other.span().end().line;
214                if end > 0 { end } else { line_start }
215            }
216        };
217
218        let normalized = normalize::normalize_closure_expr(node);
219        let node_count = node::count_nodes(&normalized);
220        let line_count = line_end.saturating_sub(line_start) + 1;
221        if node_count >= self.min_node_count
222            && (self.min_line_count == 0 || line_count >= self.min_line_count)
223        {
224            let name = format!("closure at {}:{}", self.file.display(), line_start);
225            let fingerprint = Fingerprint::from_node(&normalized);
226            self.units.push(CodeUnit {
227                kind: CodeUnitKind::Closure,
228                name,
229                file: self.file.clone(),
230                line_start,
231                line_end,
232                signature: NormalizedNode::leaf(crate::node::NodeKind::Opaque),
233                body: normalized,
234                fingerprint,
235                node_count,
236                parent_name: None,
237                is_test: self.in_test_context,
238            });
239        }
240
241        // Continue visiting nested closures
242        visit::visit_expr_closure(self, node);
243    }
244}
245
246/// Get a simple string representation of a type for naming.
247fn quote_type(ty: &Type) -> String {
248    match ty {
249        Type::Path(tp) => tp
250            .path
251            .segments
252            .iter()
253            .map(|s| s.ident.to_string())
254            .collect::<Vec<_>>()
255            .join("::"),
256        _ => "Unknown".to_string(),
257    }
258}
259
260/// Parse Rust source code and extract code units.
261///
262/// This is the core parsing entry point used by `RustAnalyzer`.
263/// `path` is used for diagnostics and naming only.
264/// Test code is always included but tagged with `is_test: true`;
265/// filtering is handled by the caller.
266pub fn parse_source(
267    path: &Path,
268    source: &str,
269    min_node_count: usize,
270    min_line_count: usize,
271) -> Result<Vec<CodeUnit>, String> {
272    let file =
273        parse_syn_file(source).map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
274
275    let mut extractor = CodeUnitExtractor::new(path.to_path_buf(), min_node_count, min_line_count);
276    extractor.visit_file(&file);
277
278    Ok(extractor.units)
279}
280
281/// Parse a single Rust file and extract code units.
282///
283/// This is a lower-level convenience function. Prefer using [`crate::rust::rust_analyzer::RustAnalyzer`]
284/// with [`crate::analysis::analyze`] for the full pipeline.
285pub fn parse_file(
286    path: &Path,
287    min_node_count: usize,
288    min_line_count: usize,
289) -> Result<Vec<CodeUnit>, String> {
290    let content = fs::read_to_string(path)
291        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
292
293    parse_source(path, &content, min_node_count, min_line_count)
294}
295
296/// Parse multiple files and collect all code units, skipping files that fail to parse.
297///
298/// This is a lower-level convenience function. Prefer using [`crate::rust::rust_analyzer::RustAnalyzer`]
299/// with [`crate::analysis::analyze`] for the full pipeline.
300#[must_use]
301pub fn parse_files(
302    paths: &[PathBuf],
303    min_node_count: usize,
304    min_line_count: usize,
305) -> (Vec<CodeUnit>, Vec<String>) {
306    let mut units = Vec::new();
307    let mut warnings = Vec::new();
308
309    for path in paths {
310        match parse_file(path, min_node_count, min_line_count) {
311            Ok(file_units) => units.extend(file_units),
312            Err(warning) => warnings.push(warning),
313        }
314    }
315
316    (units, warnings)
317}