Skip to main content

fresh/app/
lsp_requests.rs

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