use anyhow::Result;
use tree_sitter::{Node, Parser};
use crate::scanner::Finding;
pub fn make_parser() -> Result<Parser> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_python::language())
.map_err(|e| anyhow::anyhow!("failed to load Python grammar: {}", e))?;
Ok(parser)
}
pub fn detect_missing_typing(
file: &str,
source: &[u8],
parser: &mut Parser,
) -> Result<Vec<Finding>> {
let tree = parser
.parse(source, None)
.ok_or_else(|| anyhow::anyhow!("tree-sitter parse returned None for {}", file))?;
let mut findings = Vec::new();
walk_missing_typing(tree.root_node(), source, file, &mut findings);
Ok(findings)
}
fn walk_missing_typing(node: Node, source: &[u8], file: &str, out: &mut Vec<Finding>) {
if node.kind() == "function_definition" {
if let Some(params) = node.child_by_field_name("parameters") {
let has_untyped = has_untyped_param(params, source);
if has_untyped {
let name_node = node.child_by_field_name("name").unwrap_or(node);
let line = name_node.start_position().row + 1;
let snippet = source_line(source, line.saturating_sub(1));
out.push(Finding {
rule_id: "python-missing-typing".to_string(),
file: file.to_string(),
line,
snippet,
});
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_missing_typing(child, source, file, out);
}
}
fn has_untyped_param(params: Node, source: &[u8]) -> bool {
let mut cursor = params.walk();
for child in params.children(&mut cursor) {
if child.kind() == "identifier" {
let name = node_text(child, source);
if name != "self" && name != "cls" {
return true;
}
}
}
false
}
pub fn detect_django_fbv(file: &str, source: &[u8], parser: &mut Parser) -> Result<Vec<Finding>> {
let tree = parser
.parse(source, None)
.ok_or_else(|| anyhow::anyhow!("tree-sitter parse returned None for {}", file))?;
let mut findings = Vec::new();
let root = tree.root_node();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() == "function_definition" && is_fbv(child, source) {
let name_node = child.child_by_field_name("name").unwrap_or(child);
let line = name_node.start_position().row + 1;
let snippet = source_line(source, line.saturating_sub(1));
findings.push(Finding {
rule_id: "django-fbv".to_string(),
file: file.to_string(),
line,
snippet,
});
}
if child.kind() == "decorated_definition" {
let mut inner = child.walk();
for grandchild in child.children(&mut inner) {
if grandchild.kind() == "function_definition" && is_fbv(grandchild, source) {
let name_node = grandchild.child_by_field_name("name").unwrap_or(grandchild);
let line = name_node.start_position().row + 1;
let snippet = source_line(source, line.saturating_sub(1));
findings.push(Finding {
rule_id: "django-fbv".to_string(),
file: file.to_string(),
line,
snippet,
});
}
}
}
}
Ok(findings)
}
fn is_fbv(func: Node, source: &[u8]) -> bool {
let params = match func.child_by_field_name("parameters") {
Some(p) => p,
None => return false,
};
let mut cursor = params.walk();
let first_ident = params.children(&mut cursor).find(|n| {
matches!(
n.kind(),
"identifier" | "typed_parameter" | "list_splat_pattern" | "dictionary_splat_pattern"
)
});
match first_ident {
Some(n) if n.kind() == "identifier" => node_text(n, source) == "request",
_ => false,
}
}
fn node_text<'a>(node: Node, source: &'a [u8]) -> &'a str {
std::str::from_utf8(&source[node.byte_range()]).unwrap_or("")
}
pub fn source_line(source: &[u8], line_idx: usize) -> String {
let text = std::str::from_utf8(source).unwrap_or("");
text.lines().nth(line_idx).unwrap_or("").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn parser() -> Parser {
make_parser().unwrap()
}
#[test]
fn missing_typing_catches_untyped_params() {
let src = b"def foo(x, y):\n pass\n";
let findings = detect_missing_typing("test.py", src, &mut parser()).unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].line, 1);
assert_eq!(findings[0].rule_id, "python-missing-typing");
}
#[test]
fn missing_typing_skips_fully_typed() {
let src = b"def foo(x: int, y: str) -> bool:\n return True\n";
let findings = detect_missing_typing("test.py", src, &mut parser()).unwrap();
assert!(findings.is_empty(), "should be empty, got {:?}", findings);
}
#[test]
fn missing_typing_skips_no_arg_function() {
let src = b"def foo():\n pass\n";
let findings = detect_missing_typing("test.py", src, &mut parser()).unwrap();
assert!(findings.is_empty());
}
#[test]
fn missing_typing_skips_self_only() {
let src = b"class C:\n def method(self):\n pass\n";
let findings = detect_missing_typing("test.py", src, &mut parser()).unwrap();
assert!(findings.is_empty(), "self-only methods must not flag");
}
#[test]
fn missing_typing_catches_partial_typing() {
let src = b"def foo(x, y: int):\n pass\n";
let findings = detect_missing_typing("test.py", src, &mut parser()).unwrap();
assert_eq!(findings.len(), 1);
}
#[test]
fn missing_typing_skips_string_literal_lookalike() {
let src = b"s = \"def foo(x):\"\n\ndef real(x: int) -> int:\n return x\n";
let findings = detect_missing_typing("test.py", src, &mut parser()).unwrap();
assert!(
findings.is_empty(),
"must not flag def inside string literal"
);
}
#[test]
fn fbv_catches_simple_view() {
let src = b"def home(request):\n return None\n";
let findings = detect_django_fbv("views.py", src, &mut parser()).unwrap();
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].rule_id, "django-fbv");
}
#[test]
fn fbv_catches_view_with_extra_args() {
let src = b"def detail(request, pk):\n return None\n";
let findings = detect_django_fbv("views.py", src, &mut parser()).unwrap();
assert_eq!(findings.len(), 1);
}
#[test]
fn fbv_skips_cbv_get_method() {
let src = b"class V:\n def get(self, request):\n return None\n";
let findings = detect_django_fbv("views.py", src, &mut parser()).unwrap();
assert!(findings.is_empty(), "CBV method must not be flagged");
}
#[test]
fn fbv_skips_typed_request_param() {
let src = b"def view(request: HttpRequest):\n return None\n";
let _ = detect_django_fbv("views.py", src, &mut parser()).unwrap();
}
#[test]
fn fbv_skips_non_request_first_param() {
let src = b"def helper(x, y):\n return x + y\n";
let findings = detect_django_fbv("views.py", src, &mut parser()).unwrap();
assert!(findings.is_empty());
}
#[test]
fn fbv_catches_decorated_view() {
let src = b"@login_required\ndef dashboard(request):\n return None\n";
let findings = detect_django_fbv("views.py", src, &mut parser()).unwrap();
assert_eq!(findings.len(), 1);
}
#[test]
fn missing_typing_comment_false_positive() {
let src = b"# def untyped(x):\ndef real(x: int):\n pass\n";
let findings = detect_missing_typing("test.py", src, &mut parser()).unwrap();
assert!(findings.is_empty(), "comment line must not be flagged");
}
}