dimpact 0.5.3

impact analysis from diff
Documentation
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> {
    // Include both new_path (added/modified) and old_path for deletions/renames,
    // so cache can mark removed files as present=0 when they no longer exist.
    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 {
                // count Added, Removed, and Context lines as changes
                if matches!(ch.kind, ChangeKind::Added)
                    || matches!(ch.kind, ChangeKind::Removed)
                    || matches!(ch.kind, ChangeKind::Context)
                {
                    // use new_line when available, else old_line for removals
                    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();

        // Construct a diff snippet that adds a line inside foo()
        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();
        // Compute with working dir file; change current dir temporarily
        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"
        );
    }
}