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