use crate::diff::{ChangeKind, FileChanges};
use crate::ir::{Symbol, TextRange};
use crate::languages::{LanguageKind, analyzer_for_path};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LanguageMode {
Auto,
Rust,
Ruby,
Python,
Javascript,
Typescript,
Tsx,
Go,
Java,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangedOutput {
pub changed_files: Vec<String>,
pub changed_symbols: Vec<Symbol>,
}
pub fn compute_changed_symbols(
diffs: &[FileChanges],
lang: LanguageMode,
) -> anyhow::Result<ChangedOutput> {
let mut changed_files: Vec<String> = Vec::new();
for fc in diffs {
if let Some(p) = fc.new_path.clone() {
changed_files.push(p);
} else if let Some(op) = fc.old_path.clone() {
changed_files.push(op);
}
}
let mut changed_lines_by_file: HashMap<String, HashSet<u32>> = HashMap::new();
for fc in diffs {
if let Some(path) = &fc.new_path {
let set = changed_lines_by_file.entry(path.clone()).or_default();
for ch in &fc.changes {
if matches!(ch.kind, ChangeKind::Added)
|| matches!(ch.kind, ChangeKind::Removed)
|| matches!(ch.kind, ChangeKind::Context)
{
if let Some(nl) = ch.new_line {
set.insert(nl);
} else if let Some(ol) = ch.old_line {
set.insert(ol);
}
}
}
}
}
let mut changed_symbols = Vec::new();
for (path, lines) in changed_lines_by_file.iter() {
let kind = match lang {
LanguageMode::Auto => LanguageKind::Auto,
LanguageMode::Rust => LanguageKind::Rust,
LanguageMode::Ruby => LanguageKind::Ruby,
LanguageMode::Python => LanguageKind::Python,
LanguageMode::Javascript => LanguageKind::Javascript,
LanguageMode::Typescript => LanguageKind::Typescript,
LanguageMode::Tsx => LanguageKind::Tsx,
LanguageMode::Go => LanguageKind::Go,
LanguageMode::Java => LanguageKind::Java,
};
let Some(analyzer) = analyzer_for_path(path, kind) else {
continue;
};
let Ok(source) = fs::read_to_string(path) else {
continue;
};
let symbols = analyzer.symbols_in_file(path, &source);
for s in symbols {
if intersects(&s.range, lines) {
changed_symbols.push(s);
}
}
}
changed_symbols.sort_by(|a, b| a.id.0.cmp(&b.id.0));
changed_symbols.dedup_by(|a, b| a.id.0 == b.id.0);
Ok(ChangedOutput {
changed_files,
changed_symbols,
})
}
fn intersects(range: &TextRange, lines: &HashSet<u32>) -> bool {
for ln in range.start_line..=range.end_line {
if lines.contains(&ln) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::parse_unified_diff;
use serial_test::serial;
use tempfile::tempdir;
#[test]
#[serial]
fn changed_symbols_basic() {
let dir = tempdir().unwrap();
let file = dir.path().join("main.rs");
let code = r#"fn foo() {
println!("one");
}
fn bar() {}
"#;
fs::write(&file, code).unwrap();
let diff = "diff --git a/main.rs b/main.rs\n--- a/main.rs\n+++ b/main.rs\n@@ -1,3 +1,4 @@\n fn foo() {\n- println!(\"one\");\n+ println!(\"one\");\n+ println!(\"two\");\n }\n".to_string();
let parsed = parse_unified_diff(&diff).unwrap();
let cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let out = compute_changed_symbols(&parsed, LanguageMode::Rust).unwrap();
std::env::set_current_dir(cwd).unwrap();
assert!(out.changed_files.iter().any(|p| p.ends_with("main.rs")));
assert!(out.changed_symbols.iter().any(|s| s.name == "foo"));
assert!(!out.changed_symbols.iter().any(|s| s.name == "bar"));
}
#[test]
#[serial]
fn changed_symbols_are_sorted_and_unique() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("a.rs"), "fn zeta() {}\n").unwrap();
fs::write(dir.path().join("b.rs"), "fn alpha() {}\n").unwrap();
let diff = r#"diff --git a/a.rs b/a.rs
--- a/a.rs
+++ b/a.rs
@@ -1 +1,2 @@
fn zeta() {}
+// changed
diff --git a/b.rs b/b.rs
--- a/b.rs
+++ b/b.rs
@@ -1 +1,2 @@
fn alpha() {}
+// changed
"#
.to_string();
let parsed = parse_unified_diff(&diff).unwrap();
let cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let out = compute_changed_symbols(&parsed, LanguageMode::Rust).unwrap();
std::env::set_current_dir(cwd).unwrap();
let ids: Vec<String> = out.changed_symbols.iter().map(|s| s.id.0.clone()).collect();
assert!(!ids.is_empty());
assert!(
ids.windows(2).all(|w| w[0] <= w[1]),
"ids should be sorted: {ids:?}"
);
let unique: std::collections::BTreeSet<String> = ids.iter().cloned().collect();
assert_eq!(unique.len(), ids.len(), "ids should be unique: {ids:?}");
}
#[test]
#[serial]
fn changed_symbols_handles_go_java_modes() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("main.go"), "package main\nfunc main() {}\n").unwrap();
fs::write(
dir.path().join("Main.java"),
"class Main {\n static void main(String[] args) {\n }\n}\n",
)
.unwrap();
let go_diff = r#"diff --git a/main.go b/main.go
--- a/main.go
+++ b/main.go
@@ -1,2 +1,3 @@
package main
func main() {}
+// changed
"#;
let java_diff = r#"diff --git a/Main.java b/Main.java
--- a/Main.java
+++ b/Main.java
@@ -1,4 +1,5 @@
class Main {
static void main(String[] args) {
+ // changed
}
}
"#;
let parsed_go = parse_unified_diff(go_diff).unwrap();
let parsed_java = parse_unified_diff(java_diff).unwrap();
let cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let out_go = compute_changed_symbols(&parsed_go, LanguageMode::Go).unwrap();
let out_java = compute_changed_symbols(&parsed_java, LanguageMode::Java).unwrap();
std::env::set_current_dir(cwd).unwrap();
assert!(out_go.changed_files.iter().any(|p| p.ends_with("main.go")));
assert!(
out_java
.changed_files
.iter()
.any(|p| p.ends_with("Main.java"))
);
assert!(
out_go
.changed_symbols
.iter()
.any(|s| s.file.ends_with("main.go"))
);
assert!(
out_java
.changed_symbols
.iter()
.any(|s| s.file.ends_with("Main.java"))
);
}
#[test]
#[serial]
fn changed_symbols_python_mode_matches_auto_for_py_diff() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("main.py"), "def foo():\n return 1\n").unwrap();
let py_diff = r#"diff --git a/main.py b/main.py
--- a/main.py
+++ b/main.py
@@ -1,2 +1,3 @@
def foo():
+ x = 1
return 1
"#;
let parsed_py = parse_unified_diff(py_diff).unwrap();
let cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let out_auto = compute_changed_symbols(&parsed_py, LanguageMode::Auto).unwrap();
let out_python = compute_changed_symbols(&parsed_py, LanguageMode::Python).unwrap();
std::env::set_current_dir(cwd).unwrap();
assert!(
out_python
.changed_files
.iter()
.any(|p| p.ends_with("main.py"))
);
assert_eq!(out_python.changed_files, out_auto.changed_files);
assert_eq!(
out_python.changed_symbols, out_auto.changed_symbols,
"explicit python mode should preserve existing behavior"
);
}
}