Skip to main content

fresh/app/
lsp_requests.rs

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