579 lines
19 KiB
Rust
579 lines
19 KiB
Rust
//! Complete, bounded line-ordinal previews of an already prepared alias rename.
|
|
//!
|
|
//! Source text remains in the caller's immutable before/after snapshots. This
|
|
//! module owns only paths and indexes; it is not rename or commit authority.
|
|
use oa_core::source_project::SourceBundle;
|
|
use std::{fmt, ops::Range};
|
|
|
|
const CONTEXT: usize = 3;
|
|
// A byte-grid boundary can move back by at most three UTF-8 continuation bytes,
|
|
// so a 252-byte grid yields fragments of at most 255 bytes, independent of input.
|
|
const SEGMENT_GRID: usize = 252;
|
|
|
|
/// Count all bounded fragments of exact source text; an empty line has one
|
|
/// empty fragment. Page these as well as lines, never mount an unbounded line.
|
|
pub fn segment_count(text: &str) -> usize {
|
|
text.len().div_ceil(SEGMENT_GRID).max(1)
|
|
}
|
|
|
|
/// Constant-time UTF-8-safe page lookup. Concatenating every fragment gives the
|
|
/// original bytes, including CRLF. No ellipsis or truncation replaces content.
|
|
pub fn text_segment(text: &str, index: usize) -> Option<&str> {
|
|
if index >= segment_count(text) {
|
|
return None;
|
|
}
|
|
let mut start = index.checked_mul(SEGMENT_GRID)?;
|
|
let mut end = start.saturating_add(SEGMENT_GRID).min(text.len());
|
|
while !text.is_char_boundary(start) {
|
|
start -= 1;
|
|
}
|
|
while !text.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
text.get(start..end)
|
|
}
|
|
const LIMITS: Limits = Limits {
|
|
files: 256,
|
|
changed_lines: 50_000,
|
|
display_bytes: 8 * 1024 * 1024,
|
|
};
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct Limits {
|
|
files: usize,
|
|
changed_lines: usize,
|
|
display_bytes: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct Preview {
|
|
files: Vec<File>,
|
|
changed_lines: usize,
|
|
display_bytes: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct File {
|
|
path: String,
|
|
hunks: Vec<Hunk>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct Hunk {
|
|
lines: Vec<Line>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct Line {
|
|
number: usize,
|
|
before: Range<usize>,
|
|
after: Range<usize>,
|
|
changed: bool,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
pub enum PreviewError {
|
|
MembershipChanged,
|
|
LineCountChanged(String),
|
|
LineEndingChanged { path: String, line: usize },
|
|
TooManyFiles { limit: usize },
|
|
TooManyChangedLines { limit: usize },
|
|
TooManyDisplayBytes { limit: usize },
|
|
}
|
|
|
|
impl fmt::Display for PreviewError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::MembershipChanged => write!(
|
|
f,
|
|
"Rename preview refused: the entry or source file membership changed. No rename was applied."
|
|
),
|
|
Self::LineCountChanged(path) => write!(
|
|
f,
|
|
"Rename preview refused: the line count changed in {path}. No rename was applied."
|
|
),
|
|
Self::LineEndingChanged { path, line } => write!(
|
|
f,
|
|
"Rename preview refused: the line ending changed in {path}:{line}. No rename was applied."
|
|
),
|
|
Self::TooManyFiles { limit } => write!(
|
|
f,
|
|
"Rename preview exceeds {limit} changed files. A complete preview is required; no rename was applied."
|
|
),
|
|
Self::TooManyChangedLines { limit } => write!(
|
|
f,
|
|
"Rename preview exceeds {limit} changed source lines. A complete preview is required; no rename was applied."
|
|
),
|
|
Self::TooManyDisplayBytes { limit } => write!(
|
|
f,
|
|
"Rename preview exceeds {limit} display bytes (paths and both versions of changed/context lines). A complete preview is required; no rename was applied."
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for PreviewError {}
|
|
|
|
impl Preview {
|
|
pub fn build(before: &SourceBundle, after: &SourceBundle) -> Result<Self, PreviewError> {
|
|
Self::with_limits(before, after, LIMITS)
|
|
}
|
|
|
|
fn with_limits(
|
|
before: &SourceBundle,
|
|
after: &SourceBundle,
|
|
limits: Limits,
|
|
) -> Result<Self, PreviewError> {
|
|
if before.entry != after.entry
|
|
|| !before.files.contains_key(&before.entry)
|
|
|| !before.files.keys().eq(after.files.keys())
|
|
{
|
|
return Err(PreviewError::MembershipChanged);
|
|
}
|
|
let mut preview = Self {
|
|
files: Vec::new(),
|
|
changed_lines: 0,
|
|
display_bytes: 0,
|
|
};
|
|
for ((path, old), (_, new)) in before.files.iter().zip(&after.files) {
|
|
if old == new {
|
|
continue;
|
|
}
|
|
if preview.files.len() == limits.files {
|
|
return Err(PreviewError::TooManyFiles {
|
|
limit: limits.files,
|
|
});
|
|
}
|
|
// A changed source line can contain arbitrarily many renamed tokens.
|
|
// Count the line once; this count is never a semantic-reference count.
|
|
let mut changed = Vec::new();
|
|
let mut old_lines = Lines::new(old);
|
|
let mut new_lines = Lines::new(new);
|
|
let mut count = 0;
|
|
loop {
|
|
match (old_lines.next(), new_lines.next()) {
|
|
(None, None) => break,
|
|
(Some(a), Some(b)) => {
|
|
count += 1;
|
|
if old[a.content_end..a.end] != new[b.content_end..b.end] {
|
|
return Err(PreviewError::LineEndingChanged {
|
|
path: path.clone(),
|
|
line: count,
|
|
});
|
|
}
|
|
if old[a.start..a.end] != new[b.start..b.end] {
|
|
if preview.changed_lines == limits.changed_lines {
|
|
return Err(PreviewError::TooManyChangedLines {
|
|
limit: limits.changed_lines,
|
|
});
|
|
}
|
|
preview.changed_lines += 1;
|
|
changed.push(count - 1);
|
|
}
|
|
}
|
|
_ => return Err(PreviewError::LineCountChanged(path.clone())),
|
|
}
|
|
}
|
|
|
|
// Merge touching context intervals in order, without pairwise diff.
|
|
// At most 7 indexed lines per changed line are retained; unchanged
|
|
// lines outside these intervals are scanned but never indexed.
|
|
let mut ranges: Vec<Range<usize>> = Vec::new();
|
|
for line in changed {
|
|
let range =
|
|
line.saturating_sub(CONTEXT)..line.saturating_add(CONTEXT + 1).min(count);
|
|
if let Some(previous) = ranges.last_mut()
|
|
&& range.start <= previous.end
|
|
{
|
|
previous.end = previous.end.max(range.end);
|
|
continue;
|
|
}
|
|
ranges.push(range);
|
|
}
|
|
preview.charge(path.len(), limits)?;
|
|
let mut file = File {
|
|
path: path.clone(),
|
|
hunks: Vec::new(),
|
|
};
|
|
let mut pairs = Lines::new(old).zip(Lines::new(new)).enumerate();
|
|
for range in ranges {
|
|
let mut hunk = Hunk { lines: Vec::new() };
|
|
for (ordinal, (a, b)) in pairs.by_ref() {
|
|
if ordinal < range.start {
|
|
continue;
|
|
}
|
|
// Charge exact UTF-8 bytes, including preserved endings and
|
|
// both copies of shared context, before adding the index.
|
|
// No render-time omission or shortening grants admission.
|
|
preview.charge(a.end - a.start, limits)?;
|
|
preview.charge(b.end - b.start, limits)?;
|
|
hunk.lines.push(Line {
|
|
number: ordinal + 1,
|
|
changed: old[a.start..a.end] != new[b.start..b.end],
|
|
before: a.start..a.end,
|
|
after: b.start..b.end,
|
|
});
|
|
if ordinal + 1 == range.end {
|
|
break;
|
|
}
|
|
}
|
|
file.hunks.push(hunk);
|
|
}
|
|
preview.files.push(file);
|
|
}
|
|
Ok(preview)
|
|
}
|
|
|
|
fn charge(&mut self, bytes: usize, limits: Limits) -> Result<(), PreviewError> {
|
|
let Some(total) = self.display_bytes.checked_add(bytes) else {
|
|
return Err(PreviewError::TooManyDisplayBytes {
|
|
limit: limits.display_bytes,
|
|
});
|
|
};
|
|
if total > limits.display_bytes {
|
|
return Err(PreviewError::TooManyDisplayBytes {
|
|
limit: limits.display_bytes,
|
|
});
|
|
}
|
|
self.display_bytes = total;
|
|
Ok(())
|
|
}
|
|
|
|
/// All admitted files in source path order; suitable for a file picker/search.
|
|
pub fn files(&self) -> &[File] {
|
|
&self.files
|
|
}
|
|
|
|
/// Changed source lines, not occurrences, declarations or semantic uses.
|
|
pub fn changed_line_count(&self) -> usize {
|
|
self.changed_lines
|
|
}
|
|
|
|
/// Exact displayed source bytes for both sides plus each stored path once.
|
|
/// Index metadata is separately bounded by 7 lines per changed line.
|
|
pub fn display_bytes(&self) -> usize {
|
|
self.display_bytes
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.files.is_empty()
|
|
}
|
|
}
|
|
|
|
impl File {
|
|
pub fn path(&self) -> &str {
|
|
&self.path
|
|
}
|
|
|
|
/// Every hunk is present; the caller may page these indexes, never truncate.
|
|
pub fn hunks(&self) -> &[Hunk] {
|
|
&self.hunks
|
|
}
|
|
}
|
|
|
|
impl Hunk {
|
|
/// The complete ordered line slice supports paging even a large single hunk.
|
|
pub fn lines(&self) -> &[Line] {
|
|
&self.lines
|
|
}
|
|
}
|
|
|
|
impl Line {
|
|
/// One-based source line number, identical on both sides.
|
|
pub fn number(&self) -> usize {
|
|
self.number
|
|
}
|
|
|
|
pub fn changed(&self) -> bool {
|
|
self.changed
|
|
}
|
|
|
|
/// Borrow from the immutable original file; includes its exact line ending.
|
|
pub fn before_text<'a>(&self, original: &'a str) -> Option<&'a str> {
|
|
original.get(self.before.clone())
|
|
}
|
|
|
|
/// Borrow from the immutable candidate file; includes its exact line ending.
|
|
pub fn after_text<'a>(&self, candidate: &'a str) -> Option<&'a str> {
|
|
candidate.get(self.after.clone())
|
|
}
|
|
}
|
|
|
|
struct RawLine {
|
|
start: usize,
|
|
content_end: usize,
|
|
end: usize,
|
|
}
|
|
|
|
struct Lines<'a> {
|
|
bytes: &'a [u8],
|
|
offset: usize,
|
|
}
|
|
|
|
impl<'a> Lines<'a> {
|
|
fn new(text: &'a str) -> Self {
|
|
Self {
|
|
bytes: text.as_bytes(),
|
|
offset: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Iterator for Lines<'_> {
|
|
type Item = RawLine;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
if self.offset == self.bytes.len() {
|
|
return None;
|
|
}
|
|
let start = self.offset;
|
|
// The compiler and source navigation count LF, not bare CR, as a line
|
|
// boundary. Preserve CRLF as an ending and bare CR as source content.
|
|
while self.offset < self.bytes.len() && self.bytes[self.offset] != b'\n' {
|
|
self.offset += 1;
|
|
}
|
|
let mut content_end = self.offset;
|
|
if self.offset < self.bytes.len() {
|
|
if self.offset > start && self.bytes[self.offset - 1] == b'\r' {
|
|
content_end -= 1;
|
|
}
|
|
self.offset += 1;
|
|
}
|
|
Some(RawLine {
|
|
start,
|
|
content_end,
|
|
end: self.offset,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn bundle(source: &str) -> SourceBundle {
|
|
SourceBundle {
|
|
entry: "救援.arcadia".into(),
|
|
files: [("救援.arcadia".into(), source.into())].into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn long_unicode_lines_are_fully_reachable_as_bounded_fragments() {
|
|
let text = format!("{}救援🙂{}\r\n", "a".repeat(251), "🙂".repeat(200));
|
|
let pieces: Vec<_> = (0..segment_count(&text))
|
|
.map(|i| text_segment(&text, i).unwrap())
|
|
.collect();
|
|
assert!(pieces.len() > 2);
|
|
assert!(pieces.iter().all(|part| part.len() <= 255));
|
|
assert_eq!(pieces.concat(), text);
|
|
assert_eq!(text_segment(&text, segment_count(&text)), None);
|
|
assert_eq!(text_segment(&text, usize::MAX), None);
|
|
assert_eq!(segment_count(""), 1);
|
|
assert_eq!(text_segment("", 0), Some(""));
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_unicode_mixed_endings_and_counts_lines_not_repeated_uses() {
|
|
let old = bundle("// 救援\r\n@a @a\ncontext\r@a");
|
|
let new = bundle("// 救援\r\n@long @long\ncontext\r@long");
|
|
let preview = Preview::build(&old, &new).unwrap();
|
|
assert_eq!(preview.changed_line_count(), 2);
|
|
let file = &preview.files()[0];
|
|
assert_eq!(file.path(), "救援.arcadia");
|
|
assert_eq!(file.hunks().len(), 1);
|
|
let lines = file.hunks()[0].lines();
|
|
assert_eq!(
|
|
lines.iter().map(Line::number).collect::<Vec<_>>(),
|
|
[1, 2, 3]
|
|
);
|
|
assert_eq!(
|
|
lines[0].before_text(&old.files[&old.entry]),
|
|
Some("// 救援\r\n")
|
|
);
|
|
assert_eq!(
|
|
lines[1].after_text(&new.files[&new.entry]),
|
|
Some("@long @long\n")
|
|
);
|
|
assert_eq!(
|
|
lines[2].after_text(&new.files[&new.entry]),
|
|
Some("context\r@long")
|
|
);
|
|
assert!(lines[1].changed() && lines[2].changed());
|
|
assert!(!lines[0].changed());
|
|
assert_eq!(
|
|
preview.display_bytes(),
|
|
old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn first_and_last_lines_form_complete_separate_hunks_and_pages() {
|
|
let old = bundle("a\n1\n2\n3\n4\n5\n6\n7\n8\n9\na");
|
|
let new = bundle("long\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlong");
|
|
let preview = Preview::build(&old, &new).unwrap();
|
|
let hunks = preview.files()[0].hunks();
|
|
assert_eq!(hunks.len(), 2);
|
|
let page_numbers: Vec<_> = hunks
|
|
.iter()
|
|
.flat_map(|h| h.lines().chunks(2))
|
|
.flat_map(|page| page.iter().map(Line::number))
|
|
.collect();
|
|
assert_eq!(page_numbers, [1, 2, 3, 4, 8, 9, 10, 11]);
|
|
assert_eq!(
|
|
hunks[1].lines()[3].after_text(&new.files[&new.entry]),
|
|
Some("long")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nearby_changes_merge_and_all_changed_files_are_reachable() {
|
|
let mut old = bundle("a\n1\n2\n3\n4\n5\na\n");
|
|
let mut new = bundle("b\n1\n2\n3\n4\n5\nb\n");
|
|
old.files.insert("other.arcadia".into(), "a a a".into());
|
|
new.files.insert("other.arcadia".into(), "b b b".into());
|
|
let preview = Preview::build(&old, &new).unwrap();
|
|
assert_eq!(preview.changed_line_count(), 3);
|
|
assert_eq!(preview.files().len(), 2);
|
|
assert_eq!(preview.files()[0].path(), "other.arcadia");
|
|
assert_eq!(preview.files()[0].hunks()[0].lines().len(), 1);
|
|
assert_eq!(preview.files()[1].hunks().len(), 1);
|
|
assert_eq!(preview.files()[1].hunks()[0].lines().len(), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn unchanged_and_empty_sources_have_no_preview() {
|
|
for source in ["", "a\r\nb\n", "a"] {
|
|
let original = bundle(source);
|
|
let preview = Preview::build(&original, &original).unwrap();
|
|
assert!(preview.is_empty());
|
|
assert_eq!(preview.changed_line_count(), 0);
|
|
assert_eq!(preview.display_bytes(), 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn refuses_membership_entry_line_count_and_line_ending_changes() {
|
|
let old = bundle("a\nb");
|
|
let mut new = old.clone();
|
|
new.files.insert("extra.arcadia".into(), "".into());
|
|
assert_eq!(
|
|
Preview::build(&old, &new).unwrap_err(),
|
|
PreviewError::MembershipChanged
|
|
);
|
|
let mut new = old.clone();
|
|
new.entry = "missing.arcadia".into();
|
|
assert_eq!(
|
|
Preview::build(&old, &new).unwrap_err(),
|
|
PreviewError::MembershipChanged
|
|
);
|
|
assert!(matches!(
|
|
Preview::build(&old, &bundle("a\nb\nc")),
|
|
Err(PreviewError::LineEndingChanged { .. }) | Err(PreviewError::LineCountChanged(_))
|
|
));
|
|
assert!(matches!(
|
|
Preview::build(&bundle("a\nb\n"), &bundle("a\n")),
|
|
Err(PreviewError::LineCountChanged(_))
|
|
));
|
|
for (a, b) in [("a\r\n", "a\n"), ("a\r", "a\n"), ("a", "a\n")] {
|
|
assert_eq!(
|
|
Preview::build(&bundle(a), &bundle(b)).unwrap_err(),
|
|
PreviewError::LineEndingChanged {
|
|
path: old.entry.clone(),
|
|
line: 1
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn each_bound_is_exact_and_overflow_never_returns_a_partial_preview() {
|
|
let old = bundle("a\ncontext\na");
|
|
let new = bundle("bbb\ncontext\nbbb");
|
|
let exact = old.entry.len() + old.files[&old.entry].len() + new.files[&new.entry].len();
|
|
let limits = Limits {
|
|
files: 1,
|
|
changed_lines: 2,
|
|
display_bytes: exact,
|
|
};
|
|
assert!(Preview::with_limits(&old, &new, limits).is_ok());
|
|
assert_eq!(
|
|
Preview::with_limits(&old, &new, Limits { files: 0, ..limits }).unwrap_err(),
|
|
PreviewError::TooManyFiles { limit: 0 }
|
|
);
|
|
assert_eq!(
|
|
Preview::with_limits(
|
|
&old,
|
|
&new,
|
|
Limits {
|
|
changed_lines: 1,
|
|
..limits
|
|
}
|
|
)
|
|
.unwrap_err(),
|
|
PreviewError::TooManyChangedLines { limit: 1 }
|
|
);
|
|
assert_eq!(
|
|
Preview::with_limits(
|
|
&old,
|
|
&new,
|
|
Limits {
|
|
display_bytes: exact - 1,
|
|
..limits
|
|
}
|
|
)
|
|
.unwrap_err(),
|
|
PreviewError::TooManyDisplayBytes { limit: exact - 1 }
|
|
);
|
|
let long = bundle(&"a".repeat(LIMITS.display_bytes));
|
|
assert!(matches!(
|
|
Preview::build(&bundle("b"), &long),
|
|
Err(PreviewError::TooManyDisplayBytes { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn limits_apply_to_the_whole_preview_not_each_file() {
|
|
let mut old = bundle("a");
|
|
let mut new = bundle("b");
|
|
old.files.insert("second.arcadia".into(), "a".into());
|
|
new.files.insert("second.arcadia".into(), "b".into());
|
|
let limits = Limits {
|
|
files: 2,
|
|
changed_lines: 2,
|
|
display_bytes: old.entry.len() + "second.arcadia".len() + 4,
|
|
};
|
|
let complete = Preview::with_limits(&old, &new, limits).unwrap();
|
|
assert_eq!(complete.files().len(), 2);
|
|
assert_eq!(complete.changed_line_count(), 2);
|
|
assert_eq!(complete.display_bytes(), limits.display_bytes);
|
|
assert!(matches!(
|
|
Preview::with_limits(&old, &new, Limits { files: 1, ..limits }),
|
|
Err(PreviewError::TooManyFiles { limit: 1 })
|
|
));
|
|
assert!(matches!(
|
|
Preview::with_limits(
|
|
&old,
|
|
&new,
|
|
Limits {
|
|
changed_lines: 1,
|
|
..limits
|
|
}
|
|
),
|
|
Err(PreviewError::TooManyChangedLines { limit: 1 })
|
|
));
|
|
assert!(matches!(
|
|
Preview::with_limits(
|
|
&old,
|
|
&new,
|
|
Limits {
|
|
display_bytes: limits.display_bytes - 1,
|
|
..limits
|
|
}
|
|
),
|
|
Err(PreviewError::TooManyDisplayBytes { .. })
|
|
));
|
|
}
|
|
}
|