use serde::Serialize;
use super::Language;
use super::symbols::{SymbolDef, extract_symbols};
#[derive(Debug, Clone, Serialize)]
pub struct StructuralChange {
pub name: String,
pub kind: String,
pub change: ChangeKind,
pub line: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeKind {
Added,
Removed,
SignatureChanged,
BodyChanged,
}
impl std::fmt::Display for ChangeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Added => write!(f, "+"),
Self::Removed => write!(f, "-"),
Self::SignatureChanged => write!(f, "~"),
Self::BodyChanged => write!(f, "~"),
}
}
}
pub fn structural_diff(
old_source: &str,
new_source: &str,
lang: Language,
) -> Vec<StructuralChange> {
let old_symbols = extract_symbols(old_source, lang);
let new_symbols = extract_symbols(new_source, lang);
let mut changes = Vec::new();
diff_symbol_lists(
&old_symbols,
&new_symbols,
old_source,
new_source,
&mut changes,
);
changes
}
fn diff_symbol_lists(
old: &[SymbolDef],
new: &[SymbolDef],
old_source: &str,
new_source: &str,
changes: &mut Vec<StructuralChange>,
) {
let mut old_map: std::collections::HashMap<&str, Vec<&SymbolDef>> =
std::collections::HashMap::new();
for s in old {
old_map.entry(s.name.as_str()).or_default().push(s);
}
let mut new_map: std::collections::HashMap<&str, Vec<&SymbolDef>> =
std::collections::HashMap::new();
for s in new {
new_map.entry(s.name.as_str()).or_default().push(s);
}
for sym in new {
if !old_map.contains_key(sym.name.as_str()) {
changes.push(StructuralChange {
name: sym.name.clone(),
kind: sym.kind.to_string(),
change: ChangeKind::Added,
line: sym.start_line,
detail: Some(format!("added at line {}", sym.start_line)),
});
}
}
for sym in old {
if !new_map.contains_key(sym.name.as_str()) {
changes.push(StructuralChange {
name: sym.name.clone(),
kind: sym.kind.to_string(),
change: ChangeKind::Removed,
line: sym.start_line,
detail: Some(format!("was at line {}", sym.start_line)),
});
}
}
for (name, new_syms) in &new_map {
if let Some(old_syms) = old_map.get(name) {
let paired = old_syms.len().min(new_syms.len());
for i in 0..paired {
let old_sym = old_syms[i];
let new_sym = new_syms[i];
if old_sym.signature != new_sym.signature {
changes.push(StructuralChange {
name: new_sym.name.clone(),
kind: new_sym.kind.to_string(),
change: ChangeKind::SignatureChanged,
line: new_sym.start_line,
detail: Some(format!("was: {}", old_sym.signature)),
});
} else {
let old_body = extract_body(old_source, old_sym);
let new_body = extract_body(new_source, new_sym);
if old_body != new_body {
changes.push(StructuralChange {
name: new_sym.name.clone(),
kind: new_sym.kind.to_string(),
change: ChangeKind::BodyChanged,
line: new_sym.start_line,
detail: Some(format!(
"lines {}-{}",
new_sym.start_line, new_sym.end_line
)),
});
}
}
diff_symbol_lists(
&old_sym.children,
&new_sym.children,
old_source,
new_source,
changes,
);
}
for new_sym in &new_syms[paired..] {
changes.push(StructuralChange {
name: new_sym.name.clone(),
kind: new_sym.kind.to_string(),
change: ChangeKind::Added,
line: new_sym.start_line,
detail: Some(format!("added at line {}", new_sym.start_line)),
});
}
for old_sym in &old_syms[paired..] {
changes.push(StructuralChange {
name: old_sym.name.clone(),
kind: old_sym.kind.to_string(),
change: ChangeKind::Removed,
line: old_sym.start_line,
detail: Some(format!("was at line {}", old_sym.start_line)),
});
}
}
}
}
fn extract_body<'a>(source: &'a str, sym: &SymbolDef) -> &'a str {
let lines: Vec<&str> = source.lines().collect();
let start = sym.start_line.saturating_sub(1);
let end = sym.end_line.min(lines.len());
if start >= lines.len() || start >= end {
return "";
}
let line_sep_len = if source.contains("\r\n") { 2 } else { 1 };
let start_byte: usize = source
.lines()
.take(start)
.map(|l| l.len() + line_sep_len)
.sum();
let end_byte: usize = source
.lines()
.take(end)
.map(|l| l.len() + line_sep_len)
.sum();
&source[start_byte..end_byte.min(source.len())]
}
pub fn render_changes(file: &str, changes: &[StructuralChange]) -> String {
let mut out = format!("{file}\n");
for c in changes {
let detail = c.detail.as_deref().unwrap_or("");
out.push_str(&format!(
" {} {} {}: {} [{}]\n",
c.change, c.kind, c.name, detail, c.line
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_added_function() {
let old = "fn foo() {}\n";
let new = "fn foo() {}\nfn bar() {}\n";
let changes = structural_diff(old, new, Language::Rust);
assert!(
changes
.iter()
.any(|c| c.name == "bar" && c.change == ChangeKind::Added)
);
}
#[test]
fn detects_removed_function() {
let old = "fn foo() {}\nfn bar() {}\n";
let new = "fn foo() {}\n";
let changes = structural_diff(old, new, Language::Rust);
assert!(
changes
.iter()
.any(|c| c.name == "bar" && c.change == ChangeKind::Removed)
);
}
#[test]
fn detects_signature_change() {
let old = "fn foo() {}\n";
let new = "fn foo(x: i32) {}\n";
let changes = structural_diff(old, new, Language::Rust);
assert!(
changes
.iter()
.any(|c| c.name == "foo" && c.change == ChangeKind::SignatureChanged)
);
}
#[test]
fn detects_body_change() {
let old = "fn foo() {\n let x = 1;\n}\n";
let new = "fn foo() {\n let x = 2;\n}\n";
let changes = structural_diff(old, new, Language::Rust);
assert!(
changes
.iter()
.any(|c| c.name == "foo" && c.change == ChangeKind::BodyChanged)
);
}
#[test]
fn no_changes_returns_empty() {
let source = "fn foo() {\n let x = 1;\n}\n";
let changes = structural_diff(source, source, Language::Rust);
assert!(changes.is_empty());
}
#[test]
fn extract_body_crlf() {
let source = "line1\r\nline2\r\nline3\r\n";
let sym = SymbolDef {
name: "test".into(),
kind: crate::ast::symbols::SymbolKind::Function,
start_line: 2,
end_line: 3,
signature: String::new(),
children: vec![],
depth: 0,
};
let body = extract_body(source, &sym);
assert_eq!(body, "line2\r\nline3\r\n");
}
#[test]
fn python_diff() {
let old = "def hello():\n pass\n";
let new = "def hello():\n print('hi')\ndef world():\n pass\n";
let changes = structural_diff(old, new, Language::Python);
assert!(
changes
.iter()
.any(|c| c.name == "world" && c.change == ChangeKind::Added)
);
assert!(
changes
.iter()
.any(|c| c.name == "hello" && c.change == ChangeKind::BodyChanged)
);
}
#[test]
fn duplicate_symbol_names_not_lost() {
let old = "def run():\n pass\ndef run():\n pass\n";
let new = "def run():\n pass\ndef run():\n return 1\ndef run():\n return 2\n";
let changes = structural_diff(old, new, Language::Python);
assert!(
changes
.iter()
.any(|c| c.name == "run" && c.change == ChangeKind::BodyChanged),
"second run body change should be detected: {changes:?}"
);
assert!(
changes
.iter()
.any(|c| c.name == "run" && c.change == ChangeKind::Added),
"third run should be detected as added: {changes:?}"
);
}
}