Skip to main content

rust_diff_analyzer/analysis/
ast_visitor.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use proc_macro2::{Span, TokenStream, TokenTree};
5use syn::{
6    Attribute, File, ImplItem, ItemConst, ItemEnum, ItemFn, ItemImpl, ItemMacro, ItemMod,
7    ItemStatic, ItemStruct, ItemTrait, ItemType, TraitItem, Visibility as SynVisibility,
8    spanned::Spanned, visit::Visit,
9};
10
11use crate::types::{LineSpan, SemanticUnit, SemanticUnitKind, Visibility};
12
13/// Checks whether a `cfg` predicate token stream enables the item for test
14/// builds.
15///
16/// Matches a bare `test` ident at any nesting level except inside `not(...)`,
17/// so `#[cfg(test)]` and `#[cfg(any(test, unix))]` match while
18/// `#[cfg(not(test))]` and `#[cfg(feature = "latest")]` do not.
19fn cfg_predicate_enables_test(tokens: TokenStream, inside_not: bool) -> bool {
20    let mut last_ident: Option<String> = None;
21
22    for tree in tokens {
23        match tree {
24            TokenTree::Ident(ident) => {
25                let name = ident.to_string();
26                if name == "test" && !inside_not {
27                    return true;
28                }
29                last_ident = Some(name);
30            }
31            TokenTree::Group(group) => {
32                let inner_not = inside_not || last_ident.as_deref() == Some("not");
33                if cfg_predicate_enables_test(group.stream(), inner_not) {
34                    return true;
35                }
36                last_ident = None;
37            }
38            _ => {}
39        }
40    }
41
42    false
43}
44
45/// Collects `feature = "name"` values from a `cfg` predicate token stream,
46/// skipping features negated via `not(...)`.
47fn collect_cfg_features(tokens: TokenStream, inside_not: bool, out: &mut Vec<String>) {
48    let mut last_ident: Option<String> = None;
49    let mut after_feature_eq = false;
50
51    for tree in tokens {
52        match tree {
53            TokenTree::Ident(ident) => {
54                last_ident = Some(ident.to_string());
55                after_feature_eq = false;
56            }
57            TokenTree::Punct(punct) => {
58                after_feature_eq =
59                    punct.as_char() == '=' && last_ident.as_deref() == Some("feature");
60            }
61            TokenTree::Literal(literal) => {
62                if after_feature_eq && !inside_not {
63                    let raw = literal.to_string();
64                    out.push(raw.trim_matches('"').to_string());
65                }
66                after_feature_eq = false;
67                last_ident = None;
68            }
69            TokenTree::Group(group) => {
70                let inner_not = inside_not || last_ident.as_deref() == Some("not");
71                collect_cfg_features(group.stream(), inner_not, out);
72                last_ident = None;
73                after_feature_eq = false;
74            }
75        }
76    }
77}
78
79/// Visitor for extracting semantic units from Rust AST
80pub struct SemanticUnitVisitor {
81    units: Vec<SemanticUnit>,
82    in_test_module: bool,
83    current_impl_name: Option<String>,
84    current_trait_visibility: Option<Visibility>,
85}
86
87impl SemanticUnitVisitor {
88    /// Creates a new semantic unit visitor
89    ///
90    /// # Returns
91    ///
92    /// A new SemanticUnitVisitor instance
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use rust_diff_analyzer::analysis::ast_visitor::SemanticUnitVisitor;
98    ///
99    /// let visitor = SemanticUnitVisitor::new();
100    /// ```
101    pub fn new() -> Self {
102        Self {
103            units: Vec::new(),
104            in_test_module: false,
105            current_impl_name: None,
106            current_trait_visibility: None,
107        }
108    }
109
110    /// Extracts semantic units from a parsed AST
111    ///
112    /// # Arguments
113    ///
114    /// * `file` - Parsed syn File
115    ///
116    /// # Returns
117    ///
118    /// Vector of extracted semantic units
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use rust_diff_analyzer::analysis::ast_visitor::SemanticUnitVisitor;
124    ///
125    /// let code = "fn main() {}";
126    /// let file = syn::parse_file(code).unwrap();
127    /// let units = SemanticUnitVisitor::extract(&file);
128    /// assert_eq!(units.len(), 1);
129    /// ```
130    pub fn extract(file: &File) -> Vec<SemanticUnit> {
131        let mut visitor = Self::new();
132        visitor.visit_file(file);
133        visitor.units
134    }
135
136    fn span_to_line_span(&self, span: Span) -> LineSpan {
137        let start = span.start();
138        let end = span.end();
139        LineSpan::new(start.line, end.line)
140    }
141
142    fn convert_visibility(&self, vis: &SynVisibility) -> Visibility {
143        match vis {
144            SynVisibility::Public(_) => Visibility::Public,
145            SynVisibility::Restricted(r) => {
146                if r.path.is_ident("crate") {
147                    Visibility::Crate
148                } else {
149                    Visibility::Restricted
150                }
151            }
152            SynVisibility::Inherited => Visibility::Private,
153        }
154    }
155
156    fn extract_attributes(&self, attrs: &[Attribute]) -> Vec<String> {
157        let mut attributes = Vec::new();
158
159        for attr in attrs {
160            let path = attr.path();
161            let name = path
162                .segments
163                .iter()
164                .map(|s| s.ident.to_string())
165                .collect::<Vec<_>>()
166                .join("::");
167            if !name.is_empty() {
168                attributes.push(name);
169            }
170
171            if path.is_ident("cfg")
172                && let Ok(meta) = attr.meta.require_list()
173            {
174                let mut features = Vec::new();
175                collect_cfg_features(meta.tokens.clone(), false, &mut features);
176                for feature in features {
177                    attributes.push(format!("cfg_feature:{}", feature));
178                }
179            }
180        }
181
182        attributes
183    }
184
185    fn has_test_attribute(&self, attrs: &[Attribute]) -> bool {
186        attrs.iter().any(|attr| {
187            let path = attr.path();
188            let last_is_test = path
189                .segments
190                .last()
191                .map(|s| s.ident == "test" || s.ident == "bench")
192                .unwrap_or(false);
193            if last_is_test && !path.is_ident("cfg") {
194                return true;
195            }
196            if path.is_ident("cfg")
197                && let Ok(meta) = attr.meta.require_list()
198            {
199                return cfg_predicate_enables_test(meta.tokens.clone(), false);
200            }
201            false
202        })
203    }
204
205    fn is_test_module(&self, attrs: &[Attribute]) -> bool {
206        attrs.iter().any(|attr| {
207            if attr.path().is_ident("cfg")
208                && let Ok(meta) = attr.meta.require_list()
209            {
210                return cfg_predicate_enables_test(meta.tokens.clone(), false);
211            }
212            false
213        })
214    }
215
216    fn add_unit(
217        &mut self,
218        kind: SemanticUnitKind,
219        name: String,
220        visibility: Visibility,
221        span: Span,
222        attrs: &[Attribute],
223    ) {
224        let mut attributes = self.extract_attributes(attrs);
225
226        if self.in_test_module && !attributes.iter().any(|a| a == "cfg_test") {
227            attributes.push("cfg_test".to_string());
228        }
229
230        if self.has_test_attribute(attrs) && !attributes.iter().any(|a| a == "test") {
231            attributes.push("test".to_string());
232        }
233
234        let unit = match &self.current_impl_name {
235            Some(impl_name) => SemanticUnit::with_impl(
236                kind,
237                name,
238                impl_name.clone(),
239                visibility,
240                self.span_to_line_span(span),
241                attributes,
242            ),
243            None => SemanticUnit::new(
244                kind,
245                name,
246                visibility,
247                self.span_to_line_span(span),
248                attributes,
249            ),
250        };
251        self.units.push(unit);
252    }
253}
254
255impl Default for SemanticUnitVisitor {
256    fn default() -> Self {
257        Self::new()
258    }
259}
260
261impl<'ast> Visit<'ast> for SemanticUnitVisitor {
262    fn visit_item_fn(&mut self, node: &'ast ItemFn) {
263        self.add_unit(
264            SemanticUnitKind::Function,
265            node.sig.ident.to_string(),
266            self.convert_visibility(&node.vis),
267            node.span(),
268            &node.attrs,
269        );
270        syn::visit::visit_item_fn(self, node);
271    }
272
273    fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
274        self.add_unit(
275            SemanticUnitKind::Struct,
276            node.ident.to_string(),
277            self.convert_visibility(&node.vis),
278            node.span(),
279            &node.attrs,
280        );
281        syn::visit::visit_item_struct(self, node);
282    }
283
284    fn visit_item_enum(&mut self, node: &'ast ItemEnum) {
285        self.add_unit(
286            SemanticUnitKind::Enum,
287            node.ident.to_string(),
288            self.convert_visibility(&node.vis),
289            node.span(),
290            &node.attrs,
291        );
292        syn::visit::visit_item_enum(self, node);
293    }
294
295    fn visit_item_trait(&mut self, node: &'ast ItemTrait) {
296        let visibility = self.convert_visibility(&node.vis);
297        self.add_unit(
298            SemanticUnitKind::Trait,
299            node.ident.to_string(),
300            visibility.clone(),
301            node.span(),
302            &node.attrs,
303        );
304
305        let previous_visibility = self.current_trait_visibility.replace(visibility);
306        syn::visit::visit_item_trait(self, node);
307        self.current_trait_visibility = previous_visibility;
308    }
309
310    fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
311        let impl_name = if let Some((_, path, _)) = &node.trait_ {
312            format!(
313                "{} for {}",
314                path.segments
315                    .last()
316                    .map(|s| s.ident.to_string())
317                    .unwrap_or_default(),
318                type_to_string(&node.self_ty)
319            )
320        } else {
321            type_to_string(&node.self_ty)
322        };
323
324        self.add_unit(
325            SemanticUnitKind::Impl,
326            impl_name.clone(),
327            Visibility::Private,
328            node.span(),
329            &node.attrs,
330        );
331
332        let previous_impl_name = self.current_impl_name.take();
333        self.current_impl_name = Some(impl_name);
334
335        let was_in_test = self.in_test_module;
336        if self.is_test_module(&node.attrs) {
337            self.in_test_module = true;
338        }
339
340        for item in &node.items {
341            match item {
342                ImplItem::Fn(method) => {
343                    self.add_unit(
344                        SemanticUnitKind::Function,
345                        method.sig.ident.to_string(),
346                        self.convert_visibility(&method.vis),
347                        method.span(),
348                        &method.attrs,
349                    );
350                }
351                ImplItem::Const(c) => {
352                    self.add_unit(
353                        SemanticUnitKind::Const,
354                        c.ident.to_string(),
355                        self.convert_visibility(&c.vis),
356                        c.span(),
357                        &c.attrs,
358                    );
359                }
360                ImplItem::Type(t) => {
361                    self.add_unit(
362                        SemanticUnitKind::TypeAlias,
363                        t.ident.to_string(),
364                        self.convert_visibility(&t.vis),
365                        t.span(),
366                        &t.attrs,
367                    );
368                }
369                _ => {}
370            }
371        }
372
373        self.in_test_module = was_in_test;
374        self.current_impl_name = previous_impl_name;
375    }
376
377    fn visit_item_const(&mut self, node: &'ast ItemConst) {
378        self.add_unit(
379            SemanticUnitKind::Const,
380            node.ident.to_string(),
381            self.convert_visibility(&node.vis),
382            node.span(),
383            &node.attrs,
384        );
385    }
386
387    fn visit_item_static(&mut self, node: &'ast ItemStatic) {
388        self.add_unit(
389            SemanticUnitKind::Static,
390            node.ident.to_string(),
391            self.convert_visibility(&node.vis),
392            node.span(),
393            &node.attrs,
394        );
395    }
396
397    fn visit_item_type(&mut self, node: &'ast ItemType) {
398        self.add_unit(
399            SemanticUnitKind::TypeAlias,
400            node.ident.to_string(),
401            self.convert_visibility(&node.vis),
402            node.span(),
403            &node.attrs,
404        );
405    }
406
407    fn visit_item_macro(&mut self, node: &'ast ItemMacro) {
408        if let Some(ident) = &node.ident {
409            self.add_unit(
410                SemanticUnitKind::Macro,
411                ident.to_string(),
412                Visibility::Private,
413                node.span(),
414                &node.attrs,
415            );
416        }
417    }
418
419    fn visit_item_mod(&mut self, node: &'ast ItemMod) {
420        let is_test = self.is_test_module(&node.attrs) || node.ident == "tests";
421
422        self.add_unit(
423            SemanticUnitKind::Module,
424            node.ident.to_string(),
425            self.convert_visibility(&node.vis),
426            node.span(),
427            &node.attrs,
428        );
429
430        if let Some((_, items)) = &node.content {
431            let was_in_test = self.in_test_module;
432            self.in_test_module = is_test || was_in_test;
433
434            for item in items {
435                self.visit_item(item);
436            }
437
438            self.in_test_module = was_in_test;
439        }
440    }
441
442    fn visit_trait_item(&mut self, node: &'ast TraitItem) {
443        let visibility = self
444            .current_trait_visibility
445            .clone()
446            .unwrap_or(Visibility::Public);
447
448        match node {
449            TraitItem::Fn(method) => {
450                self.add_unit(
451                    SemanticUnitKind::Function,
452                    method.sig.ident.to_string(),
453                    visibility,
454                    method.span(),
455                    &method.attrs,
456                );
457            }
458            TraitItem::Const(c) => {
459                self.add_unit(
460                    SemanticUnitKind::Const,
461                    c.ident.to_string(),
462                    visibility,
463                    c.span(),
464                    &c.attrs,
465                );
466            }
467            TraitItem::Type(t) => {
468                self.add_unit(
469                    SemanticUnitKind::TypeAlias,
470                    t.ident.to_string(),
471                    visibility,
472                    t.span(),
473                    &t.attrs,
474                );
475            }
476            _ => {}
477        }
478        syn::visit::visit_trait_item(self, node);
479    }
480}
481
482fn type_to_string(ty: &syn::Type) -> String {
483    match ty {
484        syn::Type::Path(p) => p
485            .path
486            .segments
487            .last()
488            .map(|s| s.ident.to_string())
489            .unwrap_or_else(|| "Unknown".to_string()),
490        _ => "Unknown".to_string(),
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn test_extract_function() {
500        let code = "pub fn hello() {}";
501        let file = syn::parse_file(code).expect("parse failed");
502        let units = SemanticUnitVisitor::extract(&file);
503
504        assert_eq!(units.len(), 1);
505        assert_eq!(units[0].name, "hello");
506        assert!(matches!(units[0].kind, SemanticUnitKind::Function));
507        assert!(matches!(units[0].visibility, Visibility::Public));
508    }
509
510    #[test]
511    fn test_extract_struct() {
512        let code = "struct Point { x: i32, y: i32 }";
513        let file = syn::parse_file(code).expect("parse failed");
514        let units = SemanticUnitVisitor::extract(&file);
515
516        assert_eq!(units.len(), 1);
517        assert_eq!(units[0].name, "Point");
518        assert!(matches!(units[0].kind, SemanticUnitKind::Struct));
519    }
520
521    #[test]
522    fn test_extract_test_function() {
523        let code = r#"
524            #[test]
525            fn test_something() {}
526        "#;
527        let file = syn::parse_file(code).expect("parse failed");
528        let units = SemanticUnitVisitor::extract(&file);
529
530        assert_eq!(units.len(), 1);
531        assert!(units[0].has_attribute("test"));
532    }
533
534    #[test]
535    fn test_extract_impl_block() {
536        let code = r#"
537            struct Foo;
538            impl Foo {
539                pub fn new() -> Self { Foo }
540            }
541        "#;
542        let file = syn::parse_file(code).expect("parse failed");
543        let units = SemanticUnitVisitor::extract(&file);
544
545        assert_eq!(units.len(), 3);
546        assert!(
547            units
548                .iter()
549                .any(|u| u.name == "Foo" && matches!(u.kind, SemanticUnitKind::Struct))
550        );
551        assert!(
552            units
553                .iter()
554                .any(|u| u.name == "Foo" && matches!(u.kind, SemanticUnitKind::Impl))
555        );
556        assert!(
557            units
558                .iter()
559                .any(|u| u.name == "new" && matches!(u.kind, SemanticUnitKind::Function))
560        );
561    }
562
563    #[test]
564    fn test_extract_test_module() {
565        let code = r#"
566            fn production() {}
567
568            #[cfg(test)]
569            mod tests {
570                fn helper() {}
571
572                #[test]
573                fn test_it() {}
574            }
575        "#;
576        let file = syn::parse_file(code).expect("parse failed");
577        let units = SemanticUnitVisitor::extract(&file);
578
579        let prod_fn = units
580            .iter()
581            .find(|u| u.name == "production")
582            .expect("production not found");
583        assert!(!prod_fn.has_attribute("cfg_test"));
584
585        let helper_fn = units
586            .iter()
587            .find(|u| u.name == "helper")
588            .expect("helper not found");
589        assert!(helper_fn.has_attribute("cfg_test"));
590
591        let test_fn = units
592            .iter()
593            .find(|u| u.name == "test_it")
594            .expect("test_it not found");
595        assert!(test_fn.has_attribute("test"));
596        assert!(test_fn.has_attribute("cfg_test"));
597    }
598
599    #[test]
600    fn test_cfg_not_test_is_production() {
601        let code = r#"
602            #[cfg(not(test))]
603            pub fn prod_only() {}
604        "#;
605        let file = syn::parse_file(code).expect("parse failed");
606        let units = SemanticUnitVisitor::extract(&file);
607
608        assert_eq!(units.len(), 1);
609        assert!(!units[0].has_attribute("test"));
610    }
611
612    #[test]
613    fn test_cfg_feature_latest_is_not_test() {
614        let code = r#"
615            #[cfg(feature = "latest")]
616            pub fn latest_api() {}
617        "#;
618        let file = syn::parse_file(code).expect("parse failed");
619        let units = SemanticUnitVisitor::extract(&file);
620
621        assert!(!units[0].has_attribute("test"));
622        assert!(units[0].has_attribute("cfg_feature:latest"));
623    }
624
625    #[test]
626    fn test_cfg_any_test_is_test() {
627        let code = r#"
628            #[cfg(any(test, feature = "slow"))]
629            fn helper() {}
630        "#;
631        let file = syn::parse_file(code).expect("parse failed");
632        let units = SemanticUnitVisitor::extract(&file);
633
634        assert!(units[0].has_attribute("test"));
635    }
636
637    #[test]
638    fn test_multi_segment_test_attribute() {
639        let code = r#"
640            #[tokio::test]
641            async fn async_test() {}
642        "#;
643        let file = syn::parse_file(code).expect("parse failed");
644        let units = SemanticUnitVisitor::extract(&file);
645
646        assert!(units[0].has_attribute("test"));
647        assert!(units[0].has_attribute("tokio::test"));
648    }
649
650    #[test]
651    fn test_cfg_feature_marker_recorded() {
652        let code = r#"
653            #[cfg(feature = "mock")]
654            pub fn mock_helper() {}
655        "#;
656        let file = syn::parse_file(code).expect("parse failed");
657        let units = SemanticUnitVisitor::extract(&file);
658
659        assert!(units[0].has_attribute("cfg_feature:mock"));
660    }
661
662    #[test]
663    fn test_cfg_not_feature_marker_skipped() {
664        let code = r#"
665            #[cfg(not(feature = "mock"))]
666            pub fn real_impl() {}
667        "#;
668        let file = syn::parse_file(code).expect("parse failed");
669        let units = SemanticUnitVisitor::extract(&file);
670
671        assert!(!units[0].has_attribute("cfg_feature:mock"));
672    }
673
674    #[test]
675    fn test_cfg_test_impl_marks_methods() {
676        let code = r#"
677            struct Foo;
678
679            #[cfg(test)]
680            impl Foo {
681                fn helper() {}
682            }
683        "#;
684        let file = syn::parse_file(code).expect("parse failed");
685        let units = SemanticUnitVisitor::extract(&file);
686
687        let helper = units
688            .iter()
689            .find(|u| u.name == "helper")
690            .expect("helper not found");
691        assert!(helper.has_attribute("cfg_test"));
692
693        let foo = units
694            .iter()
695            .find(|u| matches!(u.kind, SemanticUnitKind::Struct))
696            .expect("struct not found");
697        assert!(!foo.has_attribute("cfg_test"));
698    }
699
700    #[test]
701    fn test_private_trait_items_inherit_visibility() {
702        let code = r#"
703            trait Internal {
704                fn hidden(&self);
705            }
706
707            pub trait External {
708                fn visible(&self);
709            }
710        "#;
711        let file = syn::parse_file(code).expect("parse failed");
712        let units = SemanticUnitVisitor::extract(&file);
713
714        let hidden = units
715            .iter()
716            .find(|u| u.name == "hidden")
717            .expect("hidden not found");
718        assert!(matches!(hidden.visibility, Visibility::Private));
719
720        let visible = units
721            .iter()
722            .find(|u| u.name == "visible")
723            .expect("visible not found");
724        assert!(matches!(visible.visibility, Visibility::Public));
725    }
726}