//! 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, changed_lines: usize, display_bytes: usize, } #[derive(Debug)] pub struct File { path: String, hunks: Vec, } #[derive(Debug)] pub struct Hunk { lines: Vec, } #[derive(Debug)] pub struct Line { number: usize, before: Range, after: Range, 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::with_limits(before, after, LIMITS) } fn with_limits( before: &SourceBundle, after: &SourceBundle, limits: Limits, ) -> Result { 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> = 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 { 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::>(), [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 { .. }) )); } }