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 = __win.lsp.as_mut()?;
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        let hover_lsp_position = Some(position);
904
905        // Gather any diagnostics whose range overlaps the hover position so
906        // they can be fused into the top of the hover card. Without this the
907        // user has to leave hover and go chase the error elsewhere in the UI
908        // even though the cursor is already on the offending symbol.
909        let diagnostic_lines = hover_lsp_position
910            .map(|pos| self.compose_hover_diagnostic_lines(pos))
911            .unwrap_or_default();
912
913        if contents.is_empty() && diagnostic_lines.is_empty() {
914            self.set_status_message(t!("lsp.no_hover").to_string());
915            self.active_window_mut().hover.set_symbol_range(None);
916            return;
917        }
918
919        // Debug: log raw hover content to diagnose formatting issues
920        tracing::debug!(
921            "LSP hover content (markdown={}):\n{}",
922            is_markdown,
923            contents
924        );
925
926        // Convert LSP range to byte offsets for highlighting
927        if let Some(((start_line, start_char), (end_line, end_char))) = range {
928            let state = self.active_state();
929            let start_byte = state
930                .buffer
931                .lsp_position_to_byte(start_line as usize, start_char as usize);
932            let end_byte = state
933                .buffer
934                .lsp_position_to_byte(end_line as usize, end_char as usize);
935            self.active_window_mut()
936                .hover
937                .set_symbol_range(Some((start_byte, end_byte)));
938            tracing::debug!(
939                "Hover symbol range: {}..{} (LSP {}:{}..{}:{})",
940                start_byte,
941                end_byte,
942                start_line,
943                start_char,
944                end_line,
945                end_char
946            );
947
948            // Remove previous hover overlay if any
949            if let Some(old_handle) = self.active_window_mut().hover.take_symbol_overlay() {
950                let remove_event = crate::model::event::Event::RemoveOverlay { handle: old_handle };
951                self.apply_event_to_active_buffer(&remove_event);
952            }
953
954            // Add overlay to highlight the hovered symbol
955            let event = crate::model::event::Event::AddOverlay {
956                namespace: None,
957                range: start_byte..end_byte,
958                face: crate::model::event::OverlayFace::Background {
959                    color: (80, 80, 120), // Subtle highlight for hovered symbol
960                },
961                priority: 90, // Below rename (100) but above syntax (lower)
962                message: None,
963                extend_to_line_end: false,
964                url: None,
965            };
966            self.apply_event_to_active_buffer(&event);
967            // Store the handle for later removal
968            if let Some(state) = self
969                .windows
970                .get(&self.active_window)
971                .map(|w| &w.buffers)
972                .expect("active window present")
973                .get(&self.active_buffer())
974            {
975                if let Some(handle) = state.overlays.all().last().map(|o| o.handle.clone()) {
976                    self.active_window_mut().hover.set_symbol_overlay(handle);
977                }
978            }
979        } else {
980            // No range provided by LSP - compute word boundaries at hover position
981            // This prevents the popup from following the mouse within the same word
982            let computed_range = if let Some((hover_byte_pos, _, _, _)) =
983                self.active_window_mut().mouse_state.lsp_hover_state
984            {
985                let state = self.active_state();
986                let start_byte = find_word_start(&state.buffer, hover_byte_pos);
987                let end_byte = find_word_end(&state.buffer, hover_byte_pos);
988                if start_byte < end_byte {
989                    tracing::debug!(
990                        "Hover symbol range (computed from word boundaries): {}..{}",
991                        start_byte,
992                        end_byte
993                    );
994                    Some((start_byte, end_byte))
995                } else {
996                    None
997                }
998            } else {
999                None
1000            };
1001            self.active_window_mut()
1002                .hover
1003                .set_symbol_range(computed_range);
1004        }
1005
1006        // Create a popup with the hover contents.
1007        //
1008        // When a diagnostic overlaps the hover position, we pre-style its
1009        // lines (severity-colored header + plain message) and concatenate
1010        // with the parsed hover body into a single `PopupContent::Markdown`
1011        // vector. This avoids the previous approach of injecting a
1012        // `**bold**` heading and a `---` horizontal rule into the markdown
1013        // input — which rendered as uncolored bold text + a thick 40-cell
1014        // divider with blank-line padding, wasting vertical space and
1015        // losing the "this is an error" visual signal.
1016        use crate::view::markdown::{parse_markdown, StyledLine};
1017        use crate::view::popup::{Popup, PopupContent, PopupPosition};
1018        use ratatui::style::Style;
1019        use unicode_width::UnicodeWidthStr;
1020
1021        let hover_lines: Vec<StyledLine> = if contents.is_empty() {
1022            Vec::new()
1023        } else if is_markdown {
1024            parse_markdown(
1025                &contents,
1026                &*self.theme.read().unwrap(),
1027                Some(&self.grammar_registry),
1028            )
1029        } else {
1030            contents
1031                .lines()
1032                .map(|s| {
1033                    let mut sl = StyledLine::new();
1034                    sl.push(
1035                        s.to_string(),
1036                        Style::default().fg(self.theme.read().unwrap().popup_text_fg),
1037                    );
1038                    sl
1039                })
1040                .collect()
1041        };
1042
1043        let has_diagnostic = !diagnostic_lines.is_empty();
1044        let mut all_lines: Vec<StyledLine> = Vec::new();
1045        all_lines.extend(diagnostic_lines);
1046        if has_diagnostic && !hover_lines.is_empty() {
1047            // Compact single-line separator — no blank padding, no 40-cell
1048            // dash run. One row of dashes the width of the content, in the
1049            // popup border color so it reads as "same card, new section."
1050            let mut sep = StyledLine::new();
1051            sep.push(
1052                "─".repeat(12),
1053                Style::default().fg(self.theme.read().unwrap().popup_border_fg),
1054            );
1055            all_lines.push(sep);
1056        }
1057        all_lines.extend(hover_lines);
1058
1059        // Drop trailing empty lines that some markdown payloads carry.
1060        while all_lines
1061            .last()
1062            .map(|l| l.spans.iter().all(|s| s.text.trim().is_empty()))
1063            .unwrap_or(false)
1064        {
1065            all_lines.pop();
1066        }
1067
1068        // Fit width to content so short hovers stop rendering in an 80-col
1069        // card with half the width empty. Measured as the widest styled
1070        // line (display cells, not bytes), plus 4 for borders + padding,
1071        // clamped to [30, 80]. Height stays dynamic on terminal size.
1072        let content_width: usize = all_lines
1073            .iter()
1074            .map(|l| {
1075                l.spans
1076                    .iter()
1077                    .map(|s| UnicodeWidthStr::width(s.text.as_str()))
1078                    .sum::<usize>()
1079            })
1080            .max()
1081            .unwrap_or(0);
1082        let popup_width = (content_width as u16 + 4).clamp(30, 80);
1083        let dynamic_height = (self.terminal_height * 60 / 100).clamp(15, 40);
1084
1085        // Construct the popup with the fused content.
1086        let mut popup = Popup::text(Vec::new(), &*self.theme.read().unwrap());
1087        popup.content = PopupContent::Markdown(all_lines);
1088        popup.title = Some(t!("lsp.popup_hover").to_string());
1089        popup.transient = true;
1090        popup.position = if let Some((x, y)) = self.active_window_mut().hover.take_screen_position()
1091        {
1092            PopupPosition::Fixed { x, y: y + 1 }
1093        } else {
1094            PopupPosition::BelowCursor
1095        };
1096        popup.width = popup_width;
1097        popup.max_height = dynamic_height;
1098        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1099        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1100        popup.focus_key_hint = self.popup_focus_key_hint();
1101
1102        // Show the popup. Replace any existing transient (hover/signature)
1103        // popup so successive hovers don't pile up on the popup stack —
1104        // the user expects exactly one hover card on screen at a time.
1105        let __buffer_id = self.active_buffer();
1106        if let Some(state) = self
1107            .windows
1108            .get_mut(&self.active_window)
1109            .map(|w| &mut w.buffers)
1110            .expect("active window present")
1111            .get_mut(&__buffer_id)
1112        {
1113            while state.popups.top().is_some_and(|p| p.transient) {
1114                state.popups.hide();
1115            }
1116            state.popups.show(popup);
1117            tracing::info!("Showing hover popup (markdown={})", is_markdown);
1118        }
1119
1120        // Mark hover request as sent to prevent duplicate popups during race conditions
1121        // (e.g., when mouse moves while a hover response is pending)
1122        self.active_window_mut().mouse_state.lsp_hover_request_sent = true;
1123    }
1124
1125    /// Pre-style any diagnostics overlapping the hover position into lines
1126    /// ready to stack into the hover popup. Each diagnostic yields two or
1127    /// more styled lines:
1128    ///   1. severity marker + label in `diagnostic_*_fg`, followed by
1129    ///      `  (source)` dimmed — italic on theme-default foreground,
1130    ///   2. one styled line per message line, in `popup_text_fg`.
1131    ///
1132    /// Multiple overlapping diagnostics are separated by a blank line.
1133    /// Returns an empty vec when there are no overlapping diagnostics,
1134    /// or no buffer/URI resolves.
1135    fn compose_hover_diagnostic_lines(
1136        &self,
1137        lsp_pos: (u32, u32),
1138    ) -> Vec<crate::view::markdown::StyledLine> {
1139        use crate::view::markdown::StyledLine;
1140        use lsp_types::DiagnosticSeverity;
1141        use ratatui::style::{Modifier, Style};
1142
1143        let buffer_id = self.active_buffer();
1144        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
1145            return Vec::new();
1146        };
1147        let Some(uri) = metadata.file_uri() else {
1148            return Vec::new();
1149        };
1150        let Some(diagnostics) = self.get_stored_diagnostics().get(uri.as_str()) else {
1151            return Vec::new();
1152        };
1153
1154        let (hover_line, hover_char) = lsp_pos;
1155        let overlapping: Vec<&lsp_types::Diagnostic> = diagnostics
1156            .iter()
1157            .filter(|d| lsp_range_contains(&d.range, hover_line, hover_char))
1158            .collect();
1159
1160        if overlapping.is_empty() {
1161            return Vec::new();
1162        }
1163
1164        let mut out: Vec<StyledLine> = Vec::new();
1165        for (idx, diag) in overlapping.iter().enumerate() {
1166            if idx > 0 {
1167                out.push(StyledLine::new());
1168            }
1169
1170            let (label, marker, severity_color) = match diag.severity {
1171                Some(DiagnosticSeverity::ERROR) => {
1172                    ("Error", "✖", self.theme.read().unwrap().diagnostic_error_fg)
1173                }
1174                Some(DiagnosticSeverity::WARNING) => (
1175                    "Warning",
1176                    "⚠",
1177                    self.theme.read().unwrap().diagnostic_warning_fg,
1178                ),
1179                Some(DiagnosticSeverity::INFORMATION) => {
1180                    ("Info", "ℹ", self.theme.read().unwrap().diagnostic_info_fg)
1181                }
1182                Some(DiagnosticSeverity::HINT) => {
1183                    ("Hint", "ℹ", self.theme.read().unwrap().diagnostic_hint_fg)
1184                }
1185                _ => ("Diagnostic", "•", self.theme.read().unwrap().popup_text_fg),
1186            };
1187
1188            let header_style = Style::default()
1189                .fg(severity_color)
1190                .add_modifier(Modifier::BOLD);
1191            let mut header = StyledLine::new();
1192            header.push(format!("{} {}", marker, label), header_style);
1193            if let Some(source) = diag.source.as_deref().filter(|s| !s.is_empty()) {
1194                // Dim italic source tag — reads as metadata, not as part
1195                // of the diagnostic text.
1196                header.push(
1197                    format!("  ({})", source),
1198                    Style::default()
1199                        .fg(self.theme.read().unwrap().tab_inactive_fg)
1200                        .add_modifier(Modifier::ITALIC),
1201                );
1202            }
1203            out.push(header);
1204
1205            // Message verbatim: one styled line per message line. Using
1206            // `popup_text_fg` lets themes override the body color; the
1207            // severity information is already conveyed by the header.
1208            for message_line in diag.message.lines() {
1209                let mut line = StyledLine::new();
1210                line.push(
1211                    message_line.to_string(),
1212                    Style::default().fg(self.theme.read().unwrap().popup_text_fg),
1213                );
1214                out.push(line);
1215            }
1216        }
1217        out
1218    }
1219
1220    /// Apply inlay hints to editor state as virtual text
1221    #[doc(hidden)]
1222    pub fn apply_inlay_hints_to_state(
1223        state: &mut crate::state::EditorState,
1224        hints: &[lsp_types::InlayHint],
1225    ) {
1226        use crate::view::virtual_text::VirtualTextPosition;
1227        use ratatui::style::{Color, Style};
1228
1229        // Clear existing inlay hints
1230        state.virtual_texts.clear(&mut state.marker_list);
1231
1232        if hints.is_empty() {
1233            return;
1234        }
1235
1236        // Fallback style for inlay hints - dimmed to not distract from actual
1237        // code. The actual on-screen color is resolved from the theme key
1238        // below (`editor.line_number_fg`) so the hints follow the active
1239        // theme. This fallback only applies when the theme doesn't define
1240        // the key.
1241        let hint_style = Style::default().fg(Color::Rgb(128, 128, 128));
1242        let hint_fg_theme_key = Some("editor.line_number_fg".to_string());
1243
1244        for hint in hints {
1245            // Convert LSP position to byte offset
1246            let byte_offset = state.buffer.lsp_position_to_byte(
1247                hint.position.line as usize,
1248                hint.position.character as usize,
1249            );
1250
1251            // Extract text from hint label
1252            let text = match &hint.label {
1253                lsp_types::InlayHintLabel::String(s) => s.clone(),
1254                lsp_types::InlayHintLabel::LabelParts(parts) => {
1255                    parts.iter().map(|p| p.value.as_str()).collect::<String>()
1256                }
1257            };
1258
1259            // LSP inlay hint positions are insertion points between characters.
1260            // For positions within the buffer, render hints before the character at the
1261            // byte offset so they appear at the correct location (e.g., before punctuation
1262            // or newline). Hints at or beyond EOF are anchored to the last character and
1263            // rendered after it.
1264            if state.buffer.is_empty() {
1265                continue;
1266            }
1267
1268            // Pick the anchor character for this hint. If the LSP-computed
1269            // byte lies on a line terminator (\n or the \r of a CRLF), the
1270            // "following character" is the first byte of the next line.
1271            // Anchoring to it would make the hint drift one line down on
1272            // any whitespace edit adjacent to the brace (issue #1572), so
1273            // instead anchor to the *preceding* non-newline character with
1274            // `AfterChar`. That keeps the hint stuck to the glyph the LSP
1275            // intended to annotate even as edits shift bytes around it.
1276            let buf_len = state.buffer.len();
1277            let byte_here = if byte_offset < buf_len {
1278                state
1279                    .buffer
1280                    .slice_bytes(byte_offset..byte_offset + 1)
1281                    .first()
1282                    .copied()
1283            } else {
1284                None
1285            };
1286            let at_line_break = matches!(byte_here, Some(b'\n' | b'\r'));
1287
1288            let (byte_offset, position) = if byte_offset >= buf_len {
1289                // Hint is at EOF: anchor to last character and render
1290                // after it.
1291                (buf_len.saturating_sub(1), VirtualTextPosition::AfterChar)
1292            } else if at_line_break && byte_offset > 0 {
1293                // Hint points past the last glyph on a line: anchor to
1294                // that glyph with AfterChar so the marker cannot drift
1295                // onto a subsequent line when whitespace is edited.
1296                (byte_offset - 1, VirtualTextPosition::AfterChar)
1297            } else {
1298                (byte_offset, VirtualTextPosition::BeforeChar)
1299            };
1300
1301            // Use the hint text as-is - spacing is handled during rendering
1302            let display_text = text;
1303
1304            state.virtual_texts.add_with_theme_keys(
1305                &mut state.marker_list,
1306                byte_offset,
1307                display_text,
1308                hint_style,
1309                hint_fg_theme_key.clone(),
1310                None,
1311                position,
1312                0, // Default priority
1313            );
1314        }
1315
1316        tracing::debug!("Applied {} inlay hints as virtual text", hints.len());
1317    }
1318
1319    /// Request LSP find references at current cursor position
1320    pub(crate) fn request_references(&mut self) -> AnyhowResult<()> {
1321        use crate::primitives::word_navigation::{find_word_end, find_word_start};
1322
1323        let cursor_pos = self.active_cursors().primary().position;
1324        let (line, character, symbol) = {
1325            let state = self.active_state();
1326            let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
1327            let word_start = find_word_start(&state.buffer, cursor_pos);
1328            let word_end = find_word_end(&state.buffer, cursor_pos);
1329            let symbol = String::from_utf8_lossy(&state.buffer.slice_bytes(word_start..word_end))
1330                .into_owned();
1331            (line, character, symbol)
1332        };
1333
1334        let buffer_id = self.active_buffer();
1335        let request_id = self.active_window_mut().next_lsp_request_id;
1336
1337        // Use helper to ensure didOpen is sent before the request
1338        let sent = self
1339            .with_lsp_for_buffer(
1340                buffer_id,
1341                LspFeature::References,
1342                |handle, uri, _language| {
1343                    let result = handle.references(
1344                        request_id,
1345                        uri.as_uri().clone(),
1346                        line as u32,
1347                        character as u32,
1348                    );
1349                    if result.is_ok() {
1350                        tracing::info!(
1351                            "Requested find references at {}:{}:{} (byte_pos={})",
1352                            uri.as_str(),
1353                            line,
1354                            character,
1355                            cursor_pos
1356                        );
1357                    }
1358                    result.is_ok()
1359                },
1360            )
1361            .unwrap_or(false);
1362
1363        if sent {
1364            self.active_window_mut().next_lsp_request_id += 1;
1365            self.active_window_mut().pending_references_request = Some(request_id);
1366            self.active_window_mut().pending_references_symbol = symbol;
1367        }
1368
1369        Ok(())
1370    }
1371
1372    /// Request LSP signature help at current cursor position
1373    pub(crate) fn request_signature_help(&mut self) {
1374        // Get the current buffer and cursor position
1375        let cursor_pos = self.active_cursors().primary().position;
1376        let state = self.active_state();
1377
1378        // Convert byte position to LSP position (line, UTF-16 code units)
1379        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
1380        let buffer_id = self.active_buffer();
1381        let request_id = self.active_window_mut().next_lsp_request_id;
1382
1383        // Use helper to ensure didOpen is sent before the request
1384        let sent = self
1385            .with_lsp_for_buffer(
1386                buffer_id,
1387                LspFeature::SignatureHelp,
1388                |handle, uri, _language| {
1389                    let result = handle.signature_help(
1390                        request_id,
1391                        uri.as_uri().clone(),
1392                        line as u32,
1393                        character as u32,
1394                    );
1395                    if result.is_ok() {
1396                        tracing::info!(
1397                            "Requested signature help at {}:{}:{} (byte_pos={})",
1398                            uri.as_str(),
1399                            line,
1400                            character,
1401                            cursor_pos
1402                        );
1403                    }
1404                    result.is_ok()
1405                },
1406            )
1407            .unwrap_or(false);
1408
1409        if sent {
1410            self.active_window_mut().next_lsp_request_id += 1;
1411            self.active_window_mut().pending_signature_help_request = Some(request_id);
1412        }
1413    }
1414
1415    /// Handle signature help response from LSP
1416    pub(crate) fn handle_signature_help_response(
1417        &mut self,
1418        request_id: u64,
1419        signature_help: Option<lsp_types::SignatureHelp>,
1420    ) {
1421        // Check if this response is for the current pending request
1422        if self.active_window_mut().pending_signature_help_request != Some(request_id) {
1423            tracing::debug!("Ignoring stale signature help response: {}", request_id);
1424            return;
1425        }
1426
1427        self.active_window_mut().pending_signature_help_request = None;
1428        let signature_help = match signature_help {
1429            Some(help) if !help.signatures.is_empty() => help,
1430            _ => {
1431                tracing::debug!("No signature help available");
1432                return;
1433            }
1434        };
1435
1436        // Get the active signature
1437        let active_signature_idx = signature_help.active_signature.unwrap_or(0) as usize;
1438        let signature = match signature_help.signatures.get(active_signature_idx) {
1439            Some(sig) => sig,
1440            None => return,
1441        };
1442
1443        // Build the display content as markdown
1444        let mut content = String::new();
1445
1446        // Add the signature label (function signature)
1447        content.push_str(&signature.label);
1448        content.push('\n');
1449
1450        // Add parameter highlighting info
1451        let active_param = signature_help
1452            .active_parameter
1453            .or(signature.active_parameter)
1454            .unwrap_or(0) as usize;
1455
1456        // If there are parameters, highlight the active one
1457        if let Some(params) = &signature.parameters {
1458            if let Some(param) = params.get(active_param) {
1459                // Get parameter label
1460                let param_label = match &param.label {
1461                    lsp_types::ParameterLabel::Simple(s) => s.clone(),
1462                    lsp_types::ParameterLabel::LabelOffsets(offsets) => {
1463                        // Extract substring from signature label
1464                        let start = offsets[0] as usize;
1465                        let end = offsets[1] as usize;
1466                        if end <= signature.label.len() {
1467                            signature.label[start..end].to_string()
1468                        } else {
1469                            String::new()
1470                        }
1471                    }
1472                };
1473
1474                if !param_label.is_empty() {
1475                    content.push_str(&format!("\n> {}\n", param_label));
1476                }
1477
1478                // Add parameter documentation if available
1479                if let Some(doc) = &param.documentation {
1480                    let doc_text = match doc {
1481                        lsp_types::Documentation::String(s) => s.clone(),
1482                        lsp_types::Documentation::MarkupContent(m) => m.value.clone(),
1483                    };
1484                    if !doc_text.is_empty() {
1485                        content.push('\n');
1486                        content.push_str(&doc_text);
1487                        content.push('\n');
1488                    }
1489                }
1490            }
1491        }
1492
1493        // Add function documentation if available
1494        if let Some(doc) = &signature.documentation {
1495            let doc_text = match doc {
1496                lsp_types::Documentation::String(s) => s.clone(),
1497                lsp_types::Documentation::MarkupContent(m) => m.value.clone(),
1498            };
1499            if !doc_text.is_empty() {
1500                content.push_str("\n---\n\n");
1501                content.push_str(&space_doc_paragraphs(&doc_text));
1502            }
1503        }
1504
1505        // Create a popup with markdown rendering (like hover popup)
1506        use crate::view::popup::{Popup, PopupPosition};
1507        use ratatui::style::Style;
1508
1509        let mut popup = Popup::markdown(
1510            &content,
1511            &*self.theme.read().unwrap(),
1512            Some(&self.grammar_registry),
1513        );
1514        popup.title = Some(t!("lsp.popup_signature").to_string());
1515        popup.transient = true;
1516        popup.position = PopupPosition::BelowCursor;
1517        popup.width = 60;
1518        popup.max_height = 20;
1519        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1520        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1521        popup.focus_key_hint = self.popup_focus_key_hint();
1522
1523        // Show the popup
1524        let __buffer_id = self.active_buffer();
1525        if let Some(state) = self
1526            .windows
1527            .get_mut(&self.active_window)
1528            .map(|w| &mut w.buffers)
1529            .expect("active window present")
1530            .get_mut(&__buffer_id)
1531        {
1532            state.popups.show(popup);
1533            tracing::info!(
1534                "Showing signature help popup for {} signatures",
1535                signature_help.signatures.len()
1536            );
1537        }
1538    }
1539
1540    /// Request LSP code actions at current cursor position.
1541    /// Sends code action requests to all eligible servers for merged results.
1542    pub(crate) fn request_code_actions(&mut self) -> AnyhowResult<()> {
1543        // A new invocation starts a fresh batch. Cancel any previous
1544        // in-flight code-action requests so their late responses are
1545        // ignored (handle_code_actions_response drops responses whose
1546        // request_id isn't in pending_code_actions_requests). Without
1547        // this, actions from a prior cursor position would be merged
1548        // into the new popup — same bug class we already avoid for
1549        // completion (sinelaw/fresh#1514) and inlay hints (multi-buffer
1550        // quiescent).
1551        if !self
1552            .active_window()
1553            .pending_code_actions_requests
1554            .is_empty()
1555        {
1556            let ids: Vec<u64> = self
1557                .active_window_mut()
1558                .pending_code_actions_requests
1559                .drain()
1560                .collect();
1561            for request_id in ids {
1562                tracing::debug!(
1563                    "Canceling previous pending LSP code actions request {}",
1564                    request_id
1565                );
1566                self.active_window_mut().send_lsp_cancel_request(request_id);
1567            }
1568        }
1569        self.active_window_mut()
1570            .pending_code_actions_server_names
1571            .clear();
1572        self.active_window_mut().pending_code_actions = None;
1573
1574        // Get the current buffer and cursor position
1575        let cursor_pos = self.active_cursors().primary().position;
1576        let selection_range = self.active_cursors().primary().selection_range();
1577        let state = self.active_state();
1578
1579        // Convert byte position to LSP position (line, UTF-16 code units)
1580        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
1581
1582        // Get selection range (if any) or use cursor position
1583        let (start_line, start_char, end_line, end_char) = if let Some(range) = selection_range {
1584            let (s_line, s_char) = state.buffer.position_to_lsp_position(range.start);
1585            let (e_line, e_char) = state.buffer.position_to_lsp_position(range.end);
1586            (s_line as u32, s_char as u32, e_line as u32, e_char as u32)
1587        } else {
1588            (line as u32, character as u32, line as u32, character as u32)
1589        };
1590
1591        // Get diagnostics at cursor position for context
1592        // TODO: Implement diagnostic retrieval when needed
1593        let diagnostics: Vec<lsp_types::Diagnostic> = Vec::new();
1594        let buffer_id = self.active_buffer();
1595
1596        // Pre-allocate request IDs for all eligible servers
1597        let base_request_id = self.active_window_mut().next_lsp_request_id;
1598        let counter = std::sync::atomic::AtomicU64::new(0);
1599
1600        let results = self.with_all_lsp_for_buffer_feature_named(
1601            buffer_id,
1602            LspFeature::CodeAction,
1603            |handle, uri, _language, server_name| {
1604                let idx = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1605                let request_id = base_request_id + idx;
1606                let result = handle.code_actions(
1607                    request_id,
1608                    uri.as_uri().clone(),
1609                    start_line,
1610                    start_char,
1611                    end_line,
1612                    end_char,
1613                    diagnostics.clone(),
1614                );
1615                if result.is_ok() {
1616                    tracing::info!(
1617                        "Requested code actions at {}:{}:{}-{}:{} (byte_pos={}, request_id={}, server={})",
1618                        uri.as_str(),
1619                        start_line,
1620                        start_char,
1621                        end_line,
1622                        end_char,
1623                        cursor_pos,
1624                        request_id,
1625                        server_name
1626                    );
1627                }
1628                (request_id, result.is_ok(), server_name.to_string())
1629            },
1630        );
1631
1632        let mut sent_ids = Vec::new();
1633        for (request_id, ok, server_name) in &results {
1634            if *ok {
1635                sent_ids.push(*request_id);
1636                self.active_window_mut()
1637                    .pending_code_actions_server_names
1638                    .insert(*request_id, server_name.clone());
1639            }
1640        }
1641        // Advance the ID counter past all allocated IDs
1642        self.active_window_mut().next_lsp_request_id = base_request_id + results.len() as u64;
1643
1644        if !sent_ids.is_empty() {
1645            // pending_code_actions was already cleared above alongside the
1646            // cancel-previous-requests logic.
1647            self.active_window_mut()
1648                .pending_code_actions_requests
1649                .extend(sent_ids);
1650        }
1651
1652        Ok(())
1653    }
1654
1655    /// Handle code actions response from LSP.
1656    /// Supports merging from multiple servers: each response extends the action
1657    /// list, and the popup is shown/updated with each arriving response.
1658    pub(crate) fn handle_code_actions_response(
1659        &mut self,
1660        request_id: u64,
1661        actions: Vec<lsp_types::CodeActionOrCommand>,
1662    ) {
1663        // Check if this response is for one of the pending requests
1664        if !self
1665            .active_window_mut()
1666            .pending_code_actions_requests
1667            .remove(&request_id)
1668        {
1669            tracing::debug!("Ignoring stale code actions response: {}", request_id);
1670            return;
1671        }
1672
1673        // Look up the server name for this request
1674        let server_name = self
1675            .active_window_mut()
1676            .pending_code_actions_server_names
1677            .remove(&request_id)
1678            .unwrap_or_default();
1679
1680        if actions.is_empty() {
1681            // Only show "no code actions" if all responses are in and we have nothing
1682            if self
1683                .active_window()
1684                .pending_code_actions_requests
1685                .is_empty()
1686                && self
1687                    .active_window_mut()
1688                    .pending_code_actions
1689                    .as_ref()
1690                    .is_none_or(|a| a.is_empty())
1691            {
1692                self.set_status_message(t!("lsp.no_code_actions").to_string());
1693            }
1694            return;
1695        }
1696
1697        // Tag each action with its server name and store/extend for merging
1698        let tagged_actions: Vec<(String, lsp_types::CodeActionOrCommand)> = actions
1699            .into_iter()
1700            .map(|a| (server_name.clone(), a))
1701            .collect();
1702
1703        match &mut self.active_window_mut().pending_code_actions {
1704            Some(existing) => {
1705                existing.extend(tagged_actions);
1706                tracing::debug!("Extended code actions, now {} total", existing.len());
1707            }
1708            None => {
1709                self.active_window_mut().pending_code_actions = Some(tagged_actions);
1710            }
1711        }
1712
1713        // Build list items from all accumulated code actions
1714        use crate::view::popup::{Popup, PopupListItem, PopupPosition};
1715        use ratatui::style::Style;
1716
1717        let items: Vec<PopupListItem> = {
1718            let all_actions = self.active_window().pending_code_actions.as_ref().unwrap();
1719            let multiple_servers = {
1720                let mut names = std::collections::HashSet::new();
1721                for (name, _) in all_actions {
1722                    names.insert(name.as_str());
1723                }
1724                names.len() > 1
1725            };
1726            all_actions
1727                .iter()
1728                .enumerate()
1729                .map(|(i, (srv_name, action))| {
1730                    let title = match action {
1731                        lsp_types::CodeActionOrCommand::Command(cmd) => &cmd.title,
1732                        lsp_types::CodeActionOrCommand::CodeAction(ca) => &ca.title,
1733                    };
1734                    let kind = match action {
1735                        lsp_types::CodeActionOrCommand::CodeAction(ca) => {
1736                            ca.kind.as_ref().map(|k| k.as_str().to_string())
1737                        }
1738                        _ => None,
1739                    };
1740                    let detail = if multiple_servers && !srv_name.is_empty() {
1741                        match kind {
1742                            Some(k) => Some(format!("[{}] {}", srv_name, k)),
1743                            None => Some(format!("[{}]", srv_name)),
1744                        }
1745                    } else {
1746                        kind
1747                    };
1748                    PopupListItem {
1749                        text: format!("{}. {}", i + 1, title),
1750                        detail,
1751                        icon: None,
1752                        data: Some(i.to_string()),
1753                        disabled: false,
1754                    }
1755                })
1756                .collect()
1757        };
1758
1759        let mut popup = Popup::list(items, &*self.theme.read().unwrap());
1760        popup.kind = crate::view::popup::PopupKind::Action;
1761        popup.title = Some(t!("lsp.popup_code_actions").to_string());
1762        popup.position = PopupPosition::BelowCursor;
1763        popup.width = 60;
1764        popup.max_height = 15;
1765        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1766        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1767        // Confirm reads the selected row's `data` as an index into
1768        // `self.active_window_mut().pending_code_actions` — the heavy lsp_types payload
1769        // stays on the Editor to keep the view crate LSP-free.
1770        popup.resolver = crate::view::popup::PopupResolver::CodeAction;
1771        // Code actions are an explicit user invocation (`lsp_code_actions`
1772        // command); the user expects to choose immediately, so the popup
1773        // grabs focus on creation. Unfocused-by-default behavior applies
1774        // only to popups that *appear under the cursor* (completion,
1775        // hover, signature help, the LSP-server auto-prompt).
1776        popup.focused = true;
1777
1778        // Show the popup, replacing any existing action popup to avoid stacking
1779        let __buffer_id = self.active_buffer();
1780        let action_count = self
1781            .active_window()
1782            .pending_code_actions
1783            .as_ref()
1784            .map_or(0, |v| v.len());
1785        if let Some(state) = self
1786            .windows
1787            .get_mut(&self.active_window)
1788            .map(|w| &mut w.buffers)
1789            .expect("active window present")
1790            .get_mut(&__buffer_id)
1791        {
1792            state.popups.show_or_replace(popup);
1793            tracing::info!("Showing code actions popup with {} actions", action_count);
1794        }
1795    }
1796
1797    /// Execute a code action by index from the stored pending_code_actions.
1798    pub(crate) fn execute_code_action(&mut self, index: usize) {
1799        let action = match &self.active_window_mut().pending_code_actions {
1800            Some(actions) => actions.get(index).map(|(_, a)| a.clone()),
1801            None => None,
1802        };
1803
1804        let Some(action) = action else {
1805            tracing::warn!("Code action index {} out of range", index);
1806            return;
1807        };
1808
1809        match action {
1810            lsp_types::CodeActionOrCommand::CodeAction(ca) => {
1811                // If the action has no edit and no command, it may need resolve first.
1812                // Only resolve if the action has `data` and the server supports resolveProvider.
1813                if ca.edit.is_none()
1814                    && ca.command.is_none()
1815                    && ca.data.is_some()
1816                    && self.active_window().server_supports_code_action_resolve()
1817                {
1818                    tracing::info!(
1819                        "Code action '{}' needs resolve, sending codeAction/resolve",
1820                        ca.title
1821                    );
1822                    self.send_code_action_resolve(ca);
1823                    return;
1824                }
1825                self.execute_resolved_code_action(ca);
1826            }
1827            lsp_types::CodeActionOrCommand::Command(cmd) => {
1828                self.send_execute_command(cmd);
1829            }
1830        }
1831    }
1832
1833    /// Execute a code action that has been fully resolved (has edit and/or command).
1834    pub(crate) fn execute_resolved_code_action(&mut self, ca: lsp_types::CodeAction) {
1835        let title = ca.title.clone();
1836
1837        // Apply workspace edit if present
1838        if let Some(edit) = ca.edit {
1839            match self.apply_workspace_edit(edit) {
1840                Ok(n) => {
1841                    self.set_status_message(
1842                        t!("lsp.code_action_applied", title = &title, count = n).to_string(),
1843                    );
1844                }
1845                Err(e) => {
1846                    self.set_status_message(format!("Code action failed: {e}"));
1847                    return;
1848                }
1849            }
1850        }
1851
1852        // Execute command if present (may trigger workspace/applyEdit from server)
1853        if let Some(cmd) = ca.command {
1854            self.send_execute_command(cmd);
1855        }
1856    }
1857
1858    /// Send workspace/executeCommand to the LSP server
1859    fn send_execute_command(&mut self, cmd: lsp_types::Command) {
1860        tracing::info!("Executing LSP command: {} ({})", cmd.title, cmd.command);
1861        self.set_status_message(
1862            t!(
1863                "lsp.code_action_applied",
1864                title = &cmd.title,
1865                count = 0_usize
1866            )
1867            .to_string(),
1868        );
1869
1870        // Get the language for this buffer to find the right LSP handle
1871        let language = match self
1872            .buffers()
1873            .get(&self.active_buffer())
1874            .map(|s| s.language.clone())
1875        {
1876            Some(l) => l,
1877            None => return,
1878        };
1879
1880        let __active_id = self.active_window;
1881
1882        if let Some(lsp) = self
1883            .windows
1884            .get_mut(&__active_id)
1885            .and_then(|w| w.lsp.as_mut())
1886        {
1887            for sh in lsp.get_handles_mut(&language) {
1888                if let Err(e) = sh
1889                    .handle
1890                    .execute_command(cmd.command.clone(), cmd.arguments.clone())
1891                {
1892                    tracing::warn!("Failed to send executeCommand to '{}': {}", sh.name, e);
1893                }
1894            }
1895        }
1896    }
1897
1898    /// Send codeAction/resolve to the LSP server
1899    fn send_code_action_resolve(&mut self, action: lsp_types::CodeAction) {
1900        let language = match self
1901            .buffers()
1902            .get(&self.active_buffer())
1903            .map(|s| s.language.clone())
1904        {
1905            Some(l) => l,
1906            None => return,
1907        };
1908
1909        self.active_window_mut().next_lsp_request_id += 1;
1910        let request_id = self.active_window_mut().next_lsp_request_id;
1911
1912        let __active_id = self.active_window;
1913
1914        if let Some(lsp) = self
1915            .windows
1916            .get_mut(&__active_id)
1917            .and_then(|w| w.lsp.as_mut())
1918        {
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
2004            .windows
2005            .get_mut(&__active_id)
2006            .and_then(|w| w.lsp.as_mut())
2007        {
2008            if let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::Format) {
2009                if let Err(e) = sh.handle.document_formatting(
2010                    request_id,
2011                    uri.as_uri().clone(),
2012                    tab_size,
2013                    insert_spaces,
2014                ) {
2015                    tracing::warn!("Failed to request formatting: {}", e);
2016                }
2017            } else {
2018                self.set_status_message("Formatting not supported by LSP server".to_string());
2019            }
2020        }
2021    }
2022
2023    /// Handle find references response from LSP
2024    pub(crate) fn handle_references_response(
2025        &mut self,
2026        request_id: u64,
2027        locations: Vec<lsp_types::Location>,
2028    ) -> AnyhowResult<()> {
2029        tracing::info!(
2030            "handle_references_response: received {} locations for request_id={}",
2031            locations.len(),
2032            request_id
2033        );
2034
2035        // Check if this response is for the current pending request
2036        if self.active_window_mut().pending_references_request != Some(request_id) {
2037            tracing::debug!("Ignoring stale references response: {}", request_id);
2038            return Ok(());
2039        }
2040
2041        self.active_window_mut().pending_references_request = None;
2042        if locations.is_empty() {
2043            self.set_status_message(t!("lsp.no_references").to_string());
2044            return Ok(());
2045        }
2046
2047        // Convert locations to hook args format. Each `loc.uri` is a
2048        // wire-side URI from the LSP, so wrap it in [`LspUri`] and run
2049        // it through the active authority's translation before
2050        // handing a host-path string to the references hook —
2051        // otherwise plugins (notably `find_references`) try to open
2052        // an in-container path on the host and fail.
2053        let translation = self.authority.path_translation.clone();
2054        let lsp_locations: Vec<crate::services::plugins::hooks::LspLocation> = locations
2055            .iter()
2056            .map(|loc| {
2057                let wire = crate::app::types::LspUri::from_wire(loc.uri.clone());
2058                // Prefer the host-side path (after translation) so
2059                // plugin-side file ops resolve. Fall back to the raw
2060                // string for non-`file://` URIs so callers can still
2061                // see *something*.
2062                let file = if loc.uri.scheme().map(|s| s.as_str()) == Some("file") {
2063                    wire.to_host_path(translation.as_ref())
2064                        .map(|p| p.to_string_lossy().into_owned())
2065                        .unwrap_or_else(|| loc.uri.path().as_str().to_string())
2066                } else {
2067                    loc.uri.as_str().to_string()
2068                };
2069
2070                crate::services::plugins::hooks::LspLocation {
2071                    file,
2072                    line: loc.range.start.line + 1, // LSP is 0-based, convert to 1-based
2073                    column: loc.range.start.character + 1, // LSP is 0-based
2074                }
2075            })
2076            .collect();
2077
2078        let count = lsp_locations.len();
2079        let symbol = std::mem::take(&mut self.active_window_mut().pending_references_symbol);
2080        self.set_status_message(
2081            t!("lsp.found_references", count = count, symbol = &symbol).to_string(),
2082        );
2083
2084        // Fire the lsp_references hook so plugins can display the results
2085        self.plugin_manager.read().unwrap().run_hook(
2086            "lsp_references",
2087            crate::services::plugins::hooks::HookArgs::LspReferences {
2088                symbol: symbol.clone(),
2089                locations: lsp_locations,
2090            },
2091        );
2092
2093        tracing::info!(
2094            "Fired lsp_references hook with {} locations for symbol '{}'",
2095            count,
2096            symbol
2097        );
2098
2099        Ok(())
2100    }
2101
2102    /// Apply LSP text edits to a buffer and return the number of changes made.
2103    /// Edits are sorted in reverse order and applied as a batch.
2104    pub(crate) fn apply_lsp_text_edits(
2105        &mut self,
2106        buffer_id: BufferId,
2107        mut edits: Vec<lsp_types::TextEdit>,
2108    ) -> AnyhowResult<usize> {
2109        if edits.is_empty() {
2110            return Ok(0);
2111        }
2112
2113        // Sort edits by position (reverse order to avoid offset issues)
2114        edits.sort_by(|a, b| {
2115            b.range
2116                .start
2117                .line
2118                .cmp(&a.range.start.line)
2119                .then(b.range.start.character.cmp(&a.range.start.character))
2120        });
2121
2122        // Collect all events for this buffer into a batch
2123        let mut batch_events = Vec::new();
2124        let mut changes = 0;
2125
2126        // Get cursor_id for this buffer from split view state
2127        let cursor_id = {
2128            let split_id = self
2129                .split_manager_mut()
2130                .splits_for_buffer(buffer_id)
2131                .into_iter()
2132                .next()
2133                .unwrap_or_else(|| {
2134                    self.windows
2135                        .get(&self.active_window)
2136                        .and_then(|w| w.buffers.splits())
2137                        .map(|(mgr, _)| mgr)
2138                        .expect("active window must have a populated split layout")
2139                        .active_split()
2140                });
2141            self.windows
2142                .get(&self.active_window)
2143                .and_then(|w| w.buffers.splits())
2144                .map(|(_, vs)| vs)
2145                .expect("active window must have a populated split layout")
2146                .get(&split_id)
2147                .map(|vs| vs.cursors.primary_id())
2148                .unwrap_or_else(|| self.active_cursors().primary_id())
2149        };
2150
2151        // Create events for all edits
2152        for edit in edits {
2153            let state = self
2154                .buffers_mut()
2155                .get_mut(&buffer_id)
2156                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))?;
2157
2158            // Convert LSP range to byte positions
2159            let start_line = edit.range.start.line as usize;
2160            let start_char = edit.range.start.character as usize;
2161            let end_line = edit.range.end.line as usize;
2162            let end_char = edit.range.end.character as usize;
2163
2164            let start_pos = state.buffer.lsp_position_to_byte(start_line, start_char);
2165            let end_pos = state.buffer.lsp_position_to_byte(end_line, end_char);
2166            let buffer_len = state.buffer.len();
2167
2168            // Log the conversion for debugging
2169            let old_text = if start_pos < end_pos && end_pos <= buffer_len {
2170                state.get_text_range(start_pos, end_pos)
2171            } else {
2172                format!(
2173                    "<invalid range: start={}, end={}, buffer_len={}>",
2174                    start_pos, end_pos, buffer_len
2175                )
2176            };
2177            tracing::debug!(
2178                "  Converting LSP range line {}:{}-{}:{} to bytes {}..{} (replacing {:?} with {:?})",
2179                start_line, start_char, end_line, end_char,
2180                start_pos, end_pos, old_text, edit.new_text
2181            );
2182
2183            // Delete old text
2184            if start_pos < end_pos {
2185                let deleted_text = state.get_text_range(start_pos, end_pos);
2186                let delete_event = Event::Delete {
2187                    range: start_pos..end_pos,
2188                    deleted_text,
2189                    cursor_id,
2190                };
2191                batch_events.push(delete_event);
2192            }
2193
2194            // Insert new text
2195            if !edit.new_text.is_empty() {
2196                let insert_event = Event::Insert {
2197                    position: start_pos,
2198                    text: edit.new_text.clone(),
2199                    cursor_id,
2200                };
2201                batch_events.push(insert_event);
2202            }
2203
2204            changes += 1;
2205        }
2206
2207        // Apply all rename changes using bulk edit for O(n) performance
2208        if !batch_events.is_empty() {
2209            self.apply_events_to_buffer_as_bulk_edit(
2210                buffer_id,
2211                batch_events,
2212                "LSP Rename".to_string(),
2213            )?;
2214        }
2215
2216        Ok(changes)
2217    }
2218
2219    /// Apply a single TextDocumentEdit from a workspace edit.
2220    ///
2221    /// Per LSP spec: if `text_document.version` is non-null, it must match the
2222    /// version we last sent via didOpen/didChange. On mismatch the edit is stale
2223    /// and we skip it to avoid corrupting the buffer.
2224    fn apply_text_document_edit(
2225        &mut self,
2226        text_doc_edit: lsp_types::TextDocumentEdit,
2227    ) -> AnyhowResult<usize> {
2228        // Wrap the incoming wire URI once; both the version-check
2229        // lookup and the file-open below need the host-path form.
2230        let uri = crate::app::types::LspUri::from_wire(text_doc_edit.text_document.uri);
2231
2232        // Version check: if the server specifies a version, verify it matches
2233        // what we sent. A mismatch means the edit was computed against stale content.
2234        if let Some(expected_version) = text_doc_edit.text_document.version {
2235            if let Ok(path) =
2236                super::lsp_uri_to_host_path(&uri, self.authority.path_translation.as_ref())
2237            {
2238                if let Some(lsp) = self.lsp() {
2239                    let language = self
2240                        .buffers()
2241                        .get(&self.active_buffer())
2242                        .map(|s| s.language.clone())
2243                        .unwrap_or_default();
2244                    for sh in lsp.get_handles(&language) {
2245                        if let Some(current_version) = sh.handle.document_version(&path) {
2246                            if (expected_version as i64) != current_version {
2247                                tracing::warn!(
2248                                    "Rejecting stale TextDocumentEdit for {:?}: \
2249                                     server version {} != our version {}",
2250                                    path,
2251                                    expected_version,
2252                                    current_version,
2253                                );
2254                                return Ok(0);
2255                            }
2256                        }
2257                    }
2258                }
2259            }
2260        }
2261
2262        if let Ok(path) =
2263            super::lsp_uri_to_host_path(&uri, self.authority.path_translation.as_ref())
2264        {
2265            let buffer_id = match self.open_file(&path) {
2266                Ok(id) => id,
2267                Err(e) => {
2268                    if let Some(confirmation) =
2269                        e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
2270                    {
2271                        self.start_large_file_encoding_confirmation(confirmation);
2272                    } else {
2273                        self.set_status_message(
2274                            t!("file.error_opening", error = e.to_string()).to_string(),
2275                        );
2276                    }
2277                    return Ok(0);
2278                }
2279            };
2280
2281            let edits: Vec<lsp_types::TextEdit> = text_doc_edit
2282                .edits
2283                .into_iter()
2284                .map(|one_of| match one_of {
2285                    lsp_types::OneOf::Left(text_edit) => text_edit,
2286                    lsp_types::OneOf::Right(annotated) => annotated.text_edit,
2287                })
2288                .collect();
2289
2290            tracing::info!("Applying {} edits for {:?}:", edits.len(), path);
2291            for (i, edit) in edits.iter().enumerate() {
2292                tracing::info!(
2293                    "  Edit {}: line {}:{}-{}:{} -> {:?}",
2294                    i,
2295                    edit.range.start.line,
2296                    edit.range.start.character,
2297                    edit.range.end.line,
2298                    edit.range.end.character,
2299                    edit.new_text
2300                );
2301            }
2302
2303            self.apply_lsp_text_edits(buffer_id, edits)
2304        } else {
2305            Ok(0)
2306        }
2307    }
2308
2309    /// Apply a resource operation (CreateFile, RenameFile, DeleteFile) from a workspace edit.
2310    fn apply_resource_operation(&mut self, op: lsp_types::ResourceOp) -> AnyhowResult<()> {
2311        // Each URI in a resource operation is wire-side and must be
2312        // translated back to the host before we touch the host
2313        // filesystem. Wrapping in [`LspUri`] and calling
2314        // `to_host_path` is the type-checked path.
2315        let translation = self.authority.path_translation.clone();
2316        let to_host = |uri: &lsp_types::Uri| -> std::path::PathBuf {
2317            crate::app::types::LspUri::from_wire(uri.clone())
2318                .to_host_path(translation.as_ref())
2319                .unwrap_or_else(|| std::path::PathBuf::from(uri.path().as_str()))
2320        };
2321        match op {
2322            lsp_types::ResourceOp::Create(create) => {
2323                let path = to_host(&create.uri);
2324                let overwrite = create
2325                    .options
2326                    .as_ref()
2327                    .and_then(|o| o.overwrite)
2328                    .unwrap_or(false);
2329                let ignore_if_exists = create
2330                    .options
2331                    .as_ref()
2332                    .and_then(|o| o.ignore_if_exists)
2333                    .unwrap_or(false);
2334
2335                if path.exists() {
2336                    if ignore_if_exists {
2337                        tracing::debug!("CreateFile: {:?} already exists, ignoring", path);
2338                        return Ok(());
2339                    }
2340                    if !overwrite {
2341                        tracing::warn!("CreateFile: {:?} already exists and overwrite=false", path);
2342                        return Ok(());
2343                    }
2344                }
2345
2346                // Create parent directories if needed
2347                if let Some(parent) = path.parent() {
2348                    std::fs::create_dir_all(parent)?;
2349                }
2350                std::fs::write(&path, "")?;
2351                tracing::info!("CreateFile: created {:?}", path);
2352
2353                // Open the new file as a buffer
2354                if let Err(e) = self.open_file(&path) {
2355                    tracing::warn!("CreateFile: failed to open created file {:?}: {}", path, e);
2356                }
2357            }
2358            lsp_types::ResourceOp::Rename(rename) => {
2359                let old_path = to_host(&rename.old_uri);
2360                let new_path = to_host(&rename.new_uri);
2361                let overwrite = rename
2362                    .options
2363                    .as_ref()
2364                    .and_then(|o| o.overwrite)
2365                    .unwrap_or(false);
2366                let ignore_if_exists = rename
2367                    .options
2368                    .as_ref()
2369                    .and_then(|o| o.ignore_if_exists)
2370                    .unwrap_or(false);
2371
2372                if new_path.exists() {
2373                    if ignore_if_exists {
2374                        tracing::debug!("RenameFile: {:?} already exists, ignoring", new_path);
2375                        return Ok(());
2376                    }
2377                    if !overwrite {
2378                        tracing::warn!(
2379                            "RenameFile: {:?} already exists and overwrite=false",
2380                            new_path
2381                        );
2382                        return Ok(());
2383                    }
2384                }
2385
2386                // Create parent directories if needed
2387                if let Some(parent) = new_path.parent() {
2388                    std::fs::create_dir_all(parent)?;
2389                }
2390                std::fs::rename(&old_path, &new_path)?;
2391                tracing::info!("RenameFile: {:?} -> {:?}", old_path, new_path);
2392            }
2393            lsp_types::ResourceOp::Delete(delete) => {
2394                let path = to_host(&delete.uri);
2395                let recursive = delete
2396                    .options
2397                    .as_ref()
2398                    .and_then(|o| o.recursive)
2399                    .unwrap_or(false);
2400                let ignore_if_not_exists = delete
2401                    .options
2402                    .as_ref()
2403                    .and_then(|o| o.ignore_if_not_exists)
2404                    .unwrap_or(false);
2405
2406                if !path.exists() {
2407                    if ignore_if_not_exists {
2408                        tracing::debug!("DeleteFile: {:?} does not exist, ignoring", path);
2409                        return Ok(());
2410                    }
2411                    tracing::warn!("DeleteFile: {:?} does not exist", path);
2412                    return Ok(());
2413                }
2414
2415                if path.is_dir() && recursive {
2416                    std::fs::remove_dir_all(&path)?;
2417                } else if path.is_file() {
2418                    std::fs::remove_file(&path)?;
2419                }
2420                tracing::info!("DeleteFile: deleted {:?}", path);
2421            }
2422        }
2423        Ok(())
2424    }
2425
2426    /// Apply an LSP WorkspaceEdit (used by rename, code actions, etc.).
2427    ///
2428    /// Returns the total number of text changes applied.
2429    pub(crate) fn apply_workspace_edit(
2430        &mut self,
2431        workspace_edit: lsp_types::WorkspaceEdit,
2432    ) -> AnyhowResult<usize> {
2433        tracing::debug!(
2434            "Applying WorkspaceEdit: changes={:?}, document_changes={:?}",
2435            workspace_edit.changes.as_ref().map(|c| c.len()),
2436            workspace_edit.document_changes.as_ref().map(|dc| match dc {
2437                lsp_types::DocumentChanges::Edits(e) => format!("{} edits", e.len()),
2438                lsp_types::DocumentChanges::Operations(o) => format!("{} operations", o.len()),
2439            })
2440        );
2441
2442        let mut total_changes = 0;
2443
2444        // Handle changes (map of URI -> Vec<TextEdit>)
2445        if let Some(changes) = workspace_edit.changes {
2446            for (uri, edits) in changes {
2447                let uri = crate::app::types::LspUri::from_wire(uri);
2448                if let Ok(path) =
2449                    super::lsp_uri_to_host_path(&uri, self.authority.path_translation.as_ref())
2450                {
2451                    let buffer_id = match self.open_file(&path) {
2452                        Ok(id) => id,
2453                        Err(e) => {
2454                            if let Some(confirmation) = e.downcast_ref::<
2455                                crate::model::buffer::LargeFileEncodingConfirmation,
2456                            >() {
2457                                self.start_large_file_encoding_confirmation(confirmation);
2458                            } else {
2459                                self.set_status_message(
2460                                    t!("file.error_opening", error = e.to_string())
2461                                        .to_string(),
2462                                );
2463                            }
2464                            return Ok(0);
2465                        }
2466                    };
2467                    total_changes += self.apply_lsp_text_edits(buffer_id, edits)?;
2468                }
2469            }
2470        }
2471
2472        // Handle document_changes (TextDocumentEdit[] or DocumentChangeOperation[])
2473        if let Some(document_changes) = workspace_edit.document_changes {
2474            use lsp_types::DocumentChanges;
2475
2476            match document_changes {
2477                DocumentChanges::Edits(edits) => {
2478                    for text_doc_edit in edits {
2479                        total_changes += self.apply_text_document_edit(text_doc_edit)?;
2480                    }
2481                }
2482                DocumentChanges::Operations(ops) => {
2483                    // Process operations in order — resource ops (create/rename/delete)
2484                    // must be applied before text edits on the created/renamed files.
2485                    for op in ops {
2486                        match op {
2487                            lsp_types::DocumentChangeOperation::Edit(text_doc_edit) => {
2488                                total_changes += self.apply_text_document_edit(text_doc_edit)?;
2489                            }
2490                            lsp_types::DocumentChangeOperation::Op(resource_op) => {
2491                                self.apply_resource_operation(resource_op)?;
2492                                total_changes += 1;
2493                            }
2494                        }
2495                    }
2496                }
2497            }
2498        }
2499
2500        Ok(total_changes)
2501    }
2502
2503    /// Handle rename response from LSP
2504    pub fn handle_rename_response(
2505        &mut self,
2506        _request_id: u64,
2507        result: Result<lsp_types::WorkspaceEdit, String>,
2508    ) -> AnyhowResult<()> {
2509        match result {
2510            Ok(workspace_edit) => {
2511                let total_changes = self.apply_workspace_edit(workspace_edit)?;
2512                self.active_window_mut().status_message =
2513                    Some(t!("lsp.renamed", count = total_changes).to_string());
2514            }
2515            Err(error) => {
2516                // Per LSP spec: ContentModified errors (-32801) should NOT be shown to user
2517                if error.contains("content modified") || error.contains("-32801") {
2518                    tracing::debug!(
2519                        "LSP rename: ContentModified error (expected, ignoring): {}",
2520                        error
2521                    );
2522                    self.active_window_mut().status_message =
2523                        Some(t!("lsp.rename_cancelled").to_string());
2524                } else {
2525                    self.active_window_mut().status_message =
2526                        Some(t!("lsp.rename_failed", error = &error).to_string());
2527                }
2528            }
2529        }
2530
2531        Ok(())
2532    }
2533
2534    /// Apply events to a specific buffer using bulk edit optimization (O(n) vs O(n²))
2535    ///
2536    /// This is similar to `apply_events_as_bulk_edit` but works on a specific buffer
2537    /// (which may not be the active buffer) and handles LSP notifications correctly.
2538    pub(crate) fn apply_events_to_buffer_as_bulk_edit(
2539        &mut self,
2540        buffer_id: BufferId,
2541        events: Vec<Event>,
2542        description: String,
2543    ) -> AnyhowResult<()> {
2544        use crate::model::event::CursorId;
2545
2546        if events.is_empty() {
2547            return Ok(());
2548        }
2549
2550        // Create a temporary batch for collecting LSP changes (before applying)
2551        let batch_for_lsp = Event::Batch {
2552            events: events.clone(),
2553            description: description.clone(),
2554        };
2555
2556        // IMPORTANT: Calculate LSP changes BEFORE applying to buffer!
2557        // The byte positions in the events are relative to the ORIGINAL buffer.
2558        //
2559        // The tree-only swap below violates the pane-buffer invariant
2560        // transiently (see active_focus.rs for the invariant's contract)
2561        // but `collect_lsp_changes` does not route any input, call
2562        // `apply_event_to_active_buffer`, or otherwise read
2563        // `active_buffer()` while the invariant is broken, so the drift
2564        // is contained within this synchronous section. If that changes,
2565        // switch to a read-only accessor that takes `buffer_id` directly
2566        // rather than mutating tree state.
2567        let original_active = self.active_buffer();
2568        self.windows
2569            .get_mut(&self.active_window)
2570            .and_then(|w| w.split_manager_mut())
2571            .expect("active window must have a populated split layout")
2572            .set_active_buffer_id(buffer_id);
2573        let lsp_changes = self.active_window().collect_lsp_changes(&batch_for_lsp);
2574        self.windows
2575            .get_mut(&self.active_window)
2576            .and_then(|w| w.split_manager_mut())
2577            .expect("active window must have a populated split layout")
2578            .set_active_buffer_id(original_active);
2579
2580        // Capture old cursor states from split view state
2581        // Find a split that has this buffer in its keyed_states
2582        let split_id_for_cursors = self
2583            .split_manager_mut()
2584            .splits_for_buffer(buffer_id)
2585            .into_iter()
2586            .next()
2587            .unwrap_or_else(|| {
2588                self.windows
2589                    .get(&self.active_window)
2590                    .and_then(|w| w.buffers.splits())
2591                    .map(|(mgr, _)| mgr)
2592                    .expect("active window must have a populated split layout")
2593                    .active_split()
2594            });
2595        let old_cursors: Vec<(CursorId, usize, Option<usize>)> = self
2596            .windows
2597            .get(&self.active_window)
2598            .and_then(|w| w.buffers.splits())
2599            .map(|(_, vs)| vs)
2600            .expect("active window must have a populated split layout")
2601            .get(&split_id_for_cursors)
2602            .and_then(|vs| vs.keyed_states.get(&buffer_id))
2603            .map(|bvs| {
2604                bvs.cursors
2605                    .iter()
2606                    .map(|(id, c)| (id, c.position, c.anchor))
2607                    .collect()
2608            })
2609            .unwrap_or_default();
2610
2611        // TODO: move this whole bulk-edit method to impl Window — the
2612        // body is window-scoped except for `send_lsp_changes_for_buffer`
2613        // at the tail which is LSP coordination on Editor. The block
2614        // below uses a single-window split borrow because the bulk edit
2615        // needs `&mut state` (buffer) and `&mut __vs_map` (split view
2616        // states) live together for the cursor-positioning loop.
2617        let __win = self
2618            .windows
2619            .get_mut(&self.active_window)
2620            .expect("active window must exist");
2621        let bulk_edit = __win
2622            .buffers
2623            .with_buffer_and_view_states(buffer_id, |state, vs_map| -> AnyhowResult<Event> {
2624                // Snapshot buffer state for undo (piece tree + buffers)
2625                let old_snapshot = state.buffer.snapshot_buffer_state();
2626
2627                // Convert events to edit tuples: (position, delete_len, insert_text)
2628                let mut edits: Vec<(usize, usize, String)> = Vec::new();
2629                for event in &events {
2630                    match event {
2631                        Event::Insert { position, text, .. } => {
2632                            edits.push((*position, 0, text.clone()));
2633                        }
2634                        Event::Delete { range, .. } => {
2635                            edits.push((range.start, range.len(), String::new()));
2636                        }
2637                        _ => {}
2638                    }
2639                }
2640
2641                // Sort edits by position descending (required by apply_bulk_edits)
2642                edits.sort_by(|a, b| b.0.cmp(&a.0));
2643
2644                // Convert to references for apply_bulk_edits
2645                let edit_refs: Vec<(usize, usize, &str)> = edits
2646                    .iter()
2647                    .map(|(pos, del, text)| (*pos, *del, text.as_str()))
2648                    .collect();
2649
2650                // Snapshot displaced markers before edits so undo can restore them exactly.
2651                let displaced_markers = state.capture_displaced_markers_bulk(&edits);
2652
2653                // Apply bulk edits - O(n) instead of O(n²)
2654                let _delta = state.buffer.apply_bulk_edits(&edit_refs);
2655
2656                // Calculate new cursor positions based on edits
2657                let mut position_deltas: Vec<(usize, isize)> = Vec::new();
2658                for (pos, del_len, text) in &edits {
2659                    let delta = text.len() as isize - *del_len as isize;
2660                    position_deltas.push((*pos, delta));
2661                }
2662                position_deltas.sort_by_key(|(pos, _)| *pos);
2663
2664                let calc_shift = |original_pos: usize| -> isize {
2665                    let mut shift: isize = 0;
2666                    for (edit_pos, delta) in &position_deltas {
2667                        if *edit_pos < original_pos {
2668                            shift += delta;
2669                        }
2670                    }
2671                    shift
2672                };
2673
2674                // Calculate new cursor positions
2675                let buffer_len = state.buffer.len();
2676                let new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors
2677                    .iter()
2678                    .map(|(id, pos, anchor)| {
2679                        let shift = calc_shift(*pos);
2680                        let new_pos = ((*pos as isize + shift).max(0) as usize).min(buffer_len);
2681                        let new_anchor = anchor.map(|a| {
2682                            let anchor_shift = calc_shift(a);
2683                            ((a as isize + anchor_shift).max(0) as usize).min(buffer_len)
2684                        });
2685                        (*id, new_pos, new_anchor)
2686                    })
2687                    .collect();
2688
2689                // Snapshot buffer state after edits (for redo)
2690                let new_snapshot = state.buffer.snapshot_buffer_state();
2691
2692                // Invalidate syntax highlighting
2693                state.highlighter.invalidate_all();
2694
2695                // Apply new cursor positions to split view state
2696                if let Some(vs) = vs_map.get_mut(&split_id_for_cursors) {
2697                    if let Some(bvs) = vs.keyed_states.get_mut(&buffer_id) {
2698                        for (cursor_id, new_pos, new_anchor) in &new_cursors {
2699                            if let Some(cursor) = bvs.cursors.get_mut(*cursor_id) {
2700                                cursor.position = *new_pos;
2701                                cursor.anchor = *new_anchor;
2702                            }
2703                        }
2704                    }
2705                }
2706
2707                // Convert edit list to lengths-only for undo/redo marker replay.
2708                // Merge edits at the same position into a single replacement.
2709                let edit_lengths: Vec<(usize, usize, usize)> = {
2710                    let mut lengths: Vec<(usize, usize, usize)> = Vec::new();
2711                    for (pos, del_len, text) in &edits {
2712                        if let Some(last) = lengths.last_mut() {
2713                            if last.0 == *pos {
2714                                last.1 += del_len;
2715                                last.2 += text.len();
2716                                continue;
2717                            }
2718                        }
2719                        lengths.push((*pos, *del_len, text.len()));
2720                    }
2721                    lengths
2722                };
2723
2724                // Adjust markers using merged net-delta
2725                for &(pos, del_len, ins_len) in &edit_lengths {
2726                    if del_len > 0 && ins_len > 0 {
2727                        if ins_len > del_len {
2728                            state.marker_list.adjust_for_insert(pos, ins_len - del_len);
2729                            state.margins.adjust_for_insert(pos, ins_len - del_len);
2730                        } else if del_len > ins_len {
2731                            state.marker_list.adjust_for_delete(pos, del_len - ins_len);
2732                            state.margins.adjust_for_delete(pos, del_len - ins_len);
2733                        }
2734                    } else if del_len > 0 {
2735                        state.marker_list.adjust_for_delete(pos, del_len);
2736                        state.margins.adjust_for_delete(pos, del_len);
2737                    } else if ins_len > 0 {
2738                        state.marker_list.adjust_for_insert(pos, ins_len);
2739                        state.margins.adjust_for_insert(pos, ins_len);
2740                    }
2741                }
2742
2743                Ok(Event::BulkEdit {
2744                    old_snapshot: Some(old_snapshot),
2745                    new_snapshot: Some(new_snapshot),
2746                    old_cursors,
2747                    new_cursors,
2748                    description,
2749                    edits: edit_lengths,
2750                    displaced_markers,
2751                })
2752            })
2753            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))??;
2754
2755        // Add to event log
2756        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
2757            event_log.append(bulk_edit);
2758        }
2759
2760        // Notify LSP about the changes using pre-calculated positions
2761        self.active_window_mut()
2762            .send_lsp_changes_for_buffer(buffer_id, lsp_changes);
2763
2764        Ok(())
2765    }
2766
2767    /// Start rename mode - select the symbol at cursor and allow inline editing
2768    pub(crate) fn start_rename(&mut self) -> AnyhowResult<()> {
2769        // If server supports prepareRename, validate first
2770        if self.active_window().server_supports_prepare_rename() {
2771            self.active_window_mut().send_prepare_rename();
2772            return Ok(());
2773        }
2774
2775        self.show_rename_prompt()
2776    }
2777
2778    /// Handle prepareRename response — if valid, show rename prompt; if error, show message.
2779    pub(crate) fn handle_prepare_rename_response(
2780        &mut self,
2781        result: Result<serde_json::Value, String>,
2782    ) {
2783        match result {
2784            Ok(value) if !value.is_null() => {
2785                // prepareRename succeeded — show the rename prompt
2786                if let Err(e) = self.show_rename_prompt() {
2787                    self.set_status_message(format!("Rename failed: {e}"));
2788                }
2789            }
2790            Ok(_) => {
2791                self.set_status_message("Cannot rename at this position".to_string());
2792            }
2793            Err(e) => {
2794                self.set_status_message(format!("Cannot rename: {e}"));
2795            }
2796        }
2797    }
2798
2799    /// Send textDocument/prepareRename to the LSP server
2800    /// Show the rename prompt (called directly or after prepareRename succeeds).
2801    fn show_rename_prompt(&mut self) -> AnyhowResult<()> {
2802        use crate::primitives::word_navigation::{find_word_end, find_word_start};
2803
2804        // Get the current buffer and cursor position
2805        let cursor_pos = self.active_cursors().primary().position;
2806        let (word_start, word_end) = {
2807            let state = self.active_state();
2808
2809            // Find the word boundaries
2810            let word_start = find_word_start(&state.buffer, cursor_pos);
2811            let word_end = find_word_end(&state.buffer, cursor_pos);
2812
2813            // Check if we're on a word
2814            if word_start >= word_end {
2815                self.active_window_mut().status_message =
2816                    Some(t!("lsp.no_symbol_at_cursor").to_string());
2817                return Ok(());
2818            }
2819
2820            (word_start, word_end)
2821        };
2822
2823        // Get the word text
2824        let word_text = self.active_state_mut().get_text_range(word_start, word_end);
2825
2826        // Create an overlay to highlight the symbol being renamed
2827        let overlay_handle = self.add_overlay(
2828            None,
2829            word_start..word_end,
2830            crate::model::event::OverlayFace::Background {
2831                color: (50, 100, 200), // Blue background for rename
2832            },
2833            100,
2834            Some(t!("lsp.popup_renaming").to_string()),
2835        );
2836
2837        // Enter rename mode using the Prompt system
2838        // Store the rename metadata in the PromptType and pre-fill the input with the current name
2839        let mut prompt = Prompt::new(
2840            "Rename to: ".to_string(),
2841            PromptType::LspRename {
2842                original_text: word_text.clone(),
2843                start_pos: word_start,
2844                end_pos: word_end,
2845                overlay_handle,
2846            },
2847        );
2848        // Pre-fill the input with the current name and position cursor at the end
2849        prompt.set_input(word_text);
2850
2851        self.active_window_mut().prompt = Some(prompt);
2852        Ok(())
2853    }
2854
2855    /// Cancel rename mode - removes overlay if the prompt was for LSP rename
2856    pub(crate) fn cancel_rename_overlay(&mut self, handle: &crate::view::overlay::OverlayHandle) {
2857        self.remove_overlay(handle.clone());
2858    }
2859
2860    /// Perform the actual LSP rename request
2861    pub(crate) fn perform_lsp_rename(
2862        &mut self,
2863        new_name: String,
2864        original_text: String,
2865        start_pos: usize,
2866        overlay_handle: crate::view::overlay::OverlayHandle,
2867    ) {
2868        // Remove the overlay first
2869        self.cancel_rename_overlay(&overlay_handle);
2870
2871        // Check if the name actually changed
2872        if new_name == original_text {
2873            self.active_window_mut().status_message = Some(t!("lsp.name_unchanged").to_string());
2874            return;
2875        }
2876
2877        // Use the position from when we entered rename mode, NOT the current cursor position
2878        // This ensures we send the rename request for the correct symbol even if cursor moved
2879        let rename_pos = start_pos;
2880
2881        // Convert byte position to LSP position (line, UTF-16 code units)
2882        // LSP uses UTF-16 code units for character offsets, not byte offsets
2883        let state = self.active_state();
2884        let (line, character) = state.buffer.position_to_lsp_position(rename_pos);
2885        let buffer_id = self.active_buffer();
2886        let request_id = self.active_window_mut().next_lsp_request_id;
2887
2888        // Use helper to ensure didOpen is sent before the request
2889        let sent = self
2890            .with_lsp_for_buffer(buffer_id, LspFeature::Rename, |handle, uri, _language| {
2891                let result = handle.rename(
2892                    request_id,
2893                    uri.as_uri().clone(),
2894                    line as u32,
2895                    character as u32,
2896                    new_name.clone(),
2897                );
2898                if result.is_ok() {
2899                    tracing::info!(
2900                        "Requested rename at {}:{}:{} to '{}'",
2901                        uri.as_str(),
2902                        line,
2903                        character,
2904                        new_name
2905                    );
2906                }
2907                result.is_ok()
2908            })
2909            .unwrap_or(false);
2910
2911        if sent {
2912            self.active_window_mut().next_lsp_request_id += 1;
2913        } else if self
2914            .active_window()
2915            .buffer_metadata
2916            .get(&buffer_id)
2917            .and_then(|m| m.file_path())
2918            .is_none()
2919        {
2920            self.active_window_mut().status_message =
2921                Some(t!("lsp.cannot_rename_unsaved").to_string());
2922        }
2923    }
2924
2925    /// Request inlay hints for the active buffer (if enabled and LSP available)
2926    pub(crate) fn request_inlay_hints_for_active_buffer(&mut self) {
2927        let buffer_id = self.active_buffer();
2928        self.request_inlay_hints_for_buffer(buffer_id);
2929    }
2930
2931    /// Request inlay hints for a specific buffer (if enabled and LSP available)
2932    pub(crate) fn request_inlay_hints_for_buffer(&mut self, buffer_id: BufferId) {
2933        if !self.config.editor.enable_inlay_hints {
2934            return;
2935        }
2936
2937        // Get line count and version from buffer state — both are needed so
2938        // the response handler can drop stale data if the buffer has moved
2939        // on by the time hints arrive.
2940        let (line_count, version) = if let Some(state) = self
2941            .windows
2942            .get(&self.active_window)
2943            .map(|w| &w.buffers)
2944            .expect("active window present")
2945            .get(&buffer_id)
2946        {
2947            (
2948                state.buffer.line_count().unwrap_or(1000),
2949                state.buffer.version(),
2950            )
2951        } else {
2952            return;
2953        };
2954        let last_line = line_count.saturating_sub(1) as u32;
2955        let request_id = self.active_window_mut().next_lsp_request_id;
2956
2957        // Use helper to ensure didOpen is sent before the request
2958        let sent = self
2959            .with_lsp_for_buffer(
2960                buffer_id,
2961                LspFeature::InlayHints,
2962                |handle, uri, _language| {
2963                    let result = handle.inlay_hints(
2964                        request_id,
2965                        uri.as_uri().clone(),
2966                        0,
2967                        0,
2968                        last_line,
2969                        10000,
2970                    );
2971                    if result.is_ok() {
2972                        tracing::info!(
2973                            "Requested inlay hints for {} (request_id={})",
2974                            uri.as_str(),
2975                            request_id
2976                        );
2977                    } else if let Err(e) = &result {
2978                        tracing::debug!("Failed to request inlay hints: {}", e);
2979                    }
2980                    result.is_ok()
2981                },
2982            )
2983            .unwrap_or(false);
2984
2985        if sent {
2986            self.active_window_mut().next_lsp_request_id += 1;
2987            self.active_window_mut()
2988                .pending_inlay_hints_requests
2989                .insert(request_id, super::InlayHintsRequest { buffer_id, version });
2990        }
2991    }
2992
2993    /// Issue a debounced folding range request if the timer has elapsed.
2994    pub(crate) fn maybe_request_folding_ranges_debounced(&mut self, buffer_id: BufferId) {
2995        let Some(ready_at) = self
2996            .active_window()
2997            .folding_ranges_debounce
2998            .get(&buffer_id)
2999            .copied()
3000        else {
3001            return;
3002        };
3003        if Instant::now() < ready_at {
3004            return;
3005        }
3006
3007        self.active_window_mut()
3008            .folding_ranges_debounce
3009            .remove(&buffer_id);
3010        self.request_folding_ranges_for_buffer(buffer_id);
3011    }
3012
3013    /// Request folding ranges for a buffer if supported and needed.
3014    pub(crate) fn request_folding_ranges_for_buffer(&mut self, buffer_id: BufferId) {
3015        if self
3016            .active_window_mut()
3017            .folding_ranges_in_flight
3018            .contains_key(&buffer_id)
3019        {
3020            return;
3021        }
3022
3023        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
3024            return;
3025        };
3026        if !metadata.lsp_enabled {
3027            return;
3028        }
3029        let Some(uri) = metadata.file_uri().cloned() else {
3030            return;
3031        };
3032        let file_path = metadata.file_path().cloned();
3033
3034        let Some(language) = self
3035            .windows
3036            .get(&self.active_window)
3037            .map(|w| &w.buffers)
3038            .expect("active window present")
3039            .get(&buffer_id)
3040            .map(|s| s.language.clone())
3041        else {
3042            return;
3043        };
3044
3045        let __active_id = self.active_window;
3046        // Pre-collect buffer version so we don't re-read self.buffers
3047        // while the &mut lsp borrow is alive.
3048        let __buffer_version_for_request = self
3049            .windows
3050            .get(&__active_id)
3051            .and_then(|w| w.buffers.get(&buffer_id))
3052            .map(|s| s.buffer.version())
3053            .unwrap_or(0);
3054
3055        let Some(__win) = self.windows.get_mut(&__active_id) else {
3056            return;
3057        };
3058        let __next_id = &mut __win.next_lsp_request_id;
3059        let __pending_folding = &mut __win.pending_folding_range_requests;
3060        let __folding_in_flight = &mut __win.folding_ranges_in_flight;
3061        let Some(lsp) = __win.lsp.as_mut() else {
3062            return;
3063        };
3064
3065        if !lsp.folding_ranges_supported(&language) {
3066            return;
3067        }
3068
3069        // Ensure there is a running server
3070        use crate::services::lsp::manager::LspSpawnResult;
3071        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
3072            return;
3073        }
3074
3075        let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::FoldingRange) else {
3076            return;
3077        };
3078        let handle = &mut sh.handle;
3079
3080        let request_id = {
3081            let id = *__next_id;
3082            *__next_id += 1;
3083            id
3084        };
3085        let buffer_version = __buffer_version_for_request;
3086        let _ = __folding_in_flight;
3087
3088        match handle.folding_ranges(request_id, uri.as_uri().clone()) {
3089            Ok(()) => {
3090                __pending_folding.insert(
3091                    request_id,
3092                    super::FoldingRangeRequest {
3093                        buffer_id,
3094                        version: buffer_version,
3095                    },
3096                );
3097                __folding_in_flight.insert(buffer_id, (request_id, buffer_version));
3098            }
3099            Err(e) => {
3100                tracing::debug!("Failed to request folding ranges: {}", e);
3101            }
3102        }
3103    }
3104
3105    /// Request semantic tokens for a specific buffer if supported and needed.
3106    pub(crate) fn maybe_request_semantic_tokens(&mut self, buffer_id: BufferId) {
3107        if !self.config.editor.enable_semantic_tokens_full {
3108            return;
3109        }
3110
3111        // Avoid duplicate in-flight requests per buffer
3112        if self
3113            .active_window_mut()
3114            .semantic_tokens_in_flight
3115            .contains_key(&buffer_id)
3116        {
3117            return;
3118        }
3119
3120        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
3121            return;
3122        };
3123        if !metadata.lsp_enabled {
3124            return;
3125        }
3126        let Some(uri) = metadata.file_uri().cloned() else {
3127            return;
3128        };
3129        let file_path_for_spawn = metadata.file_path().cloned();
3130        // Get language from buffer state
3131        let Some(language) = self
3132            .windows
3133            .get(&self.active_window)
3134            .map(|w| &w.buffers)
3135            .expect("active window present")
3136            .get(&buffer_id)
3137            .map(|s| s.language.clone())
3138        else {
3139            return;
3140        };
3141
3142        let __active_id = self.active_window;
3143        // Pre-extract buffer state info so we don't re-borrow self
3144        // while the &mut lsp borrow is alive.
3145        let Some((buffer_version, existing_version, previous_result_id)) = self
3146            .windows
3147            .get(&__active_id)
3148            .and_then(|w| w.buffers.get(&buffer_id))
3149            .map(|state| {
3150                (
3151                    state.buffer.version(),
3152                    state.semantic_tokens.as_ref().map(|s| s.version),
3153                    state
3154                        .semantic_tokens
3155                        .as_ref()
3156                        .and_then(|s| s.result_id.clone()),
3157                )
3158            })
3159        else {
3160            return;
3161        };
3162        if Some(buffer_version) == existing_version {
3163            return; // Already up to date
3164        }
3165
3166        let Some(__win) = self.windows.get_mut(&__active_id) else {
3167            return;
3168        };
3169        let __next_id = &mut __win.next_lsp_request_id;
3170        let __pending_st = &mut __win.pending_semantic_token_requests;
3171        let __st_in_flight = &mut __win.semantic_tokens_in_flight;
3172        let Some(lsp) = __win.lsp.as_mut() else {
3173            return;
3174        };
3175
3176        // Ensure there is a running server
3177        use crate::services::lsp::manager::LspSpawnResult;
3178        if lsp.try_spawn(&language, file_path_for_spawn.as_deref()) != LspSpawnResult::Spawned {
3179            return;
3180        }
3181
3182        // Check that a server actually supports full semantic tokens
3183        if !lsp.semantic_tokens_full_supported(&language) {
3184            return;
3185        }
3186        if lsp.semantic_tokens_legend(&language).is_none() {
3187            return;
3188        }
3189
3190        let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::SemanticTokens) else {
3191            return;
3192        };
3193        // Check capabilities on the specific server we'll send to
3194        let supports_delta = sh.capabilities.semantic_tokens_full_delta;
3195        let use_delta = previous_result_id.is_some() && supports_delta;
3196        let handle = &mut sh.handle;
3197
3198        let request_id = {
3199            let id = *__next_id;
3200            *__next_id += 1;
3201            id
3202        };
3203
3204        let request_kind = if use_delta {
3205            super::SemanticTokensFullRequestKind::FullDelta
3206        } else {
3207            super::SemanticTokensFullRequestKind::Full
3208        };
3209
3210        let request_result = if use_delta {
3211            handle.semantic_tokens_full_delta(
3212                request_id,
3213                uri.as_uri().clone(),
3214                previous_result_id.unwrap(),
3215            )
3216        } else {
3217            handle.semantic_tokens_full(request_id, uri.as_uri().clone())
3218        };
3219
3220        match request_result {
3221            Ok(_) => {
3222                __pending_st.insert(
3223                    request_id,
3224                    super::SemanticTokenFullRequest {
3225                        buffer_id,
3226                        version: buffer_version,
3227                        kind: request_kind,
3228                    },
3229                );
3230                __st_in_flight.insert(buffer_id, (request_id, buffer_version, request_kind));
3231            }
3232            Err(e) => {
3233                tracing::debug!("Failed to request semantic tokens: {}", e);
3234            }
3235        }
3236    }
3237
3238    /// Issue a debounced full semantic token request if the timer has elapsed.
3239    pub(crate) fn maybe_request_semantic_tokens_full_debounced(&mut self, buffer_id: BufferId) {
3240        if !self.config.editor.enable_semantic_tokens_full {
3241            self.active_window_mut()
3242                .semantic_tokens_full_debounce
3243                .remove(&buffer_id);
3244            return;
3245        }
3246
3247        let Some(ready_at) = self
3248            .active_window()
3249            .semantic_tokens_full_debounce
3250            .get(&buffer_id)
3251            .copied()
3252        else {
3253            return;
3254        };
3255        if Instant::now() < ready_at {
3256            return;
3257        }
3258
3259        self.active_window_mut()
3260            .semantic_tokens_full_debounce
3261            .remove(&buffer_id);
3262        self.maybe_request_semantic_tokens(buffer_id);
3263    }
3264
3265    /// Request semantic tokens for a viewport range (with padding).
3266    pub(crate) fn maybe_request_semantic_tokens_range(
3267        &mut self,
3268        buffer_id: BufferId,
3269        start_line: usize,
3270        end_line: usize,
3271    ) {
3272        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
3273            return;
3274        };
3275        if !metadata.lsp_enabled {
3276            return;
3277        }
3278        let Some(uri) = metadata.file_uri().cloned() else {
3279            return;
3280        };
3281        let file_path = metadata.file_path().cloned();
3282        // Get language from buffer state
3283        let Some(language) = self
3284            .windows
3285            .get(&self.active_window)
3286            .map(|w| &w.buffers)
3287            .expect("active window present")
3288            .get(&buffer_id)
3289            .map(|s| s.language.clone())
3290        else {
3291            return;
3292        };
3293
3294        let __active_id = self.active_window;
3295        // Single &mut on the active window — split-borrow into &mut lsp
3296        // and &buffers so we can use both concurrently.
3297        let __win = self
3298            .windows
3299            .get_mut(&__active_id)
3300            .expect("active window must exist");
3301        let __next_id = &mut __win.next_lsp_request_id;
3302        let __pending_st_range = &mut __win.pending_semantic_token_range_requests;
3303        let __st_range_in_flight = &mut __win.semantic_tokens_range_in_flight;
3304        let __st_range_last = &mut __win.semantic_tokens_range_last_request;
3305        let __st_range_applied = &__win.semantic_tokens_range_applied;
3306        let Some(lsp) = __win.lsp.as_mut() else {
3307            return;
3308        };
3309        let __buffers_ref: &crate::app::window::WindowBuffers = &__win.buffers;
3310
3311        // Ensure there is a running server
3312        use crate::services::lsp::manager::LspSpawnResult;
3313        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
3314            return;
3315        }
3316
3317        if !lsp.semantic_tokens_range_supported(&language) {
3318            // Fall back to full document tokens if no server supports range.
3319            self.maybe_request_semantic_tokens(buffer_id);
3320            return;
3321        }
3322        if lsp.semantic_tokens_legend(&language).is_none() {
3323            return;
3324        }
3325
3326        let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::SemanticTokens) else {
3327            return;
3328        };
3329        // The handle_for_feature_mut check ensures has_capability(SemanticTokens) which is
3330        // full || range. Double-check this specific server supports range.
3331        if !sh.capabilities.semantic_tokens_range {
3332            return;
3333        }
3334        let handle = &mut sh.handle;
3335        let Some(state) = __buffers_ref.get(&buffer_id) else {
3336            return;
3337        };
3338
3339        let buffer_version = state.buffer.version();
3340        let mut padded_start = start_line.saturating_sub(SEMANTIC_TOKENS_RANGE_PADDING_LINES);
3341        let mut padded_end = end_line.saturating_add(SEMANTIC_TOKENS_RANGE_PADDING_LINES);
3342
3343        if let Some(line_count) = state.buffer.line_count() {
3344            if line_count == 0 {
3345                return;
3346            }
3347            let max_line = line_count.saturating_sub(1);
3348            padded_start = padded_start.min(max_line);
3349            padded_end = padded_end.min(max_line);
3350        }
3351
3352        let start_byte = state.buffer.line_start_offset(padded_start).unwrap_or(0);
3353        let end_char = state
3354            .buffer
3355            .get_line(padded_end)
3356            .map(|line| String::from_utf8_lossy(&line).encode_utf16().count())
3357            .unwrap_or(0);
3358        let end_byte = if state.buffer.line_start_offset(padded_end).is_some() {
3359            state.buffer.lsp_position_to_byte(padded_end, end_char)
3360        } else {
3361            state.buffer.len()
3362        };
3363
3364        if start_byte >= end_byte {
3365            return;
3366        }
3367
3368        let range = start_byte..end_byte;
3369        if let Some((in_flight_id, in_flight_start, in_flight_end, in_flight_version)) =
3370            __st_range_in_flight.get(&buffer_id).copied()
3371        {
3372            if in_flight_start == padded_start
3373                && in_flight_end == padded_end
3374                && in_flight_version == buffer_version
3375            {
3376                return;
3377            }
3378            if let Err(e) = handle.cancel_request(in_flight_id) {
3379                tracing::debug!("Failed to cancel semantic token range request: {}", e);
3380            }
3381            __pending_st_range.remove(&in_flight_id);
3382            __st_range_in_flight.remove(&buffer_id);
3383        }
3384
3385        if let Some((applied_start, applied_end, applied_version)) =
3386            __st_range_applied.get(&buffer_id).copied()
3387        {
3388            if applied_start == padded_start
3389                && applied_end == padded_end
3390                && applied_version == buffer_version
3391            {
3392                return;
3393            }
3394        }
3395
3396        let now = Instant::now();
3397        if let Some((last_start, last_end, last_version, last_time)) =
3398            __st_range_last.get(&buffer_id).copied()
3399        {
3400            if last_start == padded_start
3401                && last_end == padded_end
3402                && last_version == buffer_version
3403                && now.duration_since(last_time)
3404                    < Duration::from_millis(SEMANTIC_TOKENS_RANGE_DEBOUNCE_MS)
3405            {
3406                return;
3407            }
3408        }
3409
3410        let lsp_range = lsp_types::Range {
3411            start: lsp_types::Position {
3412                line: padded_start as u32,
3413                character: 0,
3414            },
3415            end: lsp_types::Position {
3416                line: padded_end as u32,
3417                character: end_char as u32,
3418            },
3419        };
3420
3421        let request_id = {
3422            let id = *__next_id;
3423            *__next_id += 1;
3424            id
3425        };
3426        let _ = __st_range_applied;
3427
3428        match handle.semantic_tokens_range(request_id, uri.as_uri().clone(), lsp_range) {
3429            Ok(_) => {
3430                __pending_st_range.insert(
3431                    request_id,
3432                    SemanticTokenRangeRequest {
3433                        buffer_id,
3434                        version: buffer_version,
3435                        range: range.clone(),
3436                        start_line: padded_start,
3437                        end_line: padded_end,
3438                    },
3439                );
3440                __st_range_in_flight.insert(
3441                    buffer_id,
3442                    (request_id, padded_start, padded_end, buffer_version),
3443                );
3444                __st_range_last.insert(buffer_id, (padded_start, padded_end, buffer_version, now));
3445            }
3446            Err(e) => {
3447                tracing::debug!("Failed to request semantic token range: {}", e);
3448            }
3449        }
3450    }
3451}
3452
3453#[cfg(test)]
3454mod tests {
3455    use crate::model::filesystem::StdFileSystem;
3456    use std::sync::Arc;
3457
3458    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
3459        Arc::new(StdFileSystem)
3460    }
3461    use super::{lsp_range_contains, Editor};
3462
3463    fn range(sl: u32, sc: u32, el: u32, ec: u32) -> lsp_types::Range {
3464        lsp_types::Range {
3465            start: lsp_types::Position {
3466                line: sl,
3467                character: sc,
3468            },
3469            end: lsp_types::Position {
3470                line: el,
3471                character: ec,
3472            },
3473        }
3474    }
3475
3476    #[test]
3477    fn test_lsp_range_contains_inclusive_start_exclusive_end() {
3478        let r = range(3, 10, 3, 20);
3479        // Before start
3480        assert!(!lsp_range_contains(&r, 3, 9));
3481        assert!(!lsp_range_contains(&r, 2, 50));
3482        // At start (inclusive)
3483        assert!(lsp_range_contains(&r, 3, 10));
3484        // Inside
3485        assert!(lsp_range_contains(&r, 3, 15));
3486        // Just before end (inclusive)
3487        assert!(lsp_range_contains(&r, 3, 19));
3488        // At end (exclusive)
3489        assert!(!lsp_range_contains(&r, 3, 20));
3490        // After end
3491        assert!(!lsp_range_contains(&r, 3, 21));
3492        assert!(!lsp_range_contains(&r, 4, 0));
3493    }
3494
3495    #[test]
3496    fn test_lsp_range_contains_multiline() {
3497        let r = range(2, 5, 4, 3);
3498        // Line before start
3499        assert!(!lsp_range_contains(&r, 1, 100));
3500        // On start line, before start character
3501        assert!(!lsp_range_contains(&r, 2, 4));
3502        // On start line, at start character (inclusive)
3503        assert!(lsp_range_contains(&r, 2, 5));
3504        // Interior line — any character is inside.
3505        assert!(lsp_range_contains(&r, 3, 0));
3506        assert!(lsp_range_contains(&r, 3, 9999));
3507        // End line, before end character (inclusive)
3508        assert!(lsp_range_contains(&r, 4, 2));
3509        // End line, at end character (exclusive)
3510        assert!(!lsp_range_contains(&r, 4, 3));
3511        // Line after end
3512        assert!(!lsp_range_contains(&r, 5, 0));
3513    }
3514
3515    #[test]
3516    fn test_lsp_range_contains_zero_length_matches_anchor_only() {
3517        // Point diagnostic: start == end.
3518        let r = range(7, 4, 7, 4);
3519        assert!(lsp_range_contains(&r, 7, 4));
3520        assert!(!lsp_range_contains(&r, 7, 3));
3521        assert!(!lsp_range_contains(&r, 7, 5));
3522        assert!(!lsp_range_contains(&r, 6, 4));
3523        assert!(!lsp_range_contains(&r, 8, 4));
3524    }
3525    use crate::model::buffer::Buffer;
3526    use crate::state::EditorState;
3527    use crate::view::virtual_text::VirtualTextPosition;
3528    use lsp_types::{InlayHint, InlayHintKind, InlayHintLabel, Position};
3529
3530    fn make_hint(line: u32, character: u32, label: &str, kind: Option<InlayHintKind>) -> InlayHint {
3531        InlayHint {
3532            position: Position { line, character },
3533            label: InlayHintLabel::String(label.to_string()),
3534            kind,
3535            text_edits: None,
3536            tooltip: None,
3537            padding_left: None,
3538            padding_right: None,
3539            data: None,
3540        }
3541    }
3542
3543    #[test]
3544    fn test_inlay_hint_inserts_before_character() {
3545        let mut state = EditorState::new(
3546            80,
3547            24,
3548            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3549            test_fs(),
3550        );
3551        state.buffer = Buffer::from_str_test("ab");
3552
3553        if !state.buffer.is_empty() {
3554            state.marker_list.adjust_for_insert(0, state.buffer.len());
3555        }
3556
3557        let hints = vec![make_hint(0, 1, ": i32", Some(InlayHintKind::TYPE))];
3558        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3559
3560        let lookup = state
3561            .virtual_texts
3562            .build_lookup(&state.marker_list, 0, state.buffer.len());
3563        let vtexts = lookup.get(&1).expect("expected hint at byte offset 1");
3564        assert_eq!(vtexts.len(), 1);
3565        assert_eq!(vtexts[0].text, ": i32");
3566        assert_eq!(vtexts[0].position, VirtualTextPosition::BeforeChar);
3567    }
3568
3569    #[test]
3570    fn test_inlay_hint_at_eof_renders_after_last_char() {
3571        let mut state = EditorState::new(
3572            80,
3573            24,
3574            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3575            test_fs(),
3576        );
3577        state.buffer = Buffer::from_str_test("ab");
3578
3579        if !state.buffer.is_empty() {
3580            state.marker_list.adjust_for_insert(0, state.buffer.len());
3581        }
3582
3583        let hints = vec![make_hint(0, 2, ": i32", Some(InlayHintKind::TYPE))];
3584        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3585
3586        let lookup = state
3587            .virtual_texts
3588            .build_lookup(&state.marker_list, 0, state.buffer.len());
3589        let vtexts = lookup.get(&1).expect("expected hint anchored to last byte");
3590        assert_eq!(vtexts.len(), 1);
3591        assert_eq!(vtexts[0].text, ": i32");
3592        assert_eq!(vtexts[0].position, VirtualTextPosition::AfterChar);
3593    }
3594
3595    #[test]
3596    fn test_inlay_hint_empty_buffer_is_ignored() {
3597        let mut state = EditorState::new(
3598            80,
3599            24,
3600            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3601            test_fs(),
3602        );
3603        state.buffer = Buffer::from_str_test("");
3604
3605        let hints = vec![make_hint(0, 0, ": i32", Some(InlayHintKind::TYPE))];
3606        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3607
3608        assert!(state.virtual_texts.is_empty());
3609    }
3610
3611    #[test]
3612    fn test_inlay_hint_uses_theme_key_for_foreground() {
3613        // Verify that apply_inlay_hints_to_state stores the theme key so
3614        // hints follow the active theme rather than a hardcoded color.
3615        let mut state = EditorState::new(
3616            80,
3617            24,
3618            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3619            test_fs(),
3620        );
3621        state.buffer = Buffer::from_str_test("ab");
3622
3623        if !state.buffer.is_empty() {
3624            state.marker_list.adjust_for_insert(0, state.buffer.len());
3625        }
3626
3627        let hints = vec![make_hint(0, 1, ": i32", Some(InlayHintKind::TYPE))];
3628        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3629
3630        let lookup = state
3631            .virtual_texts
3632            .build_lookup(&state.marker_list, 0, state.buffer.len());
3633        let vtexts = lookup.get(&1).expect("expected hint at byte offset 1");
3634        assert_eq!(
3635            vtexts[0].fg_theme_key.as_deref(),
3636            Some("editor.line_number_fg")
3637        );
3638        assert_eq!(vtexts[0].bg_theme_key, None);
3639    }
3640
3641    #[test]
3642    fn test_inlay_hint_removed_when_its_range_is_deleted() {
3643        // Regression: deleting a range that covers the anchor byte of an
3644        // inlay hint used to leave the hint visible (the marker snapped to
3645        // the deletion start). apply_delete now calls
3646        // virtual_texts.remove_in_range before adjusting markers, so the
3647        // hint vanishes immediately. A future LSP refresh can repopulate
3648        // hints elsewhere.
3649        let mut state = EditorState::new(
3650            80,
3651            24,
3652            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3653            test_fs(),
3654        );
3655        state.buffer = Buffer::from_str_test("let x = 42;");
3656        state.marker_list.adjust_for_insert(0, state.buffer.len());
3657
3658        // Hint anchored at byte 5 (after "let x" -> rendered before '=').
3659        let hints = vec![make_hint(0, 5, ": i32", Some(InlayHintKind::TYPE))];
3660        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3661        assert_eq!(state.virtual_texts.len(), 1);
3662
3663        // Simulate user deleting "x = 42" (bytes 4..10, half-open) — the
3664        // hint anchor at byte 5 is inside this range.
3665        let removed = state
3666            .virtual_texts
3667            .remove_in_range(&mut state.marker_list, 4, 10);
3668        assert_eq!(removed, 1, "hint inside deleted range must be removed");
3669        assert!(state.virtual_texts.is_empty());
3670    }
3671
3672    #[test]
3673    fn test_marker_delete_after_repeat_clear_recreate() {
3674        // Regression: simulates what apply_inlay_hints_to_state does on
3675        // every LSP refresh — clear every virtual_text's marker then
3676        // recreate markers at fresh positions. After a few rounds,
3677        // delete one marker and adjust for a deletion and check the
3678        // remaining markers' positions.
3679        use crate::model::marker::MarkerList;
3680        use crate::view::virtual_text::{VirtualTextManager, VirtualTextPosition};
3681        use ratatui::style::Style;
3682
3683        let mut markers = MarkerList::new();
3684        let mut vtexts = VirtualTextManager::new();
3685
3686        // Initial marker layout at six positions (same as the e2e test).
3687        let positions = [200usize, 401, 602, 803, 1205, 1406];
3688        for &p in &positions {
3689            vtexts.add(
3690                &mut markers,
3691                p,
3692                format!("hint-at-{p}"),
3693                Style::default(),
3694                VirtualTextPosition::BeforeChar,
3695                0,
3696            );
3697        }
3698
3699        // Simulate a couple of clear/recreate cycles (each LSP refresh
3700        // goes through this exact path via apply_inlay_hints_to_state).
3701        for _ in 0..3 {
3702            vtexts.clear(&mut markers);
3703            for &p in &positions {
3704                vtexts.add(
3705                    &mut markers,
3706                    p,
3707                    format!("hint-at-{p}"),
3708                    Style::default(),
3709                    VirtualTextPosition::BeforeChar,
3710                    0,
3711                );
3712            }
3713        }
3714
3715        // remove_in_range + adjust_for_delete equivalent to apply_delete.
3716        let removed = vtexts.remove_in_range(&mut markers, 1005, 1206);
3717        assert_eq!(
3718            removed, 1,
3719            "exactly one marker inside [1005, 1206) should be removed"
3720        );
3721        markers.adjust_for_delete(1005, 201);
3722
3723        let lookup = vtexts.build_lookup(&markers, 0, 10_000);
3724        let mut positions: Vec<usize> = lookup.keys().copied().collect();
3725        positions.sort();
3726        assert_eq!(
3727            positions,
3728            vec![200, 401, 602, 803, 1205],
3729            "after delete+adjust, expected marker byte positions {:?}, got {:?}",
3730            vec![200, 401, 602, 803, 1205],
3731            positions
3732        );
3733    }
3734
3735    #[test]
3736    fn test_marker_delete_then_adjust_preserves_last_marker_position() {
3737        // Regression for the user-observed flip of an end-of-line inlay
3738        // hint to the start of its line after a nearby line is deleted.
3739        //
3740        // Scenario (real numbers from the failing e2e test): six markers
3741        // at byte offsets that correspond to the `\n` of each line,
3742        // then delete-one-marker (simulating remove_in_range on the
3743        // line being deleted) followed by adjust_for_delete on the
3744        // remaining markers.
3745        //
3746        // The last marker (at byte 1406) should end up at byte 1205
3747        // after subtracting the 201-byte deleted range. Observed bug:
3748        // it ends up at byte 1005 (the deletion start) — exactly as
3749        // though the delta were applied twice.
3750        use crate::model::marker::MarkerList;
3751
3752        let mut markers = MarkerList::new();
3753        let m0 = markers.create(200, false);
3754        let m1 = markers.create(401, false);
3755        let m2 = markers.create(602, false);
3756        let m3 = markers.create(803, false);
3757        let m5 = markers.create(1205, false);
3758        let m6 = markers.create(1406, false);
3759
3760        // Simulate remove_in_range removing marker m5 inside [1005, 1206).
3761        markers.delete(m5);
3762
3763        // Now simulate adjust_for_delete over that range.
3764        markers.adjust_for_delete(1005, 201);
3765
3766        assert_eq!(markers.get_position(m0), Some(200), "m0 unchanged");
3767        assert_eq!(markers.get_position(m1), Some(401), "m1 unchanged");
3768        assert_eq!(markers.get_position(m2), Some(602), "m2 unchanged");
3769        assert_eq!(markers.get_position(m3), Some(803), "m3 unchanged");
3770        assert_eq!(
3771            markers.get_position(m6),
3772            Some(1205),
3773            "m6 must shift from 1406 to 1205 (1406 - 201), not be clamped to delete-start 1005"
3774        );
3775    }
3776
3777    #[test]
3778    fn test_inlay_hint_outside_deletion_survives() {
3779        // Anchors outside the deleted range must not be collateral damage.
3780        let mut state = EditorState::new(
3781            80,
3782            24,
3783            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3784            test_fs(),
3785        );
3786        state.buffer = Buffer::from_str_test("let x = 42; let y = 0;");
3787        state.marker_list.adjust_for_insert(0, state.buffer.len());
3788
3789        let hints = vec![
3790            make_hint(0, 5, ": i32", Some(InlayHintKind::TYPE)), // byte 5 - inside deletion
3791            make_hint(0, 17, ": i32", Some(InlayHintKind::TYPE)), // byte 17 - outside
3792        ];
3793        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3794        assert_eq!(state.virtual_texts.len(), 2);
3795
3796        let removed = state
3797            .virtual_texts
3798            .remove_in_range(&mut state.marker_list, 4, 10);
3799        assert_eq!(removed, 1);
3800        assert_eq!(state.virtual_texts.len(), 1);
3801    }
3802
3803    #[test]
3804    fn test_space_doc_paragraphs_inserts_blank_lines() {
3805        use super::space_doc_paragraphs;
3806
3807        // Single newlines become double newlines
3808        let input = "sep\n  description.\nend\n  another.";
3809        let result = space_doc_paragraphs(input);
3810        assert_eq!(result, "sep\n\n  description.\n\nend\n\n  another.");
3811    }
3812
3813    #[test]
3814    fn test_space_doc_paragraphs_preserves_existing_blank_lines() {
3815        use super::space_doc_paragraphs;
3816
3817        // Already-double newlines stay double (not quadrupled)
3818        let input = "First paragraph.\n\nSecond paragraph.";
3819        let result = space_doc_paragraphs(input);
3820        assert_eq!(result, "First paragraph.\n\nSecond paragraph.");
3821    }
3822
3823    #[test]
3824    fn test_space_doc_paragraphs_plain_text() {
3825        use super::space_doc_paragraphs;
3826
3827        let input = "Just a single line of docs.";
3828        let result = space_doc_paragraphs(input);
3829        assert_eq!(result, "Just a single line of docs.");
3830    }
3831}