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