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}