diff --git a/TODO.md b/TODO.md index 5c1e4f2..70dc3a4 100644 --- a/TODO.md +++ b/TODO.md @@ -10,8 +10,8 @@ - per-tab pane layouts, views, explorers, and comparisons; shared buffers, tab bar, and guarded close/force-close actions. - [x] make diff selector close on escape. - [x] `:` currently overrides the diff selector input (and maybe other input fields) and spawns the command input on top. this is of course not intended behavior and should be fixed. -- [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view. - - this will require tab support. +- [x] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view. + - changed symbol ranges stay in the comparison; other targets open ordinary tabs. Historical text stays immutable; source freshness and failure atomicity are preserved. - [ ] diff view: change diff view modes; choose between split and unified diff view (split by default). - [ ] diff view: for split view, change split orientation (left/right vs above/below) - left/right by default. - [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc. diff --git a/docs/architecture.md b/docs/architecture.md index 8d7f529..cb4440d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,7 +21,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency. - `plugin`: owns generic document snapshots/events, zero-based grapheme positions, diagnostics, frontend-independent semantic token kinds/modifiers and completion edit/item DTOs, and versioned asynchronous update contracts. Semantic token and completion updates are native-only, not part of the Ruby wire protocol; it has no LSP protocol types. - `lsp`: owns opt-in native server workers, bounded framed JSON-RPC, synchronization/capability negotiation, settings, UTF-16/file-URI conversion, definition/hover results, completion decoding, and semantic token requests/legend decoding. Its private configuration resolver supplies presets without restricting language IDs. - `snippet`: owns bounded, frontend-independent template parsing and expansion into plain replacement text and byte-relative tabstops with per-occurrence ancestry. It never edits documents or owns input policy. -- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. Each tab with a comparison has a cancellable repository worker; inactive tabs keep their workers and receive tagged results without changing visible focus. Closed or superseded comparisons reject stale results. Diff focus suppresses asynchronous editing/navigation effects without retiring healthy plugins. +- `session`: `EditorSession` owns the editor, native LSP, and Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, tracks document open/change/save/close events, polls native and plugin results, validates versioned diagnostics/navigation/completion, and retires failed plugin ownership. Each tab with a comparison has a cancellable repository worker; inactive tabs keep their workers and receive tagged results without changing visible focus. Closed or superseded comparisons reject stale results. Focused diff text supports native LSP navigation, hover, and diagnostics; read-only buffers still reject asynchronous editing effects, and selectors/file lists are not document-action targets. - `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports and syntax caches by stable buffer ID or immutable diff snapshot/file identity, and advertises `text`, `styled_text`, and `diagnostics` capabilities. It maps semantic tokens and diagnostics to terminal styling and has no server/protocol logic or paste acceptance policy. - `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module. - The executable selects paths and frontend capabilities, opens an editor session, and launches the TUI. @@ -85,6 +85,8 @@ macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses. +Diff snapshots retain a separate immutable document identity, full source text, side-specific path (the old path for renamed Before), and UTF-16 positions. Native definition updates preserve their UTF-16 ranges until the editor resolves the target's actual snapshot, dirty buffer, disk text, or lazily loaded comparison file; generic plugin navigation remains grapheme-based. Target ranges overlapping actual changed rows route within the same comparison and reveal the exact selection, including an unchanged declaration above a changed body. Wholly unchanged/context/omitted targets and files outside its changes open an ordinary tab without changing the original comparison or view. Snapshot-URI targets keep their immutable identity. Freshness includes tab, pane, source buffer/revision/caret/mode, and comparison/side; deferred file-load navigation also checks its originating view before moving focus. Invalid targets fail before opening a tab. Completion remains restricted to writable Working After. + Language configurations can own an ordered server stack. The first matching language wins extension overlap; all its matching servers receive document events, while the first handles hover/definition/completion. The Python preset uses ty then Ruff. LSP merges each worker's latest diagnostic snapshot in assignment order before emitting the unchanged core-owned update. Empty publications/retirement remove only their source; revision/path/root changes and close discard all contributions. Worker snapshots retain identity so queued pre-rename diagnostics cannot attach to a new path with the same text revision. Combined diagnostic message bytes remain bounded to 4 MiB; an overflowing publication retires its worker instead of discarding healthy sibling annotations. Workers are stored in config/root order, avoiding per-poll key cloning/sorting. Shutdown flushes the exit notification and waits for peer termination within the existing bounded grace period before process cleanup. Semantic tokens belong only to the first assigned server. Initialization advertises standard token types/modifiers, relative encoding, full/range requests, and server refresh support; deltas, overlapping/multiline tokens, and dynamic registration are not advertised. Each buffer has at most one request in flight; changes/refreshes coalesce into the latest snapshot. Full requests are preferred, with whole-document range fallback. A linear UTF-16 decoder validates raw coordinates and legend indices before outward grapheme rounding; overlong ends truncate at EOL, unknown types and invisible spans fall back to syntax. Both wire frames and decoded spans are bounded to 4 MiB. Snapshot identity rejects stale/renamed/closed replies before decoding. Invalid semantic replies clear the overlay and report an error without retiring diagnostics/navigation; primary retirement clears its overlays, and sibling retirement leaves them intact. diff --git a/docs/development.md b/docs/development.md index d0a0683..7426914 100644 --- a/docs/development.md +++ b/docs/development.md @@ -55,6 +55,8 @@ Clipboard checks use isolated providers rather than reading or overwriting the d Diff checks cover staged category/filter navigation, stale request cancellation, row alignment, read-only buffer isolation, and rendered panes. Repository integration fixtures exercise Git and Git-backed jj, advertised but unfetched remote branches/bookmarks/tags, imported historical commit/change IDs, conflicts, unusual paths, binary/mode/link/submodule changes, and unchanged active metadata. Remote fixtures are local bare copies; they never push. Git fixture identities and commit dates are fixed. +Diff LSP integration checks reuse the real framed-protocol subprocess with test-controlled response/publication gates and local Git comparisons. Cover both immutable historical sides and writable Working After, snapshot text/URI isolation, Unicode UTF-16 requests/targets/diagnostics, normal overlong-column clamping, hover, completion/undo, and read-only editing guards. Definition routing must distinguish ranges overlapping actual changed rows from wholly unchanged/context/omitted targets, use full location-link target ranges with exact selection reveal and exclusive endpoints, route canonical aliases and old/new rename paths, load other changed files on demand, handle added/deleted files, preserve original tabs/panes/excerpts and shared dirty targets, retain snapshot identity in ordinary tabs, and fail atomically for missing/non-UTF-8 files, invalid ranges, and unsupported URIs. Delayed/superseded replies must remain stale across caret moves, edit/undo, side/file/pane/tab changes, and comparison closure/replacement; publications for disposed snapshot identities must not reach their replacements. Private editor checks gate pending repository-load cancellation and exact-row reveal without timing assumptions. Before marking the TODO complete, run native integration/unit checks and an actual terminal smoke of focused diff LSP actions, changed-line routing, external new tabs, and immutable/editable side behavior. + Changed-file explorer checks cover reuse of the compact directory tree, removed/historical paths, file-to-directory transitions, remapped tree/selection bindings, filter/open/focus transitions, right-dock geometry, and wheel routing without edits to the underlying document. Visibility checks preserve browsing and pane state across Ctrl+e, exclude hidden explorers from focus/wheel routing, and verify full-width review. Status checks preserve catalog associations through sorting, compaction, duplicate inputs, and file/directory collisions, and cover colored A/M/D/R markers, selected/unfocused rows, Tree/List filtering, and sanitized rename paths. Diff styling checks cover independent snapshot language/shebang/injection detection, accepted/stale snapshot identity, real-parser cache freshness/reuse/release, syntax foreground over changed backgrounds, real empty rows versus missing counterparts, scrolling-stable diagonal hatching, and tab/wide/combining/control-character clipping. TestBackend omits wide-glyph continuation cells; terminal smoke verifies their actual styling. diff --git a/docs/editing.md b/docs/editing.md index 5f8b482..7b011be 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -307,7 +307,11 @@ The changed-file explorer is **docked on the right**. Modified files show Before When a diff occupies the sole workspace pane, its top bar spans the full editor width. The right-docked changed-file explorer starts below that bar rather than covering the header; closing the dock preserves the full-width bar. -Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes remain read-only until closed. Explicit native navigation from the comparison pane to an ordinary buffer leaves the comparison at that destination. +Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes or tabs remain read-only until closed. Explicit buffer commands and Markdown/plugin navigation retain their ordinary behavior; native LSP definitions use the routing below. + +**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable Working-checkout After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets. + +A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source. Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus. @@ -319,7 +323,7 @@ In **Normal mode** with a text pane focused, **`[` expands above** and **`]` exp Live typing highlights only the displayed source rows while retaining each snapshot's original syntax tree for multiline and injected-language context. Line-width caches and unchanged hunk layouts avoid repeated full-file work; edits that change line alignment or revert changes recompute the diff. Already-queued printable Insert keys share a redraw, but every key still passes through normal bindings, snippet handling, and undo history. Mode changes and other terminal events retain their order. -Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Moving or restoring a caret never reveals omitted context: only Normal-mode `[`/`]` expands existing excerpts. Edits still recompute changed regions and retain previously expanded context. +Text-pane navigation stays within the shown source lines. At an excerpt boundary, Up/Down or `j`/`k` jumps directly to the next/previous excerpt, and counts count visible lines rather than omitted lines. This also applies to remapped movement keys, Visual-mode movement, and editable After's Insert-mode arrows. Word motions, `gt`/`gb`, search, and occurrence selection use the visible excerpts; `gt`/`gb` target their first/last source line. Hidden short lines do not clamp the caret column. The shared vertical viewport and each pane's horizontal offset stay fixed when movement reverses, scrolling only when the caret crosses a visible edge, as in a normal buffer. Ordinary motions, search, and caret restoration do not reveal omitted context; Normal-mode `[`/`]` expands existing excerpts. LSP definition routing can additionally reveal the exact selection of a symbol whose range overlaps changes. Edits still recompute changed regions and retain previously expanded context. Configure these counts in Ruby with `config.diff_context_lines = 10` (nonnegative; `0` shows changes only) and `config.diff_expand_lines = 15` (positive). diff --git a/src/diff.rs b/src/diff.rs index 4b0bad5..3c6e70d 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -188,6 +188,7 @@ pub struct DiffState { pub error: Option, pub comparison: Option, pub comparison_id: Option, + pub(crate) snapshot_roots: [Option; 2], pub files: Option, pub files_visible: bool, pub file: usize, @@ -229,6 +230,7 @@ impl DiffState { comparison: None, comparison_id: None, files: None, + snapshot_roots: [None, None], files_visible: true, file: 0, focus: Focus::Files, @@ -264,6 +266,10 @@ impl DiffState { self.pending.take() } + pub(crate) fn request_queued(&self) -> bool { + self.pending.is_some() + } + fn queue(&mut self, operation: Operation) { self.request_kind = Some(match operation { Operation::Compare { .. } => RequestKind::Comparison, @@ -291,7 +297,6 @@ impl DiffState { .. }) = &result && (self.comparison_id != Some(*comparison_id) - || self.file != *file_index || self.request_kind != Some(RequestKind::File { comparison_id: *comparison_id, @@ -347,6 +352,7 @@ impl DiffState { self.files = Some(files); self.comparison = Some(comparison); self.comparison_id = Some(id); + self.snapshot_roots = [None, None]; self.phase = Phase::View; self.file = 0; self.focus = if self.files_visible { @@ -379,8 +385,10 @@ impl DiffState { comparison.editable_paths.remove(&file.path); } *selected = file; - self.align_file(); - self.normalize_focus(); + if self.file == file_index { + self.align_file(); + self.normalize_focus(); + } self.error = None; } else { self.error = Some("Unexpected changed-file response".into()); @@ -742,9 +750,13 @@ impl DiffState { } pub fn after_editable(&self) -> bool { + matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After) + && self.working_after_available() + } + + pub(crate) fn working_after_available(&self) -> bool { self.phase == Phase::View && self.selected_file_loaded() - && matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After) && self .after .as_ref() @@ -752,6 +764,7 @@ impl DiffState { && self.comparison.as_ref().is_some_and(|comparison| { comparison.files.get(self.file).is_some_and(|file| { comparison.editable_paths.contains(&file.path) + && file.status != FileStatus::Removed && file .notice .as_deref() @@ -770,6 +783,20 @@ impl DiffState { }) else { return false; }; + self.select_file_index(index) + } + + pub(crate) fn select_file_index(&mut self, index: usize) -> bool { + let Some(file) = self + .comparison + .as_ref() + .and_then(|comparison| comparison.files.get(index)) + else { + return false; + }; + if let Some(files) = &mut self.files { + files.reveal_path(&file.path); + } self.file = index; self.normalize_focus(); self.live_buffer = None; @@ -809,6 +836,69 @@ impl DiffState { } } + pub(crate) fn load_file(&mut self, file_index: usize) { + if let Some(comparison_id) = self.comparison_id { + self.queue(Operation::LoadFile { + comparison_id, + file_index, + }); + } + } + + pub(crate) fn source_range_changed( + &self, + file_index: usize, + focus: Focus, + first: usize, + last: usize, + ) -> bool { + if file_index == self.file { + return self.rows.iter().any(|aligned| { + aligned.kind != RowKind::Equal + && match focus { + Focus::Before => aligned + .before + .is_some_and(|row| first <= row && row <= last), + Focus::After => { + aligned.after.is_some_and(|row| first <= row && row <= last) + } + Focus::Files => false, + } + }); + } + self.comparison + .as_ref() + .and_then(|comparison| comparison.files.get(file_index)) + .is_some_and(|file| { + file.hunks.iter().any(|hunk| { + let (start, len) = match focus { + Focus::Before => (hunk.before_start, hunk.before_len), + Focus::After => (hunk.after_start, hunk.after_len), + Focus::Files => return false, + }; + len > 0 && start <= last && first < start.saturating_add(len) + }) + }) + } + + pub(crate) fn reveal_source_row(&mut self, focus: Focus, row: usize) { + if self.source_display_row(focus, row).is_none() + && let Some(index) = self.rows.iter().position(|aligned| match focus { + Focus::Before => aligned.before == Some(row), + Focus::After => aligned.after == Some(row), + Focus::Files => false, + }) + { + self.excerpt_ranges.push((index, index + 1)); + self.excerpt_ranges.sort_unstable(); + self.rebuild_display_rows(); + } + if let Some(display) = self.source_display_row(focus, row) { + self.top = display; + self.follow_caret = true; + } + } + pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option { self.display_rows.iter().position(|display| { matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row)) @@ -833,14 +923,14 @@ impl DiffState { }) } - pub(crate) fn snapshot_lines(&self, focus: Focus) -> Vec { - let file = &self.comparison.as_ref().expect("snapshot comparison").files[self.file]; + pub(crate) fn snapshot_lines_at(&self, file_index: usize, focus: Focus) -> Vec { + let file = &self.comparison.as_ref().expect("snapshot comparison").files[file_index]; let (source, eof, live) = match focus { Focus::Before => (&file.before, file.before_eof, false), Focus::After => ( &file.after, file.after_eof, - self.live_files.contains(&self.file), + self.live_files.contains(&file_index), ), Focus::Files => unreachable!("snapshot belongs to a text pane"), }; @@ -901,7 +991,7 @@ impl DiffState { } pub fn refresh_after(&mut self, lines: &[String], revision: u64) { - if !self.after_editable() { + if !self.working_after_available() { return; } if self.live_revision == Some(revision) { diff --git a/src/editor.rs b/src/editor.rs index f853006..954847f 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -3,13 +3,15 @@ use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::panes::{PaneDirection, PaneRect, PaneTree, SplitAxis, directional_neighbor}; -use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition}; +use crate::plugin::{ + CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition, Utf16Range, +}; use serde::Serialize; use std::borrow::Cow; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fmt; use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; +use std::io::{self, Read, Write}; use std::ops::Range; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; @@ -1231,6 +1233,7 @@ struct Buffer { id: usize, diff_snapshot: Option<(u64, usize, Focus)>, snapshot_document_path: Option, + snapshot_root: Option, lines: Vec, saved_lines: Vec, text_revision: u64, @@ -1262,12 +1265,22 @@ impl Buffer { }, None => String::new(), }; - validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; - let lines = split_lines(&text); + Self::from_text(id, path, identity, &text) + } + + fn from_text( + id: usize, + path: Option, + identity: Option, + text: &str, + ) -> io::Result { + validate_text(text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let lines = split_lines(text); Ok(Self { id, diff_snapshot: None, snapshot_document_path: None, + snapshot_root: None, saved_lines: lines.clone(), lines, text_revision: 0, @@ -1324,6 +1337,19 @@ struct JumpLocation { cursor: Cursor, } +struct PendingLspNavigation { + tab: usize, + pane: usize, + source: JumpLocation, + revision: u64, + bound: (u64, usize, Focus, usize), + file: usize, + focus: Focus, + range: Utf16Range, + target_range: Option, + request: Option, +} + pub struct Editor { active: Buffer, parked: BTreeMap, @@ -1394,6 +1420,7 @@ pub struct Editor { diff_context_options: (usize, usize), next_diff_request: u64, outstanding_diff_request: Option, + pending_lsp_navigation: Option, } impl Editor { @@ -1509,6 +1536,7 @@ impl Editor { diff_context_options: (10, 15), next_diff_request: 0, outstanding_diff_request: None, + pending_lsp_navigation: None, }; editor.refresh_selection_navigation_hint(); editor @@ -1827,6 +1855,7 @@ impl Editor { } fn finish_pane_interaction(&mut self) { + self.pending_lsp_navigation = None; self.finish_completion(); self.snippet = None; self.clear_hover(); @@ -2128,6 +2157,9 @@ impl Editor { pub(crate) fn diff_root(&mut self) -> PathBuf { let owner = self.diff_owner.unwrap_or(self.pane_id); self.with_pane(owner, |editor| { + if let Some(root) = &editor.active.snapshot_root { + return root.clone(); + } editor .path() .and_then(Path::parent) @@ -2177,10 +2209,27 @@ impl Editor { .expect("diff request IDs exhausted"); let id = self.next_diff_request; self.outstanding_diff_request = Some(id); + if let Operation::LoadFile { + comparison_id, + file_index, + } = &operation + && let Some(pending) = &mut self.pending_lsp_navigation + && pending.bound.0 == *comparison_id + && pending.file == *file_index + { + pending.request = Some(id); + } Some((id, operation)) } pub(crate) fn apply_diff_result(&mut self, id: u64, result: Result) -> bool { + if self + .pending_lsp_navigation + .as_ref() + .is_some_and(|pending| !self.lsp_navigation_current(pending)) + { + self.pending_lsp_navigation = None; + } if self.outstanding_diff_request != Some(id) { let owner = self .tabs @@ -2198,12 +2247,46 @@ impl Editor { let Some(diff) = &mut self.diff else { return false; }; + if diff.request_queued() { + return false; + } diff.apply_result(id, result); if let Some(files) = &mut diff.files { files.set_view(self.file_explorer_view); } self.bind_diff_buffer(); self.refresh_live_diff(); + let completed_navigation = self + .pending_lsp_navigation + .as_ref() + .is_some_and(|pending| pending.request == Some(id)); + if completed_navigation + && let Some(pending) = self.pending_lsp_navigation.take() + && self.lsp_navigation_current(&pending) + { + let ready = self.diff.as_ref().is_some_and(|diff| { + diff.error.is_none() + && diff.comparison.as_ref().is_some_and(|comparison| { + comparison + .files + .get(pending.file) + .is_some_and(|file| file.loaded) + }) + }); + if ready + && let Err(error) = self.navigate_lsp_file( + pending.file, + pending.focus, + pending.range, + pending.target_range, + ) + { + self.set_message(format!("LSP: definition failed: {error}")); + } + if !ready && let Some(error) = self.diff.as_ref().and_then(|diff| diff.error.as_ref()) { + self.set_message(format!("LSP: definition failed: {error}")); + } + } true } @@ -2463,40 +2546,14 @@ impl Editor { diff.live_buffer = Some(self.active.id); diff.live_revision = None; } else { - let existing = self - .parked - .values() + let existing = std::iter::once(&self.active) + .chain(self.parked.values()) .find(|buffer| buffer.diff_snapshot == Some(key)) .map(|buffer| buffer.id); if let Some(id) = existing { self.switch_buffer(id); } else { - let mut buffer = - Buffer::open(self.next_buffer_id, None, None, false).expect("unnamed snapshot"); - buffer.diff_snapshot = Some(key); - buffer.path = Some(comparison.root.join(&file.path)); - buffer.snapshot_document_path = Some(loop { - let nonce = TEMP_ID.fetch_add(1, Ordering::Relaxed); - let directory = comparison - .root - .join(format!(".vamp-readonly-{}-{nonce}", std::process::id())); - let path = directory.join( - file.path - .file_name() - .unwrap_or_else(|| std::ffi::OsStr::new("snapshot")), - ); - if !directory.exists() - && !std::iter::once(&self.active) - .chain(self.parked.values()) - .any(|buffer| { - buffer.identity.as_ref() == Some(&path) - || buffer.document().path == Some(path.as_path()) - }) - { - break path; - } - }); - buffer.lines = diff.snapshot_lines(focus); + let buffer = self.new_diff_snapshot(key.1, focus); self.add_buffer(buffer); } } @@ -2519,6 +2576,61 @@ impl Editor { self.diff.as_mut().expect("diff remains open").follow_caret = true; } + fn new_diff_snapshot(&mut self, file_index: usize, focus: Focus) -> Buffer { + let side = usize::from(focus == Focus::After); + let diff = self.diff.as_ref().expect("snapshot comparison"); + let comparison = diff.comparison.as_ref().expect("snapshot comparison"); + let file = &comparison.files[file_index]; + let relative = if focus == Focus::Before { + file.old_path.as_ref().unwrap_or(&file.path) + } else { + &file.path + }; + let source = comparison.root.join(relative); + let root = comparison.root.clone(); + let lines = diff.snapshot_lines_at(file_index, focus); + let key = ( + diff.comparison_id.expect("snapshot comparison"), + file_index, + focus, + ); + let directory = diff.snapshot_roots[side].clone().unwrap_or_else(|| { + loop { + let nonce = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let directory = root.join(format!(".vamp-readonly-{}-{nonce}", std::process::id())); + if !directory.exists() + && !std::iter::once(&self.active) + .chain(self.parked.values()) + .any(|buffer| { + buffer + .identity + .as_ref() + .is_some_and(|path| path.starts_with(&directory)) + || buffer + .document() + .path + .is_some_and(|path| path.starts_with(&directory)) + }) + { + break directory; + } + } + }); + let document_path = directory.join(relative); + self.diff + .as_mut() + .expect("snapshot comparison") + .snapshot_roots[side] = Some(directory); + let mut buffer = + Buffer::open(self.next_buffer_id, None, None, false).expect("unnamed snapshot"); + buffer.diff_snapshot = Some(key); + buffer.path = Some(source); + buffer.snapshot_document_path = Some(document_path); + buffer.snapshot_root = Some(root); + buffer.lines = lines; + buffer + } + pub(crate) fn refresh_live_diff(&mut self) { for diff in self .diff @@ -2538,7 +2650,7 @@ impl Editor { } }); if let Some(buffer) = buffer - && diff.after_editable() + && diff.working_after_available() && diff.live_revision != Some(buffer.text_revision) { diff.refresh_after(&buffer.lines, buffer.text_revision); @@ -2575,6 +2687,9 @@ impl Editor { } pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) { + if self.diff.as_ref().is_some_and(|diff| diff.focus != focus) { + self.pending_lsp_navigation = None; + } if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { self.with_pane(owner, |editor| editor.scroll_diff(focus, down)); return; @@ -2995,6 +3110,7 @@ impl Editor { /// Reuses loaded (including dirty) buffers and leaves the source unchanged on failure. pub fn navigate_to(&mut self, path: PathBuf, position: TextPosition) -> Result<(), String> { + self.pending_lsp_navigation = None; self.finish_completion(); self.snippet = None; let source = self.jump_location(); @@ -3019,6 +3135,313 @@ impl Editor { Ok(()) } + /// Native LSP positions are UTF-16; plugin navigation remains grapheme-based. + pub(crate) fn navigate_lsp_to( + &mut self, + path: PathBuf, + range: Utf16Range, + target_range: Option, + ) -> Result<(), String> { + self.pending_lsp_navigation = None; + if path.as_os_str().is_empty() || path.to_string_lossy().chars().any(char::is_control) { + return Err("Navigation path must be nonempty and contain no controls".into()); + } + if let Some((file, focus)) = self.lsp_diff_target(&path)? { + if !self + .diff + .as_ref() + .expect("comparison target") + .comparison + .as_ref() + .expect("comparison target") + .files[file] + .loaded + { + let bound = self + .diff_bound + .ok_or("LSP navigation requires a diff text pane")?; + self.pending_lsp_navigation = Some(PendingLspNavigation { + tab: self.tab_id, + pane: self.pane_id, + source: self.jump_location(), + revision: self.text_revision(), + bound, + file, + focus, + range, + target_range, + request: None, + }); + self.diff + .as_mut() + .expect("comparison target") + .load_file(file); + return Ok(()); + } + return self.navigate_lsp_file(file, focus, range, target_range); + } + let (buffer, id) = self.preflight_lsp_buffer(path)?; + let lines = buffer + .as_ref() + .unwrap_or_else(|| self.buffer(id).expect("loaded target")); + let position = lsp_navigation_position(&lines.lines, range, target_range)?; + self.finish_lsp_navigation(buffer, id, position, self.diff.is_some()); + Ok(()) + } + + fn lsp_diff_target(&self, path: &Path) -> Result, String> { + let Some(diff) = self.diff.as_ref().filter(|diff| diff.phase == Phase::View) else { + return Ok(None); + }; + let Some(comparison) = &diff.comparison else { + return Ok(None); + }; + let identity = path_identity(path).map_err(|error| error.to_string())?; + let virtual_side = diff + .snapshot_roots + .iter() + .enumerate() + .find_map(|(side, root)| { + let root = path_identity(root.as_ref()?).ok()?; + identity.strip_prefix(root).ok().map(|relative| { + ( + if side == 0 { + Focus::Before + } else { + Focus::After + }, + relative.to_path_buf(), + ) + }) + }); + let focus = virtual_side.as_ref().map_or_else( + || self.diff_buffer_focus().unwrap_or(Focus::After), + |(focus, _)| *focus, + ); + let found = comparison + .files + .iter() + .enumerate() + .find_map(|(index, file)| { + let relative = if focus == Focus::Before { + file.old_path.as_ref().unwrap_or(&file.path) + } else { + &file.path + }; + let matches = if let Some((_, target)) = &virtual_side { + relative == target + } else { + path_identity(&comparison.root.join(relative)).ok().as_ref() == Some(&identity) + }; + matches.then_some((index, focus)) + }); + if virtual_side.is_some() && found.is_none() { + return Err("Historical navigation target is not available in this comparison".into()); + } + Ok(found) + } + + fn lsp_navigation_current(&self, pending: &PendingLspNavigation) -> bool { + self.tab_id == pending.tab + && self.pane_id == pending.pane + && self.jump_location() == pending.source + && self.text_revision() == pending.revision + && self.diff_bound == Some(pending.bound) + && self.diff_interacting() + && self.diff.as_ref().is_some_and(|diff| { + diff.comparison_id == Some(pending.bound.0) && diff.file == pending.bound.1 + }) + } + + fn preflight_lsp_buffer(&self, path: PathBuf) -> Result<(Option, usize), String> { + let identity = path_identity(&path).map_err(|error| error.to_string())?; + let loaded = std::iter::once(&self.active) + .chain(self.parked.values()) + .find(|buffer| { + buffer.identity.as_ref() == Some(&identity) + || buffer + .snapshot_document_path + .as_ref() + .is_some_and(|snapshot| { + path_identity(snapshot).ok().as_ref() == Some(&identity) + }) + }); + if let Some(buffer) = loaded { + return Ok((None, buffer.id)); + } + let file = fs::File::open(&path).map_err(|error| error.to_string())?; + let metadata = file.metadata().map_err(|error| error.to_string())?; + if !metadata.is_file() { + return Err("Definition target is not a regular file".into()); + } + if metadata.len() > MAX_EDIT_BYTES as u64 { + return Err("Definition target exceeds 4 MiB".into()); + } + let mut text = String::new(); + file.take((MAX_EDIT_BYTES + 1) as u64) + .read_to_string(&mut text) + .map_err(|error| error.to_string())?; + if text.len() > MAX_EDIT_BYTES { + return Err("Definition target exceeds 4 MiB".into()); + } + let buffer = Buffer::from_text(self.next_buffer_id, Some(path), Some(identity), &text) + .map_err(|error| error.to_string())?; + Ok((Some(buffer), self.next_buffer_id)) + } + + fn navigate_lsp_file( + &mut self, + file_index: usize, + focus: Focus, + range: Utf16Range, + target_range: Option, + ) -> Result<(), String> { + let diff = self.diff.as_ref().ok_or("Comparison is closed")?; + let comparison = diff.comparison.as_ref().ok_or("Comparison is closed")?; + let file = comparison + .files + .get(file_index) + .ok_or("Changed file is no longer available")?; + if matches!( + (file.status, focus), + (crate::diff::FileStatus::Added, Focus::Before) + | (crate::diff::FileStatus::Removed, Focus::After) + ) { + return Err("Definition target does not exist on this comparison side".into()); + } + if file + .notice + .as_deref() + .is_some_and(|notice| notice.contains("Binary or non-UTF-8")) + { + return Err("Definition target is binary or non-UTF-8".into()); + } + let writable = focus == Focus::After + && diff + .after + .as_ref() + .is_some_and(|revision| revision.target == RevisionTarget::Working) + && comparison.editable_paths.contains(&file.path) + && file.status != crate::diff::FileStatus::Removed + && file + .notice + .as_deref() + .is_none_or(|notice| !notice.contains("Binary or non-UTF-8")); + let key = ( + diff.comparison_id.ok_or("Comparison is closed")?, + file_index, + focus, + ); + let (buffer, id) = if writable { + self.preflight_lsp_buffer(comparison.root.join(&file.path))? + } else if let Some(buffer) = std::iter::once(&self.active) + .chain(self.parked.values()) + .find(|buffer| buffer.diff_snapshot == Some(key)) + { + (None, buffer.id) + } else { + let buffer = self.new_diff_snapshot(file_index, focus); + (Some(buffer), self.next_buffer_id) + }; + let target = buffer + .as_ref() + .unwrap_or_else(|| self.buffer(id).expect("loaded target")); + let position = lsp_navigation_position(&target.lines, range, target_range)?; + let row = position.row; + let extent = target_range.unwrap_or(range); + let first = extent.start.0; + let last = extent.end.0 - usize::from(extent.end.1 == 0 && extent.end.0 > extent.start.0); + let diff = self.diff.as_ref().expect("comparison target"); + let comparison = diff.comparison.as_ref().expect("comparison target"); + let file = &comparison.files[file_index]; + let live = if writable { + Some(target) + } else if focus == Focus::Before + && diff + .after + .as_ref() + .is_some_and(|revision| revision.target == RevisionTarget::Working) + && comparison.editable_paths.contains(&file.path) + { + let identity = path_identity(&comparison.root.join(&file.path)).ok(); + std::iter::once(&self.active) + .chain(self.parked.values()) + .find(|buffer| identity.is_some() && buffer.identity == identity) + } else { + None + }; + let live_id = live.map(|buffer| buffer.id); + let changed = if let Some(live) = live.filter(|live| { + diff.file != file_index + || diff.live_buffer != Some(live.id) + || diff.live_revision != Some(live.text_revision) + }) { + let eof = live.lines.len() > 1 && live.lines.last().is_some_and(String::is_empty); + let content = &live.lines[..live.lines.len() - usize::from(eof)]; + crate::diff::line_hunks(&file.before, file.before_eof, content, eof) + .iter() + .any(|hunk| { + let (start, len) = if focus == Focus::Before { + (hunk.before_start, hunk.before_len) + } else { + (hunk.after_start, hunk.after_len) + }; + len > 0 && start <= last && first < start.saturating_add(len) + }) + } else { + diff.source_range_changed(file_index, focus, first, last) + }; + if !changed { + self.finish_lsp_navigation(buffer, id, position, true); + return Ok(()); + } + let source = self.jump_location(); + if let Some(buffer) = buffer { + self.add_buffer(buffer); + } else { + self.switch_buffer(id); + } + let diff = self.diff.as_mut().expect("comparison target"); + if diff.file != file_index { + diff.select_file_index(file_index); + } + diff.focus = focus; + diff.normalize_focus(); + if let Some(live) = live_id { + diff.live_buffer = Some(live); + diff.live_revision = None; + } + self.diff_bound = Some((key.0, key.1, key.2, id)); + self.refresh_live_diff(); + let diff = self.diff.as_mut().expect("comparison target"); + diff.focus = focus; + diff.normalize_focus(); + diff.reveal_source_row(focus, row); + self.place_navigation(position); + self.record_jump(source); + Ok(()) + } + + fn finish_lsp_navigation( + &mut self, + buffer: Option, + id: usize, + position: TextPosition, + new_tab: bool, + ) { + let source = self.jump_location(); + if new_tab { + self.new_tab(); + } + if let Some(buffer) = buffer { + self.add_buffer(buffer); + } else { + self.switch_buffer(id); + } + self.place_navigation(position); + self.record_jump(source); + } + fn place_navigation(&mut self, position: TextPosition) { self.reveal_caret(); self.park_source(); @@ -3578,6 +4001,7 @@ impl Editor { && hover.revision == self.text_revision() && hover.caret == self.cursor() && self.mode() == Mode::Normal + && self.document_actions_available() && self.palette.is_none() && !self .file_picker @@ -3589,6 +4013,7 @@ impl Editor { pub fn show_hover(&mut self, text: String) -> Result<(), String> { validate_hover_text(&text)?; if self.mode() != Mode::Normal + || !self.document_actions_available() || self.palette.is_some() || self .file_picker @@ -3893,6 +4318,39 @@ impl Editor { } } + pub(crate) fn interaction_document_root(&self, id: usize) -> &Path { + if let Some(root) = self + .buffer(id) + .and_then(|buffer| buffer.snapshot_root.as_deref()) + { + return root; + } + self.workspace_diff() + .into_iter() + .chain(self.tabs.values().filter_map(Tab::diff)) + .find(|diff| diff.live_buffer == Some(id)) + .and_then(|diff| diff.comparison.as_ref()) + .map_or(&self.project_root, |comparison| comparison.root.as_path()) + } + + pub(crate) fn lsp_context(&self) -> Context { + let mut context = self.context(); + context.path = self + .active + .document() + .path + .map(|path| path.to_string_lossy().into_owned()); + context.root = self + .interaction_document_root(self.active.id) + .to_string_lossy() + .into_owned(); + context + } + + pub(crate) fn document_actions_available(&self) -> bool { + self.diff.is_none() || self.diff_interacting() + } + pub fn set_message(&mut self, message: impl Into) { let message = message.into(); self.message = if message.chars().any(char::is_control) { @@ -4410,7 +4868,11 @@ impl Editor { label, owner: "core".into(), bindings: self.palette_bindings(Some(action), ""), - enabled: modes.contains(&self.binding_mode()), + enabled: modes.contains(&self.binding_mode()) + && (!matches!( + action, + Action::LspDefinition | Action::LspHover | Action::LspDiagnostics + ) || self.document_actions_available()), modes: modes.to_vec(), }); } @@ -4605,6 +5067,7 @@ impl Editor { } pub fn handle(&mut self, input: Input) -> Option { + self.pending_lsp_navigation = None; // Normalize genuine terminal aliases before list and editing dispatch. let input = match input { Input::Ctrl(ch) => control_key(ch), @@ -5612,6 +6075,7 @@ impl Editor { } pub fn execute(&mut self, command: &str) -> Option { + self.pending_lsp_navigation = None; self.finish_diff_prompt(); let invocation = self.execute_command(command); self.leave_diff_on_navigation(); @@ -5797,6 +6261,10 @@ impl Editor { self.set_message(format!("The {name} command takes no arguments")); } Action::LspDefinition | Action::LspHover | Action::LspDiagnostics => { + if !self.document_actions_available() { + self.set_message("LSP commands require a diff text pane"); + return None; + } return Some(Invocation { plugin: "core".into(), command: name.into(), @@ -8506,6 +8974,35 @@ fn column_after_byte(line: &str, byte: usize) -> usize { .count() } +fn lsp_navigation_position( + lines: &[String], + range: Utf16Range, + target_range: Option, +) -> Result { + for range in std::iter::once(range).chain(target_range) { + if range.start > range.end { + return Err("Reversed LSP range".into()); + } + for (row, _) in [range.start, range.end] { + if row >= lines.len() { + return Err("LSP line is outside the document".into()); + } + } + } + let (row, units) = range.start; + let mut offset = 0; + let mut column = 0; + for cluster in lines[row].graphemes(true) { + let width = cluster.encode_utf16().count(); + if units < offset + width { + return Ok(TextPosition { row, column }); + } + offset += width; + column += 1; + } + Ok(TextPosition { row, column }) +} + fn path_identity(path: &Path) -> io::Result { let absolute = if path.is_absolute() { path.to_owned() @@ -9863,6 +10360,390 @@ mod tests { assert!(editor.diff_interacting()); } + fn lsp_point(row: usize, column: usize) -> Utf16Range { + Utf16Range { + start: (row, column), + end: (row, column), + } + } + + fn lazy_lsp_target(editor: &mut Editor) -> crate::diff::ChangedFile { + use crate::diff::{ChangedFile, FileStatus}; + let before = vec!["context".into(), "😀e\u{301} before".into()]; + let after = vec!["context".into(), "😀e\u{301} after".into()]; + let hunks = crate::diff::line_hunks(&before, false, &after, false); + let diff = editor.diff.as_mut().unwrap(); + let comparison = diff.comparison.as_mut().unwrap(); + comparison.files.push(ChangedFile { + path: "nested/new.txt".into(), + old_path: Some("nested/old.txt".into()), + status: FileStatus::Renamed, + loaded: false, + before: Vec::new(), + after: Vec::new(), + before_eof: false, + after_eof: false, + hunks: Vec::new(), + notice: None, + }); + diff.files = Some(FilePicker::from_paths( + comparison.root.clone(), + comparison + .files + .iter() + .enumerate() + .map(|(index, file)| (index, file.path.clone())), + )); + ChangedFile { + path: "nested/new.txt".into(), + old_path: Some("nested/old.txt".into()), + status: FileStatus::Renamed, + loaded: true, + before, + after, + before_eof: false, + after_eof: false, + hunks, + notice: None, + } + } + + #[test] + fn lsp_symbol_extent_reveals_exact_omitted_declaration_without_losing_comparison() { + let directory = Directory::new(); + let mut editor = editor(); + let before: Vec<_> = (0..70).map(|row| format!("line {row}")).collect(); + let mut after = before.clone(); + after[60] = "changed body".into(); + historical_diff(&mut editor, &directory.0, before, after); + editor.set_diff_context_options(1, 3); + let comparison = editor.diff().unwrap().comparison_id; + let snapshot = editor.buffer_id(); + let path = editor.active.snapshot_document_path.clone().unwrap(); + assert!( + editor + .diff() + .unwrap() + .source_display_row(Focus::Before, 4) + .is_none() + ); + editor + .navigate_lsp_to( + path.clone(), + lsp_point(4, 2), + Some(Utf16Range { + start: (4, 0), + end: (61, 0), + }), + ) + .unwrap(); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.buffer_id(), snapshot); + assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert!( + editor + .diff() + .unwrap() + .source_display_row(Focus::Before, 4) + .is_some() + ); + editor.refresh_live_diff(); + assert_eq!(editor.cursor().row, 4); + editor + .navigate_lsp_to(path, lsp_point(20, 1), None) + .unwrap(); + assert_eq!(editor.tab_count(), 2); + assert!(editor.diff().is_none()); + assert_eq!(editor.buffer_id(), snapshot); + assert!(editor.mutation_blocked()); + editor.execute("tab.previous"); + assert_eq!(editor.diff().unwrap().comparison_id, comparison); + assert_eq!(editor.cursor(), Cursor { row: 4, column: 2 }); + } + + #[test] + fn lazy_lsp_navigation_is_precise_and_source_departures_cancel_permanently() { + for departure in [ + "none", + "caret", + "side", + "tab", + "pane", + "close", + "replace", + "supersede", + ] { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["context".into(), "before".into()], + vec!["context".into(), "after".into()], + ); + let file = lazy_lsp_target(&mut editor); + let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); + let source = editor.buffer_id(); + let virtual_root = editor.diff().unwrap().snapshot_roots[0].clone().unwrap(); + editor + .navigate_lsp_to(virtual_root.join("nested/old.txt"), lsp_point(1, 4), None) + .unwrap(); + assert_eq!(editor.buffer_id(), source); + assert_eq!(editor.diff().unwrap().file, 0); + let (id, operation) = editor.take_diff_request().unwrap(); + assert!( + matches!(operation, Operation::LoadFile { comparison_id: owner, file_index: 1 } + if owner == comparison_id) + ); + match departure { + "none" => {} + "caret" => { + editor.handle(Input::Right); + editor.handle(Input::Left); + } + "side" => { + editor.scroll_diff(Focus::After, true); + editor.scroll_diff(Focus::Before, false); + } + "tab" => { + editor.execute("tab.new"); + editor.execute("tab.previous"); + } + "pane" => { + editor.execute("pane.split_vertical"); + } + "close" => { + editor.execute("diff.close"); + } + "replace" => { + editor.execute("diff"); + } + "supersede" => { + let path = editor.active.snapshot_document_path.clone().unwrap(); + editor.navigate_lsp_to(path, lsp_point(1, 0), None).unwrap(); + } + _ => unreachable!(), + } + let location = ( + editor.tab_id, + editor.pane_id, + editor.buffer_id(), + editor.cursor(), + ); + let selected = editor + .workspace_diff() + .map(|diff| (diff.comparison_id, diff.file, diff.focus)); + let tabs = editor.tab_count(); + editor.apply_diff_result( + id, + Ok(Outcome::File { + comparison_id, + file_index: 1, + file, + }), + ); + if departure == "none" { + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); + assert_eq!( + editor.path(), + Some(directory.path("nested/old.txt").as_path()) + ); + assert_eq!( + editor.active.snapshot_document_path.as_ref(), + Some(&virtual_root.join("nested/old.txt")) + ); + assert_eq!(editor.tab_count(), 1); + } else { + assert_eq!( + ( + editor.tab_id, + editor.pane_id, + editor.buffer_id(), + editor.cursor() + ), + location, + "{departure}" + ); + assert_eq!( + editor + .workspace_diff() + .map(|diff| (diff.comparison_id, diff.file, diff.focus)), + selected, + "{departure}" + ); + assert_eq!(editor.tab_count(), tabs, "{departure}"); + } + assert!(editor.pending_lsp_navigation.is_none()); + } + } + + #[test] + fn lazy_lsp_preflight_validates_both_ranges_before_committing_a_target() { + for invalid_target_range in [false, true] { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["context".into(), "before".into()], + vec!["context".into(), "after".into()], + ); + let file = lazy_lsp_target(&mut editor); + let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); + let source = editor.jump_location(); + let documents = editor.interaction_documents().count(); + let invalid = Utf16Range { + start: (1, 0), + end: (99, 0), + }; + editor + .navigate_lsp_to( + directory.path("nested/old.txt"), + if invalid_target_range { + lsp_point(1, 0) + } else { + invalid + }, + invalid_target_range.then_some(invalid), + ) + .unwrap(); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(Outcome::File { + comparison_id, + file_index: 1, + file, + }), + ); + assert_eq!(editor.jump_location(), source); + assert_eq!(editor.diff().unwrap().file, 0); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.interaction_documents().count(), documents); + assert!(editor.message().contains("definition failed")); + } + } + + #[test] + fn superseded_lazy_results_cannot_consume_the_new_queued_navigation() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["context".into(), "before".into()], + vec!["context".into(), "after".into()], + ); + let file = lazy_lsp_target(&mut editor); + let comparison_id = editor.diff().unwrap().comparison_id.unwrap(); + let path = directory.path("nested/old.txt"); + let source = editor.jump_location(); + editor + .navigate_lsp_to(path.clone(), lsp_point(1, 0), None) + .unwrap(); + let (old, _) = editor.take_diff_request().unwrap(); + editor.navigate_lsp_to(path, lsp_point(1, 4), None).unwrap(); + assert!(!editor.apply_diff_result(old, Err("old load failed".into()))); + assert_eq!(editor.jump_location(), source); + assert!(editor.pending_lsp_navigation.is_some()); + let (current, _) = editor.take_diff_request().unwrap(); + assert_ne!(current, old); + assert!(!editor.apply_diff_result(old, Err("late old load failed".into()))); + assert!(editor.pending_lsp_navigation.is_some()); + editor.apply_diff_result( + current, + Ok(Outcome::File { + comparison_id, + file_index: 1, + file, + }), + ); + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 2 }); + assert!(editor.pending_lsp_navigation.is_none()); + assert!(!editor.message().contains("old load failed")); + } + + #[test] + fn lazy_renamed_before_target_uses_shared_dirty_working_after_hunks() { + let directory = Directory::new(); + fs::create_dir(directory.path("nested")).unwrap(); + let path = directory.path("nested/new.txt"); + fs::write(&path, "context\nbefore").unwrap(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["context".into(), "source before".into()], + vec!["context".into(), "source after".into()], + ); + let mut file = lazy_lsp_target(&mut editor); + file.before = vec!["context".into(), "before".into()]; + file.after = file.before.clone(); + file.hunks.clear(); + let diff = editor.diff.as_mut().unwrap(); + diff.after.as_mut().unwrap().target = RevisionTarget::Working; + diff.comparison + .as_mut() + .unwrap() + .editable_paths + .insert("nested/new.txt".into()); + let comparison_id = diff.comparison_id.unwrap(); + let mut live = Buffer::open( + editor.next_buffer_id, + Some(path.clone()), + Some(path.canonicalize().unwrap()), + false, + ) + .unwrap(); + live.lines[1] = "dirty after".into(); + live.text_changed(); + let live_id = live.id; + editor.next_buffer_id += 1; + editor.parked.insert(live_id, live); + editor + .navigate_lsp_to(directory.path("nested/old.txt"), lsp_point(1, 0), None) + .unwrap(); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result( + id, + Ok(Outcome::File { + comparison_id, + file_index: 1, + file, + }), + ); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.diff().unwrap().file, 1); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + assert_eq!(editor.lines(), ["context", "before"]); + assert_eq!(editor.diff().unwrap().live_buffer, Some(live_id)); + assert!( + editor + .diff() + .unwrap() + .source_range_changed(1, Focus::Before, 1, 1) + ); + assert!(editor.mutation_blocked()); + assert_eq!( + editor.interaction_document_root(editor.buffer_id()), + directory.0.as_path() + ); + assert_eq!(editor.diff_root(), directory.0); + editor.handle(Input::Char('\t')); + assert_eq!(editor.buffer_id(), live_id); + assert!(editor.diff_editing()); + editor.navigate_lsp_to(path, lsp_point(1, 0), None).unwrap(); + assert_eq!(editor.tab_count(), 1); + assert_eq!(editor.diff().unwrap().comparison_id, Some(comparison_id)); + assert_eq!(editor.buffer_id(), live_id); + assert_eq!(editor.lines(), ["context", "dirty after"]); + assert!(editor.dirty()); + } + #[test] fn diff_documents_dismiss_notifications_but_files_and_selectors_keep_escape() { let directory = Directory::new(); diff --git a/src/lsp.rs b/src/lsp.rs index df1b79e..6f0c429 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -20,7 +20,7 @@ use crate::{ plugin::{ CompletionEdit, CompletionItem, Diagnostic, DiagnosticSeverity, DocumentEvent, DocumentEventKind, DocumentSnapshot, PluginUpdate, SemanticToken, SemanticTokenKind, - SemanticTokenModifier, TextPosition, UpdateEffect, + SemanticTokenModifier, TextPosition, UpdateEffect, Utf16Range, }, snippet, }; @@ -624,8 +624,8 @@ struct SyncOptions { enum Request { Initialize, - Definition(Source), - Hover(Source), + Definition(Source, Arc), + Hover(Source, Arc), Completion(Source, Arc), SemanticTokens(Arc), Shutdown, @@ -1001,8 +1001,8 @@ impl Runtime { params["context"] = json!({"triggerKind": 1}); } let request = match kind { - PositionKind::Definition => Request::Definition(source), - PositionKind::Hover => Request::Hover(source), + PositionKind::Definition => Request::Definition(source, Arc::clone(&doc.snapshot)), + PositionKind::Hover => Request::Hover(source, Arc::clone(&doc.snapshot)), PositionKind::Completion => Request::Completion(source, Arc::clone(&doc.snapshot)), }; self.request(method, Some(params), request) @@ -1153,8 +1153,8 @@ impl Runtime { let context = self.requests.remove(&id).ok_or("unexpected LSP response")?; if object.contains_key("result") == object.contains_key("error") { return match context { - Request::Hover(source) => { - self.hover_result(source, Err("unexpected LSP response".into())) + Request::Hover(source, snapshot) => { + self.hover_result(source, snapshot, Err("unexpected LSP response".into())) } Request::Completion(source, snapshot) => self.completion_result( source, @@ -1173,9 +1173,11 @@ impl Runtime { .filter(|_| error["code"].as_i64().is_some()); let Some(text) = text else { return match context { - Request::Hover(source) => { - self.hover_result(source, Err("invalid LSP error response".into())) - } + Request::Hover(source, snapshot) => self.hover_result( + source, + snapshot, + Err("invalid LSP error response".into()), + ), Request::Completion(source, snapshot) => self.completion_result( source, snapshot, @@ -1189,11 +1191,12 @@ impl Runtime { }; match context { Request::Initialize => Err(format!("initialization failed: {text}")), - Request::Definition(source) => self.emit(message( - &format!("definition failed: {text}"), - Some(source.request_id), - )), - Request::Hover(source) => self.hover_result(source, Err(text.to_owned())), + Request::Definition(source, snapshot) => { + self.definition_result(source, snapshot, Err(text.to_owned())) + } + Request::Hover(source, snapshot) => { + self.hover_result(source, snapshot, Err(text.to_owned())) + } Request::Completion(source, snapshot) => { self.completion_result(source, snapshot, Err(text.to_owned())) } @@ -1205,8 +1208,12 @@ impl Runtime { } else { match context { Request::Initialize => self.initialized(&frame["result"]), - Request::Definition(source) => self.definition_result(source, &frame["result"]), - Request::Hover(source) => self.hover_result(source, Ok(&frame["result"])), + Request::Definition(source, snapshot) => { + self.definition_result(source, snapshot, Ok(&frame["result"])) + } + Request::Hover(source, snapshot) => { + self.hover_result(source, snapshot, Ok(&frame["result"])) + } Request::Completion(source, snapshot) => { self.completion_result(source, snapshot, Ok(&frame["result"])) } @@ -1394,16 +1401,20 @@ impl Runtime { self.emit_diagnostics(&doc.snapshot, diagnostics) } - fn definition_result(&self, source: Source, result: &Value) -> Result<(), String> { + fn definition_result( + &self, + source: Source, + snapshot: Arc, + result: Result<&Value, String>, + ) -> Result<(), String> { if self .documents .get(&source.buffer_id) - .is_none_or(|doc| doc.snapshot.revision != source.revision) + .is_none_or(|doc| !Arc::ptr_eq(&doc.snapshot, &snapshot)) { return Ok(()); } - let result = self.definition_effects(&source, result); - match result { + match result.and_then(|result| Self::definition_effects(&source, result)) { Ok(effects) => self.emit(PluginUpdate { request_id: Some(source.request_id), effects, @@ -1415,11 +1426,16 @@ impl Runtime { } } - fn hover_result(&self, source: Source, result: Result<&Value, String>) -> Result<(), String> { + fn hover_result( + &self, + source: Source, + snapshot: Arc, + result: Result<&Value, String>, + ) -> Result<(), String> { let Some(doc) = self .documents .get(&source.buffer_id) - .filter(|doc| doc.snapshot.revision == source.revision) + .filter(|doc| Arc::ptr_eq(&doc.snapshot, &snapshot)) else { return Ok(()); }; @@ -1440,11 +1456,7 @@ impl Runtime { } } - fn definition_effects( - &self, - source: &Source, - result: &Value, - ) -> Result, String> { + fn definition_effects(source: &Source, result: &Value) -> Result, String> { let (location, count) = match result { Value::Null => return Ok(message("no definition found", None).effects), Value::Array(locations) if locations.is_empty() => { @@ -1462,53 +1474,25 @@ impl Runtime { .as_str() .ok_or("invalid definition URI")?, )?; - let identity = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); - let cached = self.snapshots.values().find(|doc| { - doc.path.as_ref().is_some_and(|candidate| { - let candidate = absolute(Path::new(candidate), &self.root); - std::fs::canonicalize(&candidate).unwrap_or(candidate) == identity - }) - }); - let disk; - let text = if let Some(doc) = cached { - &doc.text - } else { - if !std::fs::metadata(&path) - .map_err(|error| error.to_string())? - .is_file() - { - return Err("definition target must be a regular file".into()); - } - let mut bytes = Vec::new(); - std::fs::File::open(&path) - .map_err(|error| error.to_string())? - .take((MAX_BODY + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|error| error.to_string())?; - if bytes.len() > MAX_BODY { - return Err("definition target exceeds 4 MiB".into()); - } - disk = String::from_utf8(bytes).map_err(|_| "definition target is not UTF-8")?; - &disk - }; - let text = normalized(text); - if link { - range(&text, &location["targetRange"])?; - } - let (start, _) = range( - &text, + // Keep the server's document identity and UTF-16 coordinates together. + // The editor resolves historical/working text, including lazily loaded + // comparison files, before converting the target position. + let target_range = link + .then(|| range_coordinates(&location["targetRange"])) + .transpose()?; + let range = range_coordinates( &location[if link { "targetSelectionRange" } else { "range" }], )?; - let mut effects = vec![UpdateEffect::Navigate { + let mut effects = vec![UpdateEffect::NavigateUtf16 { buffer_id: source.buffer_id, revision: source.revision, path, - row: start.row, - column: start.column, + range, + target_range, }]; if count > 1 { effects.push(UpdateEffect::Message { @@ -2887,10 +2871,17 @@ fn position(text: &str, value: &Value, round_up: bool) -> Result Result<(TextPosition, TextPosition), String> { - if coordinates(&value["start"])? > coordinates(&value["end"])? { +fn range_coordinates(value: &Value) -> Result { + let start = coordinates(&value["start"])?; + let end = coordinates(&value["end"])?; + if start > end { return Err("reversed LSP range".into()); } + Ok(Utf16Range { start, end }) +} + +fn range(text: &str, value: &Value) -> Result<(TextPosition, TextPosition), String> { + range_coordinates(value)?; Ok(( position(text, &value["start"], false)?, position(text, &value["end"], true)?, @@ -3002,6 +2993,64 @@ fn uri_path(uri: &str) -> Result { mod tests { use super::*; + #[test] + fn native_definitions_preserve_snapshot_identity_and_utf16_until_navigation() { + let source = Source { + buffer_id: 7, + revision: 3, + row: 0, + column: 0, + request_id: 11, + }; + let path = Path::new("/vamp-immutable-snapshot/Before/renamed/λ.rs"); + let span = json!({ + "start": {"line": 4, "character": 5}, + "end": {"line": 4, "character": 8} + }); + for location in [ + json!({"uri": file_uri(path).unwrap(), "range": span}), + json!({"targetUri": file_uri(path).unwrap(), + "targetRange": span, "targetSelectionRange": span}), + ] { + let effects = Runtime::definition_effects(&source, &location).unwrap(); + let expected_target_range = location.get("targetUri").map(|_| Utf16Range { + start: (4, 5), + end: (4, 8), + }); + assert!(matches!( + effects.as_slice(), + [UpdateEffect::NavigateUtf16 { + buffer_id: 7, revision: 3, path: target, + range: Utf16Range { start: (4, 5), end: (4, 8) }, target_range, + }] if target == path && *target_range == expected_target_range + )); + } + for invalid in [ + json!({"start": {"line": 4, "character": 8}, + "end": {"line": 4, "character": 5}}), + json!({"start": {"line": -1, "character": 0}, + "end": {"line": 0, "character": 0}}), + ] { + for location in [ + json!({"uri": file_uri(path).unwrap(), "range": invalid}), + json!({"targetUri": file_uri(path).unwrap(), + "targetRange": invalid, "targetSelectionRange": span}), + json!({"targetUri": file_uri(path).unwrap(), + "targetRange": span, "targetSelectionRange": invalid}), + ] { + assert!(Runtime::definition_effects(&source, &location).is_err()); + } + } + assert!( + serde_json::from_value::(json!({ + "type": "navigate_utf16", "buffer_id": 7, "revision": 3, + "path": path, "range": {"start": [4,5], "end": [4,8]}, + "target_range": null + })) + .is_err() + ); + } + #[test] fn semantic_decoder_validates_raw_coordinates_before_grapheme_rounding() { let options = semantic_options(Some(&json!({ diff --git a/src/plugin.rs b/src/plugin.rs index ef8e08a..6a7048e 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -140,6 +140,13 @@ pub struct CompletionItem { pub snippet: Option, } +/// Native LSP range; each coordinate is (zero-based row, UTF-16 column). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Utf16Range { + pub start: (usize, usize), + pub end: (usize, usize), +} + #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum UpdateEffect { @@ -162,6 +169,15 @@ pub enum UpdateEffect { row: usize, column: usize, }, + /// Native LSP targets retain UTF-16 coordinates until their source text is resolved. + #[serde(skip_deserializing)] + NavigateUtf16 { + buffer_id: usize, + revision: u64, + path: PathBuf, + range: Utf16Range, + target_range: Option, + }, Hover { buffer_id: usize, revision: u64, diff --git a/src/session.rs b/src/session.rs index c808c73..461f365 100644 --- a/src/session.rs +++ b/src/session.rs @@ -166,6 +166,7 @@ pub struct EditorSession { lsp_request_id: u64, lsp_source_cursor: Option, lsp_source_mode: Mode, + lsp_source_view: Option<(usize, Option)>, pending_hover: bool, pending_completion: bool, hover_owner: Option, @@ -264,6 +265,7 @@ impl EditorSession { lsp_request_id: 0, lsp_source_cursor: None, lsp_source_mode: Mode::Normal, + lsp_source_view: None, pending_hover: false, pending_completion: false, hover_owner: None, @@ -299,10 +301,12 @@ impl EditorSession { pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) { self.editor.scroll_diff(focus, down); + self.synchronize_documents(); } pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) { self.editor.scroll_diff_horizontal(focus, right, width); + self.synchronize_documents(); } pub fn handle(&mut self, input: Input) { @@ -401,11 +405,16 @@ impl EditorSession { fn dispatch(&mut self, invocation: Invocation) { if invocation.plugin == "core" { + if !self.editor.document_actions_available() + || (invocation.command == "lsp.completion" && self.editor.mutation_blocked()) + { + return; + } let result = match invocation.command.as_str() { "lsp.definition" | "lsp.hover" | "lsp.completion" => { if let Some(id) = self.lsp_request_id.checked_add(1) { self.lsp_request_id = id; - let context = self.editor.context(); + let context = self.editor.lsp_context(); let source = InvocationSource { buffer_id: context.buffer_id, pane_id: self.editor.pane_id(), @@ -414,6 +423,10 @@ impl EditorSession { self.requests.entry("core".into()).or_default().latest = Some((id, source)); self.lsp_source_cursor = Some(self.editor.cursor()); self.lsp_source_mode = self.editor.mode(); + self.lsp_source_view = Some(( + self.editor.tab_id(), + self.editor.diff().and_then(|diff| diff.comparison_id), + )); self.pending_hover = invocation.command == "lsp.hover"; self.pending_completion = invocation.command == "lsp.completion"; match invocation.command.as_str() { @@ -502,10 +515,17 @@ impl EditorSession { || source.revision != self.editor.text_revision() || self.lsp_source_cursor != Some(self.editor.cursor()) || self.editor.mode() != self.lsp_source_mode + || self.lsp_source_view + != Some(( + self.editor.tab_id(), + self.editor.diff().and_then(|diff| diff.comparison_id), + )) + || !self.editor.document_actions_available() }) { requests.latest = None; self.lsp_source_cursor = None; + self.lsp_source_view = None; self.pending_hover = false; self.pending_completion = false; } @@ -517,9 +537,15 @@ impl EditorSession { if !self.editor.should_quit() { for document in self.editor.interaction_documents() { let path = document.path.map(Path::to_string_lossy); + let root = self + .editor + .interaction_document_root(document.id) + .to_string_lossy(); let previous = self.documents.get(&document.id); - let renamed = previous - .is_some_and(|previous| previous.snapshot.path.as_deref() != path.as_deref()); + let renamed = previous.is_some_and(|previous| { + previous.snapshot.path.as_deref() != path.as_deref() + || previous.snapshot.root.as_str() != root.as_ref() + }); if renamed { events.push(DocumentEvent { event: DocumentEventKind::Close, @@ -534,7 +560,7 @@ impl EditorSession { id: document.id, revision: document.revision, path: path.map(|path| path.into_owned()), - root: self.editor.project_root().to_string_lossy().into_owned(), + root: root.into_owned(), text: document.lines.join("\n"), }; events.push(DocumentEvent { @@ -636,14 +662,19 @@ impl EditorSession { request_id: Option, source: InvocationSource, ) -> bool { - if self.editor.diff().is_some() && !self.editor.diff_interacting() { + if !self.editor.document_actions_available() { return false; } self.editor.buffer_id() == source.buffer_id && self.source_is_current(source) && (owner != "core" || (self.lsp_source_cursor == Some(self.editor.cursor()) - && self.editor.mode() == self.lsp_source_mode)) + && self.editor.mode() == self.lsp_source_mode + && self.lsp_source_view + == Some(( + self.editor.tab_id(), + self.editor.diff().and_then(|diff| diff.comparison_id), + )))) && self .requests .get(owner) @@ -755,7 +786,16 @@ impl EditorSession { revision, path, .. + } + | UpdateEffect::NavigateUtf16 { + buffer_id, + revision, + path, + .. } => { + if owner != "core" && matches!(effect, UpdateEffect::NavigateUtf16 { .. }) { + return Err("UTF-16 navigation is a native LSP update only".into()); + } let source = InvocationSource { buffer_id: *buffer_id, pane_id: self.editor.pane_id(), @@ -827,6 +867,26 @@ impl EditorSession { .editor .replace_validated_semantic_tokens(buffer_id, revision, tokens); } + UpdateEffect::NavigateUtf16 { + buffer_id, + revision, + path, + range, + target_range, + } => { + let source = InvocationSource { + buffer_id, + pane_id: self.editor.pane_id(), + revision, + }; + if self.request_is_current(owner, update.request_id, source) { + if let Err(error) = self.editor.navigate_lsp_to(path, range, target_range) { + self.editor + .set_message(format!("LSP: definition failed: {error}")); + } + changed = true; + } + } UpdateEffect::Navigate { buffer_id, revision, @@ -1050,6 +1110,68 @@ mod tests { sync::atomic::{AtomicU64, Ordering}, }; + #[test] + fn native_navigation_resolves_utf16_and_validates_full_ranges_atomically() { + use crate::plugin::Utf16Range; + + let fixture = Fixture::new(); + let source = fixture.file("buffer.txt", "source"); + let target = fixture.file("target.txt", "A😀e\u{301}Z\n"); + let mut session = EditorSession::open(Some(source.clone()), vec![], None).unwrap(); + session.dispatch(Invocation { + plugin: "core".into(), + command: "lsp.definition".into(), + args: String::new(), + }); + let buffer_id = session.editor.buffer_id(); + let revision = session.editor.text_revision(); + let request_id = session.lsp_request_id; + let span = Utf16Range { + start: (0, 5), + end: (0, 6), + }; + let invalid = Utf16Range { + end: (9, 0), + ..span + }; + let update = |range, target_range| PluginUpdate { + request_id: Some(request_id), + effects: vec![UpdateEffect::NavigateUtf16 { + buffer_id, + revision, + path: target.clone(), + range, + target_range, + }], + }; + for (range, target_range) in [(invalid, None), (span, Some(invalid))] { + assert!( + session + .apply_update("core", update(range, target_range)) + .unwrap() + ); + assert_eq!(session.editor.buffer_id(), buffer_id); + assert_eq!(session.editor.path(), Some(source.as_path())); + assert_eq!(session.editor.buffer_count(), 1); + assert_eq!(session.editor.tab_count(), 1); + assert_eq!(session.editor.cursor(), Cursor::default()); + assert!(session.editor.message().contains("definition failed")); + } + assert!(session.apply_update("fixture", update(span, None)).is_err()); + assert_eq!(session.editor.buffer_id(), buffer_id); + assert!( + session + .apply_update("core", update(span, Some(span))) + .unwrap() + ); + assert_eq!(session.editor.path(), Some(target.as_path())); + assert_eq!(session.editor.cursor(), Cursor { row: 0, column: 3 }); + assert_eq!(session.editor.tab_count(), 1); + session.handle(Input::Ctrl('o')); + assert_eq!(session.editor.path(), Some(source.as_path())); + assert_eq!(session.editor.cursor(), Cursor::default()); + } + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); #[test] @@ -1087,6 +1209,10 @@ mod tests { )); session.lsp_source_cursor = Some(session.editor.cursor()); session.lsp_source_mode = session.editor.mode(); + session.lsp_source_view = Some(( + session.editor.tab_id(), + session.editor.diff().and_then(|diff| diff.comparison_id), + )); session.editor_mut().with_render_pane(1, |pane| { assert_eq!(pane.pane_id(), 1); assert_eq!(pane.lines(), ["source"]); diff --git a/tests/lsp.rs b/tests/lsp.rs index 68b7bee..29ccfa5 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -3,6 +3,7 @@ use std::{ fs, path::PathBuf, + process::Command, sync::atomic::{AtomicU64, Ordering}, thread, time::{Duration, Instant}, @@ -10,6 +11,7 @@ use std::{ use serde_json::{Value, json}; use vamp::{ + diff::{DisplayRow, Focus, Phase}, editor::{Cursor, Input, Mode}, plugin::{DiagnosticSeverity, SemanticTokenKind, SemanticTokenModifier, TextPosition}, session::EditorSession, @@ -68,6 +70,7 @@ initialize_id = nil initialized_sent = false held = [] semantic_held = [] +position_held = [] completion_held = [] definition_count = 0 source_uri = uri(source) @@ -117,7 +120,22 @@ loop do control = release + '.control' if File.exist?(control) command = JSON.parse(File.read(control)) - if command['id'] != control_id + # A comparison can start another root-scoped server with these same fixture args. + # Only the process owning the referenced document/request consumes its gate. + ready = case command.fetch('action') + when 'publish', 'clear' + documents.key?(command.fetch('uri', source_uri)) + when 'definition-result', 'hover-result' + method = command['action'] == 'definition-result' ? 'textDocument/definition' : 'textDocument/hover' + position_held.count { |request| request['method'] == method && request['params']['textDocument']['uri'] == command.fetch('request_uri') } > command.fetch('index', 0) + when 'semantic-result' + semantic_held.length > command.fetch('index', 0) + when 'completion-result' + completion_held.length > command.fetch('index', 0) + else + true + end + if command['id'] != control_id && ready control_id = command.fetch('id') case command.fetch('action') when 'publish', 'clear' @@ -126,6 +144,18 @@ loop do 'diagnostics' => command['action'] == 'clear' ? [] : [{'range' => range(0, 2, 4), 'severity' => 2, 'message' => command.fetch('message')}]} emit({'method' => 'textDocument/publishDiagnostics', 'params' => params}) + when 'definition-result', 'hover-result' + method = command['action'] == 'definition-result' ? 'textDocument/definition' : 'textDocument/hover' + indices = position_held.each_index.select { |index| position_held[index]['method'] == method && position_held[index]['params']['textDocument']['uri'] == command.fetch('request_uri') } + request = position_held.delete_at(indices.fetch(command.fetch('index', 0))) + response = if command.key?('response') + command.fetch('response') + else + target_uri = command.key?('uri') ? command.fetch('uri') : uri(command.fetch('path')) + row, column = command.fetch('row', 0), command.fetch('column', 0) + {'result' => {'uri' => target_uri, 'range' => range(row, column, command.fetch('end_column', column))}} + end + emit({'id' => request.fetch('id')}.merge(response)) when 'semantic-result' request = semantic_held.delete_at(command.fetch('index', 0)) raise 'no held semantic request' unless request @@ -193,6 +223,8 @@ loop do when 'textDocument/definition' definition_count += 1 case mode + when 'controlled-diff' + position_held << frame when 'stale' held << frame when 'superseded' @@ -221,7 +253,9 @@ loop do end end when 'textDocument/hover' - if mode == 'hover-stale' + if mode == 'controlled-diff' + position_held << frame + elsif mode == 'hover-stale' held << frame elsif mode == 'hover-superseded' held << frame @@ -243,7 +277,7 @@ loop do result(frame['id'], hover) end when 'textDocument/completion' - if mode.include?('completion-controlled') + if mode.include?('completion-controlled') || mode == 'controlled-diff' completion_held << frame elsif mode == 'completion-empty' result(frame['id'], nil) @@ -340,7 +374,7 @@ impl Fixture { "settings": {"rust-analyzer": {"cargo": {"allFeatures": true}}}, }); let config = format!( - "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.lsp['fixture-language'] = JSON.parse({})\n config.bind 'leader d', config.core.lsp.definition\n config.bind 'leader D', config.core.lsp.diagnostics\n config.bind 'K', config.core.lsp.hover\nend\n", + "Vamp.configure do |config|\n config.clipboard_mode = 'separate'\n config.diff_context_lines = 1\n config.diff_expand_lines = 2\n config.lsp['fixture-language'] = JSON.parse({})\n config.bind 'leader d', config.core.lsp.definition\n config.bind 'leader D', config.core.lsp.diagnostics\n config.bind 'K', config.core.lsp.hover\nend\n", serde_json::to_string(&options.to_string()).unwrap(), ); let config_path = self.root.join("config.rb"); @@ -471,6 +505,183 @@ impl Fixture { } } +fn repository(fixture: &Fixture, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(&fixture.root) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_AUTHOR_NAME", "LSP Diff Fixture") + .env("GIT_AUTHOR_EMAIL", "fixture@example.invalid") + .env("GIT_COMMITTER_NAME", "LSP Diff Fixture") + .env("GIT_COMMITTER_EMAIL", "fixture@example.invalid") + .env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z") + .env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_owned() +} + +fn comparison_text(name: &str, version: &str) -> String { + let mut lines = (0..80) + .map(|row| format!("A😀e\u{301}Z {name}_stable_{row:03}")) + .collect::>(); + for row in [19, 59] { + lines[row] = format!("A😀e\u{301}Z {name}_{version}_{row:03}"); + } + if name == "target" && version == "after" { + lines.insert(10, "inserted target line".into()); + } + lines.join("\n") + "\n" +} + +fn diff_repository(fixture: &Fixture, historical: bool) -> (String, Option) { + repository(fixture, &["init", "-q"]); + fs::write( + fixture.root.join(".gitignore"), + "config.rb\nserver.rb\nprotocol*.jsonl\nrelease*\n*.missing\n", + ) + .unwrap(); + fs::write(&fixture.source, comparison_text("source", "before")).unwrap(); + fs::write(&fixture.target, comparison_text("target", "before")).unwrap(); + repository(fixture, &["add", "."]); + repository(fixture, &["commit", "-qm", "before"]); + let before = repository(fixture, &["rev-parse", "HEAD"]); + fs::write(&fixture.source, comparison_text("source", "after")).unwrap(); + fs::write(&fixture.target, comparison_text("target", "after")).unwrap(); + let after = historical.then(|| { + repository(fixture, &["add", "."]); + repository(fixture, &["commit", "-qm", "after"]); + let after = repository(fixture, &["rev-parse", "HEAD"]); + fs::write(&fixture.source, "disk is not the historical source\n").unwrap(); + fs::write(&fixture.target, "disk is not the historical target\n").unwrap(); + after + }); + (before, after) +} + +fn diff_focus(session: &mut EditorSession, focus: Focus) { + for _ in 0..3 { + if session.editor().diff().unwrap().focus == focus { + return; + } + session.handle(Input::Char('\t')); + } + assert_eq!(session.editor().diff().unwrap().focus, focus); +} + +fn open_comparison( + session: &mut EditorSession, + fixture: &Fixture, + before: &str, + after: Option<&str>, + focus: Focus, +) { + session.execute("diff"); + until(session, fixture, |session| { + session + .editor() + .diff() + .is_some_and(|diff| diff.catalog.is_some() && !diff.loading) + }); + for ch in before.chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Down); + session.handle(Input::Enter); + assert_eq!(session.editor().diff().unwrap().phase, Phase::After); + if let Some(after) = after { + for ch in after.chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Down); + } + session.handle(Input::Enter); + until(session, fixture, |session| { + session + .editor() + .diff() + .is_some_and(|diff| diff.phase == Phase::View && diff.selected_file_loaded()) + }); + session.handle(Input::Enter); + diff_focus(session, focus); +} + +fn active_lsp_document(session: &mut EditorSession, fixture: &Fixture) -> Value { + let text = session.editor().context().text; + let id = session.editor().buffer_id(); + let snapshot = !session.editor().buffers().any(|buffer| buffer.id == id); + let matches_document = |frame: &Value| { + frame["method"] == "textDocument/didOpen" + && frame["params"]["textDocument"]["text"] == text + && frame["params"]["textDocument"]["uri"] + .as_str() + .is_some_and(|uri| uri.contains(".vamp-readonly-") == snapshot) + }; + until(session, fixture, |_| { + fixture.frames().iter().any(matches_document) + }); + fixture + .frames() + .into_iter() + .rev() + .find(matches_document) + .unwrap()["params"]["textDocument"] + .clone() +} + +fn request_position(session: &mut EditorSession, fixture: &Fixture, method: &str) { + let count = fixture.method_count(method); + session.execute(if method == "textDocument/hover" { + "lsp.hover" + } else { + "lsp.definition" + }); + until(session, fixture, |_| { + fixture.method_count(method) == count + 1 + }); +} + +fn position_reply(session: &mut EditorSession, fixture: &Fixture, id: u64, mut command: Value) { + let method = match command["action"].as_str() { + Some("definition-result") => Some("textDocument/definition"), + Some("hover-result") => Some("textDocument/hover"), + _ => None, + }; + if let Some(method) = method { + let request = fixture + .frames() + .into_iter() + .rev() + .find(|frame| frame["method"] == method) + .unwrap(); + command["request_uri"] = request["params"]["textDocument"]["uri"].clone(); + } + fixture.control(0, id, command); + until(session, fixture, |_| fixture.acknowledged(0, id)); + session.poll(); +} + +fn shown_diff_lines(session: &EditorSession, focus: Focus) -> Vec { + let diff = session.editor().diff().unwrap(); + diff.display_rows + .iter() + .filter_map(|row| match *row { + DisplayRow::Line(index) => match focus { + Focus::Before => diff.rows[index].before, + Focus::After => diff.rows[index].after, + Focus::Files => None, + }, + DisplayRow::Fold { .. } => None, + }) + .collect() +} + impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); @@ -3215,3 +3426,1055 @@ fn lsp_snippet_coincident_empty_mirrors_insert_and_delete_each_occurrence() { session.handle(Input::Char('u')); assert_eq!(session.editor().context().text, "ca\n"); } + +#[test] +fn lsp_diff_historical_sides_use_immutable_unicode_documents_for_hover_and_diagnostics() { + for (historical, focus, version) in [ + (false, Focus::Before, "before"), + (true, Focus::Before, "before"), + (true, Focus::After, "after"), + ] { + let fixture = Fixture::new(); + let (before, after) = diff_repository(&fixture, historical); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, after.as_deref(), focus); + let expected = comparison_text("source", version); + assert_eq!(session.editor().context().text, expected); + assert!(!session.editor().diff_editing()); + let document = active_lsp_document(&mut session, &fixture); + assert!( + document["uri"] + .as_str() + .unwrap() + .contains(".vamp-readonly-") + ); + assert_eq!(document["text"], expected); + + for input in [Input::Char('g'), Input::Char('t'), Input::Down, Input::Home] { + session.handle(input); + } + for _ in 0..3 { + session.handle(Input::Right); + } + let cursor = session.editor().cursor(); + assert_eq!(cursor, Cursor { row: 19, column: 3 }); + request_position(&mut session, &fixture, "textDocument/hover"); + let request = fixture + .frames() + .into_iter() + .rev() + .find(|frame| frame["method"] == "textDocument/hover") + .unwrap(); + assert_eq!(request["params"]["textDocument"]["uri"], document["uri"]); + assert_eq!( + request["params"]["position"], + json!({"line": 19, "character": 5}) + ); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "hover-result", + "response": {"result": {"contents": {"kind": "markdown", "value": "**Historical**\n```rust\nfn old() {}\n```"}}} + }), + ); + assert_eq!( + session.editor().hover().unwrap().text(), + "**Historical**\n```rust\nfn old() {}\n```" + ); + assert_eq!(session.editor().cursor(), cursor); + session.handle(Input::Escape); + + publish( + &mut session, + &fixture, + 0, + 2, + json!({ + "action": "publish", "uri": document["uri"], "message": "snapshot diagnostic" + }), + &["snapshot diagnostic"], + ); + let diagnostic = session.editor().diagnostics().next().unwrap(); + assert_eq!(diagnostic.start, TextPosition { row: 0, column: 1 }); + assert_eq!(diagnostic.end, TextPosition { row: 0, column: 3 }); + session.execute("lsp.diagnostics"); + let completion_count = fixture.method_count("textDocument/completion"); + for input in [ + Input::Char('i'), + Input::Ctrl('n'), + Input::Paste("overwrite".into()), + Input::Escape, + Input::Char('x'), + Input::Char('u'), + Input::Char('U'), + ] { + session.handle(input); + } + let forbidden = fixture.root.join("snapshot-copy.rs"); + session.execute(&format!("save {}", forbidden.display())); + assert!(!forbidden.exists()); + // A subsequent native request gates observation of all earlier document actions. + request_position(&mut session, &fixture, "textDocument/hover"); + position_reply( + &mut session, + &fixture, + 3, + json!({ + "action": "hover-result", "response": {"result": null} + }), + ); + assert_eq!(session.editor().context().text, expected); + assert_eq!(session.editor().mode(), Mode::Normal); + assert_eq!( + fixture.method_count("textDocument/completion"), + completion_count + ); + assert!(session.editor().completion().is_none()); + assert!(!session.editor().dirty()); + assert_eq!(session.editor().tab_count(), 1); + assert!(session.editor().diff().is_some()); + } +} + +#[test] +fn lsp_diff_working_after_completion_edits_the_shared_buffer_and_keeps_undo() { + let fixture = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, None, Focus::After); + let comparison = session.editor().diff().unwrap().comparison_id; + let buffer = session.editor().buffer_id(); + assert!(session.editor().diff_editing()); + for input in [ + Input::Char('g'), + Input::Char('t'), + Input::Down, + Input::Char('A'), + Input::Paste(" al".into()), + Input::Ctrl('n'), + ] { + session.handle(input); + } + until(&mut session, &fixture, |_| { + fixture.method_count("textDocument/completion") == 1 + }); + completion_reply( + &mut session, + &fixture, + 1, + json!({"result": [{"label": "alpha"}]}), + ); + assert!( + session + .editor() + .context() + .text + .contains("source_after_019 alpha\n") + ); + assert!(session.editor().completion().is_some()); + assert!(session.editor().dirty()); + assert_eq!(session.editor().buffer_id(), buffer); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + session.handle(Input::Escape); + session.handle(Input::Char('u')); + assert_eq!( + session.editor().context().text, + comparison_text("source", "after").replace("source_after_019\n", "source_after_019 al\n") + ); + // Literal paste and the following completion retain their ordinary separate groups. + session.handle(Input::Char('u')); + assert_eq!( + session.editor().context().text, + comparison_text("source", "after") + ); + session.handle(Input::Char('U')); + session.handle(Input::Char('U')); + assert!( + session + .editor() + .context() + .text + .contains("source_after_019 alpha\n") + ); + diff_focus(&mut session, Focus::Before); + assert_eq!( + session.editor().context().text, + comparison_text("source", "before") + ); + diff_focus(&mut session, Focus::After); + assert!( + session + .editor() + .context() + .text + .contains("source_after_019 alpha\n") + ); + assert_eq!( + fs::read_to_string(&fixture.source).unwrap(), + comparison_text("source", "after") + ); +} + +#[test] +fn lsp_diff_definition_routes_exact_changed_lines_in_the_same_comparison() { + for (historical, focus, row, version) in [ + (false, Focus::After, 60, "after"), + (false, Focus::Before, 59, "before"), + (true, Focus::After, 60, "after"), + ] { + let fixture = Fixture::new(); + let (before, after) = diff_repository(&fixture, historical); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, after.as_deref(), focus); + let tab = session.editor().tab_id(); + let comparison = session.editor().diff().unwrap().comparison_id; + let files = &session + .editor() + .diff() + .unwrap() + .comparison + .as_ref() + .unwrap() + .files; + let target_index = files + .iter() + .position(|file| file.path.file_name() == fixture.target.file_name()) + .unwrap(); + assert!(!files[target_index].loaded); + + // This canonical alias is deliberately outside the changed-path catalog. + let alias = fixture.root.join(".git/target-alias.rs"); + std::os::unix::fs::symlink(&fixture.target, &alias).unwrap(); + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "path": alias, "row": row, "column": 5 + }), + ); + until(&mut session, &fixture, |session| { + session.editor().diff().is_some_and(|diff| { + diff.file == target_index && diff.selected_file_loaded() && !diff.loading + }) + }); + assert_eq!(session.editor().tab_id(), tab); + assert_eq!(session.editor().tab_count(), 1); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert_eq!(session.editor().diff().unwrap().focus, focus); + assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); + assert_eq!( + session.editor().context().text, + comparison_text("target", version) + ); + assert!(shown_diff_lines(&session, focus).contains(&row)); + + request_position(&mut session, &fixture, "textDocument/definition"); + let same_row = if focus == Focus::Before { 19 } else { 20 }; + position_reply( + &mut session, + &fixture, + 2, + json!({ + "action": "definition-result", "path": fixture.target, "row": same_row, "column": 3 + }), + ); + assert_eq!( + session.editor().cursor(), + Cursor { + row: same_row, + column: 2 + } + ); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert_eq!(session.editor().tab_count(), 1); + } +} + +#[test] +fn lsp_diff_unchanged_context_and_omitted_symbols_open_shared_ordinary_tabs() { + for row in [18, 39] { + let fixture = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, None, Focus::After); + for input in [ + Input::Char('g'), + Input::Char('t'), + Input::Down, + Input::Char('A'), + Input::Char('!'), + Input::Escape, + ] { + session.handle(input); + } + let text = session.editor().context().text; + let source = session.editor().buffer_id(); + let cursor = session.editor().cursor(); + let tab = session.editor().tab_id(); + let pane = session.editor().pane_id(); + let layout = format!("{:?}", session.editor().pane_layout()); + let comparison = session.editor().diff().unwrap().comparison_id; + let excerpts = shown_diff_lines(&session, Focus::After); + assert_eq!(excerpts.contains(&row), row == 18); + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "path": fixture.source, "row": row, "column": 5 + }), + ); + assert_eq!(session.editor().tab_count(), 2); + assert_ne!(session.editor().tab_id(), tab); + assert!(session.editor().diff().is_none()); + assert_eq!(session.editor().buffer_id(), source); + assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); + assert_eq!(session.editor().context().text, text); + assert!(session.editor().dirty()); + session.execute("tab.previous"); + assert_eq!(session.editor().tab_id(), tab); + assert_eq!(session.editor().pane_id(), pane); + assert_eq!(format!("{:?}", session.editor().pane_layout()), layout); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert_eq!(shown_diff_lines(&session, Focus::After), excerpts); + session.handle(Input::Char('u')); + assert_eq!( + session.editor().context().text, + comparison_text("source", "after") + ); + session.execute("tab.next"); + assert_eq!( + session.editor().context().text, + comparison_text("source", "after") + ); + } +} + +#[test] +fn lsp_diff_external_dirty_target_opens_a_tab_without_disturbing_panes_or_comparison() { + let fixture = Fixture::new(); + let external = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + session.execute(&format!("edit {}", external.source.display())); + session.handle(Input::Char('i')); + session.handle(Input::Paste("dirty ".into())); + session.handle(Input::Escape); + let dirty = session.editor().buffer_id(); + let text = session.editor().context().text; + session.execute("buffer 1"); + session.execute("pane.split_vertical"); + open_comparison(&mut session, &fixture, &before, None, Focus::After); + let tab = session.editor().tab_id(); + let pane = session.editor().pane_id(); + let layout = format!("{:?}", session.editor().pane_layout()); + let comparison = session.editor().diff().unwrap().comparison_id; + let cursor = session.editor().cursor(); + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "path": external.source, "row": 0, "column": 11 + }), + ); + assert_eq!(session.editor().tab_count(), 2); + assert!(session.editor().diff().is_none()); + assert_eq!(session.editor().buffer_id(), dirty); + assert_eq!(session.editor().context().text, text); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 9 }); + assert!(session.editor().dirty()); + assert_eq!( + fs::read_to_string(&external.source).unwrap(), + "A😀e\u{301}Z\n" + ); + session.execute("tab.previous"); + assert_eq!(session.editor().tab_id(), tab); + assert_eq!(session.editor().pane_id(), pane); + assert_eq!(format!("{:?}", session.editor().pane_layout()), layout); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + session.execute("tab.next"); + session.handle(Input::Char('u')); + assert_eq!(session.editor().context().text, "A😀e\u{301}Z\n"); +} + +#[test] +fn lsp_diff_unchanged_snapshot_uri_opens_an_immutable_tab_that_outlives_the_comparison() { + for (focus, version) in [(Focus::Before, "before"), (Focus::After, "after")] { + let fixture = Fixture::new(); + let (before, after) = diff_repository(&fixture, true); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, after.as_deref(), focus); + let document = active_lsp_document(&mut session, &fixture); + let snapshot = session.editor().buffer_id(); + let comparison = session.editor().diff().unwrap().comparison_id; + let cursor = session.editor().cursor(); + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "uri": document["uri"], "row": 39, "column": 5 + }), + ); + assert_eq!(session.editor().tab_count(), 2); + assert!(session.editor().diff().is_none()); + assert_eq!(session.editor().buffer_id(), snapshot); + assert_eq!(session.editor().cursor(), Cursor { row: 39, column: 3 }); + assert_eq!( + session.editor().context().text, + comparison_text("source", version) + ); + session.execute("tab.previous"); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + session.execute("diff.close"); + session.execute("tab.next"); + for input in [ + Input::Char('i'), + Input::Paste("bad".into()), + Input::Escape, + Input::Char('x'), + ] { + session.handle(input); + } + assert_eq!(session.editor().buffer_id(), snapshot); + assert_eq!( + session.editor().context().text, + comparison_text("source", version) + ); + assert!(!session.editor().dirty()); + assert_eq!( + fs::read_to_string(&fixture.source).unwrap(), + "disk is not the historical source\n" + ); + } +} + +#[test] +fn lsp_diff_failed_definitions_are_atomic_for_tabs_views_text_and_history() { + for failure in ["missing", "utf8", "row", "range-order", "remote"] { + let fixture = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, None, Focus::After); + for input in [Input::Char('A'), Input::Char('!'), Input::Escape] { + session.handle(input); + } + let text = session.editor().context().text; + let buffer = session.editor().buffer_id(); + let cursor = session.editor().cursor(); + let pane = session.editor().pane_id(); + let comparison = session.editor().diff().unwrap().comparison_id; + let excerpts = shown_diff_lines(&session, Focus::After); + let buffers = session.editor().buffer_count(); + let bad = fixture.root.join(".git/invalid.rs"); + fs::write(&bad, [0xff, 0xfe]).unwrap(); + let command = match failure { + "missing" => { + json!({"action": "definition-result", "path": fixture.root.join("absent.rs.missing")}) + } + "utf8" => json!({"action": "definition-result", "path": bad}), + "row" => json!({"action": "definition-result", "path": fixture.alternate, "row": 999}), + "range-order" => { + json!({"action": "definition-result", "path": fixture.alternate, "column": 3, "end_column": 1}) + } + "remote" => { + json!({"action": "definition-result", "uri": "https://example.invalid/source.rs"}) + } + _ => unreachable!(), + }; + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply(&mut session, &fixture, 1, command); + assert_eq!(session.editor().tab_count(), 1, "{failure}"); + assert_eq!(session.editor().buffer_count(), buffers, "{failure}"); + assert_eq!(session.editor().buffer_id(), buffer, "{failure}"); + assert_eq!(session.editor().pane_id(), pane, "{failure}"); + assert_eq!(session.editor().cursor(), cursor, "{failure}"); + assert_eq!(session.editor().context().text, text, "{failure}"); + assert_eq!( + session.editor().diff().unwrap().comparison_id, + comparison, + "{failure}" + ); + assert_eq!( + shown_diff_lines(&session, Focus::After), + excerpts, + "{failure}" + ); + assert!(session.editor().dirty()); + session.handle(Input::Ctrl('o')); + assert_eq!(session.editor().buffer_id(), buffer); + assert_eq!(session.editor().cursor(), cursor); + session.handle(Input::Char('u')); + assert_eq!( + session.editor().context().text, + comparison_text("source", "after") + ); + } +} + +#[test] +fn lsp_diff_selector_and_files_focus_are_not_document_action_targets() { + let fixture = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + let document = active_lsp_document(&mut session, &fixture); + session.execute("diff"); + until(&mut session, &fixture, |session| { + session.editor().diff().unwrap().catalog.is_some() + }); + for stage in [Phase::Before, Phase::After] { + assert_eq!(session.editor().diff().unwrap().phase, stage); + for command in ["lsp.definition", "lsp.hover", "lsp.diagnostics"] { + session.execute(command); + } + if stage == Phase::Before { + for ch in before.chars() { + session.handle(Input::Char(ch)); + } + session.handle(Input::Down); + } + session.handle(Input::Enter); + } + until(&mut session, &fixture, |session| { + session + .editor() + .diff() + .is_some_and(|diff| diff.selected_file_loaded()) + }); + assert_eq!(session.editor().diff().unwrap().focus, Focus::Files); + for command in ["lsp.definition", "lsp.hover", "lsp.diagnostics"] { + session.execute(command); + } + position_reply( + &mut session, + &fixture, + 1, + json!({"action": "clear", "uri": document["uri"]}), + ); + assert_eq!(fixture.method_count("textDocument/definition"), 0); + assert_eq!(fixture.method_count("textDocument/hover"), 0); + assert!(session.editor().hover().is_none()); + assert_eq!(session.editor().tab_count(), 1); + assert_eq!(session.editor().diff().unwrap().focus, Focus::Files); + session.handle(Input::Enter); + request_position(&mut session, &fixture, "textDocument/hover"); + position_reply( + &mut session, + &fixture, + 2, + json!({ + "action": "hover-result", "response": {"result": {"contents": "text side works"}} + }), + ); + assert_eq!(session.editor().hover().unwrap().text(), "text side works"); +} + +#[test] +fn lsp_diff_delayed_position_replies_cannot_revive_a_departed_source() { + for departure in [ + "caret", + "revision", + "side", + "file", + "tab", + "inactive-tab", + "pane", + "close", + "replace", + ] { + let fixture = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, None, Focus::After); + request_position(&mut session, &fixture, "textDocument/definition"); + request_position(&mut session, &fixture, "textDocument/hover"); + match departure { + "caret" => { + session.handle(Input::Right); + session.handle(Input::Left); + } + "revision" => { + for input in [ + Input::Char('A'), + Input::Char('!'), + Input::Escape, + Input::Char('u'), + ] { + session.handle(input); + } + } + "side" => { + diff_focus(&mut session, Focus::Before); + diff_focus(&mut session, Focus::After); + } + "file" => { + diff_focus(&mut session, Focus::Files); + session.handle(Input::Ctrl('u')); + for ch in fixture + .target + .file_name() + .unwrap() + .to_str() + .unwrap() + .chars() + { + session.handle(Input::Char(ch)); + } + session.handle(Input::Enter); + until(&mut session, &fixture, |session| { + session.editor().diff().unwrap().selected_file_loaded() + }); + } + "tab" => { + session.execute("tab.new"); + session.execute("tab.previous"); + } + "inactive-tab" => session.execute("tab.new"), + "pane" => session.execute("pane.split_vertical"), + "close" => session.execute("diff.close"), + "replace" => session.execute("diff"), + _ => unreachable!(), + } + let tab = session.editor().tab_id(); + let tabs = session.editor().tab_count(); + let pane = session.editor().pane_id(); + let buffer = session.editor().buffer_id(); + let cursor = session.editor().cursor(); + let text = session.editor().context().text; + let comparison = session.editor().diff().and_then(|diff| diff.comparison_id); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "path": fixture.alternate + }), + ); + position_reply( + &mut session, + &fixture, + 2, + json!({ + "action": "hover-result", "response": {"result": {"contents": "stale historical hover"}} + }), + ); + assert_eq!(session.editor().tab_id(), tab, "{departure}"); + assert_eq!(session.editor().tab_count(), tabs, "{departure}"); + assert_eq!(session.editor().pane_id(), pane, "{departure}"); + assert_eq!(session.editor().buffer_id(), buffer, "{departure}"); + assert_eq!(session.editor().cursor(), cursor, "{departure}"); + assert_eq!(session.editor().context().text, text, "{departure}"); + assert_eq!( + session.editor().diff().and_then(|diff| diff.comparison_id), + comparison, + "{departure}" + ); + assert!(session.editor().hover().is_none(), "{departure}"); + } +} + +#[test] +fn lsp_diff_added_deleted_and_renamed_targets_follow_their_own_side_coordinates() { + for (kind, focus) in [ + ("added", Focus::After), + ("deleted", Focus::Before), + ("renamed", Focus::Before), + ("renamed-dirty", Focus::Before), + ("renamed", Focus::After), + ] { + let fixture = Fixture::new(); + diff_repository(&fixture, false); + fs::write(&fixture.source, comparison_text("source", "before")).unwrap(); + fs::write(&fixture.target, comparison_text("target", "before")).unwrap(); + let deleted = fixture.root.join("z_deleted.rs"); + let old = fixture.root.join("z_old.rs"); + let renamed = fixture.root.join("z_renamed.rs"); + let added = fixture.root.join("z_added.rs"); + fs::write(&deleted, "A😀e\u{301}Z deleted\n").unwrap(); + fs::write(&old, comparison_text("renamed", "before")).unwrap(); + repository(&fixture, &["add", "."]); + repository(&fixture, &["commit", "-qm", "old paths"]); + let before = repository(&fixture, &["rev-parse", "HEAD"]); + fs::write(&fixture.source, comparison_text("source", "after")).unwrap(); + fs::write(&fixture.target, comparison_text("target", "after")).unwrap(); + fs::remove_file(&deleted).unwrap(); + fs::rename(&old, &renamed).unwrap(); + fs::write(&added, "A😀e\u{301}Z added\n").unwrap(); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, None, focus); + let comparison = session.editor().diff().unwrap().comparison_id; + if kind.starts_with("renamed") && (focus == Focus::After || kind == "renamed-dirty") { + // Rename discovery is exact-content. Edit only after capturing that catalog. + session.execute("tab.new"); + session.execute(&format!("edit {}", renamed.display())); + for input in [ + Input::Char('g'), + Input::Char('t'), + Input::Char('5'), + Input::Char('9'), + Input::Char('j'), + Input::Char('A'), + Input::Char('!'), + Input::Escape, + ] { + session.handle(input); + } + assert!(session.editor().lines()[59].ends_with('!')); + session.execute("tab.previous"); + } + let tabs = session.editor().tab_count(); + let renamed_before = kind == "renamed" && focus == Focus::Before; + let (path, row, expected) = match (kind, focus) { + ("added", _) => (&added, 0, "A😀e\u{301}Z added\n".to_owned()), + ("deleted", _) => (&deleted, 0, "A😀e\u{301}Z deleted\n".to_owned()), + ("renamed-dirty", Focus::Before) => (&old, 59, comparison_text("renamed", "before")), + ("renamed", Focus::Before) => (&old, 59, comparison_text("renamed", "before")), + ("renamed", Focus::After) => ( + &renamed, + 59, + comparison_text("renamed", "before") + .replace("renamed_before_059\n", "renamed_before_059!\n"), + ), + _ => unreachable!(), + }; + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "path": path, "row": row, "column": 5 + }), + ); + until(&mut session, &fixture, |session| { + session.editor().context().text == expected + && if renamed_before { + session.editor().diff().is_none() + } else { + session.editor().diff().is_some_and(|diff| !diff.loading) + } + }); + assert_eq!( + session.editor().tab_count(), + tabs + usize::from(renamed_before), + "{kind} {focus:?}" + ); + if renamed_before { + assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); + let document = active_lsp_document(&mut session, &fixture); + assert!(document["uri"].as_str().unwrap().ends_with("/z_old.rs")); + session.handle(Input::Char('i')); + session.handle(Input::Paste("overwrite".into())); + session.handle(Input::Escape); + assert_eq!(session.editor().context().text, expected); + assert!(!session.editor().dirty()); + session.execute("tab.previous"); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + continue; + } + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert_eq!(session.editor().diff().unwrap().focus, focus); + assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); + let diff = session.editor().diff().unwrap(); + let file = &diff.comparison.as_ref().unwrap().files[diff.file]; + assert!(file.loaded); + if kind.starts_with("renamed") { + assert_eq!( + file.old_path.as_deref(), + Some(std::path::Path::new("z_old.rs")) + ); + assert_eq!(file.path, std::path::Path::new("z_renamed.rs")); + if focus == Focus::Before { + let document = active_lsp_document(&mut session, &fixture); + assert!(document["uri"].as_str().unwrap().ends_with("/z_old.rs")); + } + } + } +} + +#[test] +fn lsp_diff_superseded_position_requests_only_apply_the_newest_result() { + for method in ["textDocument/definition", "textDocument/hover"] { + let fixture = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, None, Focus::After); + let comparison = session.editor().diff().unwrap().comparison_id; + request_position(&mut session, &fixture, method); + request_position(&mut session, &fixture, method); + let action = if method == "textDocument/definition" { + "definition-result" + } else { + "hover-result" + }; + let newest = if method == "textDocument/definition" { + json!({"action": action, "index": 1, "path": fixture.source, "row": 59, "column": 5}) + } else { + json!({"action": action, "index": 1, "response": {"result": {"contents": "newest diff hover"}}}) + }; + position_reply(&mut session, &fixture, 1, newest); + position_reply( + &mut session, + &fixture, + 2, + json!({ + "action": action, "response": {"result": {"invalid": "stale payload must not be decoded"}} + }), + ); + assert_eq!(session.editor().tab_count(), 1); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + if method == "textDocument/definition" { + assert_eq!(session.editor().cursor(), Cursor { row: 59, column: 3 }); + } else { + assert_eq!( + session.editor().hover().unwrap().text(), + "newest diff hover" + ); + } + assert_eq!(fixture.method_count(method), 2); + } +} + +#[test] +fn lsp_diff_replaced_snapshot_diagnostics_do_not_leak_into_the_new_comparison() { + for focus in [Focus::Before, Focus::After] { + let fixture = Fixture::new(); + let (before, after) = diff_repository(&fixture, true); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, after.as_deref(), focus); + let old = active_lsp_document(&mut session, &fixture); + session.execute("diff.close"); + open_comparison(&mut session, &fixture, &before, after.as_deref(), focus); + until(&mut session, &fixture, |_| { + fixture.frames().iter().any(|frame| { + frame["method"] == "textDocument/didOpen" + && frame["params"]["textDocument"]["text"] == old["text"] + && frame["params"]["textDocument"]["uri"] != old["uri"] + }) + }); + let current = active_lsp_document(&mut session, &fixture); + assert_ne!(old["uri"], current["uri"]); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "publish", "uri": old["uri"], "message": "obsolete comparison" + }), + ); + assert_eq!(diagnostic_messages(&session), Vec::<&str>::new()); + publish( + &mut session, + &fixture, + 0, + 2, + json!({ + "action": "publish", "uri": current["uri"], "message": "current comparison" + }), + &["current comparison"], + ); + assert_eq!( + session.editor().context().text, + current["text"].as_str().unwrap() + ); + } +} + +#[test] +fn lsp_diff_pending_completion_cannot_apply_edits_to_a_readonly_side() { + let fixture = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, None, Focus::After); + for input in [ + Input::Char('A'), + Input::Paste(" al".into()), + Input::Ctrl('n'), + ] { + session.handle(input); + } + until(&mut session, &fixture, |_| { + fixture.method_count("textDocument/completion") == 1 + }); + let working = session.editor().context().text; + session.handle(Input::Escape); + diff_focus(&mut session, Focus::Before); + completion_reply( + &mut session, + &fixture, + 1, + json!({"result": [{ + "label": "bad", "textEdit": {"range": { + "start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1} + }, "newText": "overwrite historical text"} + }]}), + ); + assert_eq!( + session.editor().context().text, + comparison_text("source", "before") + ); + assert!(session.editor().completion().is_none()); + assert!(!session.editor().dirty()); + diff_focus(&mut session, Focus::After); + assert_eq!(session.editor().context().text, working); + session.handle(Input::Char('u')); + assert_eq!( + session.editor().context().text, + comparison_text("source", "after") + ); +} + +#[test] +fn lsp_diff_definition_preserves_normal_overlong_utf16_column_clamping() { + let fixture = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, None, Focus::After); + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "path": fixture.alternate, "row": 0, "column": 999 + }), + ); + assert_eq!(session.editor().tab_count(), 2); + assert!(session.editor().diff().is_none()); + assert_eq!(session.editor().path(), Some(fixture.alternate.as_path())); + assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 4 }); +} + +#[test] +fn lsp_diff_symbol_ranges_route_by_changed_body_and_reveal_the_exact_selection() { + for shape in [ + "link-overlap", + "location-overlap", + "link-unchanged", + "exclusive-end", + ] { + let fixture = Fixture::new(); + let (before, _) = diff_repository(&fixture, false); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, None, Focus::After); + let document = active_lsp_document(&mut session, &fixture); + let comparison = session.editor().diff().unwrap().comparison_id; + let (start, end, selection) = match shape { + "link-overlap" | "location-overlap" => (39, 60, 39), + "link-unchanged" => (30, 40, 39), + "exclusive-end" => (40, 59, 40), + _ => unreachable!(), + }; + assert!(!shown_diff_lines(&session, Focus::After).contains(&selection)); + let range = json!({ + "start": {"line": start, "character": 0}, + "end": {"line": end, "character": 0} + }); + let result = if shape == "location-overlap" { + json!({"uri": document["uri"], "range": range}) + } else { + json!([{ + "targetUri": document["uri"], "targetRange": range, + "targetSelectionRange": { + "start": {"line": selection, "character": 5}, + "end": {"line": selection, "character": 6} + } + }]) + }; + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "response": {"result": result} + }), + ); + let overlapping = matches!(shape, "link-overlap" | "location-overlap"); + assert_eq!( + session.editor().tab_count(), + if overlapping { 1 } else { 2 }, + "{shape}" + ); + assert_eq!( + session.editor().cursor(), + Cursor { + row: selection, + column: if shape == "location-overlap" { 0 } else { 3 } + }, + "{shape}" + ); + if overlapping { + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert!(shown_diff_lines(&session, Focus::After).contains(&selection)); + } else { + assert!(session.editor().diff().is_none()); + session.execute("tab.previous"); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert!(!shown_diff_lines(&session, Focus::After).contains(&selection)); + } + } +} + +#[test] +fn lsp_diff_lazy_unchanged_historical_target_opens_its_revision_in_an_ordinary_tab() { + for (focus, row, version) in [(Focus::Before, 39, "before"), (Focus::After, 40, "after")] { + let fixture = Fixture::new(); + let (before, after) = diff_repository(&fixture, true); + let mut session = fixture.open("controlled-diff"); + open_comparison(&mut session, &fixture, &before, after.as_deref(), focus); + let source = session.editor().buffer_id(); + let cursor = session.editor().cursor(); + let comparison = session.editor().diff().unwrap().comparison_id; + let file = session.editor().diff().unwrap().file; + request_position(&mut session, &fixture, "textDocument/definition"); + position_reply( + &mut session, + &fixture, + 1, + json!({ + "action": "definition-result", "path": fixture.target, "row": row, "column": 5 + }), + ); + until(&mut session, &fixture, |session| { + session.editor().tab_count() == 2 + }); + assert!(session.editor().diff().is_none()); + assert_eq!(session.editor().cursor(), Cursor { row, column: 3 }); + assert_eq!( + session.editor().context().text, + comparison_text("target", version) + ); + let document = active_lsp_document(&mut session, &fixture); + assert!( + document["uri"] + .as_str() + .unwrap() + .contains(".vamp-readonly-") + ); + session.handle(Input::Char('i')); + session.handle(Input::Paste("bad".into())); + session.handle(Input::Escape); + assert_eq!( + session.editor().context().text, + comparison_text("target", version) + ); + assert!(!session.editor().dirty()); + session.execute("tab.previous"); + assert_eq!(session.editor().buffer_id(), source); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().diff().unwrap().comparison_id, comparison); + assert_eq!(session.editor().diff().unwrap().file, file); + } +}