Skip to main content

fresh/app/
lsp_requests.rs

1//! LSP (Language Server Protocol) request handling for the Editor.
2//!
3//! This module contains all methods related to LSP operations including:
4//! - Completion requests and response handling
5//! - Go-to-definition
6//! - Hover documentation
7//! - Find references
8//! - Signature help
9//! - Code actions
10//! - Rename operations
11//! - Inlay hints
12
13use anyhow::Result as AnyhowResult;
14use rust_i18n::t;
15use std::io;
16use std::time::{Duration, Instant};
17
18use crate::model::event::{BufferId, Event};
19use crate::primitives::word_navigation::{find_word_end, find_word_start};
20use crate::view::prompt::{Prompt, PromptType};
21
22use crate::services::lsp::async_handler::LspHandle;
23use crate::types::LspFeature;
24
25use super::{Editor, SemanticTokenRangeRequest};
26
27/// Ensure every line in a docstring is separated by a blank line.
28///
29/// LSP documentation (e.g. from pyright) often uses single newlines between
30/// lines, which markdown treats as soft breaks within one paragraph. This
31/// doubles all single newlines so each line becomes its own paragraph with
32/// spacing between them.
33fn space_doc_paragraphs(text: &str) -> String {
34    text.replace("\n\n", "\x00").replace(['\n', '\x00'], "\n\n")
35}
36
37/// Whether an LSP range (half-open end, like `[start, end)`) contains the given
38/// `(line, character)` LSP position. Zero-length ranges (start == end) are
39/// treated as containing their single anchor point so point-style diagnostics
40/// still match a hover that lands exactly on them.
41fn lsp_range_contains(range: &lsp_types::Range, line: u32, character: u32) -> bool {
42    let start = range.start;
43    let end = range.end;
44    // Before start?
45    if line < start.line || (line == start.line && character < start.character) {
46        return false;
47    }
48    // Zero-length range: accept exact anchor match.
49    if start.line == end.line && start.character == end.character {
50        return line == start.line && character == start.character;
51    }
52    // After end? (half-open)
53    if line > end.line || (line == end.line && character >= end.character) {
54        return false;
55    }
56    true
57}
58
59const SEMANTIC_TOKENS_RANGE_DEBOUNCE_MS: u64 = 50;
60const SEMANTIC_TOKENS_RANGE_PADDING_LINES: usize = 10;
61
62impl Editor {
63    /// Handle LSP completion response.
64    /// Supports merging from multiple servers: first response creates the menu,
65    /// subsequent responses extend it.
66    pub(crate) fn handle_completion_response(
67        &mut self,
68        request_id: u64,
69        items: Vec<lsp_types::CompletionItem>,
70    ) -> AnyhowResult<()> {
71        // Check if this is one of the pending completion requests
72        if !self
73            .active_window_mut()
74            .pending_completion_requests
75            .remove(&request_id)
76        {
77            tracing::debug!(
78                "Ignoring completion response for outdated request {}",
79                request_id
80            );
81            return Ok(());
82        }
83
84        if items.is_empty() {
85            tracing::debug!("No completion items received");
86            if self.active_window().pending_completion_requests.is_empty()
87                && self.active_window().completion_items.is_none()
88            {
89                // All servers responded with nothing — fall back to buffer-word completions,
90                // matching the behaviour when no LSP servers are available at all.
91                self.show_buffer_word_completion_popup();
92            }
93            return Ok(());
94        }
95
96        // Get the partial word at cursor to filter completions
97        use crate::primitives::word_navigation::find_completion_word_start;
98        let cursor_pos = self.active_cursors().primary().position;
99        let (word_start, cursor_pos) = {
100            let state = self.active_state();
101            let word_start = find_completion_word_start(&state.buffer, cursor_pos);
102            (word_start, cursor_pos)
103        };
104        let prefix = if word_start < cursor_pos {
105            self.active_state_mut()
106                .get_text_range(word_start, cursor_pos)
107                .to_lowercase()
108        } else {
109            String::new()
110        };
111
112        let matches_prefix = |item: &lsp_types::CompletionItem| -> bool {
113            prefix.is_empty()
114                || item.label.to_lowercase().starts_with(&prefix)
115                || item
116                    .filter_text
117                    .as_ref()
118                    .map(|ft| ft.to_lowercase().starts_with(&prefix))
119                    .unwrap_or(false)
120        };
121
122        let filtered_items: Vec<&lsp_types::CompletionItem> =
123            items.iter().filter(|item| matches_prefix(item)).collect();
124
125        if filtered_items.is_empty() && self.active_window().completion_items.is_none() {
126            tracing::debug!("No completion items match prefix '{}'", prefix);
127            return Ok(());
128        }
129
130        // Store/extend original items for type-to-filter (merge from multiple servers)
131        match &mut self.active_window_mut().completion_items {
132            Some(existing) => {
133                existing.extend(items);
134                tracing::debug!("Extended completion items, now {} total", existing.len());
135            }
136            None => {
137                self.active_window_mut().completion_items = Some(items);
138            }
139        }
140
141        // Rebuild popup from ALL merged items (not just the new batch)
142        let all_items = self.active_window_mut().completion_items.as_ref().unwrap();
143        let all_filtered: Vec<&lsp_types::CompletionItem> = all_items
144            .iter()
145            .filter(|item| matches_prefix(item))
146            .collect();
147
148        if all_filtered.is_empty() {
149            tracing::debug!("No completion items match prefix '{}'", prefix);
150            return Ok(());
151        }
152
153        // Build LSP popup items, then append buffer-word items below.
154        let mut all_popup_items =
155            crate::app::popup_actions::lsp_items_to_popup_items(&all_filtered);
156        let buffer_word_items = self.get_buffer_completion_popup_items();
157        // Deduplicate: skip buffer-word items whose label already appears in LSP results.
158        let lsp_labels: std::collections::HashSet<String> = all_popup_items
159            .iter()
160            .map(|i| i.text.to_lowercase())
161            .collect();
162        all_popup_items.extend(
163            buffer_word_items
164                .into_iter()
165                .filter(|item| !lsp_labels.contains(&item.text.to_lowercase())),
166        );
167
168        let popup_data =
169            crate::app::popup_actions::build_completion_popup_from_items(all_popup_items, 0);
170        let accept_hint = self.completion_accept_key_hint();
171        let focus_hint = self.popup_focus_key_hint();
172
173        {
174            let buffer_id = self.active_buffer();
175            let state = self
176                .windows
177                .get_mut(&self.active_window)
178                .map(|w| &mut w.buffers)
179                .expect("active window present")
180                .get_mut(&buffer_id)
181                .unwrap();
182            // Convert PopupData to Popup and use show_or_replace to avoid stacking
183            let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
184            popup_obj.accept_key_hint = accept_hint;
185            popup_obj.resolver = crate::view::popup::PopupResolver::Completion;
186            popup_obj.focus_key_hint = focus_hint;
187            state.popups.show_or_replace(popup_obj);
188        }
189
190        tracing::info!(
191            "Showing completion popup with {} items",
192            self.active_window_mut()
193                .completion_items
194                .as_ref()
195                .map_or(0, |i| i.len())
196        );
197
198        Ok(())
199    }
200
201    /// Handle LSP go-to-definition response
202    pub(crate) fn handle_goto_definition_response(
203        &mut self,
204        request_id: u64,
205        locations: Vec<lsp_types::Location>,
206    ) -> AnyhowResult<()> {
207        // Check if this is the pending request
208        if self.active_window_mut().pending_goto_definition_request != Some(request_id) {
209            tracing::debug!(
210                "Ignoring go-to-definition response for outdated request {}",
211                request_id
212            );
213            return Ok(());
214        }
215
216        self.active_window_mut().pending_goto_definition_request = None;
217
218        if locations.is_empty() {
219            self.active_window_mut().status_message = Some(t!("lsp.no_definition").to_string());
220            return Ok(());
221        }
222
223        // For now, just jump to the first location
224        let location = &locations[0];
225
226        // Resolve the URI to a buffer. `open_lsp_uri_target` handles
227        // all three cases: host file under the workspace mount,
228        // container-only file fetched via `docker exec cat`, and
229        // unreachable (no file at the host path AND container fetch
230        // failed). The last case becomes a user-visible status
231        // message instead of a phantom empty buffer.
232        let wire = crate::app::types::LspUri::from_wire(location.uri.clone());
233        let buffer_id = match self.open_lsp_uri_target(&wire) {
234            Ok(id) => id,
235            Err(e) => {
236                if let Some(confirmation) =
237                    e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
238                {
239                    self.start_large_file_encoding_confirmation(confirmation);
240                } else {
241                    self.set_status_message(
242                        t!("file.error_opening", error = e.to_string()).to_string(),
243                    );
244                }
245                return Ok(());
246            }
247        };
248
249        // Move cursor to the definition position. The buffer's
250        // `file_path` is the *destination* path — the host path on a
251        // bind-mounted file, the container path on a fetched one —
252        // so we read it back for the status message rather than
253        // formatting the original wire URI.
254        let line = location.range.start.line as usize;
255        let character = location.range.start.character as usize;
256        let position = self
257            .buffers()
258            .get(&buffer_id)
259            .map(|state| state.buffer.line_col_to_position(line, character));
260
261        if let Some(position) = position {
262            let (cursor_id, old_position, old_anchor, old_sticky_column) = {
263                let cursors = self.active_cursors();
264                let primary = cursors.primary();
265                (
266                    cursors.primary_id(),
267                    primary.position,
268                    primary.anchor,
269                    primary.sticky_column,
270                )
271            };
272            let event = crate::model::event::Event::MoveCursor {
273                cursor_id,
274                old_position,
275                new_position: position,
276                old_anchor,
277                new_anchor: None,
278                old_sticky_column,
279                new_sticky_column: 0,
280            };
281
282            let split_id = self
283                .windows
284                .get(&self.active_window)
285                .and_then(|w| w.buffers.splits())
286                .map(|(mgr, _)| mgr)
287                .expect("active window must have a populated split layout")
288                .active_split();
289            self.active_window_mut()
290                .apply_event_to_buffer(buffer_id, split_id, &event);
291            // Without this the cursor lands at the definition but the
292            // viewport never scrolls when the target file is already
293            // open (#1689).
294            self.active_window_mut()
295                .ensure_active_cursor_visible_for_navigation(true);
296        }
297
298        let display_path = self
299            .buffers()
300            .get(&buffer_id)
301            .and_then(|s| s.buffer.file_path().map(|p| p.display().to_string()))
302            .unwrap_or_default();
303        self.active_window_mut().status_message = Some(
304            t!(
305                "lsp.jumped_to_definition",
306                path = display_path,
307                line = line + 1
308            )
309            .to_string(),
310        );
311
312        Ok(())
313    }
314
315    /// Dispatch an exclusive LSP feature request to the first handle that allows the feature.
316    ///
317    /// Ensures all handles receive didOpen first, then calls the closure with the first
318    /// handle matching the feature filter. For features like hover, definition, rename, etc.
319    pub(crate) fn with_lsp_for_buffer<F, R>(
320        &mut self,
321        buffer_id: BufferId,
322        feature: LspFeature,
323        f: F,
324    ) -> Option<R>
325    where
326        F: FnOnce(&LspHandle, &crate::app::types::LspUri, &str) -> R,
327    {
328        use crate::services::lsp::manager::LspSpawnResult;
329
330        let (uri, language, file_path) = {
331            let metadata = self.active_window().buffer_metadata.get(&buffer_id)?;
332            if !metadata.lsp_enabled {
333                return None;
334            }
335            let uri = metadata.file_uri()?.clone();
336            let file_path = metadata.file_path().cloned();
337            let language = self
338                .windows
339                .get(&self.active_window)
340                .map(|w| &w.buffers)
341                .expect("active window present")
342                .get(&buffer_id)?
343                .language
344                .clone();
345            (uri, language, file_path)
346        };
347
348        let lsp = self.lsp_mut()?;
349        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
350            return None;
351        }
352
353        // Ensure didOpen is sent to all handles
354        self.ensure_did_open_all(buffer_id, &uri, &language)?;
355
356        // Dispatch to the first handle that allows this feature
357        let lsp = self.lsp_mut()?;
358        let sh = lsp.handle_for_feature_mut(&language, feature)?;
359        Some(f(&sh.handle, &uri, &language))
360    }
361
362    /// Dispatch a merged LSP feature request to all handles that allow the feature.
363    ///
364    /// Ensures all handles receive didOpen first, then calls the closure for each
365    /// handle matching the feature filter, collecting all results. For features like
366    /// completion, code actions, diagnostics, etc.
367    pub(crate) fn with_all_lsp_for_buffer_feature<F, R>(
368        &mut self,
369        buffer_id: BufferId,
370        feature: LspFeature,
371        f: F,
372    ) -> Vec<R>
373    where
374        F: Fn(&LspHandle, &crate::app::types::LspUri, &str) -> R,
375    {
376        use crate::services::lsp::manager::LspSpawnResult;
377
378        let (uri, language, file_path) = match (|| {
379            let metadata = self.active_window().buffer_metadata.get(&buffer_id)?;
380            if !metadata.lsp_enabled {
381                return None;
382            }
383            let uri = metadata.file_uri()?.clone();
384            let file_path = metadata.file_path().cloned();
385            let language = self
386                .windows
387                .get(&self.active_window)
388                .map(|w| &w.buffers)
389                .expect("active window present")
390                .get(&buffer_id)?
391                .language
392                .clone();
393            Some((uri, language, file_path))
394        })() {
395            Some(v) => v,
396            None => return Vec::new(),
397        };
398
399        let lsp = match self.lsp_mut() {
400            Some(l) => l,
401            None => return Vec::new(),
402        };
403        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
404            return Vec::new();
405        }
406
407        // Ensure didOpen is sent to all handles
408        if self
409            .ensure_did_open_all(buffer_id, &uri, &language)
410            .is_none()
411        {
412            return Vec::new();
413        }
414
415        // Dispatch to all handles that allow this feature
416        let lsp = match self.lsp_mut() {
417            Some(l) => l,
418            None => return Vec::new(),
419        };
420        lsp.handles_for_feature_mut(&language, feature)
421            .into_iter()
422            .map(|sh| f(&sh.handle, &uri, &language))
423            .collect()
424    }
425
426    /// Like `with_all_lsp_for_buffer_feature`, but also passes the server name
427    /// to the closure for attribution purposes.
428    pub(crate) fn with_all_lsp_for_buffer_feature_named<F, R>(
429        &mut self,
430        buffer_id: BufferId,
431        feature: LspFeature,
432        f: F,
433    ) -> Vec<R>
434    where
435        F: Fn(&LspHandle, &crate::app::types::LspUri, &str, &str) -> R,
436    {
437        use crate::services::lsp::manager::LspSpawnResult;
438
439        let (uri, language, file_path) = match (|| {
440            let metadata = self.active_window().buffer_metadata.get(&buffer_id)?;
441            if !metadata.lsp_enabled {
442                return None;
443            }
444            let uri = metadata.file_uri()?.clone();
445            let file_path = metadata.file_path().cloned();
446            let language = self
447                .windows
448                .get(&self.active_window)
449                .map(|w| &w.buffers)
450                .expect("active window present")
451                .get(&buffer_id)?
452                .language
453                .clone();
454            Some((uri, language, file_path))
455        })() {
456            Some(v) => v,
457            None => return Vec::new(),
458        };
459
460        let lsp = match self.lsp_mut() {
461            Some(l) => l,
462            None => return Vec::new(),
463        };
464        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
465            return Vec::new();
466        }
467
468        if self
469            .ensure_did_open_all(buffer_id, &uri, &language)
470            .is_none()
471        {
472            return Vec::new();
473        }
474
475        let lsp = match self.lsp_mut() {
476            Some(l) => l,
477            None => return Vec::new(),
478        };
479        lsp.handles_for_feature_mut(&language, feature)
480            .into_iter()
481            .map(|sh| f(&sh.handle, &uri, &language, &sh.name))
482            .collect()
483    }
484
485    /// Ensure didOpen has been sent to all handles for the given buffer's language.
486    /// Returns Some(()) on success, None if we can't access required state.
487    fn ensure_did_open_all(
488        &mut self,
489        buffer_id: BufferId,
490        uri: &crate::app::types::LspUri,
491        language: &str,
492    ) -> Option<()> {
493        let lsp = self.lsp_mut()?;
494        let handle_ids: Vec<u64> = lsp
495            .get_handles(language)
496            .iter()
497            .map(|sh| sh.handle.id())
498            .collect();
499
500        let needs_open: Vec<u64> = {
501            let metadata = self.active_window().buffer_metadata.get(&buffer_id)?;
502            handle_ids
503                .iter()
504                .filter(|id| !metadata.lsp_opened_with.contains(id))
505                .copied()
506                .collect()
507        };
508
509        if !needs_open.is_empty() {
510            let text = self
511                .windows
512                .get(&self.active_window)
513                .map(|w| &w.buffers)
514                .expect("active window present")
515                .get(&buffer_id)?
516                .buffer
517                .to_string()?;
518            let active_id = self.active_window;
519            let __win = self.windows.get_mut(&active_id)?;
520            let lsp = &mut __win.lsp;
521            for sh in lsp.get_handles_mut(language) {
522                if needs_open.contains(&sh.handle.id()) {
523                    if let Err(e) =
524                        sh.handle
525                            .did_open(uri.as_uri().clone(), text.clone(), language.to_string())
526                    {
527                        tracing::warn!("Failed to send didOpen to '{}': {}", sh.name, e);
528                        continue;
529                    }
530                    let metadata = __win.buffer_metadata.get_mut(&buffer_id)?;
531                    metadata.lsp_opened_with.insert(sh.handle.id());
532                    tracing::debug!(
533                        "Sent didOpen for {} to LSP handle '{}' (language: {})",
534                        uri.as_str(),
535                        sh.name,
536                        language
537                    );
538                }
539            }
540        }
541
542        Some(())
543    }
544
545    /// Request LSP completion at current cursor position.
546    /// Sends completion requests to all eligible servers for merged results.
547    pub(crate) fn request_completion(&mut self) {
548        // A new completion request starts a fresh batch. Cancel any
549        // previous in-flight completion requests so their late responses
550        // are ignored (handle_completion_response drops responses whose
551        // request_id isn't in pending_completion_requests), and drop any
552        // leftover items from a previous popup that was closed via the
553        // "pass-through" path (hide_popup() without handle_popup_cancel,
554        // e.g. Enter or a non-word character while the popup was open).
555        // Without this, the new response would be merged into the stale
556        // items by `handle_completion_response`'s extend branch, leading
557        // to duplicate / stale entries in the rendered popup — see the
558        // regression test in
559        // crates/fresh-editor/tests/e2e/lsp_completion_duplicate_entries_1514.rs
560        // and sinelaw/fresh#1514.
561        if !self.active_window().pending_completion_requests.is_empty() {
562            let ids: Vec<u64> = self
563                .active_window_mut()
564                .pending_completion_requests
565                .drain()
566                .collect();
567            for request_id in ids {
568                tracing::debug!(
569                    "Canceling previous pending LSP completion request {}",
570                    request_id
571                );
572                self.active_window_mut().send_lsp_cancel_request(request_id);
573            }
574        }
575        self.active_window_mut().completion_items = None;
576
577        // Get the current buffer and cursor position
578        let cursor_pos = self.active_cursors().primary().position;
579        let state = self.active_state();
580
581        // Convert byte position to LSP position (line, UTF-16 code units)
582        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
583        let buffer_id = self.active_buffer();
584
585        // Pre-allocate request IDs for all eligible servers
586        let base_request_id = self.active_window_mut().next_lsp_request_id;
587        // Use an atomic counter in the closure
588        let counter = std::sync::atomic::AtomicU64::new(0);
589
590        let results = self.with_all_lsp_for_buffer_feature(
591            buffer_id,
592            LspFeature::Completion,
593            |handle, uri, _language| {
594                let idx = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
595                let request_id = base_request_id + idx;
596                let result = handle.completion(
597                    request_id,
598                    uri.as_uri().clone(),
599                    line as u32,
600                    character as u32,
601                );
602                if result.is_ok() {
603                    tracing::info!(
604                        "Requested completion at {}:{}:{} (request_id={})",
605                        uri.as_str(),
606                        line,
607                        character,
608                        request_id
609                    );
610                }
611                (request_id, result.is_ok())
612            },
613        );
614
615        let mut sent_ids = Vec::new();
616        for (request_id, ok) in &results {
617            if *ok {
618                sent_ids.push(*request_id);
619            }
620        }
621        // Advance the ID counter past all allocated IDs
622        self.active_window_mut().next_lsp_request_id = base_request_id + results.len() as u64;
623
624        if !sent_ids.is_empty() {
625            self.active_window_mut()
626                .pending_completion_requests
627                .extend(sent_ids);
628        } else {
629            // No LSP servers available — show buffer-word completions as popup.
630            self.show_buffer_word_completion_popup();
631        }
632    }
633
634    /// Show a completion popup with buffer-word results only (no LSP).
635    ///
636    /// Called when no LSP servers are available for the current buffer.
637    fn show_buffer_word_completion_popup(&mut self) {
638        let items = self.get_buffer_completion_popup_items();
639        if items.is_empty() {
640            return;
641        }
642
643        let popup_data = crate::app::popup_actions::build_completion_popup_from_items(items, 0);
644        let accept_hint = self.completion_accept_key_hint();
645        let focus_hint = self.popup_focus_key_hint();
646
647        let buffer_id = self.active_buffer();
648        let state = self
649            .windows
650            .get_mut(&self.active_window)
651            .map(|w| &mut w.buffers)
652            .expect("active window present")
653            .get_mut(&buffer_id)
654            .unwrap();
655        let mut popup_obj = crate::state::convert_popup_data_to_popup(&popup_data);
656        popup_obj.accept_key_hint = accept_hint;
657        popup_obj.resolver = crate::view::popup::PopupResolver::Completion;
658        popup_obj.focus_key_hint = focus_hint;
659        state.popups.show_or_replace(popup_obj);
660    }
661
662    /// Check if the inserted character should trigger completion
663    /// and if so, request completion automatically (possibly after a delay).
664    ///
665    /// Only triggers when `completion_popup_auto_show` is enabled. Then:
666    /// 1. Trigger characters (like `.`, `::`, etc.): immediate if suggest_on_trigger_characters is enabled
667    /// 2. Word characters: delayed by quick_suggestions_delay_ms if quick_suggestions is enabled
668    ///
669    /// This provides VS Code-like behavior where suggestions appear while typing,
670    /// with debouncing to avoid spamming the LSP server.
671    pub(crate) fn maybe_trigger_completion(&mut self, c: char) {
672        // Auto-show must be enabled for any automatic triggering
673        if !self.config.editor.completion_popup_auto_show {
674            return;
675        }
676
677        // Get the active buffer's language
678        let language = self.active_state().language.clone();
679
680        // Check if this character is a trigger character for this language
681        let is_lsp_trigger = self
682            .lsp()
683            .as_ref()
684            .map(|lsp| lsp.is_completion_trigger_char(c, &language))
685            .unwrap_or(false);
686
687        // Check if quick suggestions is enabled and this is a word character
688        let quick_suggestions_enabled = self.config.editor.quick_suggestions;
689        let suggest_on_trigger_chars = self.config.editor.suggest_on_trigger_characters;
690        let is_word_char = c.is_alphanumeric() || c == '_';
691
692        // Case 1: Trigger character - immediate trigger (bypasses delay)
693        if is_lsp_trigger && suggest_on_trigger_chars {
694            tracing::debug!(
695                "Trigger character '{}' immediately triggers completion for language {}",
696                c,
697                language
698            );
699            // Cancel any pending scheduled trigger
700            self.active_window_mut().scheduled_completion_trigger = None;
701            self.request_completion();
702            return;
703        }
704
705        // Case 2: Word character with quick suggestions - schedule delayed trigger
706        if quick_suggestions_enabled && is_word_char {
707            let delay_ms = self.config.editor.quick_suggestions_delay_ms;
708            let trigger_time = Instant::now() + Duration::from_millis(delay_ms);
709
710            tracing::debug!(
711                "Scheduling completion trigger in {}ms for language {} (char '{}')",
712                delay_ms,
713                language,
714                c
715            );
716
717            // Schedule (or reschedule) the completion trigger
718            // This effectively debounces - each keystroke resets the timer
719            self.active_window_mut().scheduled_completion_trigger = Some(trigger_time);
720        } else {
721            // Non-word, non-trigger character (space, punctuation, etc.) —
722            // cancel any pending scheduled trigger so a stale timer from the
723            // previous word doesn't fire at the wrong cursor position.
724            self.active_window_mut().scheduled_completion_trigger = None;
725        }
726    }
727
728    /// Request LSP go-to-definition at current cursor position
729    pub(crate) fn request_goto_definition(&mut self) -> AnyhowResult<()> {
730        // Get the current buffer and cursor position
731        let cursor_pos = self.active_cursors().primary().position;
732        let state = self.active_state();
733
734        // Convert byte position to LSP position (line, UTF-16 code units)
735        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
736        let buffer_id = self.active_buffer();
737        let request_id = self.active_window_mut().next_lsp_request_id;
738
739        // Use helper to ensure didOpen is sent before the request
740        let sent = self
741            .with_lsp_for_buffer(
742                buffer_id,
743                LspFeature::Definition,
744                |handle, uri, _language| {
745                    let result = handle.goto_definition(
746                        request_id,
747                        uri.as_uri().clone(),
748                        line as u32,
749                        character as u32,
750                    );
751                    if result.is_ok() {
752                        tracing::info!(
753                            "Requested go-to-definition at {}:{}:{}",
754                            uri.as_str(),
755                            line,
756                            character
757                        );
758                    }
759                    result.is_ok()
760                },
761            )
762            .unwrap_or(false);
763
764        if sent {
765            self.active_window_mut().next_lsp_request_id += 1;
766            self.active_window_mut().pending_goto_definition_request = Some(request_id);
767        }
768
769        Ok(())
770    }
771
772    /// Request LSP hover documentation at current cursor position
773    pub fn request_hover(&mut self) -> AnyhowResult<()> {
774        // Get the current buffer and cursor position
775        let cursor_pos = self.active_cursors().primary().position;
776        let state = self.active_state();
777
778        // Convert byte position to LSP position (line, UTF-16 code units)
779        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
780
781        // Debug: Log the position conversion details
782        if let Some(pos) = state.buffer.offset_to_position(cursor_pos) {
783            tracing::debug!(
784                "Hover request: cursor_byte={}, line={}, byte_col={}, utf16_col={}",
785                cursor_pos,
786                pos.line,
787                pos.column,
788                character
789            );
790        }
791
792        let buffer_id = self.active_buffer();
793        let request_id = self.active_window_mut().next_lsp_request_id;
794
795        // Use helper to ensure didOpen is sent before the request
796        let sent = self
797            .with_lsp_for_buffer(buffer_id, LspFeature::Hover, |handle, uri, _language| {
798                let result = handle.hover(
799                    request_id,
800                    uri.as_uri().clone(),
801                    line as u32,
802                    character as u32,
803                );
804                if result.is_ok() {
805                    tracing::info!(
806                        "Requested hover at {}:{}:{} (byte_pos={})",
807                        uri.as_str(),
808                        line,
809                        character,
810                        cursor_pos
811                    );
812                }
813                result.is_ok()
814            })
815            .unwrap_or(false);
816
817        if sent {
818            self.active_window_mut().next_lsp_request_id += 1;
819            self.active_window_mut().hover.record_request(
820                request_id,
821                line as u32,
822                character as u32,
823            );
824        }
825
826        Ok(())
827    }
828
829    /// Request LSP hover documentation at a specific byte position
830    /// Used for mouse-triggered hover
831    /// Returns `Ok(true)` if the request was dispatched, `Ok(false)` if no
832    /// eligible server was available (e.g. not yet initialized).
833    pub(crate) fn request_hover_at_position(&mut self, byte_pos: usize) -> AnyhowResult<bool> {
834        // Get the current buffer
835        let state = self.active_state();
836
837        // Convert byte position to LSP position (line, UTF-16 code units)
838        let (line, character) = state.buffer.position_to_lsp_position(byte_pos);
839
840        // Debug: Log the position conversion details
841        if let Some(pos) = state.buffer.offset_to_position(byte_pos) {
842            tracing::trace!(
843                "Mouse hover request: byte_pos={}, line={}, byte_col={}, utf16_col={}",
844                byte_pos,
845                pos.line,
846                pos.column,
847                character
848            );
849        }
850
851        let buffer_id = self.active_buffer();
852        let request_id = self.active_window_mut().next_lsp_request_id;
853
854        // Use helper to ensure didOpen is sent before the request
855        let sent = self
856            .with_lsp_for_buffer(buffer_id, LspFeature::Hover, |handle, uri, _language| {
857                let result = handle.hover(
858                    request_id,
859                    uri.as_uri().clone(),
860                    line as u32,
861                    character as u32,
862                );
863                if result.is_ok() {
864                    tracing::trace!(
865                        "Mouse hover requested at {}:{}:{} (byte_pos={})",
866                        uri.as_str(),
867                        line,
868                        character,
869                        byte_pos
870                    );
871                }
872                result.is_ok()
873            })
874            .unwrap_or(false);
875
876        if sent {
877            self.active_window_mut().next_lsp_request_id += 1;
878            self.active_window_mut().hover.record_request(
879                request_id,
880                line as u32,
881                character as u32,
882            );
883        }
884
885        Ok(sent)
886    }
887
888    /// Handle hover response from LSP
889    pub(crate) fn handle_hover_response(
890        &mut self,
891        request_id: u64,
892        contents: String,
893        is_markdown: bool,
894        range: Option<((u32, u32), (u32, u32))>,
895    ) {
896        // Check if this response is for the current pending request.
897        // `claim_pending` also drains the stored LSP position, which we keep
898        // around for diagnostic correlation below.
899        let Some(position) = self.active_window_mut().hover.claim_pending(request_id) else {
900            tracing::debug!("Ignoring stale hover response: {}", request_id);
901            return;
902        };
903
904        // A hover request may have been in flight when the user opened the
905        // status-bar LSP status popup. Don't show the hover card on top of it.
906        if self.is_lsp_status_popup_open() {
907            tracing::debug!("Suppressing hover response: LSP status popup is open");
908            self.active_window_mut().hover.set_symbol_range(None);
909            return;
910        }
911        let hover_lsp_position = Some(position);
912
913        // Gather any diagnostics whose range overlaps the hover position so
914        // they can be fused into the top of the hover card. Without this the
915        // user has to leave hover and go chase the error elsewhere in the UI
916        // even though the cursor is already on the offending symbol.
917        let diagnostic_lines = hover_lsp_position
918            .map(|pos| self.compose_hover_diagnostic_lines(pos))
919            .unwrap_or_default();
920
921        if contents.is_empty() && diagnostic_lines.is_empty() {
922            self.set_status_message(t!("lsp.no_hover").to_string());
923            self.active_window_mut().hover.set_symbol_range(None);
924            return;
925        }
926
927        // Debug: log raw hover content to diagnose formatting issues
928        tracing::debug!(
929            "LSP hover content (markdown={}):\n{}",
930            is_markdown,
931            contents
932        );
933
934        // Convert LSP range to byte offsets for highlighting
935        if let Some(((start_line, start_char), (end_line, end_char))) = range {
936            let state = self.active_state();
937            let start_byte = state
938                .buffer
939                .lsp_position_to_byte(start_line as usize, start_char as usize);
940            let end_byte = state
941                .buffer
942                .lsp_position_to_byte(end_line as usize, end_char as usize);
943            self.active_window_mut()
944                .hover
945                .set_symbol_range(Some((start_byte, end_byte)));
946            tracing::debug!(
947                "Hover symbol range: {}..{} (LSP {}:{}..{}:{})",
948                start_byte,
949                end_byte,
950                start_line,
951                start_char,
952                end_line,
953                end_char
954            );
955
956            // Remove previous hover overlay if any
957            if let Some(old_handle) = self.active_window_mut().hover.take_symbol_overlay() {
958                let remove_event = crate::model::event::Event::RemoveOverlay { handle: old_handle };
959                self.apply_event_to_active_buffer(&remove_event);
960            }
961
962            // Add overlay to highlight the hovered symbol
963            let event = crate::model::event::Event::AddOverlay {
964                namespace: None,
965                range: start_byte..end_byte,
966                face: crate::model::event::OverlayFace::Background {
967                    color: (80, 80, 120), // Subtle highlight for hovered symbol
968                },
969                priority: 90, // Below rename (100) but above syntax (lower)
970                message: None,
971                extend_to_line_end: false,
972                url: None,
973            };
974            self.apply_event_to_active_buffer(&event);
975            // Store the handle for later removal
976            if let Some(state) = self
977                .windows
978                .get(&self.active_window)
979                .map(|w| &w.buffers)
980                .expect("active window present")
981                .get(&self.active_buffer())
982            {
983                if let Some(handle) = state.overlays.all().last().map(|o| o.handle.clone()) {
984                    self.active_window_mut().hover.set_symbol_overlay(handle);
985                }
986            }
987        } else {
988            // No range provided by LSP - compute word boundaries at hover position
989            // This prevents the popup from following the mouse within the same word
990            let computed_range = if let Some((hover_byte_pos, _, _, _)) =
991                self.active_window_mut().mouse_state.lsp_hover_state
992            {
993                let state = self.active_state();
994                let start_byte = find_word_start(&state.buffer, hover_byte_pos);
995                let end_byte = find_word_end(&state.buffer, hover_byte_pos);
996                if start_byte < end_byte {
997                    tracing::debug!(
998                        "Hover symbol range (computed from word boundaries): {}..{}",
999                        start_byte,
1000                        end_byte
1001                    );
1002                    Some((start_byte, end_byte))
1003                } else {
1004                    None
1005                }
1006            } else {
1007                None
1008            };
1009            self.active_window_mut()
1010                .hover
1011                .set_symbol_range(computed_range);
1012        }
1013
1014        // Create a popup with the hover contents.
1015        //
1016        // When a diagnostic overlaps the hover position, we pre-style its
1017        // lines (severity-colored header + plain message) and concatenate
1018        // with the parsed hover body into a single `PopupContent::Markdown`
1019        // vector. This avoids the previous approach of injecting a
1020        // `**bold**` heading and a `---` horizontal rule into the markdown
1021        // input — which rendered as uncolored bold text + a thick 40-cell
1022        // divider with blank-line padding, wasting vertical space and
1023        // losing the "this is an error" visual signal.
1024        use crate::view::markdown::{parse_markdown, StyledLine};
1025        use crate::view::popup::{Popup, PopupContent, PopupPosition};
1026        use ratatui::style::Style;
1027        use unicode_width::UnicodeWidthStr;
1028
1029        let hover_lines: Vec<StyledLine> = if contents.is_empty() {
1030            Vec::new()
1031        } else if is_markdown {
1032            parse_markdown(
1033                &contents,
1034                &*self.theme.read().unwrap(),
1035                Some(&self.grammar_registry),
1036            )
1037        } else {
1038            contents
1039                .lines()
1040                .map(|s| {
1041                    let mut sl = StyledLine::new();
1042                    sl.push(
1043                        s.to_string(),
1044                        Style::default().fg(self.theme.read().unwrap().popup_text_fg),
1045                    );
1046                    sl
1047                })
1048                .collect()
1049        };
1050
1051        let has_diagnostic = !diagnostic_lines.is_empty();
1052        let mut all_lines: Vec<StyledLine> = Vec::new();
1053        all_lines.extend(diagnostic_lines);
1054        if has_diagnostic && !hover_lines.is_empty() {
1055            // Compact single-line separator — no blank padding, no 40-cell
1056            // dash run. One row of dashes the width of the content, in the
1057            // popup border color so it reads as "same card, new section."
1058            let mut sep = StyledLine::new();
1059            sep.push(
1060                "─".repeat(12),
1061                Style::default().fg(self.theme.read().unwrap().popup_border_fg),
1062            );
1063            all_lines.push(sep);
1064        }
1065        all_lines.extend(hover_lines);
1066
1067        // Drop trailing empty lines that some markdown payloads carry.
1068        while all_lines
1069            .last()
1070            .map(|l| l.spans.iter().all(|s| s.text.trim().is_empty()))
1071            .unwrap_or(false)
1072        {
1073            all_lines.pop();
1074        }
1075
1076        // Fit width to content so short hovers stop rendering in an 80-col
1077        // card with half the width empty. Measured as the widest styled
1078        // line (display cells, not bytes), plus 4 for borders + padding,
1079        // clamped to [30, 80]. Height stays dynamic on terminal size.
1080        let content_width: usize = all_lines
1081            .iter()
1082            .map(|l| {
1083                l.spans
1084                    .iter()
1085                    .map(|s| UnicodeWidthStr::width(s.text.as_str()))
1086                    .sum::<usize>()
1087            })
1088            .max()
1089            .unwrap_or(0);
1090        let popup_width = (content_width as u16 + 4).clamp(30, 80);
1091        let dynamic_height = (self.terminal_height * 60 / 100).clamp(15, 40);
1092
1093        // Construct the popup with the fused content.
1094        let mut popup = Popup::text(Vec::new(), &*self.theme.read().unwrap());
1095        popup.content = PopupContent::Markdown(all_lines);
1096        popup.title = Some(t!("lsp.popup_hover").to_string());
1097        popup.transient = true;
1098        popup.position = if let Some((x, y)) = self.active_window_mut().hover.take_screen_position()
1099        {
1100            PopupPosition::Fixed { x, y: y + 1 }
1101        } else {
1102            PopupPosition::BelowCursor
1103        };
1104        popup.width = popup_width;
1105        popup.max_height = dynamic_height;
1106        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1107        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1108        popup.focus_key_hint = self.popup_focus_key_hint();
1109
1110        // Show the popup. Replace any existing transient (hover/signature)
1111        // popup so successive hovers don't pile up on the popup stack —
1112        // the user expects exactly one hover card on screen at a time.
1113        let __buffer_id = self.active_buffer();
1114        if let Some(state) = self
1115            .windows
1116            .get_mut(&self.active_window)
1117            .map(|w| &mut w.buffers)
1118            .expect("active window present")
1119            .get_mut(&__buffer_id)
1120        {
1121            while state.popups.top().is_some_and(|p| p.transient) {
1122                state.popups.hide();
1123            }
1124            state.popups.show(popup);
1125            tracing::info!("Showing hover popup (markdown={})", is_markdown);
1126        }
1127
1128        // Mark hover request as sent to prevent duplicate popups during race conditions
1129        // (e.g., when mouse moves while a hover response is pending)
1130        self.active_window_mut().mouse_state.lsp_hover_request_sent = true;
1131    }
1132
1133    /// Pre-style any diagnostics overlapping the hover position into lines
1134    /// ready to stack into the hover popup. Each diagnostic yields two or
1135    /// more styled lines:
1136    ///   1. severity marker + label in `diagnostic_*_fg`, followed by
1137    ///      `  (source)` dimmed — italic on theme-default foreground,
1138    ///   2. one styled line per message line, in `popup_text_fg`.
1139    ///
1140    /// Multiple overlapping diagnostics are separated by a blank line.
1141    /// Returns an empty vec when there are no overlapping diagnostics,
1142    /// or no buffer/URI resolves.
1143    fn compose_hover_diagnostic_lines(
1144        &self,
1145        lsp_pos: (u32, u32),
1146    ) -> Vec<crate::view::markdown::StyledLine> {
1147        use crate::view::markdown::StyledLine;
1148        use lsp_types::DiagnosticSeverity;
1149        use ratatui::style::{Modifier, Style};
1150
1151        let buffer_id = self.active_buffer();
1152        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
1153            return Vec::new();
1154        };
1155        let Some(uri) = metadata.file_uri() else {
1156            return Vec::new();
1157        };
1158        let Some(diagnostics) = self.get_stored_diagnostics().get(uri.as_str()) else {
1159            return Vec::new();
1160        };
1161
1162        let (hover_line, hover_char) = lsp_pos;
1163        let overlapping: Vec<&lsp_types::Diagnostic> = diagnostics
1164            .iter()
1165            .filter(|d| lsp_range_contains(&d.range, hover_line, hover_char))
1166            .collect();
1167
1168        if overlapping.is_empty() {
1169            return Vec::new();
1170        }
1171
1172        let mut out: Vec<StyledLine> = Vec::new();
1173        for (idx, diag) in overlapping.iter().enumerate() {
1174            if idx > 0 {
1175                out.push(StyledLine::new());
1176            }
1177
1178            let (label, marker, severity_color) = match diag.severity {
1179                Some(DiagnosticSeverity::ERROR) => {
1180                    ("Error", "✖", self.theme.read().unwrap().diagnostic_error_fg)
1181                }
1182                Some(DiagnosticSeverity::WARNING) => (
1183                    "Warning",
1184                    "⚠",
1185                    self.theme.read().unwrap().diagnostic_warning_fg,
1186                ),
1187                Some(DiagnosticSeverity::INFORMATION) => {
1188                    ("Info", "ℹ", self.theme.read().unwrap().diagnostic_info_fg)
1189                }
1190                Some(DiagnosticSeverity::HINT) => {
1191                    ("Hint", "ℹ", self.theme.read().unwrap().diagnostic_hint_fg)
1192                }
1193                _ => ("Diagnostic", "•", self.theme.read().unwrap().popup_text_fg),
1194            };
1195
1196            let header_style = Style::default()
1197                .fg(severity_color)
1198                .add_modifier(Modifier::BOLD);
1199            let mut header = StyledLine::new();
1200            header.push(format!("{} {}", marker, label), header_style);
1201            if let Some(source) = diag.source.as_deref().filter(|s| !s.is_empty()) {
1202                // Dim italic source tag — reads as metadata, not as part
1203                // of the diagnostic text.
1204                header.push(
1205                    format!("  ({})", source),
1206                    Style::default()
1207                        .fg(self.theme.read().unwrap().tab_inactive_fg)
1208                        .add_modifier(Modifier::ITALIC),
1209                );
1210            }
1211            out.push(header);
1212
1213            // Message verbatim: one styled line per message line. Using
1214            // `popup_text_fg` lets themes override the body color; the
1215            // severity information is already conveyed by the header.
1216            for message_line in diag.message.lines() {
1217                let mut line = StyledLine::new();
1218                line.push(
1219                    message_line.to_string(),
1220                    Style::default().fg(self.theme.read().unwrap().popup_text_fg),
1221                );
1222                out.push(line);
1223            }
1224        }
1225        out
1226    }
1227
1228    /// Apply inlay hints to editor state as virtual text
1229    #[doc(hidden)]
1230    pub fn apply_inlay_hints_to_state(
1231        state: &mut crate::state::EditorState,
1232        hints: &[lsp_types::InlayHint],
1233    ) {
1234        use crate::view::virtual_text::VirtualTextPosition;
1235        use ratatui::style::{Color, Style};
1236
1237        // Clear existing inlay hints
1238        state.virtual_texts.clear(&mut state.marker_list);
1239
1240        if hints.is_empty() {
1241            return;
1242        }
1243
1244        // Fallback style for inlay hints - dimmed to not distract from actual
1245        // code. The actual on-screen color is resolved from the theme key
1246        // below (`editor.line_number_fg`) so the hints follow the active
1247        // theme. This fallback only applies when the theme doesn't define
1248        // the key.
1249        let hint_style = Style::default().fg(Color::Rgb(128, 128, 128));
1250        let hint_fg_theme_key = Some("editor.line_number_fg".to_string());
1251
1252        for hint in hints {
1253            // Convert LSP position to byte offset
1254            let byte_offset = state.buffer.lsp_position_to_byte(
1255                hint.position.line as usize,
1256                hint.position.character as usize,
1257            );
1258
1259            // Extract text from hint label
1260            let text = match &hint.label {
1261                lsp_types::InlayHintLabel::String(s) => s.clone(),
1262                lsp_types::InlayHintLabel::LabelParts(parts) => {
1263                    parts.iter().map(|p| p.value.as_str()).collect::<String>()
1264                }
1265            };
1266
1267            // LSP inlay hint positions are insertion points between characters.
1268            // For positions within the buffer, render hints before the character at the
1269            // byte offset so they appear at the correct location (e.g., before punctuation
1270            // or newline). Hints at or beyond EOF are anchored to the last character and
1271            // rendered after it.
1272            if state.buffer.is_empty() {
1273                continue;
1274            }
1275
1276            // Pick the anchor character for this hint. If the LSP-computed
1277            // byte lies on a line terminator (\n or the \r of a CRLF), the
1278            // "following character" is the first byte of the next line.
1279            // Anchoring to it would make the hint drift one line down on
1280            // any whitespace edit adjacent to the brace (issue #1572), so
1281            // instead anchor to the *preceding* non-newline character with
1282            // `AfterChar`. That keeps the hint stuck to the glyph the LSP
1283            // intended to annotate even as edits shift bytes around it.
1284            let buf_len = state.buffer.len();
1285            let byte_here = if byte_offset < buf_len {
1286                state
1287                    .buffer
1288                    .slice_bytes(byte_offset..byte_offset + 1)
1289                    .first()
1290                    .copied()
1291            } else {
1292                None
1293            };
1294            let at_line_break = matches!(byte_here, Some(b'\n' | b'\r'));
1295
1296            let (byte_offset, position) = if byte_offset >= buf_len {
1297                // Hint is at EOF: anchor to last character and render
1298                // after it.
1299                (buf_len.saturating_sub(1), VirtualTextPosition::AfterChar)
1300            } else if at_line_break && byte_offset > 0 {
1301                // Hint points past the last glyph on a line: anchor to
1302                // that glyph with AfterChar so the marker cannot drift
1303                // onto a subsequent line when whitespace is edited.
1304                (byte_offset - 1, VirtualTextPosition::AfterChar)
1305            } else {
1306                (byte_offset, VirtualTextPosition::BeforeChar)
1307            };
1308
1309            // Use the hint text as-is - spacing is handled during rendering
1310            let display_text = text;
1311
1312            state.virtual_texts.add_with_theme_keys(
1313                &mut state.marker_list,
1314                byte_offset,
1315                display_text,
1316                hint_style,
1317                hint_fg_theme_key.clone(),
1318                None,
1319                position,
1320                0, // Default priority
1321            );
1322        }
1323
1324        tracing::debug!("Applied {} inlay hints as virtual text", hints.len());
1325    }
1326
1327    /// Request LSP find references at current cursor position
1328    pub(crate) fn request_references(&mut self) -> AnyhowResult<()> {
1329        use crate::primitives::word_navigation::{find_word_end, find_word_start};
1330
1331        let cursor_pos = self.active_cursors().primary().position;
1332        let (line, character, symbol) = {
1333            let state = self.active_state();
1334            let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
1335            let word_start = find_word_start(&state.buffer, cursor_pos);
1336            let word_end = find_word_end(&state.buffer, cursor_pos);
1337            let symbol = String::from_utf8_lossy(&state.buffer.slice_bytes(word_start..word_end))
1338                .into_owned();
1339            (line, character, symbol)
1340        };
1341
1342        let buffer_id = self.active_buffer();
1343        let request_id = self.active_window_mut().next_lsp_request_id;
1344
1345        // Use helper to ensure didOpen is sent before the request
1346        let sent = self
1347            .with_lsp_for_buffer(
1348                buffer_id,
1349                LspFeature::References,
1350                |handle, uri, _language| {
1351                    let result = handle.references(
1352                        request_id,
1353                        uri.as_uri().clone(),
1354                        line as u32,
1355                        character as u32,
1356                    );
1357                    if result.is_ok() {
1358                        tracing::info!(
1359                            "Requested find references at {}:{}:{} (byte_pos={})",
1360                            uri.as_str(),
1361                            line,
1362                            character,
1363                            cursor_pos
1364                        );
1365                    }
1366                    result.is_ok()
1367                },
1368            )
1369            .unwrap_or(false);
1370
1371        if sent {
1372            self.active_window_mut().next_lsp_request_id += 1;
1373            self.active_window_mut().pending_references_request = Some(request_id);
1374            self.active_window_mut().pending_references_symbol = symbol;
1375        }
1376
1377        Ok(())
1378    }
1379
1380    /// Request LSP signature help at current cursor position
1381    pub(crate) fn request_signature_help(&mut self) {
1382        // Get the current buffer and cursor position
1383        let cursor_pos = self.active_cursors().primary().position;
1384        let state = self.active_state();
1385
1386        // Convert byte position to LSP position (line, UTF-16 code units)
1387        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
1388        let buffer_id = self.active_buffer();
1389        let request_id = self.active_window_mut().next_lsp_request_id;
1390
1391        // Use helper to ensure didOpen is sent before the request
1392        let sent = self
1393            .with_lsp_for_buffer(
1394                buffer_id,
1395                LspFeature::SignatureHelp,
1396                |handle, uri, _language| {
1397                    let result = handle.signature_help(
1398                        request_id,
1399                        uri.as_uri().clone(),
1400                        line as u32,
1401                        character as u32,
1402                    );
1403                    if result.is_ok() {
1404                        tracing::info!(
1405                            "Requested signature help at {}:{}:{} (byte_pos={})",
1406                            uri.as_str(),
1407                            line,
1408                            character,
1409                            cursor_pos
1410                        );
1411                    }
1412                    result.is_ok()
1413                },
1414            )
1415            .unwrap_or(false);
1416
1417        if sent {
1418            self.active_window_mut().next_lsp_request_id += 1;
1419            self.active_window_mut().pending_signature_help_request = Some(request_id);
1420        }
1421    }
1422
1423    /// Handle signature help response from LSP
1424    pub(crate) fn handle_signature_help_response(
1425        &mut self,
1426        request_id: u64,
1427        signature_help: Option<lsp_types::SignatureHelp>,
1428    ) {
1429        // Check if this response is for the current pending request
1430        if self.active_window_mut().pending_signature_help_request != Some(request_id) {
1431            tracing::debug!("Ignoring stale signature help response: {}", request_id);
1432            return;
1433        }
1434
1435        self.active_window_mut().pending_signature_help_request = None;
1436        let signature_help = match signature_help {
1437            Some(help) if !help.signatures.is_empty() => help,
1438            _ => {
1439                tracing::debug!("No signature help available");
1440                return;
1441            }
1442        };
1443
1444        // Get the active signature
1445        let active_signature_idx = signature_help.active_signature.unwrap_or(0) as usize;
1446        let signature = match signature_help.signatures.get(active_signature_idx) {
1447            Some(sig) => sig,
1448            None => return,
1449        };
1450
1451        // Build the display content as markdown
1452        let mut content = String::new();
1453
1454        // Add the signature label (function signature)
1455        content.push_str(&signature.label);
1456        content.push('\n');
1457
1458        // Add parameter highlighting info
1459        let active_param = signature_help
1460            .active_parameter
1461            .or(signature.active_parameter)
1462            .unwrap_or(0) as usize;
1463
1464        // If there are parameters, highlight the active one
1465        if let Some(params) = &signature.parameters {
1466            if let Some(param) = params.get(active_param) {
1467                // Get parameter label
1468                let param_label = match &param.label {
1469                    lsp_types::ParameterLabel::Simple(s) => s.clone(),
1470                    lsp_types::ParameterLabel::LabelOffsets(offsets) => {
1471                        // Extract substring from signature label
1472                        let start = offsets[0] as usize;
1473                        let end = offsets[1] as usize;
1474                        if end <= signature.label.len() {
1475                            signature.label[start..end].to_string()
1476                        } else {
1477                            String::new()
1478                        }
1479                    }
1480                };
1481
1482                if !param_label.is_empty() {
1483                    content.push_str(&format!("\n> {}\n", param_label));
1484                }
1485
1486                // Add parameter documentation if available
1487                if let Some(doc) = &param.documentation {
1488                    let doc_text = match doc {
1489                        lsp_types::Documentation::String(s) => s.clone(),
1490                        lsp_types::Documentation::MarkupContent(m) => m.value.clone(),
1491                    };
1492                    if !doc_text.is_empty() {
1493                        content.push('\n');
1494                        content.push_str(&doc_text);
1495                        content.push('\n');
1496                    }
1497                }
1498            }
1499        }
1500
1501        // Add function documentation if available
1502        if let Some(doc) = &signature.documentation {
1503            let doc_text = match doc {
1504                lsp_types::Documentation::String(s) => s.clone(),
1505                lsp_types::Documentation::MarkupContent(m) => m.value.clone(),
1506            };
1507            if !doc_text.is_empty() {
1508                content.push_str("\n---\n\n");
1509                content.push_str(&space_doc_paragraphs(&doc_text));
1510            }
1511        }
1512
1513        // Create a popup with markdown rendering (like hover popup)
1514        use crate::view::popup::{Popup, PopupPosition};
1515        use ratatui::style::Style;
1516
1517        let mut popup = Popup::markdown(
1518            &content,
1519            &*self.theme.read().unwrap(),
1520            Some(&self.grammar_registry),
1521        );
1522        popup.title = Some(t!("lsp.popup_signature").to_string());
1523        popup.transient = true;
1524        popup.position = PopupPosition::BelowCursor;
1525        popup.width = 60;
1526        popup.max_height = 20;
1527        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1528        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1529        popup.focus_key_hint = self.popup_focus_key_hint();
1530
1531        // Show the popup
1532        let __buffer_id = self.active_buffer();
1533        if let Some(state) = self
1534            .windows
1535            .get_mut(&self.active_window)
1536            .map(|w| &mut w.buffers)
1537            .expect("active window present")
1538            .get_mut(&__buffer_id)
1539        {
1540            state.popups.show(popup);
1541            tracing::info!(
1542                "Showing signature help popup for {} signatures",
1543                signature_help.signatures.len()
1544            );
1545        }
1546    }
1547
1548    /// Request LSP code actions at current cursor position.
1549    /// Sends code action requests to all eligible servers for merged results.
1550    pub(crate) fn request_code_actions(&mut self) -> AnyhowResult<()> {
1551        // A new invocation starts a fresh batch. Cancel any previous
1552        // in-flight code-action requests so their late responses are
1553        // ignored (handle_code_actions_response drops responses whose
1554        // request_id isn't in pending_code_actions_requests). Without
1555        // this, actions from a prior cursor position would be merged
1556        // into the new popup — same bug class we already avoid for
1557        // completion (sinelaw/fresh#1514) and inlay hints (multi-buffer
1558        // quiescent).
1559        if !self
1560            .active_window()
1561            .pending_code_actions_requests
1562            .is_empty()
1563        {
1564            let ids: Vec<u64> = self
1565                .active_window_mut()
1566                .pending_code_actions_requests
1567                .drain()
1568                .collect();
1569            for request_id in ids {
1570                tracing::debug!(
1571                    "Canceling previous pending LSP code actions request {}",
1572                    request_id
1573                );
1574                self.active_window_mut().send_lsp_cancel_request(request_id);
1575            }
1576        }
1577        self.active_window_mut()
1578            .pending_code_actions_server_names
1579            .clear();
1580        self.active_window_mut().pending_code_actions = None;
1581
1582        // Get the current buffer and cursor position
1583        let cursor_pos = self.active_cursors().primary().position;
1584        let selection_range = self.active_cursors().primary().selection_range();
1585        let state = self.active_state();
1586
1587        // Convert byte position to LSP position (line, UTF-16 code units)
1588        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
1589
1590        // Get selection range (if any) or use cursor position
1591        let (start_line, start_char, end_line, end_char) = if let Some(range) = selection_range {
1592            let (s_line, s_char) = state.buffer.position_to_lsp_position(range.start);
1593            let (e_line, e_char) = state.buffer.position_to_lsp_position(range.end);
1594            (s_line as u32, s_char as u32, e_line as u32, e_char as u32)
1595        } else {
1596            (line as u32, character as u32, line as u32, character as u32)
1597        };
1598
1599        // Get diagnostics at cursor position for context
1600        // TODO: Implement diagnostic retrieval when needed
1601        let diagnostics: Vec<lsp_types::Diagnostic> = Vec::new();
1602        let buffer_id = self.active_buffer();
1603
1604        // Pre-allocate request IDs for all eligible servers
1605        let base_request_id = self.active_window_mut().next_lsp_request_id;
1606        let counter = std::sync::atomic::AtomicU64::new(0);
1607
1608        let results = self.with_all_lsp_for_buffer_feature_named(
1609            buffer_id,
1610            LspFeature::CodeAction,
1611            |handle, uri, _language, server_name| {
1612                let idx = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1613                let request_id = base_request_id + idx;
1614                let result = handle.code_actions(
1615                    request_id,
1616                    uri.as_uri().clone(),
1617                    start_line,
1618                    start_char,
1619                    end_line,
1620                    end_char,
1621                    diagnostics.clone(),
1622                );
1623                if result.is_ok() {
1624                    tracing::info!(
1625                        "Requested code actions at {}:{}:{}-{}:{} (byte_pos={}, request_id={}, server={})",
1626                        uri.as_str(),
1627                        start_line,
1628                        start_char,
1629                        end_line,
1630                        end_char,
1631                        cursor_pos,
1632                        request_id,
1633                        server_name
1634                    );
1635                }
1636                (request_id, result.is_ok(), server_name.to_string())
1637            },
1638        );
1639
1640        let mut sent_ids = Vec::new();
1641        for (request_id, ok, server_name) in &results {
1642            if *ok {
1643                sent_ids.push(*request_id);
1644                self.active_window_mut()
1645                    .pending_code_actions_server_names
1646                    .insert(*request_id, server_name.clone());
1647            }
1648        }
1649        // Advance the ID counter past all allocated IDs
1650        self.active_window_mut().next_lsp_request_id = base_request_id + results.len() as u64;
1651
1652        if !sent_ids.is_empty() {
1653            // pending_code_actions was already cleared above alongside the
1654            // cancel-previous-requests logic.
1655            self.active_window_mut()
1656                .pending_code_actions_requests
1657                .extend(sent_ids);
1658        }
1659
1660        Ok(())
1661    }
1662
1663    /// Handle code actions response from LSP.
1664    /// Supports merging from multiple servers: each response extends the action
1665    /// list, and the popup is shown/updated with each arriving response.
1666    pub(crate) fn handle_code_actions_response(
1667        &mut self,
1668        request_id: u64,
1669        actions: Vec<lsp_types::CodeActionOrCommand>,
1670    ) {
1671        // Check if this response is for one of the pending requests
1672        if !self
1673            .active_window_mut()
1674            .pending_code_actions_requests
1675            .remove(&request_id)
1676        {
1677            tracing::debug!("Ignoring stale code actions response: {}", request_id);
1678            return;
1679        }
1680
1681        // Look up the server name for this request
1682        let server_name = self
1683            .active_window_mut()
1684            .pending_code_actions_server_names
1685            .remove(&request_id)
1686            .unwrap_or_default();
1687
1688        if actions.is_empty() {
1689            // Only show "no code actions" if all responses are in and we have nothing
1690            if self
1691                .active_window()
1692                .pending_code_actions_requests
1693                .is_empty()
1694                && self
1695                    .active_window_mut()
1696                    .pending_code_actions
1697                    .as_ref()
1698                    .is_none_or(|a| a.is_empty())
1699            {
1700                self.set_status_message(t!("lsp.no_code_actions").to_string());
1701            }
1702            return;
1703        }
1704
1705        // Tag each action with its server name and store/extend for merging
1706        let tagged_actions: Vec<(String, lsp_types::CodeActionOrCommand)> = actions
1707            .into_iter()
1708            .map(|a| (server_name.clone(), a))
1709            .collect();
1710
1711        match &mut self.active_window_mut().pending_code_actions {
1712            Some(existing) => {
1713                existing.extend(tagged_actions);
1714                tracing::debug!("Extended code actions, now {} total", existing.len());
1715            }
1716            None => {
1717                self.active_window_mut().pending_code_actions = Some(tagged_actions);
1718            }
1719        }
1720
1721        // Build list items from all accumulated code actions
1722        use crate::view::popup::{Popup, PopupListItem, PopupPosition};
1723        use ratatui::style::Style;
1724
1725        let items: Vec<PopupListItem> = {
1726            let all_actions = self.active_window().pending_code_actions.as_ref().unwrap();
1727            let multiple_servers = {
1728                let mut names = std::collections::HashSet::new();
1729                for (name, _) in all_actions {
1730                    names.insert(name.as_str());
1731                }
1732                names.len() > 1
1733            };
1734            all_actions
1735                .iter()
1736                .enumerate()
1737                .map(|(i, (srv_name, action))| {
1738                    let title = match action {
1739                        lsp_types::CodeActionOrCommand::Command(cmd) => &cmd.title,
1740                        lsp_types::CodeActionOrCommand::CodeAction(ca) => &ca.title,
1741                    };
1742                    let kind = match action {
1743                        lsp_types::CodeActionOrCommand::CodeAction(ca) => {
1744                            ca.kind.as_ref().map(|k| k.as_str().to_string())
1745                        }
1746                        _ => None,
1747                    };
1748                    let detail = if multiple_servers && !srv_name.is_empty() {
1749                        match kind {
1750                            Some(k) => Some(format!("[{}] {}", srv_name, k)),
1751                            None => Some(format!("[{}]", srv_name)),
1752                        }
1753                    } else {
1754                        kind
1755                    };
1756                    PopupListItem {
1757                        text: format!("{}. {}", i + 1, title),
1758                        detail,
1759                        icon: None,
1760                        data: Some(i.to_string()),
1761                        disabled: false,
1762                    }
1763                })
1764                .collect()
1765        };
1766
1767        let mut popup = Popup::list(items, &*self.theme.read().unwrap());
1768        popup.kind = crate::view::popup::PopupKind::Action;
1769        popup.title = Some(t!("lsp.popup_code_actions").to_string());
1770        popup.position = PopupPosition::BelowCursor;
1771        popup.width = 60;
1772        popup.max_height = 15;
1773        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1774        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1775        // Confirm reads the selected row's `data` as an index into
1776        // `self.active_window_mut().pending_code_actions` — the heavy lsp_types payload
1777        // stays on the Editor to keep the view crate LSP-free.
1778        popup.resolver = crate::view::popup::PopupResolver::CodeAction;
1779        // Code actions are an explicit user invocation (`lsp_code_actions`
1780        // command); the user expects to choose immediately, so the popup
1781        // grabs focus on creation. Unfocused-by-default behavior applies
1782        // only to popups that *appear under the cursor* (completion,
1783        // hover, signature help, the LSP-server auto-prompt).
1784        popup.focused = true;
1785
1786        // Show the popup, replacing any existing action popup to avoid stacking
1787        let __buffer_id = self.active_buffer();
1788        let action_count = self
1789            .active_window()
1790            .pending_code_actions
1791            .as_ref()
1792            .map_or(0, |v| v.len());
1793        if let Some(state) = self
1794            .windows
1795            .get_mut(&self.active_window)
1796            .map(|w| &mut w.buffers)
1797            .expect("active window present")
1798            .get_mut(&__buffer_id)
1799        {
1800            state.popups.show_or_replace(popup);
1801            tracing::info!("Showing code actions popup with {} actions", action_count);
1802        }
1803    }
1804
1805    /// Execute a code action by index from the stored pending_code_actions.
1806    pub(crate) fn execute_code_action(&mut self, index: usize) {
1807        let action = match &self.active_window_mut().pending_code_actions {
1808            Some(actions) => actions.get(index).map(|(_, a)| a.clone()),
1809            None => None,
1810        };
1811
1812        let Some(action) = action else {
1813            tracing::warn!("Code action index {} out of range", index);
1814            return;
1815        };
1816
1817        match action {
1818            lsp_types::CodeActionOrCommand::CodeAction(ca) => {
1819                // If the action has no edit and no command, it may need resolve first.
1820                // Only resolve if the action has `data` and the server supports resolveProvider.
1821                if ca.edit.is_none()
1822                    && ca.command.is_none()
1823                    && ca.data.is_some()
1824                    && self.active_window().server_supports_code_action_resolve()
1825                {
1826                    tracing::info!(
1827                        "Code action '{}' needs resolve, sending codeAction/resolve",
1828                        ca.title
1829                    );
1830                    self.send_code_action_resolve(ca);
1831                    return;
1832                }
1833                self.execute_resolved_code_action(ca);
1834            }
1835            lsp_types::CodeActionOrCommand::Command(cmd) => {
1836                self.send_execute_command(cmd);
1837            }
1838        }
1839    }
1840
1841    /// Execute a code action that has been fully resolved (has edit and/or command).
1842    pub(crate) fn execute_resolved_code_action(&mut self, ca: lsp_types::CodeAction) {
1843        let title = ca.title.clone();
1844
1845        // Apply workspace edit if present
1846        if let Some(edit) = ca.edit {
1847            match self.apply_workspace_edit(edit) {
1848                Ok(n) => {
1849                    self.set_status_message(
1850                        t!("lsp.code_action_applied", title = &title, count = n).to_string(),
1851                    );
1852                }
1853                Err(e) => {
1854                    self.set_status_message(format!("Code action failed: {e}"));
1855                    return;
1856                }
1857            }
1858        }
1859
1860        // Execute command if present (may trigger workspace/applyEdit from server)
1861        if let Some(cmd) = ca.command {
1862            self.send_execute_command(cmd);
1863        }
1864    }
1865
1866    /// Send workspace/executeCommand to the LSP server
1867    fn send_execute_command(&mut self, cmd: lsp_types::Command) {
1868        tracing::info!("Executing LSP command: {} ({})", cmd.title, cmd.command);
1869        self.set_status_message(
1870            t!(
1871                "lsp.code_action_applied",
1872                title = &cmd.title,
1873                count = 0_usize
1874            )
1875            .to_string(),
1876        );
1877
1878        // Get the language for this buffer to find the right LSP handle
1879        let language = match self
1880            .buffers()
1881            .get(&self.active_buffer())
1882            .map(|s| s.language.clone())
1883        {
1884            Some(l) => l,
1885            None => return,
1886        };
1887
1888        let __active_id = self.active_window;
1889
1890        if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
1891            for sh in lsp.get_handles_mut(&language) {
1892                if let Err(e) = sh
1893                    .handle
1894                    .execute_command(cmd.command.clone(), cmd.arguments.clone())
1895                {
1896                    tracing::warn!("Failed to send executeCommand to '{}': {}", sh.name, e);
1897                }
1898            }
1899        }
1900    }
1901
1902    /// Send codeAction/resolve to the LSP server
1903    fn send_code_action_resolve(&mut self, action: lsp_types::CodeAction) {
1904        let language = match self
1905            .buffers()
1906            .get(&self.active_buffer())
1907            .map(|s| s.language.clone())
1908        {
1909            Some(l) => l,
1910            None => return,
1911        };
1912
1913        self.active_window_mut().next_lsp_request_id += 1;
1914        let request_id = self.active_window_mut().next_lsp_request_id;
1915
1916        let __active_id = self.active_window;
1917
1918        if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
1919            for sh in lsp.get_handles_mut(&language) {
1920                if let Err(e) = sh.handle.code_action_resolve(request_id, action.clone()) {
1921                    tracing::warn!("Failed to send codeAction/resolve to '{}': {}", sh.name, e);
1922                }
1923            }
1924        }
1925    }
1926
1927    /// Handle a resolved completion item — apply additional_text_edits (e.g. auto-imports).
1928    pub(crate) fn handle_completion_resolved(&mut self, item: lsp_types::CompletionItem) {
1929        if let Some(additional_edits) = item.additional_text_edits {
1930            if !additional_edits.is_empty() {
1931                tracing::info!(
1932                    "Applying {} additional text edits from completion resolve",
1933                    additional_edits.len()
1934                );
1935                let buffer_id = self.active_buffer();
1936                if let Err(e) = self.apply_lsp_text_edits(buffer_id, additional_edits) {
1937                    tracing::error!("Failed to apply completion additional_text_edits: {}", e);
1938                }
1939            }
1940        }
1941    }
1942
1943    /// Apply formatting edits from textDocument/formatting response.
1944    pub(crate) fn apply_formatting_edits(
1945        &mut self,
1946        uri: &str,
1947        edits: Vec<lsp_types::TextEdit>,
1948    ) -> AnyhowResult<usize> {
1949        // Find the buffer for this URI
1950        let buffer_id = self
1951            .active_window()
1952            .buffer_metadata
1953            .iter()
1954            .find(|(_, meta)| meta.file_uri().map(|u| u.as_str() == uri).unwrap_or(false))
1955            .map(|(id, _)| *id);
1956
1957        if let Some(buffer_id) = buffer_id {
1958            let count = self.apply_lsp_text_edits(buffer_id, edits)?;
1959            self.set_status_message(format!("Formatted ({} edits)", count));
1960            Ok(count)
1961        } else {
1962            tracing::warn!("Cannot apply formatting: no buffer for URI {}", uri);
1963            Ok(0)
1964        }
1965    }
1966
1967    /// Request document formatting from LSP.
1968    pub(crate) fn request_formatting(&mut self) {
1969        let buffer_id = self.active_buffer();
1970        let metadata = match self.active_window().buffer_metadata.get(&buffer_id) {
1971            Some(m) if m.lsp_enabled => m,
1972            _ => {
1973                self.set_status_message("LSP not available for this buffer".to_string());
1974                return;
1975            }
1976        };
1977
1978        let uri = match metadata.file_uri() {
1979            Some(u) => u.clone(),
1980            None => return,
1981        };
1982
1983        let language = match self
1984            .windows
1985            .get(&self.active_window)
1986            .map(|w| &w.buffers)
1987            .expect("active window present")
1988            .get(&buffer_id)
1989            .map(|s| s.language.clone())
1990        {
1991            Some(l) => l,
1992            None => return,
1993        };
1994
1995        let tab_size = self.config.editor.tab_size as u32;
1996        let insert_spaces = !self.config.editor.use_tabs;
1997
1998        self.active_window_mut().next_lsp_request_id += 1;
1999        let request_id = self.active_window_mut().next_lsp_request_id;
2000
2001        let __active_id = self.active_window;
2002
2003        if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
2004            if let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::Format) {
2005                if let Err(e) = sh.handle.document_formatting(
2006                    request_id,
2007                    uri.as_uri().clone(),
2008                    tab_size,
2009                    insert_spaces,
2010                ) {
2011                    tracing::warn!("Failed to request formatting: {}", e);
2012                }
2013            } else {
2014                self.set_status_message("Formatting not supported by LSP server".to_string());
2015            }
2016        }
2017    }
2018
2019    /// Handle find references response from LSP
2020    pub(crate) fn handle_references_response(
2021        &mut self,
2022        request_id: u64,
2023        locations: Vec<lsp_types::Location>,
2024    ) -> AnyhowResult<()> {
2025        tracing::info!(
2026            "handle_references_response: received {} locations for request_id={}",
2027            locations.len(),
2028            request_id
2029        );
2030
2031        // Check if this response is for the current pending request
2032        if self.active_window_mut().pending_references_request != Some(request_id) {
2033            tracing::debug!("Ignoring stale references response: {}", request_id);
2034            return Ok(());
2035        }
2036
2037        self.active_window_mut().pending_references_request = None;
2038        if locations.is_empty() {
2039            self.set_status_message(t!("lsp.no_references").to_string());
2040            return Ok(());
2041        }
2042
2043        // Convert locations to hook args format. Each `loc.uri` is a
2044        // wire-side URI from the LSP, so wrap it in [`LspUri`] and run
2045        // it through the active authority's translation before
2046        // handing a host-path string to the references hook —
2047        // otherwise plugins (notably `find_references`) try to open
2048        // an in-container path on the host and fail.
2049        let translation = self.authority.path_translation.clone();
2050        let lsp_locations: Vec<crate::services::plugins::hooks::LspLocation> = locations
2051            .iter()
2052            .map(|loc| {
2053                let wire = crate::app::types::LspUri::from_wire(loc.uri.clone());
2054                // Prefer the host-side path (after translation) so
2055                // plugin-side file ops resolve. Fall back to the raw
2056                // string for non-`file://` URIs so callers can still
2057                // see *something*.
2058                let file = if loc.uri.scheme().map(|s| s.as_str()) == Some("file") {
2059                    wire.to_host_path(translation.as_ref())
2060                        .map(|p| p.to_string_lossy().into_owned())
2061                        .unwrap_or_else(|| loc.uri.path().as_str().to_string())
2062                } else {
2063                    loc.uri.as_str().to_string()
2064                };
2065
2066                crate::services::plugins::hooks::LspLocation {
2067                    file,
2068                    line: loc.range.start.line + 1, // LSP is 0-based, convert to 1-based
2069                    column: loc.range.start.character + 1, // LSP is 0-based
2070                }
2071            })
2072            .collect();
2073
2074        let count = lsp_locations.len();
2075        let symbol = std::mem::take(&mut self.active_window_mut().pending_references_symbol);
2076        self.set_status_message(
2077            t!("lsp.found_references", count = count, symbol = &symbol).to_string(),
2078        );
2079
2080        // Fire the lsp_references hook so plugins can display the results
2081        self.plugin_manager.read().unwrap().run_hook(
2082            "lsp_references",
2083            crate::services::plugins::hooks::HookArgs::LspReferences {
2084                symbol: symbol.clone(),
2085                locations: lsp_locations,
2086            },
2087        );
2088
2089        tracing::info!(
2090            "Fired lsp_references hook with {} locations for symbol '{}'",
2091            count,
2092            symbol
2093        );
2094
2095        Ok(())
2096    }
2097
2098    /// Apply LSP text edits to a buffer and return the number of changes made.
2099    /// Edits are sorted in reverse order and applied as a batch.
2100    pub(crate) fn apply_lsp_text_edits(
2101        &mut self,
2102        buffer_id: BufferId,
2103        mut edits: Vec<lsp_types::TextEdit>,
2104    ) -> AnyhowResult<usize> {
2105        if edits.is_empty() {
2106            return Ok(0);
2107        }
2108
2109        // Sort edits by position (reverse order to avoid offset issues)
2110        edits.sort_by(|a, b| {
2111            b.range
2112                .start
2113                .line
2114                .cmp(&a.range.start.line)
2115                .then(b.range.start.character.cmp(&a.range.start.character))
2116        });
2117
2118        // Collect all events for this buffer into a batch
2119        let mut batch_events = Vec::new();
2120        let mut changes = 0;
2121
2122        // Get cursor_id for this buffer from split view state
2123        let cursor_id = {
2124            let split_id = self
2125                .split_manager_mut()
2126                .splits_for_buffer(buffer_id)
2127                .into_iter()
2128                .next()
2129                .unwrap_or_else(|| {
2130                    self.windows
2131                        .get(&self.active_window)
2132                        .and_then(|w| w.buffers.splits())
2133                        .map(|(mgr, _)| mgr)
2134                        .expect("active window must have a populated split layout")
2135                        .active_split()
2136                });
2137            self.windows
2138                .get(&self.active_window)
2139                .and_then(|w| w.buffers.splits())
2140                .map(|(_, vs)| vs)
2141                .expect("active window must have a populated split layout")
2142                .get(&split_id)
2143                .map(|vs| vs.cursors.primary_id())
2144                .unwrap_or_else(|| self.active_cursors().primary_id())
2145        };
2146
2147        // Create events for all edits
2148        for edit in edits {
2149            let state = self
2150                .buffers_mut()
2151                .get_mut(&buffer_id)
2152                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))?;
2153
2154            // Convert LSP range to byte positions
2155            let start_line = edit.range.start.line as usize;
2156            let start_char = edit.range.start.character as usize;
2157            let end_line = edit.range.end.line as usize;
2158            let end_char = edit.range.end.character as usize;
2159
2160            let start_pos = state.buffer.lsp_position_to_byte(start_line, start_char);
2161            let end_pos = state.buffer.lsp_position_to_byte(end_line, end_char);
2162            let buffer_len = state.buffer.len();
2163
2164            // Log the conversion for debugging
2165            let old_text = if start_pos < end_pos && end_pos <= buffer_len {
2166                state.get_text_range(start_pos, end_pos)
2167            } else {
2168                format!(
2169                    "<invalid range: start={}, end={}, buffer_len={}>",
2170                    start_pos, end_pos, buffer_len
2171                )
2172            };
2173            tracing::debug!(
2174                "  Converting LSP range line {}:{}-{}:{} to bytes {}..{} (replacing {:?} with {:?})",
2175                start_line, start_char, end_line, end_char,
2176                start_pos, end_pos, old_text, edit.new_text
2177            );
2178
2179            // Delete old text
2180            if start_pos < end_pos {
2181                let deleted_text = state.get_text_range(start_pos, end_pos);
2182                let delete_event = Event::Delete {
2183                    range: start_pos..end_pos,
2184                    deleted_text,
2185                    cursor_id,
2186                };
2187                batch_events.push(delete_event);
2188            }
2189
2190            // Insert new text
2191            if !edit.new_text.is_empty() {
2192                let insert_event = Event::Insert {
2193                    position: start_pos,
2194                    text: edit.new_text.clone(),
2195                    cursor_id,
2196                };
2197                batch_events.push(insert_event);
2198            }
2199
2200            changes += 1;
2201        }
2202
2203        // Apply all rename changes using bulk edit for O(n) performance
2204        if !batch_events.is_empty() {
2205            self.apply_events_to_buffer_as_bulk_edit(
2206                buffer_id,
2207                batch_events,
2208                "LSP Rename".to_string(),
2209            )?;
2210        }
2211
2212        Ok(changes)
2213    }
2214
2215    /// Apply a single TextDocumentEdit from a workspace edit.
2216    ///
2217    /// Per LSP spec: if `text_document.version` is non-null, it must match the
2218    /// version we last sent via didOpen/didChange. On mismatch the edit is stale
2219    /// and we skip it to avoid corrupting the buffer.
2220    fn apply_text_document_edit(
2221        &mut self,
2222        text_doc_edit: lsp_types::TextDocumentEdit,
2223    ) -> AnyhowResult<usize> {
2224        // Wrap the incoming wire URI once; both the version-check
2225        // lookup and the file-open below need the host-path form.
2226        let uri = crate::app::types::LspUri::from_wire(text_doc_edit.text_document.uri);
2227
2228        // Version check: if the server specifies a version, verify it matches
2229        // what we sent. A mismatch means the edit was computed against stale content.
2230        if let Some(expected_version) = text_doc_edit.text_document.version {
2231            if let Ok(path) =
2232                super::lsp_uri_to_host_path(&uri, self.authority.path_translation.as_ref())
2233            {
2234                if let Some(lsp) = self.lsp() {
2235                    let language = self
2236                        .buffers()
2237                        .get(&self.active_buffer())
2238                        .map(|s| s.language.clone())
2239                        .unwrap_or_default();
2240                    for sh in lsp.get_handles(&language) {
2241                        if let Some(current_version) = sh.handle.document_version(&path) {
2242                            if (expected_version as i64) != current_version {
2243                                tracing::warn!(
2244                                    "Rejecting stale TextDocumentEdit for {:?}: \
2245                                     server version {} != our version {}",
2246                                    path,
2247                                    expected_version,
2248                                    current_version,
2249                                );
2250                                return Ok(0);
2251                            }
2252                        }
2253                    }
2254                }
2255            }
2256        }
2257
2258        if let Ok(path) =
2259            super::lsp_uri_to_host_path(&uri, self.authority.path_translation.as_ref())
2260        {
2261            let buffer_id = match self.open_file(&path) {
2262                Ok(id) => id,
2263                Err(e) => {
2264                    if let Some(confirmation) =
2265                        e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
2266                    {
2267                        self.start_large_file_encoding_confirmation(confirmation);
2268                    } else {
2269                        self.set_status_message(
2270                            t!("file.error_opening", error = e.to_string()).to_string(),
2271                        );
2272                    }
2273                    return Ok(0);
2274                }
2275            };
2276
2277            let edits: Vec<lsp_types::TextEdit> = text_doc_edit
2278                .edits
2279                .into_iter()
2280                .map(|one_of| match one_of {
2281                    lsp_types::OneOf::Left(text_edit) => text_edit,
2282                    lsp_types::OneOf::Right(annotated) => annotated.text_edit,
2283                })
2284                .collect();
2285
2286            tracing::info!("Applying {} edits for {:?}:", edits.len(), path);
2287            for (i, edit) in edits.iter().enumerate() {
2288                tracing::info!(
2289                    "  Edit {}: line {}:{}-{}:{} -> {:?}",
2290                    i,
2291                    edit.range.start.line,
2292                    edit.range.start.character,
2293                    edit.range.end.line,
2294                    edit.range.end.character,
2295                    edit.new_text
2296                );
2297            }
2298
2299            self.apply_lsp_text_edits(buffer_id, edits)
2300        } else {
2301            Ok(0)
2302        }
2303    }
2304
2305    /// Apply a resource operation (CreateFile, RenameFile, DeleteFile) from a workspace edit.
2306    fn apply_resource_operation(&mut self, op: lsp_types::ResourceOp) -> AnyhowResult<()> {
2307        // Each URI in a resource operation is wire-side and must be
2308        // translated back to the host before we touch the host
2309        // filesystem. Wrapping in [`LspUri`] and calling
2310        // `to_host_path` is the type-checked path.
2311        let translation = self.authority.path_translation.clone();
2312        let to_host = |uri: &lsp_types::Uri| -> std::path::PathBuf {
2313            crate::app::types::LspUri::from_wire(uri.clone())
2314                .to_host_path(translation.as_ref())
2315                .unwrap_or_else(|| std::path::PathBuf::from(uri.path().as_str()))
2316        };
2317        match op {
2318            lsp_types::ResourceOp::Create(create) => {
2319                let path = to_host(&create.uri);
2320                let overwrite = create
2321                    .options
2322                    .as_ref()
2323                    .and_then(|o| o.overwrite)
2324                    .unwrap_or(false);
2325                let ignore_if_exists = create
2326                    .options
2327                    .as_ref()
2328                    .and_then(|o| o.ignore_if_exists)
2329                    .unwrap_or(false);
2330
2331                if path.exists() {
2332                    if ignore_if_exists {
2333                        tracing::debug!("CreateFile: {:?} already exists, ignoring", path);
2334                        return Ok(());
2335                    }
2336                    if !overwrite {
2337                        tracing::warn!("CreateFile: {:?} already exists and overwrite=false", path);
2338                        return Ok(());
2339                    }
2340                }
2341
2342                // Create parent directories if needed
2343                if let Some(parent) = path.parent() {
2344                    std::fs::create_dir_all(parent)?;
2345                }
2346                std::fs::write(&path, "")?;
2347                tracing::info!("CreateFile: created {:?}", path);
2348
2349                // Open the new file as a buffer
2350                if let Err(e) = self.open_file(&path) {
2351                    tracing::warn!("CreateFile: failed to open created file {:?}: {}", path, e);
2352                }
2353            }
2354            lsp_types::ResourceOp::Rename(rename) => {
2355                let old_path = to_host(&rename.old_uri);
2356                let new_path = to_host(&rename.new_uri);
2357                let overwrite = rename
2358                    .options
2359                    .as_ref()
2360                    .and_then(|o| o.overwrite)
2361                    .unwrap_or(false);
2362                let ignore_if_exists = rename
2363                    .options
2364                    .as_ref()
2365                    .and_then(|o| o.ignore_if_exists)
2366                    .unwrap_or(false);
2367
2368                if new_path.exists() {
2369                    if ignore_if_exists {
2370                        tracing::debug!("RenameFile: {:?} already exists, ignoring", new_path);
2371                        return Ok(());
2372                    }
2373                    if !overwrite {
2374                        tracing::warn!(
2375                            "RenameFile: {:?} already exists and overwrite=false",
2376                            new_path
2377                        );
2378                        return Ok(());
2379                    }
2380                }
2381
2382                // Create parent directories if needed
2383                if let Some(parent) = new_path.parent() {
2384                    std::fs::create_dir_all(parent)?;
2385                }
2386                std::fs::rename(&old_path, &new_path)?;
2387                tracing::info!("RenameFile: {:?} -> {:?}", old_path, new_path);
2388            }
2389            lsp_types::ResourceOp::Delete(delete) => {
2390                let path = to_host(&delete.uri);
2391                let recursive = delete
2392                    .options
2393                    .as_ref()
2394                    .and_then(|o| o.recursive)
2395                    .unwrap_or(false);
2396                let ignore_if_not_exists = delete
2397                    .options
2398                    .as_ref()
2399                    .and_then(|o| o.ignore_if_not_exists)
2400                    .unwrap_or(false);
2401
2402                if !path.exists() {
2403                    if ignore_if_not_exists {
2404                        tracing::debug!("DeleteFile: {:?} does not exist, ignoring", path);
2405                        return Ok(());
2406                    }
2407                    tracing::warn!("DeleteFile: {:?} does not exist", path);
2408                    return Ok(());
2409                }
2410
2411                if path.is_dir() && recursive {
2412                    std::fs::remove_dir_all(&path)?;
2413                } else if path.is_file() {
2414                    std::fs::remove_file(&path)?;
2415                }
2416                tracing::info!("DeleteFile: deleted {:?}", path);
2417            }
2418        }
2419        Ok(())
2420    }
2421
2422    /// Apply an LSP WorkspaceEdit (used by rename, code actions, etc.).
2423    ///
2424    /// Returns the total number of text changes applied.
2425    pub(crate) fn apply_workspace_edit(
2426        &mut self,
2427        workspace_edit: lsp_types::WorkspaceEdit,
2428    ) -> AnyhowResult<usize> {
2429        tracing::debug!(
2430            "Applying WorkspaceEdit: changes={:?}, document_changes={:?}",
2431            workspace_edit.changes.as_ref().map(|c| c.len()),
2432            workspace_edit.document_changes.as_ref().map(|dc| match dc {
2433                lsp_types::DocumentChanges::Edits(e) => format!("{} edits", e.len()),
2434                lsp_types::DocumentChanges::Operations(o) => format!("{} operations", o.len()),
2435            })
2436        );
2437
2438        let mut total_changes = 0;
2439
2440        // Handle changes (map of URI -> Vec<TextEdit>)
2441        if let Some(changes) = workspace_edit.changes {
2442            for (uri, edits) in changes {
2443                let uri = crate::app::types::LspUri::from_wire(uri);
2444                if let Ok(path) =
2445                    super::lsp_uri_to_host_path(&uri, self.authority.path_translation.as_ref())
2446                {
2447                    let buffer_id = match self.open_file(&path) {
2448                        Ok(id) => id,
2449                        Err(e) => {
2450                            if let Some(confirmation) = e.downcast_ref::<
2451                                crate::model::buffer::LargeFileEncodingConfirmation,
2452                            >() {
2453                                self.start_large_file_encoding_confirmation(confirmation);
2454                            } else {
2455                                self.set_status_message(
2456                                    t!("file.error_opening", error = e.to_string())
2457                                        .to_string(),
2458                                );
2459                            }
2460                            return Ok(0);
2461                        }
2462                    };
2463                    total_changes += self.apply_lsp_text_edits(buffer_id, edits)?;
2464                }
2465            }
2466        }
2467
2468        // Handle document_changes (TextDocumentEdit[] or DocumentChangeOperation[])
2469        if let Some(document_changes) = workspace_edit.document_changes {
2470            use lsp_types::DocumentChanges;
2471
2472            match document_changes {
2473                DocumentChanges::Edits(edits) => {
2474                    for text_doc_edit in edits {
2475                        total_changes += self.apply_text_document_edit(text_doc_edit)?;
2476                    }
2477                }
2478                DocumentChanges::Operations(ops) => {
2479                    // Process operations in order — resource ops (create/rename/delete)
2480                    // must be applied before text edits on the created/renamed files.
2481                    for op in ops {
2482                        match op {
2483                            lsp_types::DocumentChangeOperation::Edit(text_doc_edit) => {
2484                                total_changes += self.apply_text_document_edit(text_doc_edit)?;
2485                            }
2486                            lsp_types::DocumentChangeOperation::Op(resource_op) => {
2487                                self.apply_resource_operation(resource_op)?;
2488                                total_changes += 1;
2489                            }
2490                        }
2491                    }
2492                }
2493            }
2494        }
2495
2496        Ok(total_changes)
2497    }
2498
2499    /// Handle rename response from LSP
2500    pub fn handle_rename_response(
2501        &mut self,
2502        _request_id: u64,
2503        result: Result<lsp_types::WorkspaceEdit, String>,
2504    ) -> AnyhowResult<()> {
2505        match result {
2506            Ok(workspace_edit) => {
2507                let total_changes = self.apply_workspace_edit(workspace_edit)?;
2508                self.active_window_mut().status_message =
2509                    Some(t!("lsp.renamed", count = total_changes).to_string());
2510            }
2511            Err(error) => {
2512                // Per LSP spec: ContentModified errors (-32801) should NOT be shown to user
2513                if error.contains("content modified") || error.contains("-32801") {
2514                    tracing::debug!(
2515                        "LSP rename: ContentModified error (expected, ignoring): {}",
2516                        error
2517                    );
2518                    self.active_window_mut().status_message =
2519                        Some(t!("lsp.rename_cancelled").to_string());
2520                } else {
2521                    self.active_window_mut().status_message =
2522                        Some(t!("lsp.rename_failed", error = &error).to_string());
2523                }
2524            }
2525        }
2526
2527        Ok(())
2528    }
2529
2530    /// Apply events to a specific buffer using bulk edit optimization (O(n) vs O(n²))
2531    ///
2532    /// This is similar to `apply_events_as_bulk_edit` but works on a specific buffer
2533    /// (which may not be the active buffer) and handles LSP notifications correctly.
2534    pub(crate) fn apply_events_to_buffer_as_bulk_edit(
2535        &mut self,
2536        buffer_id: BufferId,
2537        events: Vec<Event>,
2538        description: String,
2539    ) -> AnyhowResult<()> {
2540        use crate::model::event::CursorId;
2541
2542        if events.is_empty() {
2543            return Ok(());
2544        }
2545
2546        // Create a temporary batch for collecting LSP changes (before applying)
2547        let batch_for_lsp = Event::Batch {
2548            events: events.clone(),
2549            description: description.clone(),
2550        };
2551
2552        // IMPORTANT: Calculate LSP changes BEFORE applying to buffer!
2553        // The byte positions in the events are relative to the ORIGINAL buffer.
2554        //
2555        // The tree-only swap below violates the pane-buffer invariant
2556        // transiently (see active_focus.rs for the invariant's contract)
2557        // but `collect_lsp_changes` does not route any input, call
2558        // `apply_event_to_active_buffer`, or otherwise read
2559        // `active_buffer()` while the invariant is broken, so the drift
2560        // is contained within this synchronous section. If that changes,
2561        // switch to a read-only accessor that takes `buffer_id` directly
2562        // rather than mutating tree state.
2563        let original_active = self.active_buffer();
2564        self.windows
2565            .get_mut(&self.active_window)
2566            .and_then(|w| w.split_manager_mut())
2567            .expect("active window must have a populated split layout")
2568            .set_active_buffer_id(buffer_id);
2569        let lsp_changes = self.active_window().collect_lsp_changes(&batch_for_lsp);
2570        self.windows
2571            .get_mut(&self.active_window)
2572            .and_then(|w| w.split_manager_mut())
2573            .expect("active window must have a populated split layout")
2574            .set_active_buffer_id(original_active);
2575
2576        // Capture old cursor states from split view state
2577        // Find a split that has this buffer in its keyed_states
2578        let split_id_for_cursors = self
2579            .split_manager_mut()
2580            .splits_for_buffer(buffer_id)
2581            .into_iter()
2582            .next()
2583            .unwrap_or_else(|| {
2584                self.windows
2585                    .get(&self.active_window)
2586                    .and_then(|w| w.buffers.splits())
2587                    .map(|(mgr, _)| mgr)
2588                    .expect("active window must have a populated split layout")
2589                    .active_split()
2590            });
2591        let old_cursors: Vec<(CursorId, usize, Option<usize>)> = self
2592            .windows
2593            .get(&self.active_window)
2594            .and_then(|w| w.buffers.splits())
2595            .map(|(_, vs)| vs)
2596            .expect("active window must have a populated split layout")
2597            .get(&split_id_for_cursors)
2598            .and_then(|vs| vs.keyed_states.get(&buffer_id))
2599            .map(|bvs| {
2600                bvs.cursors
2601                    .iter()
2602                    .map(|(id, c)| (id, c.position, c.anchor))
2603                    .collect()
2604            })
2605            .unwrap_or_default();
2606
2607        // TODO: move this whole bulk-edit method to impl Window — the
2608        // body is window-scoped except for `send_lsp_changes_for_buffer`
2609        // at the tail which is LSP coordination on Editor. The block
2610        // below uses a single-window split borrow because the bulk edit
2611        // needs `&mut state` (buffer) and `&mut __vs_map` (split view
2612        // states) live together for the cursor-positioning loop.
2613        let __win = self
2614            .windows
2615            .get_mut(&self.active_window)
2616            .expect("active window must exist");
2617        let bulk_edit = __win
2618            .buffers
2619            .with_buffer_and_view_states(buffer_id, |state, vs_map| -> AnyhowResult<Event> {
2620                // Snapshot buffer state for undo (piece tree + buffers)
2621                let old_snapshot = state.buffer.snapshot_buffer_state();
2622
2623                // Convert events to edit tuples: (position, delete_len, insert_text)
2624                let mut edits: Vec<(usize, usize, String)> = Vec::new();
2625                for event in &events {
2626                    match event {
2627                        Event::Insert { position, text, .. } => {
2628                            edits.push((*position, 0, text.clone()));
2629                        }
2630                        Event::Delete { range, .. } => {
2631                            edits.push((range.start, range.len(), String::new()));
2632                        }
2633                        _ => {}
2634                    }
2635                }
2636
2637                // Sort edits by position descending (required by apply_bulk_edits)
2638                edits.sort_by(|a, b| b.0.cmp(&a.0));
2639
2640                // Convert to references for apply_bulk_edits
2641                let edit_refs: Vec<(usize, usize, &str)> = edits
2642                    .iter()
2643                    .map(|(pos, del, text)| (*pos, *del, text.as_str()))
2644                    .collect();
2645
2646                // Snapshot displaced markers before edits so undo can restore them exactly.
2647                let displaced_markers = state.capture_displaced_markers_bulk(&edits);
2648
2649                // Apply bulk edits - O(n) instead of O(n²)
2650                let _delta = state.buffer.apply_bulk_edits(&edit_refs);
2651
2652                // Calculate new cursor positions based on edits
2653                let mut position_deltas: Vec<(usize, isize)> = Vec::new();
2654                for (pos, del_len, text) in &edits {
2655                    let delta = text.len() as isize - *del_len as isize;
2656                    position_deltas.push((*pos, delta));
2657                }
2658                position_deltas.sort_by_key(|(pos, _)| *pos);
2659
2660                let calc_shift = |original_pos: usize| -> isize {
2661                    let mut shift: isize = 0;
2662                    for (edit_pos, delta) in &position_deltas {
2663                        if *edit_pos < original_pos {
2664                            shift += delta;
2665                        }
2666                    }
2667                    shift
2668                };
2669
2670                // Calculate new cursor positions
2671                let buffer_len = state.buffer.len();
2672                let new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors
2673                    .iter()
2674                    .map(|(id, pos, anchor)| {
2675                        let shift = calc_shift(*pos);
2676                        let new_pos = ((*pos as isize + shift).max(0) as usize).min(buffer_len);
2677                        let new_anchor = anchor.map(|a| {
2678                            let anchor_shift = calc_shift(a);
2679                            ((a as isize + anchor_shift).max(0) as usize).min(buffer_len)
2680                        });
2681                        (*id, new_pos, new_anchor)
2682                    })
2683                    .collect();
2684
2685                // Snapshot buffer state after edits (for redo)
2686                let new_snapshot = state.buffer.snapshot_buffer_state();
2687
2688                // Invalidate syntax highlighting
2689                state.highlighter.invalidate_all();
2690
2691                // Apply new cursor positions to split view state
2692                if let Some(vs) = vs_map.get_mut(&split_id_for_cursors) {
2693                    if let Some(bvs) = vs.keyed_states.get_mut(&buffer_id) {
2694                        for (cursor_id, new_pos, new_anchor) in &new_cursors {
2695                            if let Some(cursor) = bvs.cursors.get_mut(*cursor_id) {
2696                                cursor.position = *new_pos;
2697                                cursor.anchor = *new_anchor;
2698                            }
2699                        }
2700                    }
2701                }
2702
2703                // Convert edit list to lengths-only for undo/redo marker replay.
2704                // Merge edits at the same position into a single replacement.
2705                let edit_lengths: Vec<(usize, usize, usize)> = {
2706                    let mut lengths: Vec<(usize, usize, usize)> = Vec::new();
2707                    for (pos, del_len, text) in &edits {
2708                        if let Some(last) = lengths.last_mut() {
2709                            if last.0 == *pos {
2710                                last.1 += del_len;
2711                                last.2 += text.len();
2712                                continue;
2713                            }
2714                        }
2715                        lengths.push((*pos, *del_len, text.len()));
2716                    }
2717                    lengths
2718                };
2719
2720                // Adjust markers using merged net-delta
2721                for &(pos, del_len, ins_len) in &edit_lengths {
2722                    if del_len > 0 && ins_len > 0 {
2723                        if ins_len > del_len {
2724                            state.marker_list.adjust_for_insert(pos, ins_len - del_len);
2725                            state.margins.adjust_for_insert(pos, ins_len - del_len);
2726                        } else if del_len > ins_len {
2727                            state.marker_list.adjust_for_delete(pos, del_len - ins_len);
2728                            state.margins.adjust_for_delete(pos, del_len - ins_len);
2729                        }
2730                    } else if del_len > 0 {
2731                        state.marker_list.adjust_for_delete(pos, del_len);
2732                        state.margins.adjust_for_delete(pos, del_len);
2733                    } else if ins_len > 0 {
2734                        state.marker_list.adjust_for_insert(pos, ins_len);
2735                        state.margins.adjust_for_insert(pos, ins_len);
2736                    }
2737                }
2738
2739                Ok(Event::BulkEdit {
2740                    old_snapshot: Some(old_snapshot),
2741                    new_snapshot: Some(new_snapshot),
2742                    old_cursors,
2743                    new_cursors,
2744                    description,
2745                    edits: edit_lengths,
2746                    displaced_markers,
2747                })
2748            })
2749            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))??;
2750
2751        // Add to event log
2752        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
2753            event_log.append(bulk_edit);
2754        }
2755
2756        // Notify LSP about the changes using pre-calculated positions
2757        self.active_window_mut()
2758            .send_lsp_changes_for_buffer(buffer_id, lsp_changes);
2759
2760        Ok(())
2761    }
2762
2763    /// Start rename mode - select the symbol at cursor and allow inline editing
2764    pub(crate) fn start_rename(&mut self) -> AnyhowResult<()> {
2765        // If server supports prepareRename, validate first
2766        if self.active_window().server_supports_prepare_rename() {
2767            self.active_window_mut().send_prepare_rename();
2768            return Ok(());
2769        }
2770
2771        self.show_rename_prompt()
2772    }
2773
2774    /// Handle prepareRename response — if valid, show rename prompt; if error, show message.
2775    pub(crate) fn handle_prepare_rename_response(
2776        &mut self,
2777        result: Result<serde_json::Value, String>,
2778    ) {
2779        match result {
2780            Ok(value) if !value.is_null() => {
2781                // prepareRename succeeded — show the rename prompt
2782                if let Err(e) = self.show_rename_prompt() {
2783                    self.set_status_message(format!("Rename failed: {e}"));
2784                }
2785            }
2786            Ok(_) => {
2787                self.set_status_message("Cannot rename at this position".to_string());
2788            }
2789            Err(e) => {
2790                self.set_status_message(format!("Cannot rename: {e}"));
2791            }
2792        }
2793    }
2794
2795    /// Send textDocument/prepareRename to the LSP server
2796    /// Show the rename prompt (called directly or after prepareRename succeeds).
2797    fn show_rename_prompt(&mut self) -> AnyhowResult<()> {
2798        use crate::primitives::word_navigation::{find_word_end, find_word_start};
2799
2800        // Get the current buffer and cursor position
2801        let cursor_pos = self.active_cursors().primary().position;
2802        let (word_start, word_end) = {
2803            let state = self.active_state();
2804
2805            // Find the word boundaries
2806            let word_start = find_word_start(&state.buffer, cursor_pos);
2807            let word_end = find_word_end(&state.buffer, cursor_pos);
2808
2809            // Check if we're on a word
2810            if word_start >= word_end {
2811                self.active_window_mut().status_message =
2812                    Some(t!("lsp.no_symbol_at_cursor").to_string());
2813                return Ok(());
2814            }
2815
2816            (word_start, word_end)
2817        };
2818
2819        // Get the word text
2820        let word_text = self.active_state_mut().get_text_range(word_start, word_end);
2821
2822        // Create an overlay to highlight the symbol being renamed
2823        let overlay_handle = self.add_overlay(
2824            None,
2825            word_start..word_end,
2826            crate::model::event::OverlayFace::Background {
2827                color: (50, 100, 200), // Blue background for rename
2828            },
2829            100,
2830            Some(t!("lsp.popup_renaming").to_string()),
2831        );
2832
2833        // Enter rename mode using the Prompt system
2834        // Store the rename metadata in the PromptType and pre-fill the input with the current name
2835        let mut prompt = Prompt::new(
2836            "Rename to: ".to_string(),
2837            PromptType::LspRename {
2838                original_text: word_text.clone(),
2839                start_pos: word_start,
2840                end_pos: word_end,
2841                overlay_handle,
2842            },
2843        );
2844        // Pre-fill the input with the current name and position cursor at the end
2845        prompt.set_input(word_text);
2846
2847        self.active_window_mut().prompt = Some(prompt);
2848        Ok(())
2849    }
2850
2851    /// Cancel rename mode - removes overlay if the prompt was for LSP rename
2852    pub(crate) fn cancel_rename_overlay(&mut self, handle: &crate::view::overlay::OverlayHandle) {
2853        self.remove_overlay(handle.clone());
2854    }
2855
2856    /// Perform the actual LSP rename request
2857    pub(crate) fn perform_lsp_rename(
2858        &mut self,
2859        new_name: String,
2860        original_text: String,
2861        start_pos: usize,
2862        overlay_handle: crate::view::overlay::OverlayHandle,
2863    ) {
2864        // Remove the overlay first
2865        self.cancel_rename_overlay(&overlay_handle);
2866
2867        // Check if the name actually changed
2868        if new_name == original_text {
2869            self.active_window_mut().status_message = Some(t!("lsp.name_unchanged").to_string());
2870            return;
2871        }
2872
2873        // Use the position from when we entered rename mode, NOT the current cursor position
2874        // This ensures we send the rename request for the correct symbol even if cursor moved
2875        let rename_pos = start_pos;
2876
2877        // Convert byte position to LSP position (line, UTF-16 code units)
2878        // LSP uses UTF-16 code units for character offsets, not byte offsets
2879        let state = self.active_state();
2880        let (line, character) = state.buffer.position_to_lsp_position(rename_pos);
2881        let buffer_id = self.active_buffer();
2882        let request_id = self.active_window_mut().next_lsp_request_id;
2883
2884        // Use helper to ensure didOpen is sent before the request
2885        let sent = self
2886            .with_lsp_for_buffer(buffer_id, LspFeature::Rename, |handle, uri, _language| {
2887                let result = handle.rename(
2888                    request_id,
2889                    uri.as_uri().clone(),
2890                    line as u32,
2891                    character as u32,
2892                    new_name.clone(),
2893                );
2894                if result.is_ok() {
2895                    tracing::info!(
2896                        "Requested rename at {}:{}:{} to '{}'",
2897                        uri.as_str(),
2898                        line,
2899                        character,
2900                        new_name
2901                    );
2902                }
2903                result.is_ok()
2904            })
2905            .unwrap_or(false);
2906
2907        if sent {
2908            self.active_window_mut().next_lsp_request_id += 1;
2909        } else if self
2910            .active_window()
2911            .buffer_metadata
2912            .get(&buffer_id)
2913            .and_then(|m| m.file_path())
2914            .is_none()
2915        {
2916            self.active_window_mut().status_message =
2917                Some(t!("lsp.cannot_rename_unsaved").to_string());
2918        }
2919    }
2920
2921    /// Request inlay hints for the active buffer (if enabled and LSP available)
2922    pub(crate) fn request_inlay_hints_for_active_buffer(&mut self) {
2923        let buffer_id = self.active_buffer();
2924        self.request_inlay_hints_for_buffer(buffer_id);
2925    }
2926
2927    /// Request inlay hints for a specific buffer (if enabled and LSP available)
2928    pub(crate) fn request_inlay_hints_for_buffer(&mut self, buffer_id: BufferId) {
2929        if !self.config.editor.enable_inlay_hints {
2930            return;
2931        }
2932
2933        // Get line count and version from buffer state — both are needed so
2934        // the response handler can drop stale data if the buffer has moved
2935        // on by the time hints arrive.
2936        let (line_count, version) = if let Some(state) = self
2937            .windows
2938            .get(&self.active_window)
2939            .map(|w| &w.buffers)
2940            .expect("active window present")
2941            .get(&buffer_id)
2942        {
2943            (
2944                state.buffer.line_count().unwrap_or(1000),
2945                state.buffer.version(),
2946            )
2947        } else {
2948            return;
2949        };
2950        let last_line = line_count.saturating_sub(1) as u32;
2951        let request_id = self.active_window_mut().next_lsp_request_id;
2952
2953        // Use helper to ensure didOpen is sent before the request
2954        let sent = self
2955            .with_lsp_for_buffer(
2956                buffer_id,
2957                LspFeature::InlayHints,
2958                |handle, uri, _language| {
2959                    let result = handle.inlay_hints(
2960                        request_id,
2961                        uri.as_uri().clone(),
2962                        0,
2963                        0,
2964                        last_line,
2965                        10000,
2966                    );
2967                    if result.is_ok() {
2968                        tracing::info!(
2969                            "Requested inlay hints for {} (request_id={})",
2970                            uri.as_str(),
2971                            request_id
2972                        );
2973                    } else if let Err(e) = &result {
2974                        tracing::debug!("Failed to request inlay hints: {}", e);
2975                    }
2976                    result.is_ok()
2977                },
2978            )
2979            .unwrap_or(false);
2980
2981        if sent {
2982            self.active_window_mut().next_lsp_request_id += 1;
2983            self.active_window_mut()
2984                .pending_inlay_hints_requests
2985                .insert(request_id, super::InlayHintsRequest { buffer_id, version });
2986        }
2987    }
2988
2989    /// Issue a debounced folding range request if the timer has elapsed.
2990    pub(crate) fn maybe_request_folding_ranges_debounced(&mut self, buffer_id: BufferId) {
2991        let Some(ready_at) = self
2992            .active_window()
2993            .folding_ranges_debounce
2994            .get(&buffer_id)
2995            .copied()
2996        else {
2997            return;
2998        };
2999        if Instant::now() < ready_at {
3000            return;
3001        }
3002
3003        self.active_window_mut()
3004            .folding_ranges_debounce
3005            .remove(&buffer_id);
3006        self.request_folding_ranges_for_buffer(buffer_id);
3007    }
3008
3009    /// Request folding ranges for a buffer if supported and needed.
3010    pub(crate) fn request_folding_ranges_for_buffer(&mut self, buffer_id: BufferId) {
3011        if self
3012            .active_window_mut()
3013            .folding_ranges_in_flight
3014            .contains_key(&buffer_id)
3015        {
3016            return;
3017        }
3018
3019        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
3020            return;
3021        };
3022        if !metadata.lsp_enabled {
3023            return;
3024        }
3025        let Some(uri) = metadata.file_uri().cloned() else {
3026            return;
3027        };
3028        let file_path = metadata.file_path().cloned();
3029
3030        let Some(language) = self
3031            .windows
3032            .get(&self.active_window)
3033            .map(|w| &w.buffers)
3034            .expect("active window present")
3035            .get(&buffer_id)
3036            .map(|s| s.language.clone())
3037        else {
3038            return;
3039        };
3040
3041        let __active_id = self.active_window;
3042        // Pre-collect buffer version so we don't re-read self.buffers
3043        // while the &mut lsp borrow is alive.
3044        let __buffer_version_for_request = self
3045            .windows
3046            .get(&__active_id)
3047            .and_then(|w| w.buffers.get(&buffer_id))
3048            .map(|s| s.buffer.version())
3049            .unwrap_or(0);
3050
3051        let Some(__win) = self.windows.get_mut(&__active_id) else {
3052            return;
3053        };
3054        let __next_id = &mut __win.next_lsp_request_id;
3055        let __pending_folding = &mut __win.pending_folding_range_requests;
3056        let __folding_in_flight = &mut __win.folding_ranges_in_flight;
3057        let lsp = &mut __win.lsp;
3058
3059        if !lsp.folding_ranges_supported(&language) {
3060            return;
3061        }
3062
3063        // Ensure there is a running server
3064        use crate::services::lsp::manager::LspSpawnResult;
3065        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
3066            return;
3067        }
3068
3069        let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::FoldingRange) else {
3070            return;
3071        };
3072        let handle = &mut sh.handle;
3073
3074        let request_id = {
3075            let id = *__next_id;
3076            *__next_id += 1;
3077            id
3078        };
3079        let buffer_version = __buffer_version_for_request;
3080        let _ = __folding_in_flight;
3081
3082        match handle.folding_ranges(request_id, uri.as_uri().clone()) {
3083            Ok(()) => {
3084                __pending_folding.insert(
3085                    request_id,
3086                    super::FoldingRangeRequest {
3087                        buffer_id,
3088                        version: buffer_version,
3089                    },
3090                );
3091                __folding_in_flight.insert(buffer_id, (request_id, buffer_version));
3092            }
3093            Err(e) => {
3094                tracing::debug!("Failed to request folding ranges: {}", e);
3095            }
3096        }
3097    }
3098
3099    /// Request semantic tokens for a specific buffer if supported and needed.
3100    pub(crate) fn maybe_request_semantic_tokens(&mut self, buffer_id: BufferId) {
3101        if !self.config.editor.enable_semantic_tokens_full {
3102            return;
3103        }
3104
3105        // Avoid duplicate in-flight requests per buffer
3106        if self
3107            .active_window_mut()
3108            .semantic_tokens_in_flight
3109            .contains_key(&buffer_id)
3110        {
3111            return;
3112        }
3113
3114        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
3115            return;
3116        };
3117        if !metadata.lsp_enabled {
3118            return;
3119        }
3120        let Some(uri) = metadata.file_uri().cloned() else {
3121            return;
3122        };
3123        let file_path_for_spawn = metadata.file_path().cloned();
3124        // Get language from buffer state
3125        let Some(language) = self
3126            .windows
3127            .get(&self.active_window)
3128            .map(|w| &w.buffers)
3129            .expect("active window present")
3130            .get(&buffer_id)
3131            .map(|s| s.language.clone())
3132        else {
3133            return;
3134        };
3135
3136        let __active_id = self.active_window;
3137        // Pre-extract buffer state info so we don't re-borrow self
3138        // while the &mut lsp borrow is alive.
3139        let Some((buffer_version, existing_version, previous_result_id)) = self
3140            .windows
3141            .get(&__active_id)
3142            .and_then(|w| w.buffers.get(&buffer_id))
3143            .map(|state| {
3144                (
3145                    state.buffer.version(),
3146                    state.semantic_tokens.as_ref().map(|s| s.version),
3147                    state
3148                        .semantic_tokens
3149                        .as_ref()
3150                        .and_then(|s| s.result_id.clone()),
3151                )
3152            })
3153        else {
3154            return;
3155        };
3156        if Some(buffer_version) == existing_version {
3157            return; // Already up to date
3158        }
3159
3160        let Some(__win) = self.windows.get_mut(&__active_id) else {
3161            return;
3162        };
3163        let __next_id = &mut __win.next_lsp_request_id;
3164        let __pending_st = &mut __win.pending_semantic_token_requests;
3165        let __st_in_flight = &mut __win.semantic_tokens_in_flight;
3166        let lsp = &mut __win.lsp;
3167
3168        // Ensure there is a running server
3169        use crate::services::lsp::manager::LspSpawnResult;
3170        if lsp.try_spawn(&language, file_path_for_spawn.as_deref()) != LspSpawnResult::Spawned {
3171            return;
3172        }
3173
3174        // Check that a server actually supports full semantic tokens
3175        if !lsp.semantic_tokens_full_supported(&language) {
3176            return;
3177        }
3178        if lsp.semantic_tokens_legend(&language).is_none() {
3179            return;
3180        }
3181
3182        let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::SemanticTokens) else {
3183            return;
3184        };
3185        // Check capabilities on the specific server we'll send to
3186        let supports_delta = sh.capabilities.semantic_tokens_full_delta;
3187        let use_delta = previous_result_id.is_some() && supports_delta;
3188        let handle = &mut sh.handle;
3189
3190        let request_id = {
3191            let id = *__next_id;
3192            *__next_id += 1;
3193            id
3194        };
3195
3196        let request_kind = if use_delta {
3197            super::SemanticTokensFullRequestKind::FullDelta
3198        } else {
3199            super::SemanticTokensFullRequestKind::Full
3200        };
3201
3202        let request_result = if use_delta {
3203            handle.semantic_tokens_full_delta(
3204                request_id,
3205                uri.as_uri().clone(),
3206                previous_result_id.unwrap(),
3207            )
3208        } else {
3209            handle.semantic_tokens_full(request_id, uri.as_uri().clone())
3210        };
3211
3212        match request_result {
3213            Ok(_) => {
3214                __pending_st.insert(
3215                    request_id,
3216                    super::SemanticTokenFullRequest {
3217                        buffer_id,
3218                        version: buffer_version,
3219                        kind: request_kind,
3220                    },
3221                );
3222                __st_in_flight.insert(buffer_id, (request_id, buffer_version, request_kind));
3223            }
3224            Err(e) => {
3225                tracing::debug!("Failed to request semantic tokens: {}", e);
3226            }
3227        }
3228    }
3229
3230    /// Issue a debounced full semantic token request if the timer has elapsed.
3231    pub(crate) fn maybe_request_semantic_tokens_full_debounced(&mut self, buffer_id: BufferId) {
3232        if !self.config.editor.enable_semantic_tokens_full {
3233            self.active_window_mut()
3234                .semantic_tokens_full_debounce
3235                .remove(&buffer_id);
3236            return;
3237        }
3238
3239        let Some(ready_at) = self
3240            .active_window()
3241            .semantic_tokens_full_debounce
3242            .get(&buffer_id)
3243            .copied()
3244        else {
3245            return;
3246        };
3247        if Instant::now() < ready_at {
3248            return;
3249        }
3250
3251        self.active_window_mut()
3252            .semantic_tokens_full_debounce
3253            .remove(&buffer_id);
3254        self.maybe_request_semantic_tokens(buffer_id);
3255    }
3256
3257    /// Request semantic tokens for a viewport range (with padding).
3258    pub(crate) fn maybe_request_semantic_tokens_range(
3259        &mut self,
3260        buffer_id: BufferId,
3261        start_line: usize,
3262        end_line: usize,
3263    ) {
3264        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
3265            return;
3266        };
3267        if !metadata.lsp_enabled {
3268            return;
3269        }
3270        let Some(uri) = metadata.file_uri().cloned() else {
3271            return;
3272        };
3273        let file_path = metadata.file_path().cloned();
3274        // Get language from buffer state
3275        let Some(language) = self
3276            .windows
3277            .get(&self.active_window)
3278            .map(|w| &w.buffers)
3279            .expect("active window present")
3280            .get(&buffer_id)
3281            .map(|s| s.language.clone())
3282        else {
3283            return;
3284        };
3285
3286        let __active_id = self.active_window;
3287        // Single &mut on the active window — split-borrow into &mut lsp
3288        // and &buffers so we can use both concurrently.
3289        let __win = self
3290            .windows
3291            .get_mut(&__active_id)
3292            .expect("active window must exist");
3293        let __next_id = &mut __win.next_lsp_request_id;
3294        let __pending_st_range = &mut __win.pending_semantic_token_range_requests;
3295        let __st_range_in_flight = &mut __win.semantic_tokens_range_in_flight;
3296        let __st_range_last = &mut __win.semantic_tokens_range_last_request;
3297        let __st_range_applied = &__win.semantic_tokens_range_applied;
3298        let lsp = &mut __win.lsp;
3299        let __buffers_ref: &crate::app::window::WindowBuffers = &__win.buffers;
3300
3301        // Ensure there is a running server
3302        use crate::services::lsp::manager::LspSpawnResult;
3303        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
3304            return;
3305        }
3306
3307        if !lsp.semantic_tokens_range_supported(&language) {
3308            // Fall back to full document tokens if no server supports range.
3309            self.maybe_request_semantic_tokens(buffer_id);
3310            return;
3311        }
3312        if lsp.semantic_tokens_legend(&language).is_none() {
3313            return;
3314        }
3315
3316        let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::SemanticTokens) else {
3317            return;
3318        };
3319        // The handle_for_feature_mut check ensures has_capability(SemanticTokens) which is
3320        // full || range. Double-check this specific server supports range.
3321        if !sh.capabilities.semantic_tokens_range {
3322            return;
3323        }
3324        let handle = &mut sh.handle;
3325        let Some(state) = __buffers_ref.get(&buffer_id) else {
3326            return;
3327        };
3328
3329        let buffer_version = state.buffer.version();
3330        let mut padded_start = start_line.saturating_sub(SEMANTIC_TOKENS_RANGE_PADDING_LINES);
3331        let mut padded_end = end_line.saturating_add(SEMANTIC_TOKENS_RANGE_PADDING_LINES);
3332
3333        if let Some(line_count) = state.buffer.line_count() {
3334            if line_count == 0 {
3335                return;
3336            }
3337            let max_line = line_count.saturating_sub(1);
3338            padded_start = padded_start.min(max_line);
3339            padded_end = padded_end.min(max_line);
3340        }
3341
3342        let start_byte = state.buffer.line_start_offset(padded_start).unwrap_or(0);
3343        let end_char = state
3344            .buffer
3345            .get_line(padded_end)
3346            .map(|line| String::from_utf8_lossy(&line).encode_utf16().count())
3347            .unwrap_or(0);
3348        let end_byte = if state.buffer.line_start_offset(padded_end).is_some() {
3349            state.buffer.lsp_position_to_byte(padded_end, end_char)
3350        } else {
3351            state.buffer.len()
3352        };
3353
3354        if start_byte >= end_byte {
3355            return;
3356        }
3357
3358        let range = start_byte..end_byte;
3359        if let Some((in_flight_id, in_flight_start, in_flight_end, in_flight_version)) =
3360            __st_range_in_flight.get(&buffer_id).copied()
3361        {
3362            if in_flight_start == padded_start
3363                && in_flight_end == padded_end
3364                && in_flight_version == buffer_version
3365            {
3366                return;
3367            }
3368            if let Err(e) = handle.cancel_request(in_flight_id) {
3369                tracing::debug!("Failed to cancel semantic token range request: {}", e);
3370            }
3371            __pending_st_range.remove(&in_flight_id);
3372            __st_range_in_flight.remove(&buffer_id);
3373        }
3374
3375        if let Some((applied_start, applied_end, applied_version)) =
3376            __st_range_applied.get(&buffer_id).copied()
3377        {
3378            if applied_start == padded_start
3379                && applied_end == padded_end
3380                && applied_version == buffer_version
3381            {
3382                return;
3383            }
3384        }
3385
3386        let now = Instant::now();
3387        if let Some((last_start, last_end, last_version, last_time)) =
3388            __st_range_last.get(&buffer_id).copied()
3389        {
3390            if last_start == padded_start
3391                && last_end == padded_end
3392                && last_version == buffer_version
3393                && now.duration_since(last_time)
3394                    < Duration::from_millis(SEMANTIC_TOKENS_RANGE_DEBOUNCE_MS)
3395            {
3396                return;
3397            }
3398        }
3399
3400        let lsp_range = lsp_types::Range {
3401            start: lsp_types::Position {
3402                line: padded_start as u32,
3403                character: 0,
3404            },
3405            end: lsp_types::Position {
3406                line: padded_end as u32,
3407                character: end_char as u32,
3408            },
3409        };
3410
3411        let request_id = {
3412            let id = *__next_id;
3413            *__next_id += 1;
3414            id
3415        };
3416        let _ = __st_range_applied;
3417
3418        match handle.semantic_tokens_range(request_id, uri.as_uri().clone(), lsp_range) {
3419            Ok(_) => {
3420                __pending_st_range.insert(
3421                    request_id,
3422                    SemanticTokenRangeRequest {
3423                        buffer_id,
3424                        version: buffer_version,
3425                        range: range.clone(),
3426                        start_line: padded_start,
3427                        end_line: padded_end,
3428                    },
3429                );
3430                __st_range_in_flight.insert(
3431                    buffer_id,
3432                    (request_id, padded_start, padded_end, buffer_version),
3433                );
3434                __st_range_last.insert(buffer_id, (padded_start, padded_end, buffer_version, now));
3435            }
3436            Err(e) => {
3437                tracing::debug!("Failed to request semantic token range: {}", e);
3438            }
3439        }
3440    }
3441}
3442
3443#[cfg(test)]
3444mod tests {
3445    use crate::model::filesystem::StdFileSystem;
3446    use std::sync::Arc;
3447
3448    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
3449        Arc::new(StdFileSystem)
3450    }
3451    use super::{lsp_range_contains, Editor};
3452
3453    fn range(sl: u32, sc: u32, el: u32, ec: u32) -> lsp_types::Range {
3454        lsp_types::Range {
3455            start: lsp_types::Position {
3456                line: sl,
3457                character: sc,
3458            },
3459            end: lsp_types::Position {
3460                line: el,
3461                character: ec,
3462            },
3463        }
3464    }
3465
3466    #[test]
3467    fn test_lsp_range_contains_inclusive_start_exclusive_end() {
3468        let r = range(3, 10, 3, 20);
3469        // Before start
3470        assert!(!lsp_range_contains(&r, 3, 9));
3471        assert!(!lsp_range_contains(&r, 2, 50));
3472        // At start (inclusive)
3473        assert!(lsp_range_contains(&r, 3, 10));
3474        // Inside
3475        assert!(lsp_range_contains(&r, 3, 15));
3476        // Just before end (inclusive)
3477        assert!(lsp_range_contains(&r, 3, 19));
3478        // At end (exclusive)
3479        assert!(!lsp_range_contains(&r, 3, 20));
3480        // After end
3481        assert!(!lsp_range_contains(&r, 3, 21));
3482        assert!(!lsp_range_contains(&r, 4, 0));
3483    }
3484
3485    #[test]
3486    fn test_lsp_range_contains_multiline() {
3487        let r = range(2, 5, 4, 3);
3488        // Line before start
3489        assert!(!lsp_range_contains(&r, 1, 100));
3490        // On start line, before start character
3491        assert!(!lsp_range_contains(&r, 2, 4));
3492        // On start line, at start character (inclusive)
3493        assert!(lsp_range_contains(&r, 2, 5));
3494        // Interior line — any character is inside.
3495        assert!(lsp_range_contains(&r, 3, 0));
3496        assert!(lsp_range_contains(&r, 3, 9999));
3497        // End line, before end character (inclusive)
3498        assert!(lsp_range_contains(&r, 4, 2));
3499        // End line, at end character (exclusive)
3500        assert!(!lsp_range_contains(&r, 4, 3));
3501        // Line after end
3502        assert!(!lsp_range_contains(&r, 5, 0));
3503    }
3504
3505    #[test]
3506    fn test_lsp_range_contains_zero_length_matches_anchor_only() {
3507        // Point diagnostic: start == end.
3508        let r = range(7, 4, 7, 4);
3509        assert!(lsp_range_contains(&r, 7, 4));
3510        assert!(!lsp_range_contains(&r, 7, 3));
3511        assert!(!lsp_range_contains(&r, 7, 5));
3512        assert!(!lsp_range_contains(&r, 6, 4));
3513        assert!(!lsp_range_contains(&r, 8, 4));
3514    }
3515    use crate::model::buffer::Buffer;
3516    use crate::state::EditorState;
3517    use crate::view::virtual_text::VirtualTextPosition;
3518    use lsp_types::{InlayHint, InlayHintKind, InlayHintLabel, Position};
3519
3520    fn make_hint(line: u32, character: u32, label: &str, kind: Option<InlayHintKind>) -> InlayHint {
3521        InlayHint {
3522            position: Position { line, character },
3523            label: InlayHintLabel::String(label.to_string()),
3524            kind,
3525            text_edits: None,
3526            tooltip: None,
3527            padding_left: None,
3528            padding_right: None,
3529            data: None,
3530        }
3531    }
3532
3533    #[test]
3534    fn test_inlay_hint_inserts_before_character() {
3535        let mut state = EditorState::new(
3536            80,
3537            24,
3538            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3539            test_fs(),
3540        );
3541        state.buffer = Buffer::from_str_test("ab");
3542
3543        if !state.buffer.is_empty() {
3544            state.marker_list.adjust_for_insert(0, state.buffer.len());
3545        }
3546
3547        let hints = vec![make_hint(0, 1, ": i32", Some(InlayHintKind::TYPE))];
3548        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3549
3550        let lookup = state
3551            .virtual_texts
3552            .build_lookup(&state.marker_list, 0, state.buffer.len());
3553        let vtexts = lookup.get(&1).expect("expected hint at byte offset 1");
3554        assert_eq!(vtexts.len(), 1);
3555        assert_eq!(vtexts[0].text, ": i32");
3556        assert_eq!(vtexts[0].position, VirtualTextPosition::BeforeChar);
3557    }
3558
3559    #[test]
3560    fn test_inlay_hint_at_eof_renders_after_last_char() {
3561        let mut state = EditorState::new(
3562            80,
3563            24,
3564            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3565            test_fs(),
3566        );
3567        state.buffer = Buffer::from_str_test("ab");
3568
3569        if !state.buffer.is_empty() {
3570            state.marker_list.adjust_for_insert(0, state.buffer.len());
3571        }
3572
3573        let hints = vec![make_hint(0, 2, ": i32", Some(InlayHintKind::TYPE))];
3574        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3575
3576        let lookup = state
3577            .virtual_texts
3578            .build_lookup(&state.marker_list, 0, state.buffer.len());
3579        let vtexts = lookup.get(&1).expect("expected hint anchored to last byte");
3580        assert_eq!(vtexts.len(), 1);
3581        assert_eq!(vtexts[0].text, ": i32");
3582        assert_eq!(vtexts[0].position, VirtualTextPosition::AfterChar);
3583    }
3584
3585    #[test]
3586    fn test_inlay_hint_empty_buffer_is_ignored() {
3587        let mut state = EditorState::new(
3588            80,
3589            24,
3590            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3591            test_fs(),
3592        );
3593        state.buffer = Buffer::from_str_test("");
3594
3595        let hints = vec![make_hint(0, 0, ": i32", Some(InlayHintKind::TYPE))];
3596        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3597
3598        assert!(state.virtual_texts.is_empty());
3599    }
3600
3601    #[test]
3602    fn test_inlay_hint_uses_theme_key_for_foreground() {
3603        // Verify that apply_inlay_hints_to_state stores the theme key so
3604        // hints follow the active theme rather than a hardcoded color.
3605        let mut state = EditorState::new(
3606            80,
3607            24,
3608            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3609            test_fs(),
3610        );
3611        state.buffer = Buffer::from_str_test("ab");
3612
3613        if !state.buffer.is_empty() {
3614            state.marker_list.adjust_for_insert(0, state.buffer.len());
3615        }
3616
3617        let hints = vec![make_hint(0, 1, ": i32", Some(InlayHintKind::TYPE))];
3618        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3619
3620        let lookup = state
3621            .virtual_texts
3622            .build_lookup(&state.marker_list, 0, state.buffer.len());
3623        let vtexts = lookup.get(&1).expect("expected hint at byte offset 1");
3624        assert_eq!(
3625            vtexts[0].fg_theme_key.as_deref(),
3626            Some("editor.line_number_fg")
3627        );
3628        assert_eq!(vtexts[0].bg_theme_key, None);
3629    }
3630
3631    #[test]
3632    fn test_inlay_hint_removed_when_its_range_is_deleted() {
3633        // Regression: deleting a range that covers the anchor byte of an
3634        // inlay hint used to leave the hint visible (the marker snapped to
3635        // the deletion start). apply_delete now calls
3636        // virtual_texts.remove_in_range before adjusting markers, so the
3637        // hint vanishes immediately. A future LSP refresh can repopulate
3638        // hints elsewhere.
3639        let mut state = EditorState::new(
3640            80,
3641            24,
3642            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3643            test_fs(),
3644        );
3645        state.buffer = Buffer::from_str_test("let x = 42;");
3646        state.marker_list.adjust_for_insert(0, state.buffer.len());
3647
3648        // Hint anchored at byte 5 (after "let x" -> rendered before '=').
3649        let hints = vec![make_hint(0, 5, ": i32", Some(InlayHintKind::TYPE))];
3650        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3651        assert_eq!(state.virtual_texts.len(), 1);
3652
3653        // Simulate user deleting "x = 42" (bytes 4..10, half-open) — the
3654        // hint anchor at byte 5 is inside this range.
3655        let removed = state
3656            .virtual_texts
3657            .remove_in_range(&mut state.marker_list, 4, 10);
3658        assert_eq!(removed, 1, "hint inside deleted range must be removed");
3659        assert!(state.virtual_texts.is_empty());
3660    }
3661
3662    #[test]
3663    fn test_marker_delete_after_repeat_clear_recreate() {
3664        // Regression: simulates what apply_inlay_hints_to_state does on
3665        // every LSP refresh — clear every virtual_text's marker then
3666        // recreate markers at fresh positions. After a few rounds,
3667        // delete one marker and adjust for a deletion and check the
3668        // remaining markers' positions.
3669        use crate::model::marker::MarkerList;
3670        use crate::view::virtual_text::{VirtualTextManager, VirtualTextPosition};
3671        use ratatui::style::Style;
3672
3673        let mut markers = MarkerList::new();
3674        let mut vtexts = VirtualTextManager::new();
3675
3676        // Initial marker layout at six positions (same as the e2e test).
3677        let positions = [200usize, 401, 602, 803, 1205, 1406];
3678        for &p in &positions {
3679            vtexts.add(
3680                &mut markers,
3681                p,
3682                format!("hint-at-{p}"),
3683                Style::default(),
3684                VirtualTextPosition::BeforeChar,
3685                0,
3686            );
3687        }
3688
3689        // Simulate a couple of clear/recreate cycles (each LSP refresh
3690        // goes through this exact path via apply_inlay_hints_to_state).
3691        for _ in 0..3 {
3692            vtexts.clear(&mut markers);
3693            for &p in &positions {
3694                vtexts.add(
3695                    &mut markers,
3696                    p,
3697                    format!("hint-at-{p}"),
3698                    Style::default(),
3699                    VirtualTextPosition::BeforeChar,
3700                    0,
3701                );
3702            }
3703        }
3704
3705        // remove_in_range + adjust_for_delete equivalent to apply_delete.
3706        let removed = vtexts.remove_in_range(&mut markers, 1005, 1206);
3707        assert_eq!(
3708            removed, 1,
3709            "exactly one marker inside [1005, 1206) should be removed"
3710        );
3711        markers.adjust_for_delete(1005, 201);
3712
3713        let lookup = vtexts.build_lookup(&markers, 0, 10_000);
3714        let mut positions: Vec<usize> = lookup.keys().copied().collect();
3715        positions.sort();
3716        assert_eq!(
3717            positions,
3718            vec![200, 401, 602, 803, 1205],
3719            "after delete+adjust, expected marker byte positions {:?}, got {:?}",
3720            vec![200, 401, 602, 803, 1205],
3721            positions
3722        );
3723    }
3724
3725    #[test]
3726    fn test_marker_delete_then_adjust_preserves_last_marker_position() {
3727        // Regression for the user-observed flip of an end-of-line inlay
3728        // hint to the start of its line after a nearby line is deleted.
3729        //
3730        // Scenario (real numbers from the failing e2e test): six markers
3731        // at byte offsets that correspond to the `\n` of each line,
3732        // then delete-one-marker (simulating remove_in_range on the
3733        // line being deleted) followed by adjust_for_delete on the
3734        // remaining markers.
3735        //
3736        // The last marker (at byte 1406) should end up at byte 1205
3737        // after subtracting the 201-byte deleted range. Observed bug:
3738        // it ends up at byte 1005 (the deletion start) — exactly as
3739        // though the delta were applied twice.
3740        use crate::model::marker::MarkerList;
3741
3742        let mut markers = MarkerList::new();
3743        let m0 = markers.create(200, false);
3744        let m1 = markers.create(401, false);
3745        let m2 = markers.create(602, false);
3746        let m3 = markers.create(803, false);
3747        let m5 = markers.create(1205, false);
3748        let m6 = markers.create(1406, false);
3749
3750        // Simulate remove_in_range removing marker m5 inside [1005, 1206).
3751        markers.delete(m5);
3752
3753        // Now simulate adjust_for_delete over that range.
3754        markers.adjust_for_delete(1005, 201);
3755
3756        assert_eq!(markers.get_position(m0), Some(200), "m0 unchanged");
3757        assert_eq!(markers.get_position(m1), Some(401), "m1 unchanged");
3758        assert_eq!(markers.get_position(m2), Some(602), "m2 unchanged");
3759        assert_eq!(markers.get_position(m3), Some(803), "m3 unchanged");
3760        assert_eq!(
3761            markers.get_position(m6),
3762            Some(1205),
3763            "m6 must shift from 1406 to 1205 (1406 - 201), not be clamped to delete-start 1005"
3764        );
3765    }
3766
3767    #[test]
3768    fn test_inlay_hint_outside_deletion_survives() {
3769        // Anchors outside the deleted range must not be collateral damage.
3770        let mut state = EditorState::new(
3771            80,
3772            24,
3773            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3774            test_fs(),
3775        );
3776        state.buffer = Buffer::from_str_test("let x = 42; let y = 0;");
3777        state.marker_list.adjust_for_insert(0, state.buffer.len());
3778
3779        let hints = vec![
3780            make_hint(0, 5, ": i32", Some(InlayHintKind::TYPE)), // byte 5 - inside deletion
3781            make_hint(0, 17, ": i32", Some(InlayHintKind::TYPE)), // byte 17 - outside
3782        ];
3783        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3784        assert_eq!(state.virtual_texts.len(), 2);
3785
3786        let removed = state
3787            .virtual_texts
3788            .remove_in_range(&mut state.marker_list, 4, 10);
3789        assert_eq!(removed, 1);
3790        assert_eq!(state.virtual_texts.len(), 1);
3791    }
3792
3793    #[test]
3794    fn test_space_doc_paragraphs_inserts_blank_lines() {
3795        use super::space_doc_paragraphs;
3796
3797        // Single newlines become double newlines
3798        let input = "sep\n  description.\nend\n  another.";
3799        let result = space_doc_paragraphs(input);
3800        assert_eq!(result, "sep\n\n  description.\n\nend\n\n  another.");
3801    }
3802
3803    #[test]
3804    fn test_space_doc_paragraphs_preserves_existing_blank_lines() {
3805        use super::space_doc_paragraphs;
3806
3807        // Already-double newlines stay double (not quadrupled)
3808        let input = "First paragraph.\n\nSecond paragraph.";
3809        let result = space_doc_paragraphs(input);
3810        assert_eq!(result, "First paragraph.\n\nSecond paragraph.");
3811    }
3812
3813    #[test]
3814    fn test_space_doc_paragraphs_plain_text() {
3815        use super::space_doc_paragraphs;
3816
3817        let input = "Just a single line of docs.";
3818        let result = space_doc_paragraphs(input);
3819        assert_eq!(result, "Just a single line of docs.");
3820    }
3821}