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