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