use tree_sitter::Node;
use crate::code::Code;
use crate::diff::{ASTDiff, NodeCache, nodes};
use super::render_options::{RangeMatch, TextOperation};
use super::{NodeChange, classify_node};
pub fn line_operations(ranges: &[RangeMatch], line_count: usize) -> Vec<TextOperation> {
let mut ops = vec![TextOperation::Identical; line_count];
for rm in ranges {
let r = &rm.source;
if r.is_empty() {
continue;
}
let end_row = if r.end_column == 0 {
r.end_row
} else {
r.end_row + 1
};
for row_op in ops
.iter_mut()
.take(end_row.min(line_count))
.skip(r.start_row)
{
if rm.operation != TextOperation::Identical || *row_op == TextOperation::Identical {
*row_op = rm.operation.clone();
}
}
}
ops
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ChangeCounts {
pub insertions: usize,
pub deletions: usize,
pub updates: usize,
pub moves: usize,
}
pub fn change_counts(
before_contents: &str,
after_contents: &str,
before_ranges: &[RangeMatch],
after_ranges: &[RangeMatch],
) -> ChangeCounts {
let before_ops = line_operations(before_ranges, before_contents.split('\n').count());
let after_ops = line_operations(after_ranges, after_contents.split('\n').count());
ChangeCounts {
insertions: after_ops
.iter()
.filter(|op| **op == TextOperation::Insert)
.count(),
deletions: before_ops
.iter()
.filter(|op| **op == TextOperation::Delete)
.count(),
updates: after_ops
.iter()
.filter(|op| **op == TextOperation::Update)
.count(),
moves: after_ops
.iter()
.filter(|op| **op == TextOperation::Move)
.count(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffSummary {
NoChanges,
NewFile,
DeletedFile,
WhitespaceOnly,
CommentOnly,
RefactorMovedOnly,
}
impl DiffSummary {
pub fn label(self) -> &'static str {
match self {
DiffSummary::NoChanges => "No changes - files are identical",
DiffSummary::NewFile => "New file - everything inserted",
DiffSummary::DeletedFile => "Deleted file - everything removed",
DiffSummary::WhitespaceOnly => "Whitespace changes only",
DiffSummary::CommentOnly => "Comment changes only",
DiffSummary::RefactorMovedOnly => "Refactor - code moved, no content changes",
}
}
}
pub(crate) fn whitespace_stripped_equal(a: &str, b: &str) -> bool {
a.chars()
.filter(|c| !c.is_whitespace())
.eq(b.chars().filter(|c| !c.is_whitespace()))
}
pub fn summarize_diff(
before_contents: &str,
after_contents: &str,
before_ranges: &[RangeMatch],
after_ranges: &[RangeMatch],
) -> Option<DiffSummary> {
if before_contents == after_contents {
return Some(DiffSummary::NoChanges);
}
if whitespace_stripped_equal(before_contents, after_contents) {
return Some(DiffSummary::WhitespaceOnly);
}
let mut has_insert = false;
let mut has_delete = false;
let mut has_update = false;
let mut has_move = false;
let mut has_identical = false;
for range in before_ranges.iter().chain(after_ranges.iter()) {
match range.operation {
TextOperation::Insert => has_insert = true,
TextOperation::Delete => has_delete = true,
TextOperation::Update => has_update = true,
TextOperation::Move => has_move = true,
TextOperation::Identical => has_identical = true,
TextOperation::NotYetSet => {}
}
}
if has_insert && !has_delete && !has_update && !has_move && !has_identical {
return Some(DiffSummary::NewFile);
}
if has_delete && !has_insert && !has_update && !has_move && !has_identical {
return Some(DiffSummary::DeletedFile);
}
if has_move && !has_insert && !has_delete && !has_update {
return Some(DiffSummary::RefactorMovedOnly);
}
None
}
pub fn is_comment_only_diff(
before: &Code,
after: &Code,
diff: &ASTDiff,
node_cache: &NodeCache,
) -> bool {
let (Some(before_ast), Some(after_ast)) = (before.ast.as_ref(), after.ast.as_ref()) else {
return false;
};
fn is_comment_or_inside_comment(node_id: usize, meta: &crate::code::ASTMetadata) -> bool {
let mut current = Some(node_id);
while let Some(id) = current {
if meta
.node_info
.get(&id)
.is_some_and(|info| nodes::is_comment(&info.kind))
{
return true;
}
current = meta.node_to_parent.get(&id).copied();
}
false
}
fn scan(
root: Node,
diff: &ASTDiff,
node_cache: &NodeCache,
own_meta: &crate::code::ASTMetadata,
own_bytes: &[u8],
other_bytes: &[u8],
) -> (bool, bool) {
let mut found_any = false;
let mut all_comments = true;
let mut stack = vec![root];
while let Some(node) = stack.pop() {
let mut descend = true;
let mut mark_found = || {
found_any = true;
if !is_comment_or_inside_comment(node.id(), own_meta) {
all_comments = false;
}
};
if let Some((mapped_id, mapping)) = diff.mapping_for_node(&node.id()) {
match classify_node(
node,
mapped_id,
&mapping.operation,
node_cache,
own_bytes,
other_bytes,
) {
NodeChange::Identical(_) => descend = false,
NodeChange::PrunedSubtree(_) | NodeChange::OwnContentChanged(_) => {
descend = false;
mark_found();
}
NodeChange::Leaf(_) | NodeChange::Update(_) => mark_found(),
NodeChange::Descend => {}
}
}
if descend {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
stack.push(child);
}
}
}
(found_any, all_comments)
}
let before_bytes = before.contents.as_bytes();
let after_bytes = after.contents.as_bytes();
let before_meta = crate::code::metadata::metadata_of(before);
let after_meta = crate::code::metadata::metadata_of(after);
let (before_found, before_all_comments) = scan(
before_ast.root_node(),
diff,
node_cache,
&before_meta,
before_bytes,
after_bytes,
);
let (after_found, after_all_comments) = scan(
after_ast.root_node(),
diff,
node_cache,
&after_meta,
after_bytes,
before_bytes,
);
(before_found || after_found)
&& (!before_found || before_all_comments)
&& (!after_found || after_all_comments)
}
pub fn summarize_diff_with_comment_check(
before_contents: &str,
after_contents: &str,
before_ranges: &[RangeMatch],
after_ranges: &[RangeMatch],
is_comment_only: bool,
) -> Option<DiffSummary> {
let summary = summarize_diff(before_contents, after_contents, before_ranges, after_ranges);
if is_comment_only && matches!(summary, None | Some(DiffSummary::RefactorMovedOnly)) {
return Some(DiffSummary::CommentOnly);
}
summary
}