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::services::lsp::manager::detect_language;
23use crate::view::prompt::{Prompt, PromptType};
24
25use super::{uri_to_path, Editor, SemanticTokenRangeRequest};
26
27const SEMANTIC_TOKENS_FULL_DEBOUNCE_MS: u64 = 500;
28const SEMANTIC_TOKENS_RANGE_DEBOUNCE_MS: u64 = 50;
29const SEMANTIC_TOKENS_RANGE_PADDING_LINES: usize = 10;
30
31impl Editor {
32    /// Handle LSP completion response
33    pub(crate) fn handle_completion_response(
34        &mut self,
35        request_id: u64,
36        items: Vec<lsp_types::CompletionItem>,
37    ) -> AnyhowResult<()> {
38        // Check if this is the pending completion request
39        if self.pending_completion_request != Some(request_id) {
40            tracing::debug!(
41                "Ignoring completion response for outdated request {}",
42                request_id
43            );
44            return Ok(());
45        }
46
47        self.pending_completion_request = None;
48        self.lsp_status.clear();
49
50        if items.is_empty() {
51            tracing::debug!("No completion items received");
52            return Ok(());
53        }
54
55        // Get the partial word at cursor to filter completions
56        use crate::primitives::word_navigation::find_completion_word_start;
57        let (word_start, cursor_pos) = {
58            let state = self.active_state();
59            let cursor_pos = state.cursors.primary().position;
60            let word_start = find_completion_word_start(&state.buffer, cursor_pos);
61            (word_start, cursor_pos)
62        };
63        let prefix = if word_start < cursor_pos {
64            self.active_state_mut()
65                .get_text_range(word_start, cursor_pos)
66                .to_lowercase()
67        } else {
68            String::new()
69        };
70
71        // Filter completions to match the typed prefix
72        let filtered_items: Vec<&lsp_types::CompletionItem> = if prefix.is_empty() {
73            // No prefix - show all completions
74            items.iter().collect()
75        } else {
76            // Filter to items that start with the prefix (case-insensitive)
77            items
78                .iter()
79                .filter(|item| {
80                    item.label.to_lowercase().starts_with(&prefix)
81                        || item
82                            .filter_text
83                            .as_ref()
84                            .map(|ft| ft.to_lowercase().starts_with(&prefix))
85                            .unwrap_or(false)
86                })
87                .collect()
88        };
89
90        if filtered_items.is_empty() {
91            tracing::debug!("No completion items match prefix '{}'", prefix);
92            return Ok(());
93        }
94
95        // Convert CompletionItem to PopupListItem
96        use crate::view::popup::PopupListItem;
97
98        let popup_items: Vec<PopupListItem> = filtered_items
99            .iter()
100            .map(|item| {
101                let text = item.label.clone();
102                let detail = item.detail.clone();
103                let icon = match item.kind {
104                    Some(lsp_types::CompletionItemKind::FUNCTION)
105                    | Some(lsp_types::CompletionItemKind::METHOD) => Some("λ".to_string()),
106                    Some(lsp_types::CompletionItemKind::VARIABLE) => Some("v".to_string()),
107                    Some(lsp_types::CompletionItemKind::STRUCT)
108                    | Some(lsp_types::CompletionItemKind::CLASS) => Some("S".to_string()),
109                    Some(lsp_types::CompletionItemKind::CONSTANT) => Some("c".to_string()),
110                    Some(lsp_types::CompletionItemKind::KEYWORD) => Some("k".to_string()),
111                    _ => None,
112                };
113
114                let mut list_item = PopupListItem::new(text);
115                if let Some(detail) = detail {
116                    list_item = list_item.with_detail(detail);
117                }
118                if let Some(icon) = icon {
119                    list_item = list_item.with_icon(icon);
120                }
121                // Store the insert_text or label as data
122                let data = item
123                    .insert_text
124                    .clone()
125                    .or_else(|| Some(item.label.clone()));
126                if let Some(data) = data {
127                    list_item = list_item.with_data(data);
128                }
129                list_item
130            })
131            .collect();
132
133        // Show the popup
134        use crate::model::event::{
135            PopupContentData, PopupData, PopupListItemData, PopupPositionData,
136        };
137        let popup_data = PopupData {
138            title: Some(t!("lsp.popup_completion").to_string()),
139            description: None,
140            transient: false,
141            content: PopupContentData::List {
142                items: popup_items
143                    .into_iter()
144                    .map(|item| PopupListItemData {
145                        text: item.text,
146                        detail: item.detail,
147                        icon: item.icon,
148                        data: item.data,
149                    })
150                    .collect(),
151                selected: 0,
152            },
153            position: PopupPositionData::BelowCursor,
154            width: 50,
155            max_height: 15,
156            bordered: true,
157        };
158
159        // Store original items for type-to-filter
160        self.completion_items = Some(items);
161
162        self.active_state_mut()
163            .apply(&crate::model::event::Event::ShowPopup { popup: popup_data });
164
165        tracing::info!(
166            "Showing completion popup with {} items",
167            self.completion_items.as_ref().map_or(0, |i| i.len())
168        );
169
170        Ok(())
171    }
172
173    /// Handle LSP go-to-definition response
174    pub(crate) fn handle_goto_definition_response(
175        &mut self,
176        request_id: u64,
177        locations: Vec<lsp_types::Location>,
178    ) -> AnyhowResult<()> {
179        // Check if this is the pending request
180        if self.pending_goto_definition_request != Some(request_id) {
181            tracing::debug!(
182                "Ignoring go-to-definition response for outdated request {}",
183                request_id
184            );
185            return Ok(());
186        }
187
188        self.pending_goto_definition_request = None;
189
190        if locations.is_empty() {
191            self.status_message = Some(t!("lsp.no_definition").to_string());
192            return Ok(());
193        }
194
195        // For now, just jump to the first location
196        let location = &locations[0];
197
198        // Convert URI to file path
199        if let Ok(path) = uri_to_path(&location.uri) {
200            // Open the file
201            let buffer_id = self.open_file(&path)?;
202
203            // Check if file is outside project root (library file)
204            let is_library_file = self.is_library_file(&path);
205            if is_library_file {
206                // Mark as read-only
207                if let Some(state) = self.buffers.get_mut(&buffer_id) {
208                    state.editing_disabled = true;
209                }
210                if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
211                    metadata.read_only = true;
212                }
213            }
214
215            // Move cursor to the definition position
216            let line = location.range.start.line as usize;
217            let character = location.range.start.character as usize;
218
219            // Calculate byte position from line and character
220            if let Some(state) = self.buffers.get(&buffer_id) {
221                let position = state.buffer.line_col_to_position(line, character);
222
223                // Move cursor
224                let cursor_id = state.cursors.primary_id();
225                let old_position = state.cursors.primary().position;
226                let old_anchor = state.cursors.primary().anchor;
227                let old_sticky_column = state.cursors.primary().sticky_column;
228                let event = crate::model::event::Event::MoveCursor {
229                    cursor_id,
230                    old_position,
231                    new_position: position,
232                    old_anchor,
233                    new_anchor: None,
234                    old_sticky_column,
235                    new_sticky_column: 0, // Reset sticky column for goto definition
236                };
237
238                if let Some(state) = self.buffers.get_mut(&buffer_id) {
239                    state.apply(&event);
240                }
241            }
242
243            self.status_message = Some(
244                t!(
245                    "lsp.jumped_to_definition",
246                    path = path.display().to_string(),
247                    line = line + 1
248                )
249                .to_string(),
250            );
251        } else {
252            self.status_message = Some(t!("lsp.cannot_open_definition").to_string());
253        }
254
255        Ok(())
256    }
257
258    /// Check if a file path is a library file (outside project root or in common library directories).
259    /// Library files should be opened as read-only.
260    fn is_library_file(&self, path: &std::path::Path) -> bool {
261        super::types::BufferMetadata::is_library_path(path, &self.working_dir)
262    }
263
264    /// Check if there are any pending LSP requests
265    pub fn has_pending_lsp_requests(&self) -> bool {
266        self.pending_completion_request.is_some() || self.pending_goto_definition_request.is_some()
267    }
268
269    /// Cancel any pending LSP requests
270    /// This should be called when the user performs an action that would make
271    /// the pending request's results stale (e.g., cursor movement, text editing)
272    pub(crate) fn cancel_pending_lsp_requests(&mut self) {
273        if let Some(request_id) = self.pending_completion_request.take() {
274            tracing::debug!("Canceling pending LSP completion request {}", request_id);
275            // Send cancellation to the LSP server
276            self.send_lsp_cancel_request(request_id);
277            self.lsp_status.clear();
278        }
279        if let Some(request_id) = self.pending_goto_definition_request.take() {
280            tracing::debug!(
281                "Canceling pending LSP goto-definition request {}",
282                request_id
283            );
284            // Send cancellation to the LSP server
285            self.send_lsp_cancel_request(request_id);
286            self.lsp_status.clear();
287        }
288    }
289
290    /// Send a cancel request to the LSP server for a specific request ID
291    fn send_lsp_cancel_request(&mut self, request_id: u64) {
292        // Get the current file path to determine language
293        let metadata = self.buffer_metadata.get(&self.active_buffer());
294        let file_path = metadata.and_then(|meta| meta.file_path());
295
296        if let Some(path) = file_path {
297            if let Some(language) = detect_language(path, &self.config.languages) {
298                if let Some(lsp) = self.lsp.as_mut() {
299                    // Only send cancel if LSP is already running (no need to spawn just to cancel)
300                    if let Some(handle) = lsp.get_handle_mut(&language) {
301                        if let Err(e) = handle.cancel_request(request_id) {
302                            tracing::warn!("Failed to send LSP cancel request: {}", e);
303                        } else {
304                            tracing::debug!("Sent $/cancelRequest for request_id={}", request_id);
305                        }
306                    }
307                }
308            }
309        }
310    }
311
312    /// Execute a closure with LSP handle, ensuring didOpen was sent first.
313    ///
314    /// This helper centralizes the logic for:
315    /// 1. Getting buffer metadata, URI, and language
316    /// 2. Checking if LSP can be spawned (respects auto_start setting)
317    /// 3. Ensuring didOpen was sent to this server instance (lazy - only gets text if needed)
318    /// 4. Calling the provided closure with the handle
319    ///
320    /// Returns None if any step fails (no file, no language, LSP disabled, auto_start=false, etc.)
321    /// Note: This respects the auto_start setting. If auto_start is false and the server
322    /// hasn't been manually started, this will return None without spawning the server.
323    pub(crate) fn with_lsp_for_buffer<F, R>(&mut self, buffer_id: BufferId, f: F) -> Option<R>
324    where
325        F: FnOnce(&crate::services::lsp::async_handler::LspHandle, &lsp_types::Uri, &str) -> R,
326    {
327        use crate::services::lsp::manager::LspSpawnResult;
328
329        // Get metadata (immutable borrow first to extract what we need)
330        let (uri, _path, language) = {
331            let metadata = self.buffer_metadata.get(&buffer_id)?;
332            if !metadata.lsp_enabled {
333                return None;
334            }
335            let uri = metadata.file_uri()?.clone();
336            let path = metadata.file_path()?.to_path_buf();
337            let language = detect_language(&path, &self.config.languages)?;
338            (uri, path, language)
339        };
340
341        // Try to spawn LSP (respects auto_start setting)
342        // This will only spawn if auto_start=true or the language was manually allowed
343        let lsp = self.lsp.as_mut()?;
344        if lsp.try_spawn(&language) != LspSpawnResult::Spawned {
345            return None;
346        }
347
348        // Get handle ID (handle exists since try_spawn succeeded)
349        let handle_id = lsp.get_handle_mut(&language)?.id();
350
351        // Check if didOpen is needed
352        let needs_open = {
353            let metadata = self.buffer_metadata.get(&buffer_id)?;
354            !metadata.lsp_opened_with.contains(&handle_id)
355        };
356
357        if needs_open {
358            // Only now get the text (can be expensive for large buffers)
359            let text = self.buffers.get(&buffer_id)?.buffer.to_string()?;
360
361            // Send didOpen
362            let lsp = self.lsp.as_mut()?;
363            let handle = lsp.get_handle_mut(&language)?;
364            if let Err(e) = handle.did_open(uri.clone(), text, language.clone()) {
365                tracing::warn!("Failed to send didOpen: {}", e);
366                return None;
367            }
368
369            // Mark as opened with this server instance
370            let metadata = self.buffer_metadata.get_mut(&buffer_id)?;
371            metadata.lsp_opened_with.insert(handle_id);
372
373            tracing::debug!(
374                "Sent didOpen for {} to LSP handle {} (language: {})",
375                uri.as_str(),
376                handle_id,
377                language
378            );
379        }
380
381        // Call the closure with the handle
382        let lsp = self.lsp.as_mut()?;
383        let handle = lsp.get_handle_mut(&language)?;
384        Some(f(handle, &uri, &language))
385    }
386
387    /// Request LSP completion at current cursor position
388    pub(crate) fn request_completion(&mut self) -> AnyhowResult<()> {
389        // Get the current buffer and cursor position
390        let state = self.active_state();
391        let cursor_pos = state.cursors.primary().position;
392
393        // Convert byte position to LSP position (line, UTF-16 code units)
394        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
395        let buffer_id = self.active_buffer();
396        let request_id = self.next_lsp_request_id;
397
398        // Use helper to ensure didOpen is sent before the request
399        let sent = self
400            .with_lsp_for_buffer(buffer_id, |handle, uri, _language| {
401                let result =
402                    handle.completion(request_id, uri.clone(), line as u32, character as u32);
403                if result.is_ok() {
404                    tracing::info!(
405                        "Requested completion at {}:{}:{}",
406                        uri.as_str(),
407                        line,
408                        character
409                    );
410                }
411                result.is_ok()
412            })
413            .unwrap_or(false);
414
415        if sent {
416            self.next_lsp_request_id += 1;
417            self.pending_completion_request = Some(request_id);
418            self.lsp_status = "LSP: completion...".to_string();
419        }
420
421        Ok(())
422    }
423
424    /// Check if the inserted character should trigger completion
425    /// and if so, request completion automatically (possibly after a delay).
426    ///
427    /// Triggers completion in two cases:
428    /// 1. Trigger characters (like `.`, `::`, etc.): immediate if suggest_on_trigger_characters is enabled
429    /// 2. Word characters: delayed by quick_suggestions_delay_ms if quick_suggestions is enabled
430    ///
431    /// This provides VS Code-like behavior where suggestions appear while typing,
432    /// with debouncing to avoid spamming the LSP server.
433    pub(crate) fn maybe_trigger_completion(&mut self, c: char) {
434        // Get the active buffer's file path and detect its language
435        let path = match self.active_state().buffer.file_path() {
436            Some(p) => p,
437            None => return, // No path, no language detection
438        };
439
440        let language = match detect_language(path, &self.config.languages) {
441            Some(lang) => lang,
442            None => return, // Unknown language
443        };
444
445        // Check if this character is a trigger character for this language
446        let is_lsp_trigger = self
447            .lsp
448            .as_ref()
449            .map(|lsp| lsp.is_completion_trigger_char(c, &language))
450            .unwrap_or(false);
451
452        // Check if quick suggestions is enabled and this is a word character
453        let quick_suggestions_enabled = self.config.editor.quick_suggestions;
454        let suggest_on_trigger_chars = self.config.editor.suggest_on_trigger_characters;
455        let is_word_char = c.is_alphanumeric() || c == '_';
456
457        // Case 1: Trigger character - immediate trigger (bypasses delay)
458        if is_lsp_trigger && suggest_on_trigger_chars {
459            tracing::debug!(
460                "Trigger character '{}' immediately triggers completion for language {}",
461                c,
462                language
463            );
464            // Cancel any pending scheduled trigger
465            self.scheduled_completion_trigger = None;
466            let _ = self.request_completion();
467            return;
468        }
469
470        // Case 2: Word character with quick suggestions - schedule delayed trigger
471        if quick_suggestions_enabled && is_word_char {
472            let delay_ms = self.config.editor.quick_suggestions_delay_ms;
473            let trigger_time = Instant::now() + Duration::from_millis(delay_ms);
474
475            tracing::debug!(
476                "Scheduling completion trigger in {}ms for language {} (char '{}')",
477                delay_ms,
478                language,
479                c
480            );
481
482            // Schedule (or reschedule) the completion trigger
483            // This effectively debounces - each keystroke resets the timer
484            self.scheduled_completion_trigger = Some(trigger_time);
485        }
486    }
487
488    /// Request LSP go-to-definition at current cursor position
489    pub(crate) fn request_goto_definition(&mut self) -> AnyhowResult<()> {
490        // Get the current buffer and cursor position
491        let state = self.active_state();
492        let cursor_pos = state.cursors.primary().position;
493
494        // Convert byte position to LSP position (line, UTF-16 code units)
495        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
496        let buffer_id = self.active_buffer();
497        let request_id = self.next_lsp_request_id;
498
499        // Use helper to ensure didOpen is sent before the request
500        let sent = self
501            .with_lsp_for_buffer(buffer_id, |handle, uri, _language| {
502                let result =
503                    handle.goto_definition(request_id, uri.clone(), line as u32, character as u32);
504                if result.is_ok() {
505                    tracing::info!(
506                        "Requested go-to-definition at {}:{}:{}",
507                        uri.as_str(),
508                        line,
509                        character
510                    );
511                }
512                result.is_ok()
513            })
514            .unwrap_or(false);
515
516        if sent {
517            self.next_lsp_request_id += 1;
518            self.pending_goto_definition_request = Some(request_id);
519        }
520
521        Ok(())
522    }
523
524    /// Request LSP hover documentation at current cursor position
525    pub(crate) fn request_hover(&mut self) -> AnyhowResult<()> {
526        // Get the current buffer and cursor position
527        let state = self.active_state();
528        let cursor_pos = state.cursors.primary().position;
529
530        // Convert byte position to LSP position (line, UTF-16 code units)
531        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
532
533        // Debug: Log the position conversion details
534        if let Some(pos) = state.buffer.offset_to_position(cursor_pos) {
535            tracing::debug!(
536                "Hover request: cursor_byte={}, line={}, byte_col={}, utf16_col={}",
537                cursor_pos,
538                pos.line,
539                pos.column,
540                character
541            );
542        }
543
544        let buffer_id = self.active_buffer();
545        let request_id = self.next_lsp_request_id;
546
547        // Use helper to ensure didOpen is sent before the request
548        let sent = self
549            .with_lsp_for_buffer(buffer_id, |handle, uri, _language| {
550                let result = handle.hover(request_id, uri.clone(), line as u32, character as u32);
551                if result.is_ok() {
552                    tracing::info!(
553                        "Requested hover at {}:{}:{} (byte_pos={})",
554                        uri.as_str(),
555                        line,
556                        character,
557                        cursor_pos
558                    );
559                }
560                result.is_ok()
561            })
562            .unwrap_or(false);
563
564        if sent {
565            self.next_lsp_request_id += 1;
566            self.pending_hover_request = Some(request_id);
567            self.lsp_status = "LSP: hover...".to_string();
568        }
569
570        Ok(())
571    }
572
573    /// Request LSP hover documentation at a specific byte position
574    /// Used for mouse-triggered hover
575    pub(crate) fn request_hover_at_position(&mut self, byte_pos: usize) -> AnyhowResult<()> {
576        // Get the current buffer
577        let state = self.active_state();
578
579        // Convert byte position to LSP position (line, UTF-16 code units)
580        let (line, character) = state.buffer.position_to_lsp_position(byte_pos);
581
582        // Debug: Log the position conversion details
583        if let Some(pos) = state.buffer.offset_to_position(byte_pos) {
584            tracing::trace!(
585                "Mouse hover request: byte_pos={}, line={}, byte_col={}, utf16_col={}",
586                byte_pos,
587                pos.line,
588                pos.column,
589                character
590            );
591        }
592
593        let buffer_id = self.active_buffer();
594        let request_id = self.next_lsp_request_id;
595
596        // Use helper to ensure didOpen is sent before the request
597        let sent = self
598            .with_lsp_for_buffer(buffer_id, |handle, uri, _language| {
599                let result = handle.hover(request_id, uri.clone(), line as u32, character as u32);
600                if result.is_ok() {
601                    tracing::trace!(
602                        "Mouse hover requested at {}:{}:{} (byte_pos={})",
603                        uri.as_str(),
604                        line,
605                        character,
606                        byte_pos
607                    );
608                }
609                result.is_ok()
610            })
611            .unwrap_or(false);
612
613        if sent {
614            self.next_lsp_request_id += 1;
615            self.pending_hover_request = Some(request_id);
616            self.lsp_status = "LSP: hover...".to_string();
617        }
618
619        Ok(())
620    }
621
622    /// Handle hover response from LSP
623    pub(crate) fn handle_hover_response(
624        &mut self,
625        request_id: u64,
626        contents: String,
627        is_markdown: bool,
628        range: Option<((u32, u32), (u32, u32))>,
629    ) {
630        // Check if this response is for the current pending request
631        if self.pending_hover_request != Some(request_id) {
632            tracing::debug!("Ignoring stale hover response: {}", request_id);
633            return;
634        }
635
636        self.pending_hover_request = None;
637        self.lsp_status.clear();
638
639        if contents.is_empty() {
640            self.set_status_message(t!("lsp.no_hover").to_string());
641            self.hover_symbol_range = None;
642            return;
643        }
644
645        // Debug: log raw hover content to diagnose formatting issues
646        tracing::debug!(
647            "LSP hover content (markdown={}):\n{}",
648            is_markdown,
649            contents
650        );
651
652        // Convert LSP range to byte offsets for highlighting
653        if let Some(((start_line, start_char), (end_line, end_char))) = range {
654            let state = self.active_state();
655            let start_byte = state
656                .buffer
657                .lsp_position_to_byte(start_line as usize, start_char as usize);
658            let end_byte = state
659                .buffer
660                .lsp_position_to_byte(end_line as usize, end_char as usize);
661            self.hover_symbol_range = Some((start_byte, end_byte));
662            tracing::debug!(
663                "Hover symbol range: {}..{} (LSP {}:{}..{}:{})",
664                start_byte,
665                end_byte,
666                start_line,
667                start_char,
668                end_line,
669                end_char
670            );
671
672            // Remove previous hover overlay if any
673            if let Some(old_handle) = self.hover_symbol_overlay.take() {
674                let remove_event = crate::model::event::Event::RemoveOverlay { handle: old_handle };
675                self.apply_event_to_active_buffer(&remove_event);
676            }
677
678            // Add overlay to highlight the hovered symbol
679            let event = crate::model::event::Event::AddOverlay {
680                namespace: None,
681                range: start_byte..end_byte,
682                face: crate::model::event::OverlayFace::Background {
683                    color: (80, 80, 120), // Subtle highlight for hovered symbol
684                },
685                priority: 90, // Below rename (100) but above syntax (lower)
686                message: None,
687                extend_to_line_end: false,
688            };
689            self.apply_event_to_active_buffer(&event);
690            // Store the handle for later removal
691            if let Some(state) = self.buffers.get(&self.active_buffer()) {
692                self.hover_symbol_overlay = state.overlays.all().last().map(|o| o.handle.clone());
693            }
694        } else {
695            // No range provided by LSP - compute word boundaries at hover position
696            // This prevents the popup from following the mouse within the same word
697            if let Some((hover_byte_pos, _, _, _)) = self.mouse_state.lsp_hover_state {
698                let state = self.active_state();
699                let start_byte = find_word_start(&state.buffer, hover_byte_pos);
700                let end_byte = find_word_end(&state.buffer, hover_byte_pos);
701                if start_byte < end_byte {
702                    self.hover_symbol_range = Some((start_byte, end_byte));
703                    tracing::debug!(
704                        "Hover symbol range (computed from word boundaries): {}..{}",
705                        start_byte,
706                        end_byte
707                    );
708                } else {
709                    self.hover_symbol_range = None;
710                }
711            } else {
712                self.hover_symbol_range = None;
713            }
714        }
715
716        // Create a popup with the hover contents
717        use crate::view::popup::{Popup, PopupPosition};
718        use ratatui::style::Style;
719
720        // Use markdown rendering if the content is markdown
721        let mut popup = if is_markdown {
722            Popup::markdown(&contents, &self.theme, Some(&self.grammar_registry))
723        } else {
724            // Plain text - split by lines
725            let lines: Vec<String> = contents.lines().map(|s| s.to_string()).collect();
726            Popup::text(lines, &self.theme)
727        };
728
729        // Configure popup properties
730        popup.title = Some(t!("lsp.popup_hover").to_string());
731        popup.transient = true;
732        // Use mouse position if this was a mouse-triggered hover, otherwise use cursor position
733        popup.position = if let Some((x, y)) = self.mouse_hover_screen_position.take() {
734            // Position below the mouse, offset by 1 row
735            PopupPosition::Fixed { x, y: y + 1 }
736        } else {
737            PopupPosition::BelowCursor
738        };
739        popup.width = 80;
740        // Use dynamic max_height based on terminal size (60% of height, min 15, max 40)
741        // This allows hover popups to show more documentation on larger terminals
742        let dynamic_height = (self.terminal_height * 60 / 100).clamp(15, 40);
743        popup.max_height = dynamic_height;
744        popup.border_style = Style::default().fg(self.theme.popup_border_fg);
745        popup.background_style = Style::default().bg(self.theme.popup_bg);
746
747        // Show the popup
748        if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
749            state.popups.show(popup);
750            tracing::info!("Showing hover popup (markdown={})", is_markdown);
751        }
752
753        // Mark hover request as sent to prevent duplicate popups during race conditions
754        // (e.g., when mouse moves while a hover response is pending)
755        self.mouse_state.lsp_hover_request_sent = true;
756    }
757
758    /// Apply inlay hints to editor state as virtual text
759    pub(crate) fn apply_inlay_hints_to_state(
760        state: &mut crate::state::EditorState,
761        hints: &[lsp_types::InlayHint],
762    ) {
763        use crate::view::virtual_text::VirtualTextPosition;
764        use ratatui::style::{Color, Style};
765
766        // Clear existing inlay hints
767        state.virtual_texts.clear(&mut state.marker_list);
768
769        if hints.is_empty() {
770            return;
771        }
772
773        // Style for inlay hints - dimmed to not distract from actual code
774        let hint_style = Style::default().fg(Color::Rgb(128, 128, 128));
775
776        for hint in hints {
777            // Convert LSP position to byte offset
778            let byte_offset = state.buffer.lsp_position_to_byte(
779                hint.position.line as usize,
780                hint.position.character as usize,
781            );
782
783            // Extract text from hint label
784            let text = match &hint.label {
785                lsp_types::InlayHintLabel::String(s) => s.clone(),
786                lsp_types::InlayHintLabel::LabelParts(parts) => {
787                    parts.iter().map(|p| p.value.as_str()).collect::<String>()
788                }
789            };
790
791            // LSP inlay hint positions are insertion points between characters.
792            // For positions within the buffer, render hints before the character at the
793            // byte offset so they appear at the correct location (e.g., before punctuation
794            // or newline). Hints at or beyond EOF are anchored to the last character and
795            // rendered after it.
796            if state.buffer.is_empty() {
797                continue;
798            }
799
800            let (byte_offset, position) = if byte_offset >= state.buffer.len() {
801                // If hint is at EOF, anchor to last character and render after it.
802                (
803                    state.buffer.len().saturating_sub(1),
804                    VirtualTextPosition::AfterChar,
805                )
806            } else {
807                (byte_offset, VirtualTextPosition::BeforeChar)
808            };
809
810            // Use the hint text as-is - spacing is handled during rendering
811            let display_text = text;
812
813            state.virtual_texts.add(
814                &mut state.marker_list,
815                byte_offset,
816                display_text,
817                hint_style,
818                position,
819                0, // Default priority
820            );
821        }
822
823        tracing::debug!("Applied {} inlay hints as virtual text", hints.len());
824    }
825
826    /// Request LSP find references at current cursor position
827    pub(crate) fn request_references(&mut self) -> AnyhowResult<()> {
828        // Get the current buffer and cursor position
829        let state = self.active_state();
830        let cursor_pos = state.cursors.primary().position;
831
832        // Extract the word under cursor for display
833        let symbol = {
834            let text = match state.buffer.to_string() {
835                Some(t) => t,
836                None => {
837                    self.set_status_message(t!("error.buffer_not_loaded").to_string());
838                    return Ok(());
839                }
840            };
841            let bytes = text.as_bytes();
842            let buf_len = bytes.len();
843
844            if cursor_pos <= buf_len {
845                // Find word boundaries
846                let is_word_char = |c: char| c.is_alphanumeric() || c == '_';
847
848                // Find start of word
849                let mut start = cursor_pos;
850                while start > 0 {
851                    // Move to previous byte
852                    start -= 1;
853                    // Skip continuation bytes (UTF-8)
854                    while start > 0 && (bytes[start] & 0xC0) == 0x80 {
855                        start -= 1;
856                    }
857                    // Get the character at this position
858                    if let Some(ch) = text[start..].chars().next() {
859                        if !is_word_char(ch) {
860                            start += ch.len_utf8();
861                            break;
862                        }
863                    } else {
864                        break;
865                    }
866                }
867
868                // Find end of word
869                let mut end = cursor_pos;
870                while end < buf_len {
871                    if let Some(ch) = text[end..].chars().next() {
872                        if is_word_char(ch) {
873                            end += ch.len_utf8();
874                        } else {
875                            break;
876                        }
877                    } else {
878                        break;
879                    }
880                }
881
882                if start < end {
883                    text[start..end].to_string()
884                } else {
885                    String::new()
886                }
887            } else {
888                String::new()
889            }
890        };
891
892        // Convert byte position to LSP position (line, UTF-16 code units)
893        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
894        let buffer_id = self.active_buffer();
895        let request_id = self.next_lsp_request_id;
896
897        // Use helper to ensure didOpen is sent before the request
898        let sent = self
899            .with_lsp_for_buffer(buffer_id, |handle, uri, _language| {
900                let result =
901                    handle.references(request_id, uri.clone(), line as u32, character as u32);
902                if result.is_ok() {
903                    tracing::info!(
904                        "Requested find references at {}:{}:{} (byte_pos={})",
905                        uri.as_str(),
906                        line,
907                        character,
908                        cursor_pos
909                    );
910                }
911                result.is_ok()
912            })
913            .unwrap_or(false);
914
915        if sent {
916            self.next_lsp_request_id += 1;
917            self.pending_references_request = Some(request_id);
918            self.pending_references_symbol = symbol;
919            self.lsp_status = "LSP: finding references...".to_string();
920        }
921
922        Ok(())
923    }
924
925    /// Request LSP signature help at current cursor position
926    pub(crate) fn request_signature_help(&mut self) -> AnyhowResult<()> {
927        // Get the current buffer and cursor position
928        let state = self.active_state();
929        let cursor_pos = state.cursors.primary().position;
930
931        // Convert byte position to LSP position (line, UTF-16 code units)
932        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
933        let buffer_id = self.active_buffer();
934        let request_id = self.next_lsp_request_id;
935
936        // Use helper to ensure didOpen is sent before the request
937        let sent = self
938            .with_lsp_for_buffer(buffer_id, |handle, uri, _language| {
939                let result =
940                    handle.signature_help(request_id, uri.clone(), line as u32, character as u32);
941                if result.is_ok() {
942                    tracing::info!(
943                        "Requested signature help at {}:{}:{} (byte_pos={})",
944                        uri.as_str(),
945                        line,
946                        character,
947                        cursor_pos
948                    );
949                }
950                result.is_ok()
951            })
952            .unwrap_or(false);
953
954        if sent {
955            self.next_lsp_request_id += 1;
956            self.pending_signature_help_request = Some(request_id);
957            self.lsp_status = "LSP: signature help...".to_string();
958        }
959
960        Ok(())
961    }
962
963    /// Handle signature help response from LSP
964    pub(crate) fn handle_signature_help_response(
965        &mut self,
966        request_id: u64,
967        signature_help: Option<lsp_types::SignatureHelp>,
968    ) {
969        // Check if this response is for the current pending request
970        if self.pending_signature_help_request != Some(request_id) {
971            tracing::debug!("Ignoring stale signature help response: {}", request_id);
972            return;
973        }
974
975        self.pending_signature_help_request = None;
976        self.lsp_status.clear();
977
978        let signature_help = match signature_help {
979            Some(help) if !help.signatures.is_empty() => help,
980            _ => {
981                tracing::debug!("No signature help available");
982                return;
983            }
984        };
985
986        // Get the active signature
987        let active_signature_idx = signature_help.active_signature.unwrap_or(0) as usize;
988        let signature = match signature_help.signatures.get(active_signature_idx) {
989            Some(sig) => sig,
990            None => return,
991        };
992
993        // Build the display content
994        let mut lines: Vec<String> = Vec::new();
995
996        // Add the signature label (function signature)
997        lines.push(signature.label.clone());
998
999        // Add parameter highlighting info
1000        let active_param = signature_help
1001            .active_parameter
1002            .or(signature.active_parameter)
1003            .unwrap_or(0) as usize;
1004
1005        // If there are parameters, highlight the active one
1006        if let Some(params) = &signature.parameters {
1007            if let Some(param) = params.get(active_param) {
1008                // Get parameter label
1009                let param_label = match &param.label {
1010                    lsp_types::ParameterLabel::Simple(s) => s.clone(),
1011                    lsp_types::ParameterLabel::LabelOffsets(offsets) => {
1012                        // Extract substring from signature label
1013                        let start = offsets[0] as usize;
1014                        let end = offsets[1] as usize;
1015                        if end <= signature.label.len() {
1016                            signature.label[start..end].to_string()
1017                        } else {
1018                            String::new()
1019                        }
1020                    }
1021                };
1022
1023                if !param_label.is_empty() {
1024                    lines.push(format!("> {}", param_label));
1025                }
1026
1027                // Add parameter documentation if available
1028                if let Some(doc) = &param.documentation {
1029                    let doc_text = match doc {
1030                        lsp_types::Documentation::String(s) => s.clone(),
1031                        lsp_types::Documentation::MarkupContent(m) => m.value.clone(),
1032                    };
1033                    if !doc_text.is_empty() {
1034                        lines.push(String::new());
1035                        lines.push(doc_text);
1036                    }
1037                }
1038            }
1039        }
1040
1041        // Add function documentation if available
1042        if let Some(doc) = &signature.documentation {
1043            let doc_text = match doc {
1044                lsp_types::Documentation::String(s) => s.clone(),
1045                lsp_types::Documentation::MarkupContent(m) => m.value.clone(),
1046            };
1047            if !doc_text.is_empty() {
1048                if lines.len() > 1 {
1049                    lines.push(String::new());
1050                    lines.push("---".to_string());
1051                }
1052                lines.push(doc_text);
1053            }
1054        }
1055
1056        // Create a popup with the signature help
1057        use crate::view::popup::{Popup, PopupPosition};
1058        use ratatui::style::Style;
1059
1060        let mut popup = Popup::text(lines, &self.theme);
1061        popup.title = Some(t!("lsp.popup_signature").to_string());
1062        popup.transient = true;
1063        popup.position = PopupPosition::BelowCursor;
1064        popup.width = 60;
1065        popup.max_height = 10;
1066        popup.border_style = Style::default().fg(self.theme.popup_border_fg);
1067        popup.background_style = Style::default().bg(self.theme.popup_bg);
1068
1069        // Show the popup
1070        if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
1071            state.popups.show(popup);
1072            tracing::info!(
1073                "Showing signature help popup for {} signatures",
1074                signature_help.signatures.len()
1075            );
1076        }
1077    }
1078
1079    /// Request LSP code actions at current cursor position
1080    pub(crate) fn request_code_actions(&mut self) -> AnyhowResult<()> {
1081        // Get the current buffer and cursor position
1082        let state = self.active_state();
1083        let cursor_pos = state.cursors.primary().position;
1084
1085        // Convert byte position to LSP position (line, UTF-16 code units)
1086        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
1087
1088        // Get selection range (if any) or use cursor position
1089        let (start_line, start_char, end_line, end_char) =
1090            if let Some(range) = state.cursors.primary().selection_range() {
1091                let (s_line, s_char) = state.buffer.position_to_lsp_position(range.start);
1092                let (e_line, e_char) = state.buffer.position_to_lsp_position(range.end);
1093                (s_line as u32, s_char as u32, e_line as u32, e_char as u32)
1094            } else {
1095                (line as u32, character as u32, line as u32, character as u32)
1096            };
1097
1098        // Get diagnostics at cursor position for context
1099        // TODO: Implement diagnostic retrieval when needed
1100        let diagnostics: Vec<lsp_types::Diagnostic> = Vec::new();
1101        let buffer_id = self.active_buffer();
1102        let request_id = self.next_lsp_request_id;
1103
1104        // Use helper to ensure didOpen is sent before the request
1105        let sent = self
1106            .with_lsp_for_buffer(buffer_id, |handle, uri, _language| {
1107                let result = handle.code_actions(
1108                    request_id,
1109                    uri.clone(),
1110                    start_line,
1111                    start_char,
1112                    end_line,
1113                    end_char,
1114                    diagnostics,
1115                );
1116                if result.is_ok() {
1117                    tracing::info!(
1118                        "Requested code actions at {}:{}:{}-{}:{} (byte_pos={})",
1119                        uri.as_str(),
1120                        start_line,
1121                        start_char,
1122                        end_line,
1123                        end_char,
1124                        cursor_pos
1125                    );
1126                }
1127                result.is_ok()
1128            })
1129            .unwrap_or(false);
1130
1131        if sent {
1132            self.next_lsp_request_id += 1;
1133            self.pending_code_actions_request = Some(request_id);
1134            self.lsp_status = "LSP: code actions...".to_string();
1135        }
1136
1137        Ok(())
1138    }
1139
1140    /// Handle code actions response from LSP
1141    pub(crate) fn handle_code_actions_response(
1142        &mut self,
1143        request_id: u64,
1144        actions: Vec<lsp_types::CodeActionOrCommand>,
1145    ) {
1146        // Check if this response is for the current pending request
1147        if self.pending_code_actions_request != Some(request_id) {
1148            tracing::debug!("Ignoring stale code actions response: {}", request_id);
1149            return;
1150        }
1151
1152        self.pending_code_actions_request = None;
1153        self.lsp_status.clear();
1154
1155        if actions.is_empty() {
1156            self.set_status_message(t!("lsp.no_code_actions").to_string());
1157            return;
1158        }
1159
1160        // Build the display content
1161        let mut lines: Vec<String> = Vec::new();
1162        lines.push(format!("Code Actions ({}):", actions.len()));
1163        lines.push(String::new());
1164
1165        for (i, action) in actions.iter().enumerate() {
1166            let title = match action {
1167                lsp_types::CodeActionOrCommand::Command(cmd) => &cmd.title,
1168                lsp_types::CodeActionOrCommand::CodeAction(ca) => &ca.title,
1169            };
1170            lines.push(format!("  {}. {}", i + 1, title));
1171        }
1172
1173        lines.push(String::new());
1174        lines.push(t!("lsp.code_action_hint").to_string());
1175
1176        // Create a popup with the code actions
1177        use crate::view::popup::{Popup, PopupPosition};
1178        use ratatui::style::Style;
1179
1180        let mut popup = Popup::text(lines, &self.theme);
1181        popup.title = Some(t!("lsp.popup_code_actions").to_string());
1182        popup.position = PopupPosition::BelowCursor;
1183        popup.width = 60;
1184        popup.max_height = 15;
1185        popup.border_style = Style::default().fg(self.theme.popup_border_fg);
1186        popup.background_style = Style::default().bg(self.theme.popup_bg);
1187
1188        // Show the popup
1189        if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
1190            state.popups.show(popup);
1191            tracing::info!("Showing code actions popup with {} actions", actions.len());
1192        }
1193
1194        // Note: Executing code actions would require storing the actions and handling
1195        // key presses to select and apply them. This is left for future enhancement.
1196        self.set_status_message(
1197            t!("lsp.code_actions_not_implemented", count = actions.len()).to_string(),
1198        );
1199    }
1200
1201    /// Handle find references response from LSP
1202    pub(crate) fn handle_references_response(
1203        &mut self,
1204        request_id: u64,
1205        locations: Vec<lsp_types::Location>,
1206    ) -> AnyhowResult<()> {
1207        tracing::info!(
1208            "handle_references_response: received {} locations for request_id={}",
1209            locations.len(),
1210            request_id
1211        );
1212
1213        // Check if this response is for the current pending request
1214        if self.pending_references_request != Some(request_id) {
1215            tracing::debug!("Ignoring stale references response: {}", request_id);
1216            return Ok(());
1217        }
1218
1219        self.pending_references_request = None;
1220        self.lsp_status.clear();
1221
1222        if locations.is_empty() {
1223            self.set_status_message(t!("lsp.no_references").to_string());
1224            return Ok(());
1225        }
1226
1227        // Convert locations to hook args format
1228        let lsp_locations: Vec<crate::services::plugins::hooks::LspLocation> = locations
1229            .iter()
1230            .map(|loc| {
1231                // Convert URI to file path
1232                let file = if loc.uri.scheme().map(|s| s.as_str()) == Some("file") {
1233                    // Extract path from file:// URI
1234                    loc.uri.path().as_str().to_string()
1235                } else {
1236                    loc.uri.as_str().to_string()
1237                };
1238
1239                crate::services::plugins::hooks::LspLocation {
1240                    file,
1241                    line: loc.range.start.line + 1, // LSP is 0-based, convert to 1-based
1242                    column: loc.range.start.character + 1, // LSP is 0-based
1243                }
1244            })
1245            .collect();
1246
1247        let count = lsp_locations.len();
1248        let symbol = std::mem::take(&mut self.pending_references_symbol);
1249        self.set_status_message(
1250            t!("lsp.found_references", count = count, symbol = &symbol).to_string(),
1251        );
1252
1253        // Fire the lsp_references hook so plugins can display the results
1254        self.plugin_manager.run_hook(
1255            "lsp_references",
1256            crate::services::plugins::hooks::HookArgs::LspReferences {
1257                symbol: symbol.clone(),
1258                locations: lsp_locations,
1259            },
1260        );
1261
1262        tracing::info!(
1263            "Fired lsp_references hook with {} locations for symbol '{}'",
1264            count,
1265            symbol
1266        );
1267
1268        Ok(())
1269    }
1270
1271    /// Apply LSP text edits to a buffer and return the number of changes made.
1272    /// Edits are sorted in reverse order and applied as a batch.
1273    pub(crate) fn apply_lsp_text_edits(
1274        &mut self,
1275        buffer_id: BufferId,
1276        mut edits: Vec<lsp_types::TextEdit>,
1277    ) -> AnyhowResult<usize> {
1278        if edits.is_empty() {
1279            return Ok(0);
1280        }
1281
1282        // Sort edits by position (reverse order to avoid offset issues)
1283        edits.sort_by(|a, b| {
1284            b.range
1285                .start
1286                .line
1287                .cmp(&a.range.start.line)
1288                .then(b.range.start.character.cmp(&a.range.start.character))
1289        });
1290
1291        // Collect all events for this buffer into a batch
1292        let mut batch_events = Vec::new();
1293        let mut changes = 0;
1294
1295        // Create events for all edits
1296        for edit in edits {
1297            let state = self
1298                .buffers
1299                .get_mut(&buffer_id)
1300                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))?;
1301
1302            // Convert LSP range to byte positions
1303            let start_line = edit.range.start.line as usize;
1304            let start_char = edit.range.start.character as usize;
1305            let end_line = edit.range.end.line as usize;
1306            let end_char = edit.range.end.character as usize;
1307
1308            let start_pos = state.buffer.lsp_position_to_byte(start_line, start_char);
1309            let end_pos = state.buffer.lsp_position_to_byte(end_line, end_char);
1310            let buffer_len = state.buffer.len();
1311
1312            // Log the conversion for debugging
1313            let old_text = if start_pos < end_pos && end_pos <= buffer_len {
1314                state.get_text_range(start_pos, end_pos)
1315            } else {
1316                format!(
1317                    "<invalid range: start={}, end={}, buffer_len={}>",
1318                    start_pos, end_pos, buffer_len
1319                )
1320            };
1321            tracing::debug!(
1322                "  Converting LSP range line {}:{}-{}:{} to bytes {}..{} (replacing {:?} with {:?})",
1323                start_line, start_char, end_line, end_char,
1324                start_pos, end_pos, old_text, edit.new_text
1325            );
1326
1327            // Delete old text
1328            if start_pos < end_pos {
1329                let deleted_text = state.get_text_range(start_pos, end_pos);
1330                let cursor_id = state.cursors.primary_id();
1331                let delete_event = Event::Delete {
1332                    range: start_pos..end_pos,
1333                    deleted_text,
1334                    cursor_id,
1335                };
1336                batch_events.push(delete_event);
1337            }
1338
1339            // Insert new text
1340            if !edit.new_text.is_empty() {
1341                let state = self
1342                    .buffers
1343                    .get(&buffer_id)
1344                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))?;
1345                let cursor_id = state.cursors.primary_id();
1346                let insert_event = Event::Insert {
1347                    position: start_pos,
1348                    text: edit.new_text.clone(),
1349                    cursor_id,
1350                };
1351                batch_events.push(insert_event);
1352            }
1353
1354            changes += 1;
1355        }
1356
1357        // Apply all rename changes using bulk edit for O(n) performance
1358        if !batch_events.is_empty() {
1359            self.apply_events_to_buffer_as_bulk_edit(
1360                buffer_id,
1361                batch_events,
1362                "LSP Rename".to_string(),
1363            )?;
1364        }
1365
1366        Ok(changes)
1367    }
1368
1369    /// Handle rename response from LSP
1370    pub fn handle_rename_response(
1371        &mut self,
1372        _request_id: u64,
1373        result: Result<lsp_types::WorkspaceEdit, String>,
1374    ) -> AnyhowResult<()> {
1375        self.lsp_status.clear();
1376
1377        match result {
1378            Ok(workspace_edit) => {
1379                // Log the full workspace edit for debugging
1380                tracing::debug!(
1381                    "Received WorkspaceEdit: changes={:?}, document_changes={:?}",
1382                    workspace_edit.changes.as_ref().map(|c| c.len()),
1383                    workspace_edit.document_changes.as_ref().map(|dc| match dc {
1384                        lsp_types::DocumentChanges::Edits(e) => format!("{} edits", e.len()),
1385                        lsp_types::DocumentChanges::Operations(o) =>
1386                            format!("{} operations", o.len()),
1387                    })
1388                );
1389
1390                // Apply the workspace edit
1391                let mut total_changes = 0;
1392
1393                // Handle changes (map of URI -> Vec<TextEdit>)
1394                if let Some(changes) = workspace_edit.changes {
1395                    for (uri, edits) in changes {
1396                        if let Ok(path) = uri_to_path(&uri) {
1397                            let buffer_id = self.open_file(&path)?;
1398                            total_changes += self.apply_lsp_text_edits(buffer_id, edits)?;
1399                        }
1400                    }
1401                }
1402
1403                // Handle document_changes (TextDocumentEdit[])
1404                // This is what rust-analyzer sends instead of changes
1405                if let Some(document_changes) = workspace_edit.document_changes {
1406                    use lsp_types::DocumentChanges;
1407
1408                    let text_edits = match document_changes {
1409                        DocumentChanges::Edits(edits) => edits,
1410                        DocumentChanges::Operations(ops) => {
1411                            // Extract TextDocumentEdit from operations
1412                            ops.into_iter()
1413                                .filter_map(|op| {
1414                                    if let lsp_types::DocumentChangeOperation::Edit(edit) = op {
1415                                        Some(edit)
1416                                    } else {
1417                                        None
1418                                    }
1419                                })
1420                                .collect()
1421                        }
1422                    };
1423
1424                    for text_doc_edit in text_edits {
1425                        let uri = text_doc_edit.text_document.uri;
1426
1427                        if let Ok(path) = uri_to_path(&uri) {
1428                            let buffer_id = self.open_file(&path)?;
1429
1430                            // Extract TextEdit from OneOf<TextEdit, AnnotatedTextEdit>
1431                            let edits: Vec<lsp_types::TextEdit> = text_doc_edit
1432                                .edits
1433                                .into_iter()
1434                                .map(|one_of| match one_of {
1435                                    lsp_types::OneOf::Left(text_edit) => text_edit,
1436                                    lsp_types::OneOf::Right(annotated) => annotated.text_edit,
1437                                })
1438                                .collect();
1439
1440                            // Log the edits for debugging
1441                            tracing::info!(
1442                                "Applying {} edits from rust-analyzer for {:?}:",
1443                                edits.len(),
1444                                path
1445                            );
1446                            for (i, edit) in edits.iter().enumerate() {
1447                                tracing::info!(
1448                                    "  Edit {}: line {}:{}-{}:{} -> {:?}",
1449                                    i,
1450                                    edit.range.start.line,
1451                                    edit.range.start.character,
1452                                    edit.range.end.line,
1453                                    edit.range.end.character,
1454                                    edit.new_text
1455                                );
1456                            }
1457
1458                            total_changes += self.apply_lsp_text_edits(buffer_id, edits)?;
1459                        }
1460                    }
1461                }
1462
1463                self.status_message = Some(t!("lsp.renamed", count = total_changes).to_string());
1464            }
1465            Err(error) => {
1466                // Per LSP spec: ContentModified errors (-32801) should NOT be shown to user
1467                // These are expected when document changes during LSP operations
1468                // Reference: https://github.com/neovim/neovim/issues/16900
1469                if error.contains("content modified") || error.contains("-32801") {
1470                    tracing::debug!(
1471                        "LSP rename: ContentModified error (expected, ignoring): {}",
1472                        error
1473                    );
1474                    self.status_message = Some(t!("lsp.rename_cancelled").to_string());
1475                } else {
1476                    // Show other errors to user
1477                    self.status_message = Some(t!("lsp.rename_failed", error = &error).to_string());
1478                }
1479            }
1480        }
1481
1482        Ok(())
1483    }
1484
1485    /// Apply events to a specific buffer using bulk edit optimization (O(n) vs O(n²))
1486    ///
1487    /// This is similar to `apply_events_as_bulk_edit` but works on a specific buffer
1488    /// (which may not be the active buffer) and handles LSP notifications correctly.
1489    pub(crate) fn apply_events_to_buffer_as_bulk_edit(
1490        &mut self,
1491        buffer_id: BufferId,
1492        events: Vec<Event>,
1493        description: String,
1494    ) -> AnyhowResult<()> {
1495        use crate::model::event::CursorId;
1496
1497        if events.is_empty() {
1498            return Ok(());
1499        }
1500
1501        // Create a temporary batch for collecting LSP changes (before applying)
1502        let batch_for_lsp = Event::Batch {
1503            events: events.clone(),
1504            description: description.clone(),
1505        };
1506
1507        // IMPORTANT: Calculate LSP changes BEFORE applying to buffer!
1508        // The byte positions in the events are relative to the ORIGINAL buffer.
1509        let original_active = self.active_buffer();
1510        self.split_manager.set_active_buffer_id(buffer_id);
1511        let lsp_changes = self.collect_lsp_changes(&batch_for_lsp);
1512        self.split_manager.set_active_buffer_id(original_active);
1513
1514        let state = self
1515            .buffers
1516            .get_mut(&buffer_id)
1517            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))?;
1518
1519        // Capture old cursor states
1520        let old_cursors: Vec<(CursorId, usize, Option<usize>)> = state
1521            .cursors
1522            .iter()
1523            .map(|(id, c)| (id, c.position, c.anchor))
1524            .collect();
1525
1526        // Snapshot the tree for undo (O(1) - Arc clone)
1527        let old_tree = state.buffer.snapshot_piece_tree();
1528
1529        // Convert events to edit tuples: (position, delete_len, insert_text)
1530        let mut edits: Vec<(usize, usize, String)> = Vec::new();
1531        for event in &events {
1532            match event {
1533                Event::Insert { position, text, .. } => {
1534                    edits.push((*position, 0, text.clone()));
1535                }
1536                Event::Delete { range, .. } => {
1537                    edits.push((range.start, range.len(), String::new()));
1538                }
1539                _ => {}
1540            }
1541        }
1542
1543        // Sort edits by position descending (required by apply_bulk_edits)
1544        edits.sort_by(|a, b| b.0.cmp(&a.0));
1545
1546        // Convert to references for apply_bulk_edits
1547        let edit_refs: Vec<(usize, usize, &str)> = edits
1548            .iter()
1549            .map(|(pos, del, text)| (*pos, *del, text.as_str()))
1550            .collect();
1551
1552        // Apply bulk edits - O(n) instead of O(n²)
1553        let _delta = state.buffer.apply_bulk_edits(&edit_refs);
1554
1555        // Calculate new cursor positions based on edits
1556        let mut position_deltas: Vec<(usize, isize)> = Vec::new();
1557        for (pos, del_len, text) in &edits {
1558            let delta = text.len() as isize - *del_len as isize;
1559            position_deltas.push((*pos, delta));
1560        }
1561        position_deltas.sort_by_key(|(pos, _)| *pos);
1562
1563        let calc_shift = |original_pos: usize| -> isize {
1564            let mut shift: isize = 0;
1565            for (edit_pos, delta) in &position_deltas {
1566                if *edit_pos < original_pos {
1567                    shift += delta;
1568                }
1569            }
1570            shift
1571        };
1572
1573        // Calculate new cursor positions
1574        let buffer_len = state.buffer.len();
1575        let new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors
1576            .iter()
1577            .map(|(id, pos, anchor)| {
1578                let shift = calc_shift(*pos);
1579                let new_pos = ((*pos as isize + shift).max(0) as usize).min(buffer_len);
1580                let new_anchor = anchor.map(|a| {
1581                    let anchor_shift = calc_shift(a);
1582                    ((a as isize + anchor_shift).max(0) as usize).min(buffer_len)
1583                });
1584                (*id, new_pos, new_anchor)
1585            })
1586            .collect();
1587
1588        // Apply new cursor positions
1589        for (cursor_id, new_pos, new_anchor) in &new_cursors {
1590            if let Some(cursor) = state.cursors.get_mut(*cursor_id) {
1591                cursor.position = *new_pos;
1592                cursor.anchor = *new_anchor;
1593            }
1594        }
1595
1596        // Snapshot the tree after edits (for redo) - O(1) Arc clone
1597        let new_tree = state.buffer.snapshot_piece_tree();
1598
1599        // Invalidate syntax highlighting
1600        state.highlighter.invalidate_all();
1601
1602        // Create BulkEdit event for undo log
1603        let bulk_edit = Event::BulkEdit {
1604            old_tree: Some(old_tree),
1605            new_tree: Some(new_tree),
1606            old_cursors,
1607            new_cursors,
1608            description,
1609        };
1610
1611        // Add to event log
1612        if let Some(event_log) = self.event_logs.get_mut(&buffer_id) {
1613            event_log.append(bulk_edit);
1614        }
1615
1616        // Notify LSP about the changes using pre-calculated positions
1617        self.send_lsp_changes_for_buffer(buffer_id, lsp_changes);
1618
1619        Ok(())
1620    }
1621
1622    /// Send pre-calculated LSP changes for a specific buffer
1623    pub(crate) fn send_lsp_changes_for_buffer(
1624        &mut self,
1625        buffer_id: BufferId,
1626        changes: Vec<TextDocumentContentChangeEvent>,
1627    ) {
1628        if changes.is_empty() {
1629            return;
1630        }
1631
1632        // Check if LSP is enabled for this buffer
1633        let metadata = match self.buffer_metadata.get(&buffer_id) {
1634            Some(m) => m,
1635            None => {
1636                tracing::debug!(
1637                    "send_lsp_changes_for_buffer: no metadata for buffer {:?}",
1638                    buffer_id
1639                );
1640                return;
1641            }
1642        };
1643
1644        if !metadata.lsp_enabled {
1645            tracing::debug!("send_lsp_changes_for_buffer: LSP disabled for this buffer");
1646            return;
1647        }
1648
1649        // Get the URI
1650        let uri = match metadata.file_uri() {
1651            Some(u) => u.clone(),
1652            None => {
1653                tracing::debug!(
1654                    "send_lsp_changes_for_buffer: no URI for buffer (not a file or URI creation failed)"
1655                );
1656                return;
1657            }
1658        };
1659
1660        // Get the file path for language detection
1661        let path = match metadata.file_path() {
1662            Some(p) => p,
1663            None => {
1664                tracing::debug!("send_lsp_changes_for_buffer: no file path for buffer");
1665                return;
1666            }
1667        };
1668
1669        let language = match detect_language(path, &self.config.languages) {
1670            Some(l) => l,
1671            None => {
1672                tracing::debug!(
1673                    "send_lsp_changes_for_buffer: no language detected for {:?}",
1674                    path
1675                );
1676                return;
1677            }
1678        };
1679
1680        tracing::trace!(
1681            "send_lsp_changes_for_buffer: sending {} changes to {} in single didChange notification",
1682            changes.len(),
1683            uri.as_str()
1684        );
1685
1686        // Check if we can use LSP (respects auto_start setting)
1687        use crate::services::lsp::manager::LspSpawnResult;
1688        let Some(lsp) = self.lsp.as_mut() else {
1689            tracing::debug!("send_lsp_changes_for_buffer: no LSP manager available");
1690            return;
1691        };
1692
1693        if lsp.try_spawn(&language) != LspSpawnResult::Spawned {
1694            tracing::debug!(
1695                "send_lsp_changes_for_buffer: LSP not running for {} (auto_start disabled)",
1696                language
1697            );
1698            return;
1699        }
1700
1701        // Get handle ID (handle exists since try_spawn succeeded)
1702        let Some(handle) = lsp.get_handle_mut(&language) else {
1703            return;
1704        };
1705        let handle_id = handle.id();
1706
1707        // Check if didOpen needs to be sent first
1708        let needs_open = {
1709            let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
1710                return;
1711            };
1712            !metadata.lsp_opened_with.contains(&handle_id)
1713        };
1714
1715        if needs_open {
1716            // Get text for didOpen
1717            let text = match self
1718                .buffers
1719                .get(&buffer_id)
1720                .and_then(|s| s.buffer.to_string())
1721            {
1722                Some(t) => t,
1723                None => {
1724                    tracing::debug!(
1725                        "send_lsp_changes_for_buffer: buffer text not available for didOpen"
1726                    );
1727                    return;
1728                }
1729            };
1730
1731            // Send didOpen first
1732            let Some(lsp) = self.lsp.as_mut() else { return };
1733            let Some(handle) = lsp.get_handle_mut(&language) else {
1734                return;
1735            };
1736            if let Err(e) = handle.did_open(uri.clone(), text, language.clone()) {
1737                tracing::warn!("Failed to send didOpen before didChange: {}", e);
1738                return;
1739            }
1740            tracing::debug!(
1741                "Sent didOpen for {} to LSP handle {} before didChange",
1742                uri.as_str(),
1743                handle_id
1744            );
1745
1746            // Mark as opened
1747            if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
1748                metadata.lsp_opened_with.insert(handle_id);
1749            }
1750        }
1751
1752        // Now send didChange
1753        let Some(lsp) = self.lsp.as_mut() else { return };
1754        let Some(client) = lsp.get_handle_mut(&language) else {
1755            return;
1756        };
1757        if let Err(e) = client.did_change(uri, changes) {
1758            tracing::warn!("Failed to send didChange to LSP: {}", e);
1759        } else {
1760            tracing::trace!("Successfully sent batched didChange to LSP");
1761        }
1762    }
1763
1764    /// Start rename mode - select the symbol at cursor and allow inline editing
1765    pub(crate) fn start_rename(&mut self) -> AnyhowResult<()> {
1766        use crate::primitives::word_navigation::{find_word_end, find_word_start};
1767
1768        // Get the current buffer and cursor position
1769        let (word_start, word_end) = {
1770            let state = self.active_state();
1771            let cursor_pos = state.cursors.primary().position;
1772
1773            // Find the word boundaries
1774            let word_start = find_word_start(&state.buffer, cursor_pos);
1775            let word_end = find_word_end(&state.buffer, cursor_pos);
1776
1777            // Check if we're on a word
1778            if word_start >= word_end {
1779                self.status_message = Some(t!("lsp.no_symbol_at_cursor").to_string());
1780                return Ok(());
1781            }
1782
1783            (word_start, word_end)
1784        };
1785
1786        // Get the word text
1787        let word_text = self.active_state_mut().get_text_range(word_start, word_end);
1788
1789        // Create an overlay to highlight the symbol being renamed
1790        let overlay_handle = self.add_overlay(
1791            None,
1792            word_start..word_end,
1793            crate::model::event::OverlayFace::Background {
1794                color: (50, 100, 200), // Blue background for rename
1795            },
1796            100,
1797            Some(t!("lsp.popup_renaming").to_string()),
1798        );
1799
1800        // Enter rename mode using the Prompt system
1801        // Store the rename metadata in the PromptType and pre-fill the input with the current name
1802        let mut prompt = Prompt::new(
1803            "Rename to: ".to_string(),
1804            PromptType::LspRename {
1805                original_text: word_text.clone(),
1806                start_pos: word_start,
1807                end_pos: word_end,
1808                overlay_handle,
1809            },
1810        );
1811        // Pre-fill the input with the current name and position cursor at the end
1812        prompt.set_input(word_text);
1813
1814        self.prompt = Some(prompt);
1815        Ok(())
1816    }
1817
1818    /// Cancel rename mode - removes overlay if the prompt was for LSP rename
1819    pub(crate) fn cancel_rename_overlay(&mut self, handle: &crate::view::overlay::OverlayHandle) {
1820        self.remove_overlay(handle.clone());
1821    }
1822
1823    /// Perform the actual LSP rename request
1824    pub(crate) fn perform_lsp_rename(
1825        &mut self,
1826        new_name: String,
1827        original_text: String,
1828        start_pos: usize,
1829        overlay_handle: crate::view::overlay::OverlayHandle,
1830    ) {
1831        // Remove the overlay first
1832        self.cancel_rename_overlay(&overlay_handle);
1833
1834        // Check if the name actually changed
1835        if new_name == original_text {
1836            self.status_message = Some(t!("lsp.name_unchanged").to_string());
1837            return;
1838        }
1839
1840        // Use the position from when we entered rename mode, NOT the current cursor position
1841        // This ensures we send the rename request for the correct symbol even if cursor moved
1842        let rename_pos = start_pos;
1843
1844        // Convert byte position to LSP position (line, UTF-16 code units)
1845        // LSP uses UTF-16 code units for character offsets, not byte offsets
1846        let state = self.active_state();
1847        let (line, character) = state.buffer.position_to_lsp_position(rename_pos);
1848        let buffer_id = self.active_buffer();
1849        let request_id = self.next_lsp_request_id;
1850
1851        // Use helper to ensure didOpen is sent before the request
1852        let sent = self
1853            .with_lsp_for_buffer(buffer_id, |handle, uri, _language| {
1854                let result = handle.rename(
1855                    request_id,
1856                    uri.clone(),
1857                    line as u32,
1858                    character as u32,
1859                    new_name.clone(),
1860                );
1861                if result.is_ok() {
1862                    tracing::info!(
1863                        "Requested rename at {}:{}:{} to '{}'",
1864                        uri.as_str(),
1865                        line,
1866                        character,
1867                        new_name
1868                    );
1869                }
1870                result.is_ok()
1871            })
1872            .unwrap_or(false);
1873
1874        if sent {
1875            self.next_lsp_request_id += 1;
1876            self.lsp_status = "LSP: rename...".to_string();
1877        } else if self
1878            .buffer_metadata
1879            .get(&buffer_id)
1880            .and_then(|m| m.file_path())
1881            .is_none()
1882        {
1883            self.status_message = Some(t!("lsp.cannot_rename_unsaved").to_string());
1884        }
1885    }
1886
1887    /// Request inlay hints for the active buffer (if enabled and LSP available)
1888    pub(crate) fn request_inlay_hints_for_active_buffer(&mut self) {
1889        if !self.config.editor.enable_inlay_hints {
1890            return;
1891        }
1892
1893        let buffer_id = self.active_buffer();
1894
1895        // Get line count from buffer state
1896        let line_count = if let Some(state) = self.buffers.get(&buffer_id) {
1897            state.buffer.line_count().unwrap_or(1000)
1898        } else {
1899            return;
1900        };
1901        let last_line = line_count.saturating_sub(1) as u32;
1902        let request_id = self.next_lsp_request_id;
1903
1904        // Use helper to ensure didOpen is sent before the request
1905        let sent = self
1906            .with_lsp_for_buffer(buffer_id, |handle, uri, _language| {
1907                let result = handle.inlay_hints(request_id, uri.clone(), 0, 0, last_line, 10000);
1908                if result.is_ok() {
1909                    tracing::info!(
1910                        "Requested inlay hints for {} (request_id={})",
1911                        uri.as_str(),
1912                        request_id
1913                    );
1914                } else if let Err(e) = &result {
1915                    tracing::debug!("Failed to request inlay hints: {}", e);
1916                }
1917                result.is_ok()
1918            })
1919            .unwrap_or(false);
1920
1921        if sent {
1922            self.next_lsp_request_id += 1;
1923            self.pending_inlay_hints_request = Some(request_id);
1924        }
1925    }
1926
1927    /// Request semantic tokens for a specific buffer if supported and needed.
1928    pub(crate) fn maybe_request_semantic_tokens(&mut self, buffer_id: BufferId) {
1929        if !self.config.editor.enable_semantic_tokens_full {
1930            return;
1931        }
1932
1933        // Avoid duplicate in-flight requests per buffer
1934        if self.semantic_tokens_in_flight.contains_key(&buffer_id) {
1935            return;
1936        }
1937
1938        let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
1939            return;
1940        };
1941        if !metadata.lsp_enabled {
1942            return;
1943        }
1944        let Some(uri) = metadata.file_uri().cloned() else {
1945            return;
1946        };
1947        let Some(path) = metadata.file_path() else {
1948            return;
1949        };
1950        let Some(language) = detect_language(path, &self.config.languages) else {
1951            return;
1952        };
1953
1954        let Some(lsp) = self.lsp.as_mut() else {
1955            return;
1956        };
1957
1958        if !lsp.semantic_tokens_full_supported(&language) {
1959            return;
1960        }
1961        if lsp.semantic_tokens_legend(&language).is_none() {
1962            return;
1963        }
1964
1965        // Ensure there is a running server
1966        use crate::services::lsp::manager::LspSpawnResult;
1967        if lsp.try_spawn(&language) != LspSpawnResult::Spawned {
1968            return;
1969        }
1970
1971        let Some(state) = self.buffers.get(&buffer_id) else {
1972            return;
1973        };
1974        let buffer_version = state.buffer.version();
1975        if let Some(store) = state.semantic_tokens.as_ref() {
1976            if store.version == buffer_version {
1977                return; // Already up to date
1978            }
1979        }
1980
1981        let previous_result_id = state
1982            .semantic_tokens
1983            .as_ref()
1984            .and_then(|store| store.result_id.clone());
1985        let supports_delta = lsp.semantic_tokens_full_delta_supported(&language);
1986        let use_delta = previous_result_id.is_some() && supports_delta;
1987
1988        let Some(handle) = lsp.get_handle_mut(&language) else {
1989            return;
1990        };
1991
1992        let request_id = self.next_lsp_request_id;
1993        self.next_lsp_request_id += 1;
1994
1995        let request_kind = if use_delta {
1996            super::SemanticTokensFullRequestKind::FullDelta
1997        } else {
1998            super::SemanticTokensFullRequestKind::Full
1999        };
2000
2001        let request_result = if use_delta {
2002            handle.semantic_tokens_full_delta(request_id, uri, previous_result_id.unwrap())
2003        } else {
2004            handle.semantic_tokens_full(request_id, uri)
2005        };
2006
2007        match request_result {
2008            Ok(_) => {
2009                self.pending_semantic_token_requests.insert(
2010                    request_id,
2011                    super::SemanticTokenFullRequest {
2012                        buffer_id,
2013                        version: buffer_version,
2014                        kind: request_kind,
2015                    },
2016                );
2017                self.semantic_tokens_in_flight
2018                    .insert(buffer_id, (request_id, buffer_version, request_kind));
2019            }
2020            Err(e) => {
2021                tracing::debug!("Failed to request semantic tokens: {}", e);
2022            }
2023        }
2024    }
2025
2026    /// Schedule a full semantic token refresh for a buffer (debounced).
2027    pub(crate) fn schedule_semantic_tokens_full_refresh(&mut self, buffer_id: BufferId) {
2028        if !self.config.editor.enable_semantic_tokens_full {
2029            return;
2030        }
2031
2032        let next_time = Instant::now() + Duration::from_millis(SEMANTIC_TOKENS_FULL_DEBOUNCE_MS);
2033        self.semantic_tokens_full_debounce
2034            .insert(buffer_id, next_time);
2035    }
2036
2037    /// Issue a debounced full semantic token request if the timer has elapsed.
2038    pub(crate) fn maybe_request_semantic_tokens_full_debounced(&mut self, buffer_id: BufferId) {
2039        if !self.config.editor.enable_semantic_tokens_full {
2040            self.semantic_tokens_full_debounce.remove(&buffer_id);
2041            return;
2042        }
2043
2044        let Some(ready_at) = self.semantic_tokens_full_debounce.get(&buffer_id).copied() else {
2045            return;
2046        };
2047        if Instant::now() < ready_at {
2048            return;
2049        }
2050
2051        self.semantic_tokens_full_debounce.remove(&buffer_id);
2052        self.maybe_request_semantic_tokens(buffer_id);
2053    }
2054
2055    /// Request semantic tokens for a viewport range (with padding).
2056    pub(crate) fn maybe_request_semantic_tokens_range(
2057        &mut self,
2058        buffer_id: BufferId,
2059        start_line: usize,
2060        end_line: usize,
2061    ) {
2062        let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
2063            return;
2064        };
2065        if !metadata.lsp_enabled {
2066            return;
2067        }
2068        let Some(uri) = metadata.file_uri().cloned() else {
2069            return;
2070        };
2071        let Some(path) = metadata.file_path() else {
2072            return;
2073        };
2074        let Some(language) = detect_language(path, &self.config.languages) else {
2075            return;
2076        };
2077
2078        let Some(lsp) = self.lsp.as_mut() else {
2079            return;
2080        };
2081
2082        if !lsp.semantic_tokens_range_supported(&language) {
2083            // Fall back to full document tokens if range not supported.
2084            self.maybe_request_semantic_tokens(buffer_id);
2085            return;
2086        }
2087        if lsp.semantic_tokens_legend(&language).is_none() {
2088            return;
2089        }
2090
2091        // Ensure there is a running server
2092        use crate::services::lsp::manager::LspSpawnResult;
2093        if lsp.try_spawn(&language) != LspSpawnResult::Spawned {
2094            return;
2095        }
2096
2097        let Some(handle) = lsp.get_handle_mut(&language) else {
2098            return;
2099        };
2100        let Some(state) = self.buffers.get(&buffer_id) else {
2101            return;
2102        };
2103
2104        let buffer_version = state.buffer.version();
2105        let mut padded_start = start_line.saturating_sub(SEMANTIC_TOKENS_RANGE_PADDING_LINES);
2106        let mut padded_end = end_line.saturating_add(SEMANTIC_TOKENS_RANGE_PADDING_LINES);
2107
2108        if let Some(line_count) = state.buffer.line_count() {
2109            if line_count == 0 {
2110                return;
2111            }
2112            let max_line = line_count.saturating_sub(1);
2113            padded_start = padded_start.min(max_line);
2114            padded_end = padded_end.min(max_line);
2115        }
2116
2117        let start_byte = state.buffer.line_start_offset(padded_start).unwrap_or(0);
2118        let end_char = state
2119            .buffer
2120            .get_line(padded_end)
2121            .map(|line| String::from_utf8_lossy(&line).encode_utf16().count())
2122            .unwrap_or(0);
2123        let end_byte = if state.buffer.line_start_offset(padded_end).is_some() {
2124            state.buffer.lsp_position_to_byte(padded_end, end_char)
2125        } else {
2126            state.buffer.len()
2127        };
2128
2129        if start_byte >= end_byte {
2130            return;
2131        }
2132
2133        let range = start_byte..end_byte;
2134        if let Some((in_flight_id, in_flight_start, in_flight_end, in_flight_version)) =
2135            self.semantic_tokens_range_in_flight.get(&buffer_id)
2136        {
2137            if *in_flight_start == padded_start
2138                && *in_flight_end == padded_end
2139                && *in_flight_version == buffer_version
2140            {
2141                return;
2142            }
2143            if let Err(e) = handle.cancel_request(*in_flight_id) {
2144                tracing::debug!("Failed to cancel semantic token range request: {}", e);
2145            }
2146            self.pending_semantic_token_range_requests
2147                .remove(in_flight_id);
2148            self.semantic_tokens_range_in_flight.remove(&buffer_id);
2149        }
2150
2151        if let Some((applied_start, applied_end, applied_version)) =
2152            self.semantic_tokens_range_applied.get(&buffer_id)
2153        {
2154            if *applied_start == padded_start
2155                && *applied_end == padded_end
2156                && *applied_version == buffer_version
2157            {
2158                return;
2159            }
2160        }
2161
2162        let now = Instant::now();
2163        if let Some((last_start, last_end, last_version, last_time)) =
2164            self.semantic_tokens_range_last_request.get(&buffer_id)
2165        {
2166            if *last_start == padded_start
2167                && *last_end == padded_end
2168                && *last_version == buffer_version
2169                && now.duration_since(*last_time)
2170                    < Duration::from_millis(SEMANTIC_TOKENS_RANGE_DEBOUNCE_MS)
2171            {
2172                return;
2173            }
2174        }
2175
2176        let lsp_range = lsp_types::Range {
2177            start: lsp_types::Position {
2178                line: padded_start as u32,
2179                character: 0,
2180            },
2181            end: lsp_types::Position {
2182                line: padded_end as u32,
2183                character: end_char as u32,
2184            },
2185        };
2186
2187        let request_id = self.next_lsp_request_id;
2188        self.next_lsp_request_id += 1;
2189
2190        match handle.semantic_tokens_range(request_id, uri, lsp_range) {
2191            Ok(_) => {
2192                self.pending_semantic_token_range_requests.insert(
2193                    request_id,
2194                    SemanticTokenRangeRequest {
2195                        buffer_id,
2196                        version: buffer_version,
2197                        range: range.clone(),
2198                        start_line: padded_start,
2199                        end_line: padded_end,
2200                    },
2201                );
2202                self.semantic_tokens_range_in_flight.insert(
2203                    buffer_id,
2204                    (request_id, padded_start, padded_end, buffer_version),
2205                );
2206                self.semantic_tokens_range_last_request
2207                    .insert(buffer_id, (padded_start, padded_end, buffer_version, now));
2208            }
2209            Err(e) => {
2210                tracing::debug!("Failed to request semantic token range: {}", e);
2211            }
2212        }
2213    }
2214}
2215
2216#[cfg(test)]
2217mod tests {
2218    use crate::model::filesystem::StdFileSystem;
2219    use std::sync::Arc;
2220
2221    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
2222        Arc::new(StdFileSystem)
2223    }
2224    use super::Editor;
2225    use crate::model::buffer::Buffer;
2226    use crate::state::EditorState;
2227    use crate::view::virtual_text::VirtualTextPosition;
2228    use lsp_types::{InlayHint, InlayHintKind, InlayHintLabel, Position};
2229
2230    fn make_hint(line: u32, character: u32, label: &str, kind: Option<InlayHintKind>) -> InlayHint {
2231        InlayHint {
2232            position: Position { line, character },
2233            label: InlayHintLabel::String(label.to_string()),
2234            kind,
2235            text_edits: None,
2236            tooltip: None,
2237            padding_left: None,
2238            padding_right: None,
2239            data: None,
2240        }
2241    }
2242
2243    #[test]
2244    fn test_inlay_hint_inserts_before_character() {
2245        let mut state = EditorState::new(
2246            80,
2247            24,
2248            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2249            test_fs(),
2250        );
2251        state.buffer = Buffer::from_str_test("ab");
2252
2253        if !state.buffer.is_empty() {
2254            state.marker_list.adjust_for_insert(0, state.buffer.len());
2255        }
2256
2257        let hints = vec![make_hint(0, 1, ": i32", Some(InlayHintKind::TYPE))];
2258        Editor::apply_inlay_hints_to_state(&mut state, &hints);
2259
2260        let lookup = state
2261            .virtual_texts
2262            .build_lookup(&state.marker_list, 0, state.buffer.len());
2263        let vtexts = lookup.get(&1).expect("expected hint at byte offset 1");
2264        assert_eq!(vtexts.len(), 1);
2265        assert_eq!(vtexts[0].text, ": i32");
2266        assert_eq!(vtexts[0].position, VirtualTextPosition::BeforeChar);
2267    }
2268
2269    #[test]
2270    fn test_inlay_hint_at_eof_renders_after_last_char() {
2271        let mut state = EditorState::new(
2272            80,
2273            24,
2274            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2275            test_fs(),
2276        );
2277        state.buffer = Buffer::from_str_test("ab");
2278
2279        if !state.buffer.is_empty() {
2280            state.marker_list.adjust_for_insert(0, state.buffer.len());
2281        }
2282
2283        let hints = vec![make_hint(0, 2, ": i32", Some(InlayHintKind::TYPE))];
2284        Editor::apply_inlay_hints_to_state(&mut state, &hints);
2285
2286        let lookup = state
2287            .virtual_texts
2288            .build_lookup(&state.marker_list, 0, state.buffer.len());
2289        let vtexts = lookup.get(&1).expect("expected hint anchored to last byte");
2290        assert_eq!(vtexts.len(), 1);
2291        assert_eq!(vtexts[0].text, ": i32");
2292        assert_eq!(vtexts[0].position, VirtualTextPosition::AfterChar);
2293    }
2294
2295    #[test]
2296    fn test_inlay_hint_empty_buffer_is_ignored() {
2297        let mut state = EditorState::new(
2298            80,
2299            24,
2300            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
2301            test_fs(),
2302        );
2303        state.buffer = Buffer::from_str_test("");
2304
2305        let hints = vec![make_hint(0, 0, ": i32", Some(InlayHintKind::TYPE))];
2306        Editor::apply_inlay_hints_to_state(&mut state, &hints);
2307
2308        assert!(state.virtual_texts.is_empty());
2309    }
2310}