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}