433 lines
13 KiB
Rust
433 lines
13 KiB
Rust
//! Editor-independent retained surface computation.
|
|
//!
|
|
//! This crate owns only deterministic data transformations. It deliberately
|
|
//! has no editor, FFI, serialization, marker, face, or buffer dependency.
|
|
|
|
/// Monotonic retained surface revision.
|
|
pub type Revision = u64;
|
|
|
|
/// Half-open coordinate range in one retained surface revision.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct TextRange {
|
|
pub start: usize,
|
|
pub end: usize,
|
|
}
|
|
|
|
impl TextRange {
|
|
pub fn new(start: usize, end: usize) -> Result<Self, CommitError> {
|
|
if start > end {
|
|
return Err(CommitError::InvalidRange { start, end });
|
|
}
|
|
Ok(Self { start, end })
|
|
}
|
|
}
|
|
|
|
/// One aligned replacement between base and target revisions.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct SpanEdit {
|
|
pub old_start: usize,
|
|
pub old_end: usize,
|
|
pub new_start: usize,
|
|
pub new_end: usize,
|
|
}
|
|
|
|
/// Pure computation result consumed by an editor-specific commit adapter.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct CommitBatch {
|
|
pub base_revision: Revision,
|
|
pub target_revision: Revision,
|
|
pub base_extent: usize,
|
|
pub target_extent: usize,
|
|
/// Text or semantic state changed in these aligned spans.
|
|
pub semantic_edits: Vec<SpanEdit>,
|
|
/// Only text coordinates changed in these aligned spans.
|
|
pub coordinate_edits: Vec<SpanEdit>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum CommitError {
|
|
NonConsecutiveRevision {
|
|
base: Revision,
|
|
target: Revision,
|
|
},
|
|
InvalidRange {
|
|
start: usize,
|
|
end: usize,
|
|
},
|
|
}
|
|
|
|
const MYERS_MAX_SEARCH_DISTANCE: usize = 1024;
|
|
|
|
fn trace_value(trace: &[isize], distance: usize, diagonal: isize) -> isize {
|
|
let distance = distance as isize;
|
|
if diagonal < -distance || diagonal > distance {
|
|
-1
|
|
} else {
|
|
trace[(diagonal + distance) as usize]
|
|
}
|
|
}
|
|
|
|
fn core_matches<T, F>(
|
|
old: &[T],
|
|
new: &[T],
|
|
equal: &F,
|
|
max_distance: usize,
|
|
) -> Option<Vec<(usize, usize, usize)>>
|
|
where
|
|
F: Fn(&T, &T) -> bool,
|
|
{
|
|
let old_length = old.len();
|
|
let new_length = new.len();
|
|
let maximum = old_length.saturating_add(new_length);
|
|
let bounded = maximum.min(max_distance);
|
|
let mut traces: Vec<Vec<isize>> = Vec::with_capacity(bounded.saturating_add(1));
|
|
let mut final_distance = maximum;
|
|
let mut found = false;
|
|
|
|
'search: for distance in 0..=bounded {
|
|
let previous = distance.checked_sub(1).and_then(|index| traces.get(index));
|
|
let mut current = vec![-1; 1 + 2 * distance];
|
|
let mut diagonal = -(distance as isize);
|
|
while diagonal <= distance as isize {
|
|
let down = previous.map(|trace| trace_value(trace, distance - 1, diagonal + 1));
|
|
let right = previous.map(|trace| trace_value(trace, distance - 1, diagonal - 1));
|
|
let mut old_position = if distance == 0 {
|
|
0
|
|
} else if diagonal == -(distance as isize) {
|
|
down.unwrap_or(-1)
|
|
} else if diagonal == distance as isize {
|
|
right.unwrap_or(-1) + 1
|
|
} else if right.unwrap_or(-1) < down.unwrap_or(-1) {
|
|
down.unwrap_or(-1)
|
|
} else {
|
|
right.unwrap_or(-1) + 1
|
|
};
|
|
let mut new_position = old_position - diagonal;
|
|
while old_position >= 0
|
|
&& new_position >= 0
|
|
&& (old_position as usize) < old_length
|
|
&& (new_position as usize) < new_length
|
|
&& equal(&old[old_position as usize], &new[new_position as usize])
|
|
{
|
|
old_position += 1;
|
|
new_position += 1;
|
|
}
|
|
current[(diagonal + distance as isize) as usize] = old_position;
|
|
if old_position >= old_length as isize && new_position >= new_length as isize {
|
|
traces.push(current);
|
|
final_distance = distance;
|
|
found = true;
|
|
break 'search;
|
|
}
|
|
diagonal += 2;
|
|
}
|
|
traces.push(current);
|
|
}
|
|
if !found {
|
|
return None;
|
|
}
|
|
|
|
let mut old_position = old_length as isize;
|
|
let mut new_position = new_length as isize;
|
|
let mut matches = Vec::new();
|
|
for distance in (1..=final_distance).rev() {
|
|
let previous_distance = distance - 1;
|
|
let previous = &traces[previous_distance];
|
|
let diagonal = old_position - new_position;
|
|
let previous_diagonal = if diagonal == -(distance as isize)
|
|
|| (diagonal != distance as isize
|
|
&& trace_value(previous, previous_distance, diagonal - 1)
|
|
< trace_value(previous, previous_distance, diagonal + 1))
|
|
{
|
|
diagonal + 1
|
|
} else {
|
|
diagonal - 1
|
|
};
|
|
let previous_old = trace_value(previous, previous_distance, previous_diagonal);
|
|
let previous_new = previous_old - previous_diagonal;
|
|
if old_position > previous_old && new_position > previous_new {
|
|
let length = (old_position - previous_old).min(new_position - previous_new) as usize;
|
|
matches.push((
|
|
old_position as usize - length,
|
|
new_position as usize - length,
|
|
length,
|
|
));
|
|
}
|
|
old_position = previous_old;
|
|
new_position = previous_new;
|
|
}
|
|
if old_position > 0 && new_position > 0 {
|
|
let length = old_position.min(new_position) as usize;
|
|
matches.push((
|
|
old_position as usize - length,
|
|
new_position as usize - length,
|
|
length,
|
|
));
|
|
}
|
|
matches.reverse();
|
|
Some(matches)
|
|
}
|
|
|
|
fn matches<T, F>(old: &[T], new: &[T], equal: &F) -> Vec<(usize, usize, usize)>
|
|
where
|
|
F: Fn(&T, &T) -> bool,
|
|
{
|
|
let prefix = old
|
|
.iter()
|
|
.zip(new)
|
|
.take_while(|(left, right)| equal(left, right))
|
|
.count();
|
|
let old_rest = &old[prefix..];
|
|
let new_rest = &new[prefix..];
|
|
let suffix = old_rest
|
|
.iter()
|
|
.rev()
|
|
.zip(new_rest.iter().rev())
|
|
.take_while(|(left, right)| equal(left, right))
|
|
.count();
|
|
let old_core = &old_rest[..old_rest.len() - suffix];
|
|
let new_core = &new_rest[..new_rest.len() - suffix];
|
|
let mut result = Vec::new();
|
|
if prefix > 0 {
|
|
result.push((0, 0, prefix));
|
|
}
|
|
if let Some(core) = core_matches(old_core, new_core, equal, MYERS_MAX_SEARCH_DISTANCE) {
|
|
result.extend(
|
|
core.into_iter()
|
|
.map(|(old_start, new_start, length)| {
|
|
(old_start + prefix, new_start + prefix, length)
|
|
}),
|
|
);
|
|
}
|
|
if suffix > 0 {
|
|
result.push((old.len() - suffix, new.len() - suffix, suffix));
|
|
}
|
|
result
|
|
}
|
|
|
|
fn push_edit(
|
|
edits: &mut Vec<SpanEdit>,
|
|
old_start: usize,
|
|
old_end: usize,
|
|
new_start: usize,
|
|
new_end: usize,
|
|
) {
|
|
if old_start == old_end && new_start == new_end {
|
|
return;
|
|
}
|
|
if let Some(previous) = edits.last_mut() {
|
|
if previous.old_end == old_start && previous.new_end == new_start {
|
|
previous.old_end = old_end;
|
|
previous.new_end = new_end;
|
|
return;
|
|
}
|
|
}
|
|
edits.push(SpanEdit {
|
|
old_start,
|
|
old_end,
|
|
new_start,
|
|
new_end,
|
|
});
|
|
}
|
|
|
|
fn coordinate_edits(
|
|
old_len: usize,
|
|
new_len: usize,
|
|
text_matches: &[(usize, usize, usize)],
|
|
) -> Vec<SpanEdit> {
|
|
let mut old_position = 0;
|
|
let mut new_position = 0;
|
|
let mut edits = Vec::new();
|
|
for &(matched_old, matched_new, length) in text_matches {
|
|
if old_position < matched_old || new_position < matched_new {
|
|
push_edit(
|
|
&mut edits,
|
|
old_position,
|
|
matched_old,
|
|
new_position,
|
|
matched_new,
|
|
);
|
|
}
|
|
old_position = matched_old + length;
|
|
new_position = matched_new + length;
|
|
}
|
|
push_edit(
|
|
&mut edits,
|
|
old_position,
|
|
old_len,
|
|
new_position,
|
|
new_len,
|
|
);
|
|
edits
|
|
}
|
|
|
|
fn semantic_edits<T, FSemantic>(
|
|
old: &[T],
|
|
new: &[T],
|
|
text_matches: &[(usize, usize, usize)],
|
|
semantic_equal: &FSemantic,
|
|
) -> Vec<SpanEdit>
|
|
where
|
|
FSemantic: Fn(&T, &T) -> bool,
|
|
{
|
|
let mut old_position = 0;
|
|
let mut new_position = 0;
|
|
let mut edits = Vec::new();
|
|
for &(matched_old, matched_new, length) in text_matches {
|
|
if old_position < matched_old || new_position < matched_new {
|
|
push_edit(
|
|
&mut edits,
|
|
old_position,
|
|
matched_old,
|
|
new_position,
|
|
matched_new,
|
|
);
|
|
}
|
|
let mut run_start = None;
|
|
for offset in 0..length {
|
|
let equal = semantic_equal(&old[matched_old + offset], &new[matched_new + offset]);
|
|
match (run_start, equal) {
|
|
(None, false) => run_start = Some(offset),
|
|
(Some(start), true) => {
|
|
push_edit(
|
|
&mut edits,
|
|
matched_old + start,
|
|
matched_old + offset,
|
|
matched_new + start,
|
|
matched_new + offset,
|
|
);
|
|
run_start = None;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if let Some(start) = run_start {
|
|
push_edit(
|
|
&mut edits,
|
|
matched_old + start,
|
|
matched_old + length,
|
|
matched_new + start,
|
|
matched_new + length,
|
|
);
|
|
}
|
|
old_position = matched_old + length;
|
|
new_position = matched_new + length;
|
|
}
|
|
push_edit(
|
|
&mut edits,
|
|
old_position,
|
|
old.len(),
|
|
new_position,
|
|
new.len(),
|
|
);
|
|
edits
|
|
}
|
|
|
|
/// Diff two retained frames without knowing their editor-specific cell type.
|
|
pub fn diff_commit_batch<T, FText, FSemantic>(
|
|
base_revision: Revision,
|
|
target_revision: Revision,
|
|
old: &[T],
|
|
new: &[T],
|
|
text_equal: FText,
|
|
semantic_equal: FSemantic,
|
|
) -> Result<CommitBatch, CommitError>
|
|
where
|
|
FText: Fn(&T, &T) -> bool,
|
|
FSemantic: Fn(&T, &T) -> bool,
|
|
{
|
|
if target_revision != base_revision.saturating_add(1) {
|
|
return Err(CommitError::NonConsecutiveRevision {
|
|
base: base_revision,
|
|
target: target_revision,
|
|
});
|
|
}
|
|
let text_matches = matches(old, new, &text_equal);
|
|
Ok(CommitBatch {
|
|
base_revision,
|
|
target_revision,
|
|
base_extent: old.len(),
|
|
target_extent: new.len(),
|
|
semantic_edits: semantic_edits(old, new, &text_matches, &semantic_equal),
|
|
coordinate_edits: coordinate_edits(old.len(), new.len(), &text_matches),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
struct Cell {
|
|
text: char,
|
|
style: u32,
|
|
}
|
|
|
|
#[test]
|
|
fn commit_batch_separates_text_and_style_changes() {
|
|
let old = [
|
|
Cell { text: 'a', style: 1 },
|
|
Cell { text: 'b', style: 1 },
|
|
Cell { text: 'c', style: 1 },
|
|
];
|
|
let new = [
|
|
Cell { text: 'a', style: 1 },
|
|
Cell { text: 'B', style: 2 },
|
|
Cell { text: 'c', style: 3 },
|
|
];
|
|
let batch = diff_commit_batch(
|
|
7,
|
|
8,
|
|
&old,
|
|
&new,
|
|
|left, right| left.text == right.text,
|
|
|left, right| left.style == right.style,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
batch.coordinate_edits,
|
|
vec![SpanEdit {
|
|
old_start: 1,
|
|
old_end: 2,
|
|
new_start: 1,
|
|
new_end: 2,
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
batch.semantic_edits,
|
|
vec![SpanEdit {
|
|
old_start: 1,
|
|
old_end: 3,
|
|
new_start: 1,
|
|
new_end: 3,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn commit_batch_rejects_revision_gaps() {
|
|
let result = diff_commit_batch::<u8, _, _>(1, 3, &[], &[], |a, b| a == b, |a, b| a == b);
|
|
assert_eq!(
|
|
result,
|
|
Err(CommitError::NonConsecutiveRevision { base: 1, target: 3 })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pathological_diff_degrades_to_one_bounded_edit() {
|
|
let old = vec![b'a'; 30_000];
|
|
let new = vec![b'b'; 30_000];
|
|
let batch = diff_commit_batch(0, 1, &old, &new, |a, b| a == b, |a, b| a == b)
|
|
.unwrap();
|
|
assert_eq!(
|
|
batch.semantic_edits,
|
|
vec![SpanEdit {
|
|
old_start: 0,
|
|
old_end: 30_000,
|
|
new_start: 0,
|
|
new_end: 30_000,
|
|
}]
|
|
);
|
|
}
|
|
}
|