use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use dk_core::{Error, Result, Symbol};
use crate::parser::ParserRegistry;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeStatus {
Clean,
Conflict,
}
#[derive(Debug)]
pub struct MergeResult {
pub status: MergeStatus,
pub merged_content: String,
pub conflicts: Vec<SymbolConflict>,
}
#[derive(Debug)]
pub struct SymbolConflict {
pub qualified_name: String,
pub kind: String,
pub version_a: String,
pub version_b: String,
pub base: String,
}
#[derive(Debug, Clone)]
struct SymbolSpan {
qualified_name: String,
kind: String,
text: String,
_order: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct ImportLine {
text: String,
}
fn extract_spans(
source: &str,
symbols: &[Symbol],
) -> (Vec<ImportLine>, Vec<SymbolSpan>) {
let bytes = source.as_bytes();
let mut import_lines = Vec::new();
let mut symbol_spans = Vec::new();
let mut symbol_ranges: Vec<(usize, usize)> = symbols
.iter()
.map(|s| (s.span.start_byte as usize, s.span.end_byte as usize))
.collect();
symbol_ranges.sort_by_key(|r| r.0);
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("use ")
|| trimmed.starts_with("import ")
|| trimmed.starts_with("from ")
{
let line_start = line.as_ptr() as usize - bytes.as_ptr() as usize;
let inside_symbol = symbol_ranges
.iter()
.any(|(start, end)| line_start >= *start && line_start < *end);
if !inside_symbol {
import_lines.push(ImportLine {
text: line.to_string(),
});
}
}
}
for (order, sym) in symbols.iter().enumerate() {
let start = sym.span.start_byte as usize;
let end = sym.span.end_byte as usize;
if end <= bytes.len() {
let extended_end = if end < bytes.len() {
let rest = &source[end..];
if let Some(nl) = rest.find('\n') {
let trailing = rest[..nl].trim();
if trailing.starts_with('#') || trailing.is_empty() {
end + nl
} else {
end
}
} else {
source.len() }
} else {
end
};
let body = String::from_utf8_lossy(&bytes[start..extended_end]).to_string();
let text = match &sym.doc_comment {
Some(doc) if !doc.is_empty() && !body.contains(doc.as_str()) => {
format!("{doc}\n{body}")
}
_ => body,
};
symbol_spans.push(SymbolSpan {
qualified_name: sym.qualified_name.clone(),
kind: sym.kind.to_string(),
text,
_order: order,
});
}
}
(import_lines, symbol_spans)
}
pub fn ast_merge(
registry: &ParserRegistry,
file_path: &str,
base: &str,
version_a: &str,
version_b: &str,
) -> Result<MergeResult> {
let path = Path::new(file_path);
if !registry.supports_file(path) {
return Err(Error::UnsupportedLanguage(format!(
"AST merge not supported for file: {file_path}"
)));
}
let base_analysis = registry.parse_file(path, base.as_bytes())?;
let a_analysis = registry.parse_file(path, version_a.as_bytes())?;
let b_analysis = registry.parse_file(path, version_b.as_bytes())?;
let (base_imports, base_spans) = extract_spans(base, &base_analysis.symbols);
let (a_imports, a_spans) = extract_spans(version_a, &a_analysis.symbols);
let (b_imports, b_spans) = extract_spans(version_b, &b_analysis.symbols);
let base_map: BTreeMap<&str, &SymbolSpan> =
base_spans.iter().map(|s| (s.qualified_name.as_str(), s)).collect();
let a_map: BTreeMap<&str, &SymbolSpan> =
a_spans.iter().map(|s| (s.qualified_name.as_str(), s)).collect();
let b_map: BTreeMap<&str, &SymbolSpan> =
b_spans.iter().map(|s| (s.qualified_name.as_str(), s)).collect();
let mut all_names: Vec<&str> = Vec::new();
let mut seen: HashSet<&str> = HashSet::new();
for span in &base_spans {
let name = span.qualified_name.as_str();
if seen.insert(name) {
all_names.push(name);
}
}
for span in &a_spans {
let name = span.qualified_name.as_str();
if seen.insert(name) {
all_names.push(name);
}
}
for span in &b_spans {
let name = span.qualified_name.as_str();
if seen.insert(name) {
all_names.push(name);
}
}
let mut merged_symbols: Vec<SymbolSpan> = Vec::new();
let mut conflicts: Vec<SymbolConflict> = Vec::new();
let mut order_counter: usize = 0;
for name in &all_names {
let in_base = base_map.get(name);
let in_a = a_map.get(name);
let in_b = b_map.get(name);
let a_modified = match (in_base, in_a) {
(Some(base_s), Some(a_s)) => base_s.text != a_s.text,
(None, Some(_)) => true, (Some(_), None) => true, (None, None) => false,
};
let b_modified = match (in_base, in_b) {
(Some(base_s), Some(b_s)) => base_s.text != b_s.text,
(None, Some(_)) => true, (Some(_), None) => true, (None, None) => false,
};
match (a_modified, b_modified) {
(false, false) => {
if let Some(base_s) = in_base {
merged_symbols.push(SymbolSpan {
qualified_name: base_s.qualified_name.clone(),
kind: base_s.kind.clone(),
text: base_s.text.clone(),
_order: order_counter,
});
order_counter += 1;
}
}
(true, false) => {
if let Some(a_s) = in_a {
merged_symbols.push(SymbolSpan {
qualified_name: a_s.qualified_name.clone(),
kind: a_s.kind.clone(),
text: a_s.text.clone(),
_order: order_counter,
});
order_counter += 1;
}
}
(false, true) => {
if let Some(b_s) = in_b {
merged_symbols.push(SymbolSpan {
qualified_name: b_s.qualified_name.clone(),
kind: b_s.kind.clone(),
text: b_s.text.clone(),
_order: order_counter,
});
order_counter += 1;
}
}
(true, true) => {
match (in_base, in_a, in_b) {
(None, Some(a_s), Some(b_s)) => {
conflicts.push(SymbolConflict {
qualified_name: name.to_string(),
kind: a_s.kind.clone(),
version_a: a_s.text.clone(),
version_b: b_s.text.clone(),
base: String::new(),
});
merged_symbols.push(SymbolSpan {
qualified_name: a_s.qualified_name.clone(),
kind: a_s.kind.clone(),
text: a_s.text.clone(),
_order: order_counter,
});
order_counter += 1;
}
(Some(base_s), Some(a_s), Some(b_s)) => {
if a_s.text == b_s.text {
merged_symbols.push(SymbolSpan {
qualified_name: a_s.qualified_name.clone(),
kind: a_s.kind.clone(),
text: a_s.text.clone(),
_order: order_counter,
});
order_counter += 1;
} else {
conflicts.push(SymbolConflict {
qualified_name: name.to_string(),
kind: base_s.kind.clone(),
version_a: a_s.text.clone(),
version_b: b_s.text.clone(),
base: base_s.text.clone(),
});
merged_symbols.push(SymbolSpan {
qualified_name: a_s.qualified_name.clone(),
kind: a_s.kind.clone(),
text: a_s.text.clone(),
_order: order_counter,
});
order_counter += 1;
}
}
(Some(base_s), None, Some(b_s)) => {
conflicts.push(SymbolConflict {
qualified_name: name.to_string(),
kind: base_s.kind.clone(),
version_a: String::new(),
version_b: b_s.text.clone(),
base: base_s.text.clone(),
});
merged_symbols.push(SymbolSpan {
qualified_name: b_s.qualified_name.clone(),
kind: b_s.kind.clone(),
text: b_s.text.clone(),
_order: order_counter,
});
order_counter += 1;
}
(Some(base_s), Some(a_s), None) => {
conflicts.push(SymbolConflict {
qualified_name: name.to_string(),
kind: base_s.kind.clone(),
version_a: a_s.text.clone(),
version_b: String::new(),
base: base_s.text.clone(),
});
merged_symbols.push(SymbolSpan {
qualified_name: a_s.qualified_name.clone(),
kind: a_s.kind.clone(),
text: a_s.text.clone(),
_order: order_counter,
});
order_counter += 1;
}
(Some(_), None, None) => {
}
_ => {}
}
}
}
}
let mut merged_imports: Vec<String> = Vec::new();
let mut import_seen: HashSet<String> = HashSet::new();
for imp in base_imports.iter().chain(a_imports.iter()).chain(b_imports.iter()) {
if import_seen.insert(imp.text.clone()) {
merged_imports.push(imp.text.clone());
}
}
let mut output = String::new();
if !merged_imports.is_empty() {
for imp in &merged_imports {
output.push_str(imp);
output.push('\n');
}
output.push('\n');
}
for (i, sym) in merged_symbols.iter().enumerate() {
if i > 0 {
let prev_is_var = merged_symbols[i - 1].kind == "variable";
let curr_is_var = sym.kind == "variable";
if prev_is_var && curr_is_var {
output.push('\n');
} else {
output.push_str("\n\n");
}
}
output.push_str(&sym.text);
}
if !output.ends_with('\n') {
output.push('\n');
}
let status = if conflicts.is_empty() {
MergeStatus::Clean
} else {
MergeStatus::Conflict
};
Ok(MergeResult {
status,
merged_content: output,
conflicts,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_merge_status_eq() {
assert_eq!(MergeStatus::Clean, MergeStatus::Clean);
assert_eq!(MergeStatus::Conflict, MergeStatus::Conflict);
assert_ne!(MergeStatus::Clean, MergeStatus::Conflict);
}
}