use std::collections::HashSet;
use reqwest::Client;
use tree_sitter::{Node, Parser, Point};
use crate::config::Config;
use crate::diff::{parse_valid_lines, split_diff_sections};
use crate::providers::{PrMeta, Provider};
fn hunk_trailing(line: &str) -> Option<&str> {
let rest = line.strip_prefix("@@")?;
let idx = rest.find("@@")?;
let trailing = rest[idx + 2..].trim();
(!trailing.is_empty()).then_some(trailing)
}
fn plusplus_path(line: &str) -> Option<String> {
let rest = line.strip_prefix("+++ ")?;
let p = rest.trim();
let p = p.strip_prefix("b/").unwrap_or(p);
(p != "/dev/null").then(|| p.to_string())
}
fn hunk_regions(diff: &str) -> Vec<(String, Vec<String>)> {
let mut out: Vec<(String, Vec<String>)> = Vec::new();
let mut cur: Option<String> = None;
for line in diff.lines() {
if let Some(path) = line.strip_prefix("+++ ").and_then(|_| plusplus_path(line)) {
cur = Some(path);
} else if line.starts_with("@@") {
let (Some(path), Some(sig)) = (cur.as_ref(), hunk_trailing(line)) else {
continue;
};
let sig = sig.to_string();
match out.iter_mut().find(|(p, _)| p == path) {
Some((_, sigs)) => {
if !sigs.contains(&sig) {
sigs.push(sig);
}
}
None => out.push((path.clone(), vec![sig])),
}
}
}
out
}
fn format_regions(regions: &[(String, Vec<String>)]) -> String {
let mut s = String::from("Changed regions:");
for (path, sigs) in regions {
s.push_str(&format!("\n- {}: {}", path, sigs.join("; ")));
}
s
}
pub fn hunk_context(diff: &str) -> String {
let regions = hunk_regions(diff);
if regions.is_empty() {
return String::new();
}
format_regions(®ions)
}
struct Sym {
label: &'static str,
name: String,
start: u64,
end: u64,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Lang {
Rust,
TypeScript,
Tsx,
JavaScript,
Python,
Go,
}
pub(crate) fn is_js_family(path: &str) -> bool {
matches!(
language_for_path(path),
Some(Lang::TypeScript | Lang::Tsx | Lang::JavaScript)
)
}
fn language_for_path(path: &str) -> Option<Lang> {
let ext = path.rsplit('.').next()?.to_ascii_lowercase();
Some(match ext.as_str() {
"rs" => Lang::Rust,
"ts" | "mts" | "cts" => Lang::TypeScript,
"tsx" => Lang::Tsx,
"js" | "jsx" | "mjs" | "cjs" => Lang::JavaScript,
"py" | "pyi" => Lang::Python,
"go" => Lang::Go,
_ => return None,
})
}
impl Lang {
fn ts_language(self) -> tree_sitter::Language {
match self {
Lang::Rust => tree_sitter_rust::LANGUAGE.into(),
Lang::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
Lang::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(),
Lang::JavaScript => tree_sitter_javascript::LANGUAGE.into(),
Lang::Python => tree_sitter_python::LANGUAGE.into(),
Lang::Go => tree_sitter_go::LANGUAGE.into(),
}
}
fn def_label(self, kind: &str) -> Option<&'static str> {
match self {
Lang::Rust => match kind {
"function_item" => Some("fn"),
"struct_item" => Some("struct"),
"enum_item" => Some("enum"),
"union_item" => Some("union"),
"trait_item" => Some("trait"),
"impl_item" => Some("impl"),
"mod_item" => Some("mod"),
"type_item" => Some("type"),
"const_item" => Some("const"),
"static_item" => Some("static"),
"macro_definition" => Some("macro"),
_ => None,
},
Lang::TypeScript | Lang::Tsx | Lang::JavaScript => match kind {
"function_declaration" | "generator_function_declaration" => Some("function"),
"method_definition" => Some("method"),
"class_declaration" | "abstract_class_declaration" => Some("class"),
"interface_declaration" => Some("interface"),
"type_alias_declaration" => Some("type"),
"enum_declaration" => Some("enum"),
_ => None,
},
Lang::Python => match kind {
"function_definition" => Some("function"),
"class_definition" => Some("class"),
_ => None,
},
Lang::Go => match kind {
"function_declaration" | "method_declaration" => Some("func"),
"type_spec" => Some("type"),
_ => None,
},
}
}
}
fn unwrap_export(lang: Lang, node: Node) -> Node {
if matches!(lang, Lang::TypeScript | Lang::Tsx | Lang::JavaScript)
&& node.kind() == "export_statement"
{
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if lang.def_label(child.kind()).is_some() {
return child;
}
}
}
node
}
fn node_name(lang: Lang, node: Node, src: &[u8]) -> Option<String> {
if let Some(n) = node.child_by_field_name("name") {
if let Ok(t) = n.utf8_text(src) {
return Some(t.to_string());
}
}
if lang == Lang::Rust && node.kind() == "impl_item" {
if let Some(t) = node.child_by_field_name("type") {
if let Ok(s) = t.utf8_text(src) {
return Some(s.to_string());
}
}
}
None
}
fn symbols_for_file(lang: Lang, source: &str, changed: &HashSet<u64>) -> Vec<Sym> {
let mut parser = Parser::new();
if parser.set_language(&lang.ts_language()).is_err() {
return Vec::new();
}
let Some(tree) = parser.parse(source, None) else {
return Vec::new();
};
symbols_in_tree(lang, tree.root_node(), source, changed)
}
fn symbols_in_tree(lang: Lang, root: Node, source: &str, changed: &HashSet<u64>) -> Vec<Sym> {
let src = source.as_bytes();
let mut lines: Vec<u64> = changed.iter().copied().collect();
lines.sort_unstable();
let mut seen: HashSet<(&'static str, String, u64, u64)> = HashSet::new();
let mut out: Vec<Sym> = Vec::new();
for line in lines {
if line == 0 {
continue;
}
let pt = Point {
row: (line - 1) as usize,
column: 0,
};
let mut node = match root.descendant_for_point_range(pt, pt) {
Some(n) => n,
None => continue,
};
loop {
let def = unwrap_export(lang, node);
if let Some(label) = lang.def_label(def.kind()) {
if let Some(name) = node_name(lang, def, src) {
let start = def.start_position().row as u64 + 1;
let end = def.end_position().row as u64 + 1;
if seen.insert((label, name.clone(), start, end)) {
out.push(Sym {
label,
name,
start,
end,
});
}
break;
}
}
match node.parent() {
Some(p) => node = p,
None => break,
}
}
}
out
}
pub(crate) struct ChangedSymbol {
pub label: &'static str,
pub name: String,
pub start: u64,
pub end: u64,
}
pub(crate) fn changed_symbols(
path: &str,
source: &str,
changed: &HashSet<u64>,
) -> Vec<ChangedSymbol> {
let Some(lang) = language_for_path(path) else {
return Vec::new();
};
symbols_for_file(lang, source, changed)
.into_iter()
.map(|s| ChangedSymbol {
label: s.label,
name: s.name,
start: s.start,
end: s.end,
})
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub(crate) enum RefKind {
Call,
Jsx,
Type,
}
pub(crate) struct SymbolRef {
pub line: u64,
pub kind: RefKind,
}
fn ref_name<'a>(node: Node, src: &'a [u8]) -> Option<&'a str> {
match node.kind() {
"identifier" | "type_identifier" | "property_identifier" => node.utf8_text(src).ok(),
"member_expression" | "nested_identifier" => node
.child_by_field_name("property")
.and_then(|p| p.utf8_text(src).ok())
.or_else(|| node.utf8_text(src).ok().and_then(|t| t.rsplit('.').next())),
_ => None,
}
}
pub(crate) fn references_in_source(path: &str, source: &str, name: &str) -> Vec<SymbolRef> {
let lang = match language_for_path(path) {
Some(l @ (Lang::TypeScript | Lang::Tsx | Lang::JavaScript)) => l,
_ => return Vec::new(),
};
if name.is_empty() {
return Vec::new();
}
let mut parser = Parser::new();
if parser.set_language(&lang.ts_language()).is_err() {
return Vec::new();
}
let tree = match parser.parse(source, None) {
Some(t) => t,
None => return Vec::new(),
};
let src = source.as_bytes();
let mut seen: HashSet<(u64, RefKind)> = HashSet::new();
let mut out: Vec<SymbolRef> = Vec::new();
let mut push = |node: Node, kind: RefKind, out: &mut Vec<SymbolRef>| {
let line = node.start_position().row as u64 + 1;
if seen.insert((line, kind)) {
out.push(SymbolRef { line, kind });
}
};
let mut stack = vec![tree.root_node()];
while let Some(node) = stack.pop() {
match node.kind() {
"call_expression" => {
if node
.child_by_field_name("function")
.and_then(|f| ref_name(f, src))
== Some(name)
{
push(node, RefKind::Call, &mut out);
}
}
"new_expression" => {
if node
.child_by_field_name("constructor")
.and_then(|c| ref_name(c, src))
== Some(name)
{
push(node, RefKind::Call, &mut out);
}
}
"jsx_opening_element" | "jsx_self_closing_element" => {
if node
.child_by_field_name("name")
.and_then(|n| ref_name(n, src))
== Some(name)
{
push(node, RefKind::Jsx, &mut out);
}
}
"type_identifier" if node.utf8_text(src) == Ok(name) => {
push(node, RefKind::Type, &mut out);
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
stack.push(child);
}
}
out.sort_by_key(|r| r.line);
out
}
fn format_symbols(path: &str, syms: &[Sym]) -> String {
let parts: Vec<String> = syms
.iter()
.map(|s| {
format!(
"{} {} (lines {}\u{2013}{})",
s.label, s.name, s.start, s.end
)
})
.collect();
format!("- {}: {}", path, parts.join(", "))
}
pub(crate) fn diff_file_order(diff: &str) -> Vec<String> {
let mut seen: HashSet<String> = HashSet::new();
let mut order: Vec<String> = Vec::new();
for (path, _) in split_diff_sections(diff) {
if !path.is_empty() && seen.insert(path.clone()) {
order.push(path);
}
}
order
}
pub async fn structural_context(
provider: &Provider,
client: &Client,
cfg: &Config,
repo: &str,
meta: &PrMeta,
diff: &str,
) -> String {
let head = match meta.head_sha.as_deref() {
Some(s) if !s.is_empty() => s,
_ => return hunk_context(diff),
};
let mut loaded: Vec<(ChangedFile, String)> = Vec::new();
for f in attemptable_files(cfg, diff) {
match provider
.get_file_contents(client, cfg, repo, head, &f.path)
.await
{
Ok(Some(c)) if c.len() <= MAX_FILE_BYTES => loaded.push((f, c)),
_ => continue,
}
}
build_context(cfg, diff, &loaded)
}
pub fn structural_context_local(cfg: &Config, root: &std::path::Path, diff: &str) -> String {
let mut loaded: Vec<(ChangedFile, String)> = Vec::new();
for f in attemptable_files(cfg, diff) {
if f.path.starts_with('/') || f.path.split('/').any(|seg| seg == "..") {
continue;
}
match std::fs::read_to_string(root.join(&f.path)) {
Ok(c) if c.len() <= MAX_FILE_BYTES => loaded.push((f, c)),
_ => continue,
}
}
build_context(cfg, diff, &loaded)
}
const MAX_FILE_BYTES: usize = 400_000;
pub(crate) struct ChangedFile {
path: String,
lang: Lang,
lines: HashSet<u64>,
}
fn attemptable_files(cfg: &Config, diff: &str) -> Vec<ChangedFile> {
let valid = parse_valid_lines(diff);
let mut out = Vec::new();
for path in diff_file_order(diff) {
if out.len() >= cfg.structural_max_files {
break;
}
let Some(lang) = language_for_path(&path) else {
continue;
};
let Some(lines) = valid.get(&path).filter(|s| !s.is_empty()) else {
continue;
};
out.push(ChangedFile {
path,
lang,
lines: lines.clone(),
});
}
out
}
fn build_context(cfg: &Config, diff: &str, loaded: &[(ChangedFile, String)]) -> String {
let mut covered: HashSet<String> = HashSet::new();
let mut ts_blocks: Vec<String> = Vec::new();
let mut complexity_lines: Vec<String> = Vec::new();
for (f, content) in loaded {
let (path, lang, lines) = (&f.path, f.lang, &f.lines);
let mut parser = Parser::new();
let tree = if parser.set_language(&lang.ts_language()).is_ok() {
parser.parse(content, None)
} else {
None
};
let Some(tree) = tree else {
continue; };
let root = tree.root_node();
let syms = symbols_in_tree(lang, root, content, lines);
if !syms.is_empty() {
covered.insert(path.clone());
ts_blocks.push(format_symbols(path, &syms));
}
if cfg.complexity_metrics {
for c in crate::complexity::changed_fn_complexity_in(path, root, content, lines) {
if c.cyclomatic >= cfg.complexity_min_cyclomatic {
complexity_lines.push(format!(
"- {} {} ({}): cyclomatic {}, cognitive {} — grade {}",
c.label,
c.name,
path,
c.cyclomatic,
c.cognitive,
c.grade()
));
}
}
}
}
let mut out = String::new();
if !ts_blocks.is_empty() {
out.push_str("Changed symbols:\n");
out.push_str(&ts_blocks.join("\n"));
}
let regions: Vec<(String, Vec<String>)> = hunk_regions(diff)
.into_iter()
.filter(|(p, _)| !covered.contains(p))
.collect();
if !regions.is_empty() {
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(&format_regions(®ions));
}
if !complexity_lines.is_empty() {
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(
"Complexity of changed functions (cyclomatic / cognitive; grade A best, F worst):\n",
);
out.push_str(&complexity_lines.join("\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hunk_context_extracts_trailing_signature() {
let d = "+++ b/orders.go\n\
@@ -10,7 +10,8 @@ func processOrder(o Order) {\n\
+added\n";
let ctx = hunk_context(d);
assert!(ctx.starts_with("Changed regions:"));
assert!(ctx.contains("orders.go"));
assert!(ctx.contains("func processOrder(o Order) {"));
}
#[test]
fn hunk_context_empty_when_no_trailing() {
let d = "+++ b/a.rs\n@@ -1,2 +1,3 @@\n+added\n";
assert!(hunk_context(d).is_empty());
}
#[test]
fn hunk_context_dedups_and_groups_per_file() {
let d = "+++ b/a.ts\n\
@@ -1,3 +1,4 @@ class Foo {\n\
+a\n\
@@ -20,3 +21,4 @@ class Foo {\n\
+b\n\
+++ b/b.ts\n\
@@ -1,3 +1,4 @@ function bar() {\n\
+c\n";
let ctx = hunk_context(d);
assert_eq!(ctx.matches("class Foo {").count(), 1);
assert!(ctx.contains("a.ts: class Foo {"));
assert!(ctx.contains("b.ts: function bar() {"));
}
#[test]
fn hunk_context_skips_dev_null() {
let d = "+++ /dev/null\n@@ -1,1 +0,0 @@ func gone() {\n-gone\n";
assert!(hunk_context(d).is_empty());
}
#[test]
fn ts_finds_enclosing_rust_fn_and_impl() {
let src = "\
struct Point { x: i32 }
impl Point {
fn clamp(v: i32) -> i32 {
v
}
}
";
let changed: HashSet<u64> = [5u64].into_iter().collect();
let syms = symbols_for_file(Lang::Rust, src, &changed);
assert_eq!(syms.len(), 1, "smallest enclosing def only");
assert_eq!(syms[0].label, "fn");
assert_eq!(syms[0].name, "clamp");
}
#[test]
fn ts_reports_outer_def_for_line_outside_inner() {
let src = "\
impl Point {
fn clamp(v: i32) -> i32 {
v
}
}
";
let changed: HashSet<u64> = [1u64].into_iter().collect();
let syms = symbols_for_file(Lang::Rust, src, &changed);
assert_eq!(syms.len(), 1);
assert_eq!(syms[0].label, "impl");
assert_eq!(syms[0].name, "Point");
}
#[test]
fn ts_finds_typescript_class_and_method() {
let src = "\
export class OrderService {
processOrder(o: Order) {
return o;
}
}
";
let changed: HashSet<u64> = [1u64, 3u64].into_iter().collect();
let syms = symbols_for_file(Lang::TypeScript, src, &changed);
let names: Vec<_> = syms.iter().map(|s| (s.label, s.name.as_str())).collect();
assert!(names.contains(&("class", "OrderService")));
assert!(names.contains(&("method", "processOrder")));
}
#[test]
fn ts_finds_python_function() {
let src = "\
def process(order):
total = 0
return total
";
let changed: HashSet<u64> = [2u64].into_iter().collect();
let syms = symbols_for_file(Lang::Python, src, &changed);
assert_eq!(syms.len(), 1);
assert_eq!(syms[0].label, "function");
assert_eq!(syms[0].name, "process");
}
#[test]
fn ts_finds_go_func() {
let src = "\
package main
func processOrder(o Order) int {
return 0
}
";
let changed: HashSet<u64> = [4u64].into_iter().collect();
let syms = symbols_for_file(Lang::Go, src, &changed);
assert_eq!(syms.len(), 1);
assert_eq!(syms[0].label, "func");
assert_eq!(syms[0].name, "processOrder");
}
#[test]
fn language_for_path_maps_extensions() {
assert!(matches!(language_for_path("a/b.rs"), Some(Lang::Rust)));
assert!(matches!(
language_for_path("a/b.ts"),
Some(Lang::TypeScript)
));
assert!(matches!(language_for_path("a/b.tsx"), Some(Lang::Tsx)));
assert!(matches!(
language_for_path("a/b.jsx"),
Some(Lang::JavaScript)
));
assert!(matches!(language_for_path("a/b.py"), Some(Lang::Python)));
assert!(matches!(language_for_path("a/b.go"), Some(Lang::Go)));
assert!(language_for_path("a/b.md").is_none());
assert!(language_for_path("Makefile").is_none());
}
#[test]
fn format_symbols_uses_en_dash_span() {
let syms = vec![Sym {
label: "fn",
name: "clamp".to_string(),
start: 5,
end: 9,
}];
let s = format_symbols("src/util.rs", &syms);
assert_eq!(s, "- src/util.rs: fn clamp (lines 5\u{2013}9)");
}
fn kinds_on(refs: &[SymbolRef]) -> Vec<(u64, RefKind)> {
refs.iter().map(|r| (r.line, r.kind)).collect()
}
#[test]
fn refs_classify_jsx_component_usage() {
let src = "\
function App() {
return <FindingCard title=\"x\" />;
}
function Other() {
return <FindingCard>{y}</FindingCard>;
}
";
let refs = references_in_source("app.tsx", src, "FindingCard");
assert_eq!(kinds_on(&refs), vec![(2, RefKind::Jsx), (5, RefKind::Jsx)]);
}
#[test]
fn refs_classify_namespaced_jsx() {
let src = "const x = <Ns.Panel/>;\n";
let refs = references_in_source("a.tsx", src, "Panel");
assert_eq!(kinds_on(&refs), vec![(1, RefKind::Jsx)]);
}
#[test]
fn refs_classify_type_positions() {
let src = "\
function f(x: Finding): Finding { return x; }
const a: Array<Finding> = [];
";
let finding = references_in_source("a.ts", src, "Finding");
assert_eq!(
kinds_on(&finding),
vec![(1, RefKind::Type), (2, RefKind::Type)]
);
}
#[test]
fn refs_ignore_class_extends_value_identifier() {
let refs = references_in_source("a.ts", "class B extends Base {}\n", "Base");
assert!(refs.is_empty());
}
#[test]
fn refs_classify_calls_and_new() {
let src = "\
scoreColor(1);
foo.analyzeGlstat(y);
const w = new Widget();
";
assert_eq!(
kinds_on(&references_in_source("a.ts", src, "scoreColor")),
vec![(1, RefKind::Call)]
);
assert_eq!(
kinds_on(&references_in_source("a.ts", src, "analyzeGlstat")),
vec![(2, RefKind::Call)]
);
assert_eq!(
kinds_on(&references_in_source("a.ts", src, "Widget")),
vec![(3, RefKind::Call)]
);
}
#[test]
fn refs_empty_for_non_js_family_and_no_match() {
assert!(references_in_source("a.rs", "fn f() { g(); }", "g").is_empty());
assert!(references_in_source("a.tsx", "const x = 1;\n", "Missing").is_empty());
}
}