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