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