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