pub mod coverage;
pub mod detect;
pub mod functions;
pub mod imports;
pub mod source;
use fxrank_core::frontend::{Frontend, FrontendOutput, Language, SourceFile};
use fxrank_core::model::Diagnostic;
use libcst_native::parse_module;
pub struct PythonFrontend {
pub include_tests: bool,
}
impl Frontend for PythonFrontend {
fn language(&self) -> Language {
Language::Python
}
fn analyze(&self, files: &[SourceFile]) -> FrontendOutput {
let mut output = FrontendOutput::default();
for file in files {
let src = source::strip_bom(&file.text);
match parse_module(src, None) {
Err(e) => {
output.diagnostics.push(Diagnostic {
path: file.path.clone(),
parsed: false,
error: format!("{e}"),
});
}
Ok(module) => {
let imports = imports::Imports::build(&module);
let module_bindings = crate::imports::module_bindings(&module);
let span = source::SpanIndex::new(src);
let anchors = match source::lambda_anchors(src) {
Some(a) => a,
None => {
output.diagnostics.push(Diagnostic {
path: file.path.clone(),
parsed: true,
error: "lambda anchoring unavailable: tokenizer failed on \
a file that parsed successfully; hotspots for this \
file are omitted to avoid mis-anchored output"
.into(),
});
continue;
}
};
let (units, lambda_node_count) =
functions::collect(&module, src, &span, &anchors);
{
let anchor_count = anchors.len();
if lambda_node_count != anchor_count {
output.diagnostics.push(Diagnostic {
path: file.path.clone(),
parsed: true,
error: format!(
"lambda-anchor mismatch: CST walk found {lambda_node_count} Lambda node(s) \
but tokenizer found {anchor_count} lambda keyword(s); \
hotspots for this file are omitted to avoid mis-anchored output"
),
});
continue;
}
}
if !self.include_tests && is_test_file(&file.path) {
output.skipped_tests += units.len();
} else {
for unit in &units {
if !self.include_tests && unit.is_test_unit {
output.skipped_tests += 1;
} else {
output.functions.push(detect::analyze_unit(
unit,
&file.path,
&imports,
&module_bindings,
&span,
));
}
}
}
}
}
}
output
}
}
pub fn is_test_file(path: &str) -> bool {
let base = path.split(['/', '\\']).next_back().unwrap_or(path);
if base == "conftest.py" {
return true;
}
if (base.starts_with("test_") || base.ends_with("_test.py")) && base.ends_with(".py") {
return true;
}
path.split(['/', '\\']).any(|seg| seg == "tests")
}
#[cfg(test)]
mod tests {
use super::*;
fn analyze_files(paths: &[&str], include_tests: bool) -> FrontendOutput {
let files: Vec<SourceFile> = paths
.iter()
.map(|p| {
let text =
std::fs::read_to_string(p).unwrap_or_else(|e| panic!("cannot read {p}: {e}"));
SourceFile {
path: p.to_string(),
text,
}
})
.collect();
PythonFrontend { include_tests }.analyze(&files)
}
fn analyze_fixture_as(
fixture: &str,
logical_path: &str,
include_tests: bool,
) -> FrontendOutput {
let text = std::fs::read_to_string(fixture)
.unwrap_or_else(|e| panic!("cannot read {fixture}: {e}"));
PythonFrontend { include_tests }.analyze(&[SourceFile {
path: logical_path.to_string(),
text,
}])
}
#[test]
fn skips_test_code_by_default_and_counts() {
let out = analyze_files(
&["tests/fixtures/test_sample.py"],
false,
);
assert_eq!(out.functions.len(), 0);
assert!(out.skipped_tests >= 1);
let inc = analyze_files(
&["tests/fixtures/test_sample.py"],
true,
);
assert!(inc.functions.len() >= 3);
}
#[test]
fn source_based_skip_independent_of_path_skip() {
let out = analyze_fixture_as(
"tests/fixtures/mixed_tests.py",
"src/mixed_tests.py",
false,
);
let symbols: Vec<&str> = out.functions.iter().map(|h| h.symbol.as_str()).collect();
assert!(
symbols.contains(&"normal_function"),
"normal_function must not be skipped; got: {symbols:?}"
);
assert!(
!symbols.contains(&"test_something"),
"test_something must be skipped; got: {symbols:?}"
);
assert!(
!symbols.contains(&"test_render"),
"test_render must be skipped; got: {symbols:?}"
);
assert!(
!symbols.contains(&"helper"),
"helper must be skipped (method of Test* class TestWidget); got: {symbols:?}"
);
assert!(
!symbols.contains(&"test_case"),
"test_case must be skipped; got: {symbols:?}"
);
assert!(
out.skipped_tests >= 1,
"expected skipped_tests >= 1; got: {}",
out.skipped_tests
);
let inc = analyze_fixture_as(
"tests/fixtures/mixed_tests.py",
"src/mixed_tests.py",
true,
);
let inc_symbols: Vec<&str> = inc.functions.iter().map(|h| h.symbol.as_str()).collect();
assert!(
inc_symbols.contains(&"test_something"),
"with include_tests, test_something must be scored; got: {inc_symbols:?}"
);
}
}