Skip to main content

fxrank_lang_python/
lib.rs

1//! Python (libcst-based) frontend for FxRank's effect-cost profiler.
2
3pub mod coverage;
4pub mod detect;
5pub mod functions;
6pub mod imports;
7pub mod source;
8
9use fxrank_core::frontend::{Frontend, FrontendOutput, Language, SourceFile};
10use fxrank_core::model::Diagnostic;
11use libcst_native::parse_module;
12
13/// The Python language frontend.
14///
15/// When `include_tests` is `false` (the default), two skip mechanisms apply:
16///
17/// - **Path-based**: entire files whose base name matches `test_*.py` / `*_test.py` /
18///   `conftest.py`, or whose path contains a `tests/` directory segment, are skipped.
19/// - **Source-based**: within an otherwise-scanned file, units that are a `test_*`-named
20///   function, a method of a `Test*`-named class, or a method of a `unittest.TestCase`
21///   subclass are skipped.
22///
23/// Skipped units are counted in `FrontendOutput::skipped_tests`.
24/// `--include-tests` (`include_tests: true`) disables both skip mechanisms.
25pub struct PythonFrontend {
26    pub include_tests: bool,
27}
28
29impl Frontend for PythonFrontend {
30    fn language(&self) -> Language {
31        Language::Python
32    }
33
34    fn analyze(&self, files: &[SourceFile]) -> FrontendOutput {
35        let mut output = FrontendOutput::default();
36        for file in files {
37            let src = source::strip_bom(&file.text);
38            match parse_module(src, None) {
39                Err(e) => {
40                    output.diagnostics.push(Diagnostic {
41                        path: file.path.clone(),
42                        parsed: false,
43                        error: format!("{e}"),
44                    });
45                }
46                Ok(module) => {
47                    // Single borrowed pass: collect + analyze while `module`/`src`
48                    // are both alive. `analyze_unit` emits owned `Hotspot`s.
49                    let imports = imports::Imports::build(&module);
50                    let module_bindings = crate::imports::module_bindings(&module);
51                    // Build SpanIndex once per file; pass it into both `collect`
52                    // (for lambda-anchor line/col) and `analyze_unit` (for effect
53                    // line resolution) — no duplicate O(n) line-start indexing.
54                    let span = source::SpanIndex::new(src);
55
56                    // Tokenize once to obtain lambda anchors. `parse_module`
57                    // succeeded above, so tokenization is a strict subset and
58                    // must also succeed — `None` is a theoretically-impossible
59                    // state. We propagate failure rather than swallowing it with
60                    // `unwrap_or_default()`: an empty anchor vec would make the
61                    // count guard see `0 == 0` and silently drop every lambda.
62                    let anchors = match source::lambda_anchors(src) {
63                        Some(a) => a,
64                        None => {
65                            output.diagnostics.push(Diagnostic {
66                                path: file.path.clone(),
67                                parsed: true,
68                                error: "lambda anchoring unavailable: tokenizer failed on \
69                                        a file that parsed successfully; hotspots for this \
70                                        file are omitted to avoid mis-anchored output"
71                                    .into(),
72                            });
73                            continue;
74                        }
75                    };
76
77                    // Pass the pre-computed anchors into collect so tokenization
78                    // runs exactly once per file (no second tokenizer pass for the
79                    // mismatch guard).
80                    let (units, lambda_node_count) =
81                        functions::collect(&module, src, &span, &anchors);
82
83                    // Runtime lambda-anchor mismatch guard (node count, not emitted count).
84                    // `collect` guards the bijection with a `debug_assert_eq!`
85                    // (loud in tests/debug builds). In a release build that assert
86                    // is stripped, so we add a non-panicking runtime check here.
87                    //
88                    // CRITICAL: we compare the Lambda-NODE count (`lambda_node_count`,
89                    // which `collect` increments on every Lambda node visited, even when
90                    // `anchors.get(idx)` returns `None` and no unit is emitted) against
91                    // `anchors.len()`. Comparing emitted-unit count instead would miss
92                    // the N>M case: if there are more Lambda nodes (N) than anchors (M),
93                    // the first M emit normally, the remaining N−M are silently skipped,
94                    // and emitted(M)==anchors(M) → guard passes → silent drop. Using the
95                    // node count detects the mismatch in both directions (N<M and N>M).
96                    {
97                        let anchor_count = anchors.len();
98                        if lambda_node_count != anchor_count {
99                            output.diagnostics.push(Diagnostic {
100                                path: file.path.clone(),
101                                parsed: true,
102                                error: format!(
103                                    "lambda-anchor mismatch: CST walk found {lambda_node_count} Lambda node(s) \
104                                     but tokenizer found {anchor_count} lambda keyword(s); \
105                                     hotspots for this file are omitted to avoid mis-anchored output"
106                                ),
107                            });
108                            continue;
109                        }
110                    }
111
112                    if !self.include_tests && is_test_file(&file.path) {
113                        // Path-based skip: the entire file is test code.
114                        output.skipped_tests += units.len();
115                    } else {
116                        for unit in &units {
117                            if !self.include_tests && unit.is_test_unit {
118                                // Source-based skip: individual test unit within a
119                                // non-test-named file.
120                                output.skipped_tests += 1;
121                            } else {
122                                output.functions.push(detect::analyze_unit(
123                                    unit,
124                                    &file.path,
125                                    &imports,
126                                    &module_bindings,
127                                    &span,
128                                ));
129                            }
130                        }
131                    }
132                }
133            }
134        }
135        output
136    }
137}
138
139/// Return `true` if `path` identifies a test file by Python convention:
140///
141/// - base name matches `test_*.py` or `*_test.py` (pytest conventions), OR
142/// - base name is `conftest.py` (pytest configuration / shared fixtures), OR
143/// - any path segment is exactly `tests` (e.g. `src/tests/foo.py`).
144pub fn is_test_file(path: &str) -> bool {
145    // Extract the base name (last path segment).
146    let base = path.split(['/', '\\']).next_back().unwrap_or(path);
147
148    // conftest.py is always a test-support file.
149    if base == "conftest.py" {
150        return true;
151    }
152
153    // test_*.py  and  *_test.py
154    if (base.starts_with("test_") || base.ends_with("_test.py")) && base.ends_with(".py") {
155        return true;
156    }
157
158    // Any segment named exactly `tests` (singular `test` is too broad).
159    path.split(['/', '\\']).any(|seg| seg == "tests")
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    /// Build a `FrontendOutput` from the given fixture file paths.
167    ///
168    /// The path used for `is_test_file` detection is the same as the read path.
169    fn analyze_files(paths: &[&str], include_tests: bool) -> FrontendOutput {
170        let files: Vec<SourceFile> = paths
171            .iter()
172            .map(|p| {
173                let text =
174                    std::fs::read_to_string(p).unwrap_or_else(|e| panic!("cannot read {p}: {e}"));
175                SourceFile {
176                    path: p.to_string(),
177                    text,
178                }
179            })
180            .collect();
181        PythonFrontend { include_tests }.analyze(&files)
182    }
183
184    /// Build a `FrontendOutput` from a fixture file, but report it under a
185    /// different `logical_path` for `is_test_file` detection.
186    ///
187    /// This lets source-based skip tests use a fixture that lives under
188    /// `tests/fixtures/` (which would otherwise trigger the `tests/` path rule)
189    /// while controlling the path that the skip logic sees.
190    fn analyze_fixture_as(
191        fixture: &str,
192        logical_path: &str,
193        include_tests: bool,
194    ) -> FrontendOutput {
195        let text = std::fs::read_to_string(fixture)
196            .unwrap_or_else(|e| panic!("cannot read {fixture}: {e}"));
197        PythonFrontend { include_tests }.analyze(&[SourceFile {
198            path: logical_path.to_string(),
199            text,
200        }])
201    }
202
203    #[test]
204    fn skips_test_code_by_default_and_counts() {
205        // File named test_sample.py → path-based file skip.
206        let out = analyze_files(
207            &["tests/fixtures/test_sample.py"],
208            /*include_tests=*/ false,
209        );
210        assert_eq!(out.functions.len(), 0);
211        assert!(out.skipped_tests >= 1);
212
213        // With --include-tests, all units are scored.
214        let inc = analyze_files(
215            &["tests/fixtures/test_sample.py"],
216            /*include_tests=*/ true,
217        );
218        assert!(inc.functions.len() >= 3);
219    }
220
221    #[test]
222    fn source_based_skip_independent_of_path_skip() {
223        // The fixture is read from tests/fixtures/ but we report it under
224        // "src/mixed_tests.py" so the path-based skip rule doesn't apply
225        // (no test_*/conftest base name, no `tests/` segment in the logical path).
226        // Source-based rules must skip test_something, TestWidget.test_render,
227        // TestWidget.helper (ALL methods of a Test* class are skipped, even
228        // non-test_*-named ones), and MyCase.test_case (unittest.TestCase
229        // subclass method).
230        let out = analyze_fixture_as(
231            "tests/fixtures/mixed_tests.py",
232            "src/mixed_tests.py",
233            /*include_tests=*/ false,
234        );
235        let symbols: Vec<&str> = out.functions.iter().map(|h| h.symbol.as_str()).collect();
236
237        // normal_function is always kept.
238        assert!(
239            symbols.contains(&"normal_function"),
240            "normal_function must not be skipped; got: {symbols:?}"
241        );
242        // test_* function is skipped.
243        assert!(
244            !symbols.contains(&"test_something"),
245            "test_something must be skipped; got: {symbols:?}"
246        );
247        // TestWidget.test_render is skipped (method of Test* class).
248        assert!(
249            !symbols.contains(&"test_render"),
250            "test_render must be skipped; got: {symbols:?}"
251        );
252        // TestWidget.helper is skipped too: ALL methods of a Test* class are
253        // skipped, including non-test_*-named ones (spec: "Test* class → its
254        // methods skipped"). Keeping helper would violate the spec.
255        assert!(
256            !symbols.contains(&"helper"),
257            "helper must be skipped (method of Test* class TestWidget); got: {symbols:?}"
258        );
259        // MyCase.test_case is skipped (unittest.TestCase subclass method).
260        assert!(
261            !symbols.contains(&"test_case"),
262            "test_case must be skipped; got: {symbols:?}"
263        );
264        // Some units must have been skipped.
265        assert!(
266            out.skipped_tests >= 1,
267            "expected skipped_tests >= 1; got: {}",
268            out.skipped_tests
269        );
270
271        // With --include-tests, all units including test ones are returned.
272        let inc = analyze_fixture_as(
273            "tests/fixtures/mixed_tests.py",
274            "src/mixed_tests.py",
275            /*include_tests=*/ true,
276        );
277        let inc_symbols: Vec<&str> = inc.functions.iter().map(|h| h.symbol.as_str()).collect();
278        assert!(
279            inc_symbols.contains(&"test_something"),
280            "with include_tests, test_something must be scored; got: {inc_symbols:?}"
281        );
282    }
283}