Files
ARCADIA-Studio/crates/oa-desktop/src/source_rename/preview.rs
T

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 { .. })
));
}
}