feat: basic pr diff rendering

This commit is contained in:
2026-05-24 16:44:10 +01:00
parent 1843622540
commit b3e041a257
23 changed files with 903 additions and 353 deletions

View File

@@ -1,12 +1,7 @@
use std::ops::Range;
use std::{ops::Range, slice::Iter, sync::Arc, thread::current};
pub(crate) struct ContentDiff {
pub(crate) old_content: bytes::Bytes,
pub(crate) new_content: bytes::Bytes,
pub(crate) spans: Vec<Span>,
old_line_ranges: Vec<Range<usize>>,
new_line_ranges: Vec<Range<usize>>,
}
use memchr::{memchr2, memchr2_iter, memchr3_iter};
use similar::DiffableStr;
pub(crate) struct Span {
pub(crate) op: Op,
@@ -14,7 +9,7 @@ pub(crate) struct Span {
pub(crate) new_range: Range<usize>,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Op {
Equal,
Delete,
@@ -37,147 +32,184 @@ pub(crate) struct DiffRow {
pub(crate) new_content_range: Option<Range<usize>>,
}
pub(crate) fn diff_content(old_content: bytes::Bytes, new_content: bytes::Bytes) -> ContentDiff {
#[derive(Clone)]
pub(crate) struct DiffLine {
pub(crate) op: Op,
pub(crate) old_content: Option<Arc<str>>,
pub(crate) old_line: usize,
pub(crate) new_content: Option<Arc<str>>,
pub(crate) new_line: usize,
}
#[derive(Clone)]
pub(crate) struct ContentDiff(Vec<DiffLine>);
pub(crate) fn diff_content(
old_content: bytes::Bytes,
new_content: bytes::Bytes,
) -> Option<ContentDiff> {
let old_line_ranges = line_ranges(&old_content);
let new_line_ranges = line_ranges(&new_content);
let diff = similar::TextDiff::from_lines::<[u8]>(&old_content, &new_content);
let spans = diff
.ops()
.iter()
.map(|op| match op {
| &similar::DiffOp::Equal {
old_index,
new_index,
len,
} => Span {
op: Op::Equal,
old_range: old_index..(old_index + len),
new_range: new_index..(new_index + len),
},
let mut diff_lines: Vec<DiffLine> = Vec::new();
| &similar::DiffOp::Delete {
old_index,
old_len,
new_index,
} => Span {
op: Op::Delete,
old_range: old_index..(old_index + old_len),
new_range: new_index..new_index,
},
for op in diff.ops() {
match op {
| &similar::DiffOp::Equal {
old_index,
new_index,
len,
} => {
for i in 0..len {
let old_line = old_index + i;
let new_line = new_index + i;
let old_line_range = &old_line_ranges[old_line];
let content = Arc::from(old_content.slice(old_line_range.clone()).as_str()?);
diff_lines.push(DiffLine {
op: Op::Equal,
old_line,
old_content: Some(Arc::clone(&content)),
new_line,
new_content: Some(content),
});
}
}
| &similar::DiffOp::Insert {
old_index,
new_index,
new_len,
} => Span {
op: Op::Insert,
old_range: old_index..old_index,
new_range: new_index..(new_index + new_len),
},
| &similar::DiffOp::Insert {
old_index,
new_index,
new_len,
} => {
for i in 0..new_len {
let new_line_range = &new_line_ranges[new_index + i];
let content = Arc::from(new_content.slice(new_line_range.clone()).as_str()?);
diff_lines.push(DiffLine {
op: Op::Insert,
old_line: old_index,
old_content: None,
new_line: new_index + i,
new_content: Some(content),
})
}
}
| &similar::DiffOp::Replace {
old_index,
old_len,
new_index,
new_len,
} => Span {
op: Op::Replace,
old_range: old_index..(old_index + old_len),
new_range: new_index..(new_index + new_len),
},
})
.collect();
| &similar::DiffOp::Replace {
old_index,
old_len,
new_index,
new_len,
} => {
for i in 0..new_len.max(old_len) {
let old_line = old_index + i;
let new_line = new_index + i;
ContentDiff {
old_content,
new_content,
spans,
old_line_ranges,
new_line_ranges,
let diff_line = match (old_line_ranges.get(old_line), new_line_ranges.get(new_line))
{
| (Some(old_range), Some(new_range)) => DiffLine {
op: Op::Replace,
old_line,
old_content: Some(Arc::from(old_content.slice(old_range.clone()).as_str()?)),
new_line: new_index + i,
new_content: Some(Arc::from(new_content.slice(new_range.clone()).as_str()?)),
},
| (None, Some(new_range)) => DiffLine {
op: Op::Replace,
old_line: old_index + old_len,
old_content: None,
new_line: new_index + i,
new_content: Some(Arc::from(new_content.slice(new_range.clone()).as_str()?)),
},
| (Some(old_range), None) => DiffLine {
op: Op::Replace,
old_line: old_index + i,
old_content: Some(Arc::from(old_content.slice(old_range.clone()).as_str()?)),
new_line: new_index + new_len,
new_content: None,
},
| (None, None) => {
// unlickly to happen, but if it does, idk
panic!(
"the unlikely happened: both old & new index of DiffOps::Replace don't point to any line in the parsed line ranges."
)
}
};
diff_lines.push(diff_line);
}
}
| &similar::DiffOp::Delete {
old_index,
old_len,
new_index,
} => {
for i in 0..old_len {
let old_line_range = &old_line_ranges[old_index];
let content = Arc::from(old_content.slice(old_line_range.clone()).as_str()?);
diff_lines.push(DiffLine {
op: Op::Delete,
old_line: old_index + i,
old_content: Some(content),
new_line: new_index,
new_content: None,
})
}
}
}
}
Some(ContentDiff(diff_lines))
}
impl ContentDiff {
pub(crate) fn spans(&self) -> &[Span] {
&self.spans
pub(crate) fn len(&self) -> usize {
self.0.len()
}
pub(crate) fn old_line_count(&self) -> usize {
self.old_line_ranges.len()
pub(crate) fn get(&self, i: usize) -> &DiffLine {
&self.0[i]
}
pub(crate) fn new_line_count(&self) -> usize {
self.new_line_ranges.len()
}
pub(crate) fn line_slice(&self, side: DiffSide, range: &Range<usize>) -> &[u8] {
match side {
| DiffSide::Old => &self.old_content[range.clone()],
| DiffSide::New => &self.new_content[range.clone()],
}
}
pub(crate) fn line_slice_at(&self, side: DiffSide, line: usize) -> &[u8] {
match side {
| DiffSide::Old => self.line_slice(DiffSide::Old, &self.old_line_ranges[line]),
| DiffSide::New => self.line_slice(DiffSide::New, &self.new_line_ranges[line]),
}
}
pub(crate) fn rows_for_span(&self, span_index: usize) -> Vec<DiffRow> {
let span = &self.spans[span_index];
let old_len = span.old_range.end.saturating_sub(span.old_range.start);
let new_len = span.new_range.end.saturating_sub(span.new_range.start);
let row_count = old_len.max(new_len);
let mut rows = Vec::with_capacity(row_count);
for offset in 0..row_count {
let old_line = (offset < old_len).then_some(span.old_range.start + offset);
let new_line = (offset < new_len).then_some(span.new_range.start + offset);
rows.push(DiffRow {
op_index: span_index,
op: span.op,
old_line,
old_content_range: old_line.map(|line| self.old_line_ranges[line].clone()),
new_line,
new_content_range: new_line.map(|line| self.new_line_ranges[line].clone()),
});
}
rows
pub(crate) fn last(&self) -> Option<&DiffLine> {
self.0.last()
}
}
fn line_ranges(content: &[u8]) -> Vec<Range<usize>> {
let mut ranges = Vec::new();
let mut start = 0;
let mut index = 0;
let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
let mut line_start: usize = 0;
let mut skip_next = false;
while index < content.len() {
match content[index] {
| b'\r' => {
index += 1;
if index < content.len() && content[index] == b'\n' {
index += 1;
}
ranges.push(start..index);
start = index;
}
| b'\n' => {
index += 1;
ranges.push(start..index);
start = index;
}
| _ => {
index += 1;
}
for i in memchr2_iter(b'\n', b'\r', content) {
if skip_next {
skip_next = false;
continue;
}
let c = content[i];
match (c, content.get(i + 1)) {
| (b'\r', Some(b'\n')) => {
// if \r found, check if its \r\n or if its a lone \r
// if \r\n, then treat as one line break
ranges.push(line_start..i + 1);
// because we already counted the \n byte, the next iter into it needs to be skipped
skip_next = true;
line_start = i + 2;
}
| _ => {
ranges.push(line_start..i);
line_start = i + 1;
}
}
}
if start < content.len() {
ranges.push(start..content.len());
if line_start < content.len() {
ranges.push(line_start..content.len());
}
ranges

View File

@@ -5,18 +5,6 @@ pub(crate) enum ContentType {
Binary,
}
pub(crate) struct ContentDiff {
old_content: bytes::Bytes,
new_content: bytes::Bytes,
}
pub(crate) struct LineDiff {
old_line: Option<usize>,
old_content_range: std::ops::Range<usize>,
new_line: Option<usize>,
new_content_range: std::ops::Range<usize>,
}
pub(crate) fn classify_content(content: &[u8]) -> ContentType {
if content.is_empty() {
ContentType::Text
@@ -28,9 +16,9 @@ pub(crate) fn classify_content(content: &[u8]) -> ContentType {
{
ContentType::Text
} else {
match memchr(0, &content[0..8192]) {
| None => ContentType::Text,
| Some(_) => ContentType::Binary,
match memchr(0, &content[..content.len().min(8192)]) {
| None => ContentType::Text,
| Some(_) => ContentType::Binary,
}
}
}