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 signature help at current cursor position
1431    pub(crate) fn request_signature_help(&mut self) {
1432        // Get the current buffer and cursor position
1433        let cursor_pos = self.active_cursors().primary().position;
1434        let state = self.active_state();
1435
1436        // Convert byte position to LSP position (line, UTF-16 code units)
1437        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
1438        let buffer_id = self.active_buffer();
1439        let request_id = self.active_window_mut().next_lsp_request_id;
1440
1441        // Use helper to ensure didOpen is sent before the request
1442        let sent = self
1443            .with_lsp_for_buffer(
1444                buffer_id,
1445                LspFeature::SignatureHelp,
1446                |handle, uri, _language| {
1447                    let result = handle.signature_help(
1448                        request_id,
1449                        uri.as_uri().clone(),
1450                        line as u32,
1451                        character as u32,
1452                    );
1453                    if result.is_ok() {
1454                        tracing::info!(
1455                            "Requested signature help at {}:{}:{} (byte_pos={})",
1456                            uri.as_str(),
1457                            line,
1458                            character,
1459                            cursor_pos
1460                        );
1461                    }
1462                    result.is_ok()
1463                },
1464            )
1465            .unwrap_or(false);
1466
1467        if sent {
1468            self.active_window_mut().next_lsp_request_id += 1;
1469            self.active_window_mut().pending_signature_help_request = Some(request_id);
1470        }
1471    }
1472
1473    /// Handle signature help response from LSP
1474    pub(crate) fn handle_signature_help_response(
1475        &mut self,
1476        request_id: u64,
1477        signature_help: Option<lsp_types::SignatureHelp>,
1478    ) {
1479        // Check if this response is for the current pending request
1480        if self.active_window_mut().pending_signature_help_request != Some(request_id) {
1481            tracing::debug!("Ignoring stale signature help response: {}", request_id);
1482            return;
1483        }
1484
1485        self.active_window_mut().pending_signature_help_request = None;
1486        let signature_help = match signature_help {
1487            Some(help) if !help.signatures.is_empty() => help,
1488            _ => {
1489                tracing::debug!("No signature help available");
1490                return;
1491            }
1492        };
1493
1494        // Get the active signature
1495        let active_signature_idx = signature_help.active_signature.unwrap_or(0) as usize;
1496        let signature = match signature_help.signatures.get(active_signature_idx) {
1497            Some(sig) => sig,
1498            None => return,
1499        };
1500
1501        // Build the display content as markdown
1502        let mut content = String::new();
1503
1504        // Add the signature label (function signature)
1505        content.push_str(&signature.label);
1506        content.push('\n');
1507
1508        // Add parameter highlighting info
1509        let active_param = signature_help
1510            .active_parameter
1511            .or(signature.active_parameter)
1512            .unwrap_or(0) as usize;
1513
1514        // If there are parameters, highlight the active one
1515        if let Some(params) = &signature.parameters {
1516            if let Some(param) = params.get(active_param) {
1517                // Get parameter label
1518                let param_label = match &param.label {
1519                    lsp_types::ParameterLabel::Simple(s) => s.clone(),
1520                    lsp_types::ParameterLabel::LabelOffsets(offsets) => {
1521                        // Extract substring from signature label
1522                        let start = offsets[0] as usize;
1523                        let end = offsets[1] as usize;
1524                        if end <= signature.label.len() {
1525                            signature.label[start..end].to_string()
1526                        } else {
1527                            String::new()
1528                        }
1529                    }
1530                };
1531
1532                if !param_label.is_empty() {
1533                    content.push_str(&format!("\n> {}\n", param_label));
1534                }
1535
1536                // Add parameter documentation if available
1537                if let Some(doc) = &param.documentation {
1538                    let doc_text = match doc {
1539                        lsp_types::Documentation::String(s) => s.clone(),
1540                        lsp_types::Documentation::MarkupContent(m) => m.value.clone(),
1541                    };
1542                    if !doc_text.is_empty() {
1543                        content.push('\n');
1544                        content.push_str(&doc_text);
1545                        content.push('\n');
1546                    }
1547                }
1548            }
1549        }
1550
1551        // Add function documentation if available
1552        if let Some(doc) = &signature.documentation {
1553            let doc_text = match doc {
1554                lsp_types::Documentation::String(s) => s.clone(),
1555                lsp_types::Documentation::MarkupContent(m) => m.value.clone(),
1556            };
1557            if !doc_text.is_empty() {
1558                content.push_str("\n---\n\n");
1559                content.push_str(&space_doc_paragraphs(&doc_text));
1560            }
1561        }
1562
1563        // Create a popup with markdown rendering (like hover popup)
1564        use crate::view::popup::{Popup, PopupPosition};
1565        use ratatui::style::Style;
1566
1567        let mut popup = Popup::markdown(
1568            &content,
1569            &*self.theme.read().unwrap(),
1570            Some(&self.grammar_registry),
1571        );
1572        popup.title = Some(t!("lsp.popup_signature").to_string());
1573        popup.transient = true;
1574        popup.position = PopupPosition::BelowCursor;
1575        popup.width = 60;
1576        popup.max_height = 20;
1577        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1578        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1579        popup.focus_key_hint = self.popup_focus_key_hint();
1580
1581        // Show the popup
1582        let __buffer_id = self.active_buffer();
1583        if let Some(state) = self
1584            .windows
1585            .get_mut(&self.active_window)
1586            .map(|w| &mut w.buffers)
1587            .expect("active window present")
1588            .get_mut(&__buffer_id)
1589        {
1590            state.popups.show(popup);
1591            tracing::info!(
1592                "Showing signature help popup for {} signatures",
1593                signature_help.signatures.len()
1594            );
1595        }
1596    }
1597
1598    /// Request LSP code actions at current cursor position.
1599    /// Sends code action requests to all eligible servers for merged results.
1600    pub(crate) fn request_code_actions(&mut self) -> AnyhowResult<()> {
1601        // A new invocation starts a fresh batch. Cancel any previous
1602        // in-flight code-action requests so their late responses are
1603        // ignored (handle_code_actions_response drops responses whose
1604        // request_id isn't in pending_code_actions_requests). Without
1605        // this, actions from a prior cursor position would be merged
1606        // into the new popup — same bug class we already avoid for
1607        // completion (sinelaw/fresh#1514) and inlay hints (multi-buffer
1608        // quiescent).
1609        if !self
1610            .active_window()
1611            .pending_code_actions_requests
1612            .is_empty()
1613        {
1614            let ids: Vec<u64> = self
1615                .active_window_mut()
1616                .pending_code_actions_requests
1617                .drain()
1618                .collect();
1619            for request_id in ids {
1620                tracing::debug!(
1621                    "Canceling previous pending LSP code actions request {}",
1622                    request_id
1623                );
1624                self.active_window_mut().send_lsp_cancel_request(request_id);
1625            }
1626        }
1627        self.active_window_mut()
1628            .pending_code_actions_server_names
1629            .clear();
1630        self.active_window_mut().pending_code_actions = None;
1631
1632        // Get the current buffer and cursor position
1633        let cursor_pos = self.active_cursors().primary().position;
1634        let selection_range = self.active_cursors().primary().selection_range();
1635        let state = self.active_state();
1636
1637        // Convert byte position to LSP position (line, UTF-16 code units)
1638        let (line, character) = state.buffer.position_to_lsp_position(cursor_pos);
1639
1640        // Get selection range (if any) or use cursor position
1641        let (start_line, start_char, end_line, end_char) = if let Some(range) = selection_range {
1642            let (s_line, s_char) = state.buffer.position_to_lsp_position(range.start);
1643            let (e_line, e_char) = state.buffer.position_to_lsp_position(range.end);
1644            (s_line as u32, s_char as u32, e_line as u32, e_char as u32)
1645        } else {
1646            (line as u32, character as u32, line as u32, character as u32)
1647        };
1648
1649        let buffer_id = self.active_buffer();
1650
1651        // Populate `context.diagnostics` with stored diagnostics whose ranges
1652        // overlap the code-action request range. Many servers (clangd,
1653        // eslint, ...) gate quickfix actions on this — without it, every
1654        // diagnostic-driven "fix available" produces zero actions
1655        // (sinelaw/fresh#2212).
1656        let diagnostics: Vec<lsp_types::Diagnostic> = {
1657            let window = self.active_window();
1658            window
1659                .buffer_metadata
1660                .get(&buffer_id)
1661                .and_then(|m| m.file_uri())
1662                .and_then(|uri| window.stored_diagnostics.get(uri.as_str()))
1663                .map(|diags| {
1664                    diags
1665                        .iter()
1666                        .filter(|d| {
1667                            lsp_range_overlaps(&d.range, start_line, start_char, end_line, end_char)
1668                        })
1669                        .cloned()
1670                        .collect()
1671                })
1672                .unwrap_or_default()
1673        };
1674
1675        // Pre-allocate request IDs for all eligible servers
1676        let base_request_id = self.active_window_mut().next_lsp_request_id;
1677        let counter = std::sync::atomic::AtomicU64::new(0);
1678
1679        let results = self.with_all_lsp_for_buffer_feature_named(
1680            buffer_id,
1681            LspFeature::CodeAction,
1682            |handle, uri, _language, server_name| {
1683                let idx = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1684                let request_id = base_request_id + idx;
1685                let result = handle.code_actions(
1686                    request_id,
1687                    uri.as_uri().clone(),
1688                    start_line,
1689                    start_char,
1690                    end_line,
1691                    end_char,
1692                    diagnostics.clone(),
1693                );
1694                if result.is_ok() {
1695                    tracing::info!(
1696                        "Requested code actions at {}:{}:{}-{}:{} (byte_pos={}, request_id={}, server={})",
1697                        uri.as_str(),
1698                        start_line,
1699                        start_char,
1700                        end_line,
1701                        end_char,
1702                        cursor_pos,
1703                        request_id,
1704                        server_name
1705                    );
1706                }
1707                (request_id, result.is_ok(), server_name.to_string())
1708            },
1709        );
1710
1711        let mut sent_ids = Vec::new();
1712        for (request_id, ok, server_name) in &results {
1713            if *ok {
1714                sent_ids.push(*request_id);
1715                self.active_window_mut()
1716                    .pending_code_actions_server_names
1717                    .insert(*request_id, server_name.clone());
1718            }
1719        }
1720        // Advance the ID counter past all allocated IDs
1721        self.active_window_mut().next_lsp_request_id = base_request_id + results.len() as u64;
1722
1723        if !sent_ids.is_empty() {
1724            // pending_code_actions was already cleared above alongside the
1725            // cancel-previous-requests logic.
1726            self.active_window_mut()
1727                .pending_code_actions_requests
1728                .extend(sent_ids);
1729        }
1730
1731        Ok(())
1732    }
1733
1734    /// Handle code actions response from LSP.
1735    /// Supports merging from multiple servers: each response extends the action
1736    /// list, and the popup is shown/updated with each arriving response.
1737    pub(crate) fn handle_code_actions_response(
1738        &mut self,
1739        request_id: u64,
1740        actions: Vec<lsp_types::CodeActionOrCommand>,
1741    ) {
1742        // Check if this response is for one of the pending requests
1743        if !self
1744            .active_window_mut()
1745            .pending_code_actions_requests
1746            .remove(&request_id)
1747        {
1748            tracing::debug!("Ignoring stale code actions response: {}", request_id);
1749            return;
1750        }
1751
1752        // Look up the server name for this request
1753        let server_name = self
1754            .active_window_mut()
1755            .pending_code_actions_server_names
1756            .remove(&request_id)
1757            .unwrap_or_default();
1758
1759        if actions.is_empty() {
1760            // Only show "no code actions" if all responses are in and we have nothing
1761            if self
1762                .active_window()
1763                .pending_code_actions_requests
1764                .is_empty()
1765                && self
1766                    .active_window_mut()
1767                    .pending_code_actions
1768                    .as_ref()
1769                    .is_none_or(|a| a.is_empty())
1770            {
1771                self.set_status_message(t!("lsp.no_code_actions").to_string());
1772            }
1773            return;
1774        }
1775
1776        // Tag each action with its server name and store/extend for merging
1777        let tagged_actions: Vec<(String, lsp_types::CodeActionOrCommand)> = actions
1778            .into_iter()
1779            .map(|a| (server_name.clone(), a))
1780            .collect();
1781
1782        match &mut self.active_window_mut().pending_code_actions {
1783            Some(existing) => {
1784                existing.extend(tagged_actions);
1785                tracing::debug!("Extended code actions, now {} total", existing.len());
1786            }
1787            None => {
1788                self.active_window_mut().pending_code_actions = Some(tagged_actions);
1789            }
1790        }
1791
1792        // Build list items from all accumulated code actions
1793        use crate::view::popup::{Popup, PopupListItem, PopupPosition};
1794        use ratatui::style::Style;
1795
1796        let items: Vec<PopupListItem> = {
1797            let all_actions = self.active_window().pending_code_actions.as_ref().unwrap();
1798            let multiple_servers = {
1799                let mut names = std::collections::HashSet::new();
1800                for (name, _) in all_actions {
1801                    names.insert(name.as_str());
1802                }
1803                names.len() > 1
1804            };
1805            all_actions
1806                .iter()
1807                .enumerate()
1808                .map(|(i, (srv_name, action))| {
1809                    let title = match action {
1810                        lsp_types::CodeActionOrCommand::Command(cmd) => &cmd.title,
1811                        lsp_types::CodeActionOrCommand::CodeAction(ca) => &ca.title,
1812                    };
1813                    let kind = match action {
1814                        lsp_types::CodeActionOrCommand::CodeAction(ca) => {
1815                            ca.kind.as_ref().map(|k| k.as_str().to_string())
1816                        }
1817                        _ => None,
1818                    };
1819                    let detail = if multiple_servers && !srv_name.is_empty() {
1820                        match kind {
1821                            Some(k) => Some(format!("[{}] {}", srv_name, k)),
1822                            None => Some(format!("[{}]", srv_name)),
1823                        }
1824                    } else {
1825                        kind
1826                    };
1827                    PopupListItem {
1828                        text: format!("{}. {}", i + 1, title),
1829                        detail,
1830                        icon: None,
1831                        data: Some(i.to_string()),
1832                        disabled: false,
1833                    }
1834                })
1835                .collect()
1836        };
1837
1838        let mut popup = Popup::list(items, &*self.theme.read().unwrap());
1839        popup.kind = crate::view::popup::PopupKind::Action;
1840        popup.title = Some(t!("lsp.popup_code_actions").to_string());
1841        popup.position = PopupPosition::BelowCursor;
1842        popup.width = 60;
1843        popup.max_height = 15;
1844        popup.border_style = Style::default().fg(self.theme.read().unwrap().popup_border_fg);
1845        popup.background_style = Style::default().bg(self.theme.read().unwrap().popup_bg);
1846        // Confirm reads the selected row's `data` as an index into
1847        // `self.active_window_mut().pending_code_actions` — the heavy lsp_types payload
1848        // stays on the Editor to keep the view crate LSP-free.
1849        popup.resolver = crate::view::popup::PopupResolver::CodeAction;
1850        // Code actions are an explicit user invocation (`lsp_code_actions`
1851        // command); the user expects to choose immediately, so the popup
1852        // grabs focus on creation. Unfocused-by-default behavior applies
1853        // only to popups that *appear under the cursor* (completion,
1854        // hover, signature help, the LSP-server auto-prompt).
1855        popup.focused = true;
1856
1857        // Show the popup, replacing any existing action popup to avoid stacking
1858        let __buffer_id = self.active_buffer();
1859        let action_count = self
1860            .active_window()
1861            .pending_code_actions
1862            .as_ref()
1863            .map_or(0, |v| v.len());
1864        if let Some(state) = self
1865            .windows
1866            .get_mut(&self.active_window)
1867            .map(|w| &mut w.buffers)
1868            .expect("active window present")
1869            .get_mut(&__buffer_id)
1870        {
1871            state.popups.show_or_replace(popup);
1872            tracing::info!("Showing code actions popup with {} actions", action_count);
1873        }
1874    }
1875
1876    /// Execute a code action by index from the stored pending_code_actions.
1877    pub(crate) fn execute_code_action(&mut self, index: usize) {
1878        let action = match &self.active_window_mut().pending_code_actions {
1879            Some(actions) => actions.get(index).map(|(_, a)| a.clone()),
1880            None => None,
1881        };
1882
1883        let Some(action) = action else {
1884            tracing::warn!("Code action index {} out of range", index);
1885            return;
1886        };
1887
1888        match action {
1889            lsp_types::CodeActionOrCommand::CodeAction(ca) => {
1890                // If the action has no edit and no command, it may need resolve first.
1891                // Only resolve if the action has `data` and the server supports resolveProvider.
1892                if ca.edit.is_none()
1893                    && ca.command.is_none()
1894                    && ca.data.is_some()
1895                    && self.active_window().server_supports_code_action_resolve()
1896                {
1897                    tracing::info!(
1898                        "Code action '{}' needs resolve, sending codeAction/resolve",
1899                        ca.title
1900                    );
1901                    self.send_code_action_resolve(ca);
1902                    return;
1903                }
1904                self.execute_resolved_code_action(ca);
1905            }
1906            lsp_types::CodeActionOrCommand::Command(cmd) => {
1907                self.send_execute_command(cmd);
1908            }
1909        }
1910    }
1911
1912    /// Execute a code action that has been fully resolved (has edit and/or command).
1913    pub(crate) fn execute_resolved_code_action(&mut self, ca: lsp_types::CodeAction) {
1914        let title = ca.title.clone();
1915
1916        // Apply workspace edit if present
1917        if let Some(edit) = ca.edit {
1918            match self.apply_workspace_edit(edit) {
1919                Ok(n) => {
1920                    self.set_status_message(
1921                        t!("lsp.code_action_applied", title = &title, count = n).to_string(),
1922                    );
1923                }
1924                Err(e) => {
1925                    self.set_status_message(format!("Code action failed: {e}"));
1926                    return;
1927                }
1928            }
1929        }
1930
1931        // Execute command if present (may trigger workspace/applyEdit from server)
1932        if let Some(cmd) = ca.command {
1933            self.send_execute_command(cmd);
1934        }
1935    }
1936
1937    /// Send workspace/executeCommand to the LSP server
1938    fn send_execute_command(&mut self, cmd: lsp_types::Command) {
1939        tracing::info!("Executing LSP command: {} ({})", cmd.title, cmd.command);
1940        self.set_status_message(
1941            t!(
1942                "lsp.code_action_applied",
1943                title = &cmd.title,
1944                count = 0_usize
1945            )
1946            .to_string(),
1947        );
1948
1949        // Get the language for this buffer to find the right LSP handle
1950        let language = match self
1951            .buffers()
1952            .get(&self.active_buffer())
1953            .map(|s| s.language.clone())
1954        {
1955            Some(l) => l,
1956            None => return,
1957        };
1958
1959        let __active_id = self.active_window;
1960
1961        if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
1962            for sh in lsp.get_handles_mut(&language) {
1963                if let Err(e) = sh
1964                    .handle
1965                    .execute_command(cmd.command.clone(), cmd.arguments.clone())
1966                {
1967                    tracing::warn!("Failed to send executeCommand to '{}': {}", sh.name, e);
1968                }
1969            }
1970        }
1971    }
1972
1973    /// Send codeAction/resolve to the LSP server
1974    fn send_code_action_resolve(&mut self, action: lsp_types::CodeAction) {
1975        let language = match self
1976            .buffers()
1977            .get(&self.active_buffer())
1978            .map(|s| s.language.clone())
1979        {
1980            Some(l) => l,
1981            None => return,
1982        };
1983
1984        self.active_window_mut().next_lsp_request_id += 1;
1985        let request_id = self.active_window_mut().next_lsp_request_id;
1986
1987        let __active_id = self.active_window;
1988
1989        if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
1990            for sh in lsp.get_handles_mut(&language) {
1991                if let Err(e) = sh.handle.code_action_resolve(request_id, action.clone()) {
1992                    tracing::warn!("Failed to send codeAction/resolve to '{}': {}", sh.name, e);
1993                }
1994            }
1995        }
1996    }
1997
1998    /// Handle a resolved completion item — apply additional_text_edits (e.g. auto-imports).
1999    pub(crate) fn handle_completion_resolved(&mut self, item: lsp_types::CompletionItem) {
2000        if let Some(additional_edits) = item.additional_text_edits {
2001            if !additional_edits.is_empty() {
2002                tracing::info!(
2003                    "Applying {} additional text edits from completion resolve",
2004                    additional_edits.len()
2005                );
2006                let buffer_id = self.active_buffer();
2007                if let Err(e) = self.apply_lsp_text_edits(buffer_id, additional_edits) {
2008                    tracing::error!("Failed to apply completion additional_text_edits: {}", e);
2009                }
2010            }
2011        }
2012    }
2013
2014    /// Apply formatting edits from textDocument/formatting response.
2015    pub(crate) fn apply_formatting_edits(
2016        &mut self,
2017        uri: &str,
2018        edits: Vec<lsp_types::TextEdit>,
2019    ) -> AnyhowResult<usize> {
2020        // Find the buffer for this URI
2021        let buffer_id = self
2022            .active_window()
2023            .buffer_metadata
2024            .iter()
2025            .find(|(_, meta)| meta.file_uri().map(|u| u.as_str() == uri).unwrap_or(false))
2026            .map(|(id, _)| *id);
2027
2028        if let Some(buffer_id) = buffer_id {
2029            let count = self.apply_lsp_text_edits(buffer_id, edits)?;
2030            self.set_status_message(format!("Formatted ({} edits)", count));
2031            Ok(count)
2032        } else {
2033            tracing::warn!("Cannot apply formatting: no buffer for URI {}", uri);
2034            Ok(0)
2035        }
2036    }
2037
2038    /// Request document formatting from LSP.
2039    pub(crate) fn request_formatting(&mut self) {
2040        let buffer_id = self.active_buffer();
2041        let metadata = match self.active_window().buffer_metadata.get(&buffer_id) {
2042            Some(m) if m.lsp_enabled => m,
2043            _ => {
2044                self.set_status_message("LSP not available for this buffer".to_string());
2045                return;
2046            }
2047        };
2048
2049        let uri = match metadata.file_uri() {
2050            Some(u) => u.clone(),
2051            None => return,
2052        };
2053
2054        let language = match self
2055            .windows
2056            .get(&self.active_window)
2057            .map(|w| &w.buffers)
2058            .expect("active window present")
2059            .get(&buffer_id)
2060            .map(|s| s.language.clone())
2061        {
2062            Some(l) => l,
2063            None => return,
2064        };
2065
2066        let tab_size = self.config.editor.tab_size as u32;
2067        let insert_spaces = !self.config.editor.use_tabs;
2068
2069        self.active_window_mut().next_lsp_request_id += 1;
2070        let request_id = self.active_window_mut().next_lsp_request_id;
2071
2072        let __active_id = self.active_window;
2073
2074        if let Some(lsp) = self.windows.get_mut(&__active_id).map(|w| &mut w.lsp) {
2075            if let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::Format) {
2076                if let Err(e) = sh.handle.document_formatting(
2077                    request_id,
2078                    uri.as_uri().clone(),
2079                    tab_size,
2080                    insert_spaces,
2081                ) {
2082                    tracing::warn!("Failed to request formatting: {}", e);
2083                }
2084            } else {
2085                self.set_status_message("Formatting not supported by LSP server".to_string());
2086            }
2087        }
2088    }
2089
2090    /// Handle find references response from LSP
2091    pub(crate) fn handle_references_response(
2092        &mut self,
2093        request_id: u64,
2094        locations: Vec<lsp_types::Location>,
2095    ) -> AnyhowResult<()> {
2096        tracing::info!(
2097            "handle_references_response: received {} locations for request_id={}",
2098            locations.len(),
2099            request_id
2100        );
2101
2102        // Check if this response is for the current pending request
2103        if self.active_window_mut().pending_references_request != Some(request_id) {
2104            tracing::debug!("Ignoring stale references response: {}", request_id);
2105            return Ok(());
2106        }
2107
2108        self.active_window_mut().pending_references_request = None;
2109        if locations.is_empty() {
2110            self.set_status_message(t!("lsp.no_references").to_string());
2111            return Ok(());
2112        }
2113
2114        // Convert locations to hook args format. Each `loc.uri` is a
2115        // wire-side URI from the LSP, so wrap it in [`LspUri`] and run
2116        // it through the active authority's translation before
2117        // handing a host-path string to the references hook —
2118        // otherwise plugins (notably `find_references`) try to open
2119        // an in-container path on the host and fail.
2120        let translation = self.authority().path_translation.clone();
2121        let lsp_locations: Vec<crate::services::plugins::hooks::LspLocation> = locations
2122            .iter()
2123            .map(|loc| {
2124                let wire = crate::app::types::LspUri::from_wire(loc.uri.clone());
2125                // Prefer the host-side path (after translation) so
2126                // plugin-side file ops resolve. Fall back to the raw
2127                // string for non-`file://` URIs so callers can still
2128                // see *something*.
2129                let file = if loc.uri.scheme().map(|s| s.as_str()) == Some("file") {
2130                    wire.to_host_path(translation.as_ref())
2131                        .map(|p| p.to_string_lossy().into_owned())
2132                        .unwrap_or_else(|| loc.uri.path().as_str().to_string())
2133                } else {
2134                    loc.uri.as_str().to_string()
2135                };
2136
2137                crate::services::plugins::hooks::LspLocation {
2138                    file,
2139                    line: loc.range.start.line + 1, // LSP is 0-based, convert to 1-based
2140                    column: loc.range.start.character + 1, // LSP is 0-based
2141                }
2142            })
2143            .collect();
2144
2145        let count = lsp_locations.len();
2146        let symbol = std::mem::take(&mut self.active_window_mut().pending_references_symbol);
2147        self.set_status_message(
2148            t!("lsp.found_references", count = count, symbol = &symbol).to_string(),
2149        );
2150
2151        // Fire the lsp_references hook so plugins can display the results
2152        self.plugin_manager.read().unwrap().run_hook(
2153            "lsp_references",
2154            crate::services::plugins::hooks::HookArgs::LspReferences {
2155                symbol: symbol.clone(),
2156                locations: lsp_locations,
2157            },
2158        );
2159
2160        tracing::info!(
2161            "Fired lsp_references hook with {} locations for symbol '{}'",
2162            count,
2163            symbol
2164        );
2165
2166        Ok(())
2167    }
2168
2169    /// Apply LSP text edits to a buffer and return the number of changes made.
2170    /// Edits are sorted in reverse order and applied as a batch.
2171    pub(crate) fn apply_lsp_text_edits(
2172        &mut self,
2173        buffer_id: BufferId,
2174        mut edits: Vec<lsp_types::TextEdit>,
2175    ) -> AnyhowResult<usize> {
2176        if edits.is_empty() {
2177            return Ok(0);
2178        }
2179
2180        // Sort edits by position (reverse order to avoid offset issues)
2181        edits.sort_by(|a, b| {
2182            b.range
2183                .start
2184                .line
2185                .cmp(&a.range.start.line)
2186                .then(b.range.start.character.cmp(&a.range.start.character))
2187        });
2188
2189        // Collect all events for this buffer into a batch
2190        let mut batch_events = Vec::new();
2191        let mut changes = 0;
2192
2193        // Get cursor_id for this buffer from split view state
2194        let cursor_id = {
2195            let split_id = self
2196                .split_manager_mut()
2197                .splits_for_buffer(buffer_id)
2198                .into_iter()
2199                .next()
2200                .unwrap_or_else(|| {
2201                    self.windows
2202                        .get(&self.active_window)
2203                        .and_then(|w| w.buffers.splits())
2204                        .map(|(mgr, _)| mgr)
2205                        .expect("active window must have a populated split layout")
2206                        .active_split()
2207                });
2208            self.windows
2209                .get(&self.active_window)
2210                .and_then(|w| w.buffers.splits())
2211                .map(|(_, vs)| vs)
2212                .expect("active window must have a populated split layout")
2213                .get(&split_id)
2214                .map(|vs| vs.cursors.primary_id())
2215                .unwrap_or_else(|| self.active_cursors().primary_id())
2216        };
2217
2218        // Create events for all edits
2219        for edit in edits {
2220            let state = self
2221                .buffers_mut()
2222                .get_mut(&buffer_id)
2223                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))?;
2224
2225            // Convert LSP range to byte positions
2226            let start_line = edit.range.start.line as usize;
2227            let start_char = edit.range.start.character as usize;
2228            let end_line = edit.range.end.line as usize;
2229            let end_char = edit.range.end.character as usize;
2230
2231            let start_pos = state.buffer.lsp_position_to_byte(start_line, start_char);
2232            let end_pos = state.buffer.lsp_position_to_byte(end_line, end_char);
2233            let buffer_len = state.buffer.len();
2234
2235            // Log the conversion for debugging
2236            let old_text = if start_pos < end_pos && end_pos <= buffer_len {
2237                state.get_text_range(start_pos, end_pos)
2238            } else {
2239                format!(
2240                    "<invalid range: start={}, end={}, buffer_len={}>",
2241                    start_pos, end_pos, buffer_len
2242                )
2243            };
2244            tracing::debug!(
2245                "  Converting LSP range line {}:{}-{}:{} to bytes {}..{} (replacing {:?} with {:?})",
2246                start_line, start_char, end_line, end_char,
2247                start_pos, end_pos, old_text, edit.new_text
2248            );
2249
2250            // Delete old text
2251            if start_pos < end_pos {
2252                let deleted_text = state.get_text_range(start_pos, end_pos);
2253                let delete_event = Event::Delete {
2254                    range: start_pos..end_pos,
2255                    deleted_text,
2256                    cursor_id,
2257                };
2258                batch_events.push(delete_event);
2259            }
2260
2261            // Insert new text
2262            if !edit.new_text.is_empty() {
2263                let insert_event = Event::Insert {
2264                    position: start_pos,
2265                    text: edit.new_text.clone(),
2266                    cursor_id,
2267                };
2268                batch_events.push(insert_event);
2269            }
2270
2271            changes += 1;
2272        }
2273
2274        // Apply all rename changes using bulk edit for O(n) performance
2275        if !batch_events.is_empty() {
2276            self.apply_events_to_buffer_as_bulk_edit(
2277                buffer_id,
2278                batch_events,
2279                "LSP Rename".to_string(),
2280            )?;
2281        }
2282
2283        Ok(changes)
2284    }
2285
2286    /// Apply a single TextDocumentEdit from a workspace edit.
2287    ///
2288    /// Per LSP spec: if `text_document.version` is non-null, it must match the
2289    /// version we last sent via didOpen/didChange. On mismatch the edit is stale
2290    /// and we skip it to avoid corrupting the buffer.
2291    fn apply_text_document_edit(
2292        &mut self,
2293        text_doc_edit: lsp_types::TextDocumentEdit,
2294    ) -> AnyhowResult<usize> {
2295        // Wrap the incoming wire URI once; both the version-check
2296        // lookup and the file-open below need the host-path form.
2297        let uri = crate::app::types::LspUri::from_wire(text_doc_edit.text_document.uri);
2298
2299        // Version check: if the server specifies a version, verify it matches
2300        // what we sent. A mismatch means the edit was computed against stale content.
2301        if let Some(expected_version) = text_doc_edit.text_document.version {
2302            if let Ok(path) =
2303                super::lsp_uri_to_host_path(&uri, self.authority().path_translation.as_ref())
2304            {
2305                if let Some(lsp) = self.lsp() {
2306                    let language = self
2307                        .buffers()
2308                        .get(&self.active_buffer())
2309                        .map(|s| s.language.clone())
2310                        .unwrap_or_default();
2311                    for sh in lsp.get_handles(&language) {
2312                        if let Some(current_version) = sh.handle.document_version(&path) {
2313                            if (expected_version as i64) != current_version {
2314                                tracing::warn!(
2315                                    "Rejecting stale TextDocumentEdit for {:?}: \
2316                                     server version {} != our version {}",
2317                                    path,
2318                                    expected_version,
2319                                    current_version,
2320                                );
2321                                return Ok(0);
2322                            }
2323                        }
2324                    }
2325                }
2326            }
2327        }
2328
2329        if let Ok(path) =
2330            super::lsp_uri_to_host_path(&uri, self.authority().path_translation.as_ref())
2331        {
2332            let buffer_id = match self.open_file(&path) {
2333                Ok(id) => id,
2334                Err(e) => {
2335                    if let Some(confirmation) =
2336                        e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
2337                    {
2338                        self.start_large_file_encoding_confirmation(confirmation);
2339                    } else {
2340                        self.set_status_message(
2341                            t!("file.error_opening", error = e.to_string()).to_string(),
2342                        );
2343                    }
2344                    return Ok(0);
2345                }
2346            };
2347
2348            let edits: Vec<lsp_types::TextEdit> = text_doc_edit
2349                .edits
2350                .into_iter()
2351                .map(|one_of| match one_of {
2352                    lsp_types::OneOf::Left(text_edit) => text_edit,
2353                    lsp_types::OneOf::Right(annotated) => annotated.text_edit,
2354                })
2355                .collect();
2356
2357            tracing::info!("Applying {} edits for {:?}:", edits.len(), path);
2358            for (i, edit) in edits.iter().enumerate() {
2359                tracing::info!(
2360                    "  Edit {}: line {}:{}-{}:{} -> {:?}",
2361                    i,
2362                    edit.range.start.line,
2363                    edit.range.start.character,
2364                    edit.range.end.line,
2365                    edit.range.end.character,
2366                    edit.new_text
2367                );
2368            }
2369
2370            self.apply_lsp_text_edits(buffer_id, edits)
2371        } else {
2372            Ok(0)
2373        }
2374    }
2375
2376    /// Apply a resource operation (CreateFile, RenameFile, DeleteFile) from a workspace edit.
2377    fn apply_resource_operation(&mut self, op: lsp_types::ResourceOp) -> AnyhowResult<()> {
2378        // Each URI in a resource operation is wire-side and must be
2379        // translated back to the host before we touch the host
2380        // filesystem. Wrapping in [`LspUri`] and calling
2381        // `to_host_path` is the type-checked path.
2382        let translation = self.authority().path_translation.clone();
2383        let to_host = |uri: &lsp_types::Uri| -> std::path::PathBuf {
2384            crate::app::types::LspUri::from_wire(uri.clone())
2385                .to_host_path(translation.as_ref())
2386                .unwrap_or_else(|| std::path::PathBuf::from(uri.path().as_str()))
2387        };
2388        match op {
2389            lsp_types::ResourceOp::Create(create) => {
2390                let path = to_host(&create.uri);
2391                let overwrite = create
2392                    .options
2393                    .as_ref()
2394                    .and_then(|o| o.overwrite)
2395                    .unwrap_or(false);
2396                let ignore_if_exists = create
2397                    .options
2398                    .as_ref()
2399                    .and_then(|o| o.ignore_if_exists)
2400                    .unwrap_or(false);
2401
2402                if path.exists() {
2403                    if ignore_if_exists {
2404                        tracing::debug!("CreateFile: {:?} already exists, ignoring", path);
2405                        return Ok(());
2406                    }
2407                    if !overwrite {
2408                        tracing::warn!("CreateFile: {:?} already exists and overwrite=false", path);
2409                        return Ok(());
2410                    }
2411                }
2412
2413                // Create parent directories if needed
2414                if let Some(parent) = path.parent() {
2415                    std::fs::create_dir_all(parent)?;
2416                }
2417                std::fs::write(&path, "")?;
2418                tracing::info!("CreateFile: created {:?}", path);
2419
2420                // Open the new file as a buffer
2421                if let Err(e) = self.open_file(&path) {
2422                    tracing::warn!("CreateFile: failed to open created file {:?}: {}", path, e);
2423                }
2424            }
2425            lsp_types::ResourceOp::Rename(rename) => {
2426                let old_path = to_host(&rename.old_uri);
2427                let new_path = to_host(&rename.new_uri);
2428                let overwrite = rename
2429                    .options
2430                    .as_ref()
2431                    .and_then(|o| o.overwrite)
2432                    .unwrap_or(false);
2433                let ignore_if_exists = rename
2434                    .options
2435                    .as_ref()
2436                    .and_then(|o| o.ignore_if_exists)
2437                    .unwrap_or(false);
2438
2439                if new_path.exists() {
2440                    if ignore_if_exists {
2441                        tracing::debug!("RenameFile: {:?} already exists, ignoring", new_path);
2442                        return Ok(());
2443                    }
2444                    if !overwrite {
2445                        tracing::warn!(
2446                            "RenameFile: {:?} already exists and overwrite=false",
2447                            new_path
2448                        );
2449                        return Ok(());
2450                    }
2451                }
2452
2453                // Create parent directories if needed
2454                if let Some(parent) = new_path.parent() {
2455                    std::fs::create_dir_all(parent)?;
2456                }
2457                std::fs::rename(&old_path, &new_path)?;
2458                tracing::info!("RenameFile: {:?} -> {:?}", old_path, new_path);
2459            }
2460            lsp_types::ResourceOp::Delete(delete) => {
2461                let path = to_host(&delete.uri);
2462                let recursive = delete
2463                    .options
2464                    .as_ref()
2465                    .and_then(|o| o.recursive)
2466                    .unwrap_or(false);
2467                let ignore_if_not_exists = delete
2468                    .options
2469                    .as_ref()
2470                    .and_then(|o| o.ignore_if_not_exists)
2471                    .unwrap_or(false);
2472
2473                if !path.exists() {
2474                    if ignore_if_not_exists {
2475                        tracing::debug!("DeleteFile: {:?} does not exist, ignoring", path);
2476                        return Ok(());
2477                    }
2478                    tracing::warn!("DeleteFile: {:?} does not exist", path);
2479                    return Ok(());
2480                }
2481
2482                if path.is_dir() && recursive {
2483                    std::fs::remove_dir_all(&path)?;
2484                } else if path.is_file() {
2485                    std::fs::remove_file(&path)?;
2486                }
2487                tracing::info!("DeleteFile: deleted {:?}", path);
2488            }
2489        }
2490        Ok(())
2491    }
2492
2493    /// Apply an LSP WorkspaceEdit (used by rename, code actions, etc.).
2494    ///
2495    /// Returns the total number of text changes applied.
2496    pub(crate) fn apply_workspace_edit(
2497        &mut self,
2498        workspace_edit: lsp_types::WorkspaceEdit,
2499    ) -> AnyhowResult<usize> {
2500        tracing::debug!(
2501            "Applying WorkspaceEdit: changes={:?}, document_changes={:?}",
2502            workspace_edit.changes.as_ref().map(|c| c.len()),
2503            workspace_edit.document_changes.as_ref().map(|dc| match dc {
2504                lsp_types::DocumentChanges::Edits(e) => format!("{} edits", e.len()),
2505                lsp_types::DocumentChanges::Operations(o) => format!("{} operations", o.len()),
2506            })
2507        );
2508
2509        let mut total_changes = 0;
2510
2511        // Handle changes (map of URI -> Vec<TextEdit>)
2512        if let Some(changes) = workspace_edit.changes {
2513            for (uri, edits) in changes {
2514                let uri = crate::app::types::LspUri::from_wire(uri);
2515                if let Ok(path) =
2516                    super::lsp_uri_to_host_path(&uri, self.authority().path_translation.as_ref())
2517                {
2518                    let buffer_id = match self.open_file(&path) {
2519                        Ok(id) => id,
2520                        Err(e) => {
2521                            if let Some(confirmation) = e.downcast_ref::<
2522                                crate::model::buffer::LargeFileEncodingConfirmation,
2523                            >() {
2524                                self.start_large_file_encoding_confirmation(confirmation);
2525                            } else {
2526                                self.set_status_message(
2527                                    t!("file.error_opening", error = e.to_string())
2528                                        .to_string(),
2529                                );
2530                            }
2531                            return Ok(0);
2532                        }
2533                    };
2534                    total_changes += self.apply_lsp_text_edits(buffer_id, edits)?;
2535                }
2536            }
2537        }
2538
2539        // Handle document_changes (TextDocumentEdit[] or DocumentChangeOperation[])
2540        if let Some(document_changes) = workspace_edit.document_changes {
2541            use lsp_types::DocumentChanges;
2542
2543            match document_changes {
2544                DocumentChanges::Edits(edits) => {
2545                    for text_doc_edit in edits {
2546                        total_changes += self.apply_text_document_edit(text_doc_edit)?;
2547                    }
2548                }
2549                DocumentChanges::Operations(ops) => {
2550                    // Process operations in order — resource ops (create/rename/delete)
2551                    // must be applied before text edits on the created/renamed files.
2552                    for op in ops {
2553                        match op {
2554                            lsp_types::DocumentChangeOperation::Edit(text_doc_edit) => {
2555                                total_changes += self.apply_text_document_edit(text_doc_edit)?;
2556                            }
2557                            lsp_types::DocumentChangeOperation::Op(resource_op) => {
2558                                self.apply_resource_operation(resource_op)?;
2559                                total_changes += 1;
2560                            }
2561                        }
2562                    }
2563                }
2564            }
2565        }
2566
2567        Ok(total_changes)
2568    }
2569
2570    /// Handle rename response from LSP
2571    pub fn handle_rename_response(
2572        &mut self,
2573        _request_id: u64,
2574        result: Result<lsp_types::WorkspaceEdit, String>,
2575    ) -> AnyhowResult<()> {
2576        match result {
2577            Ok(workspace_edit) => {
2578                let total_changes = self.apply_workspace_edit(workspace_edit)?;
2579                self.active_window_mut().status_message =
2580                    Some(t!("lsp.renamed", count = total_changes).to_string());
2581            }
2582            Err(error) => {
2583                // Per LSP spec: ContentModified errors (-32801) should NOT be shown to user
2584                if error.contains("content modified") || error.contains("-32801") {
2585                    tracing::debug!(
2586                        "LSP rename: ContentModified error (expected, ignoring): {}",
2587                        error
2588                    );
2589                    self.active_window_mut().status_message =
2590                        Some(t!("lsp.rename_cancelled").to_string());
2591                } else {
2592                    self.active_window_mut().status_message =
2593                        Some(t!("lsp.rename_failed", error = &error).to_string());
2594                }
2595            }
2596        }
2597
2598        Ok(())
2599    }
2600
2601    /// Apply events to a specific buffer using bulk edit optimization (O(n) vs O(n²))
2602    ///
2603    /// This is similar to `apply_events_as_bulk_edit` but works on a specific buffer
2604    /// (which may not be the active buffer) and handles LSP notifications correctly.
2605    pub(crate) fn apply_events_to_buffer_as_bulk_edit(
2606        &mut self,
2607        buffer_id: BufferId,
2608        events: Vec<Event>,
2609        description: String,
2610    ) -> AnyhowResult<()> {
2611        use crate::model::event::CursorId;
2612
2613        if events.is_empty() {
2614            return Ok(());
2615        }
2616
2617        // Create a temporary batch for collecting LSP changes (before applying)
2618        let batch_for_lsp = Event::Batch {
2619            events: events.clone(),
2620            description: description.clone(),
2621        };
2622
2623        // IMPORTANT: Calculate LSP changes BEFORE applying to buffer!
2624        // The byte positions in the events are relative to the ORIGINAL buffer.
2625        //
2626        // The tree-only swap below violates the pane-buffer invariant
2627        // transiently (see active_focus.rs for the invariant's contract)
2628        // but `collect_lsp_changes` does not route any input, call
2629        // `apply_event_to_active_buffer`, or otherwise read
2630        // `active_buffer()` while the invariant is broken, so the drift
2631        // is contained within this synchronous section. If that changes,
2632        // switch to a read-only accessor that takes `buffer_id` directly
2633        // rather than mutating tree state.
2634        let original_active = self.active_buffer();
2635        self.windows
2636            .get_mut(&self.active_window)
2637            .and_then(|w| w.split_manager_mut())
2638            .expect("active window must have a populated split layout")
2639            .set_active_buffer_id(buffer_id);
2640        let lsp_changes = self.active_window().collect_lsp_changes(&batch_for_lsp);
2641        self.windows
2642            .get_mut(&self.active_window)
2643            .and_then(|w| w.split_manager_mut())
2644            .expect("active window must have a populated split layout")
2645            .set_active_buffer_id(original_active);
2646
2647        // Capture old cursor states from split view state
2648        // Find a split that has this buffer in its keyed_states
2649        let split_id_for_cursors = self
2650            .split_manager_mut()
2651            .splits_for_buffer(buffer_id)
2652            .into_iter()
2653            .next()
2654            .unwrap_or_else(|| {
2655                self.windows
2656                    .get(&self.active_window)
2657                    .and_then(|w| w.buffers.splits())
2658                    .map(|(mgr, _)| mgr)
2659                    .expect("active window must have a populated split layout")
2660                    .active_split()
2661            });
2662        let old_cursors: Vec<(CursorId, usize, Option<usize>)> = self
2663            .windows
2664            .get(&self.active_window)
2665            .and_then(|w| w.buffers.splits())
2666            .map(|(_, vs)| vs)
2667            .expect("active window must have a populated split layout")
2668            .get(&split_id_for_cursors)
2669            .and_then(|vs| vs.keyed_states.get(&buffer_id))
2670            .map(|bvs| {
2671                bvs.cursors
2672                    .iter()
2673                    .map(|(id, c)| (id, c.position, c.anchor))
2674                    .collect()
2675            })
2676            .unwrap_or_default();
2677
2678        // TODO: move this whole bulk-edit method to impl Window — the
2679        // body is window-scoped except for `send_lsp_changes_for_buffer`
2680        // at the tail which is LSP coordination on Editor. The block
2681        // below uses a single-window split borrow because the bulk edit
2682        // needs `&mut state` (buffer) and `&mut __vs_map` (split view
2683        // states) live together for the cursor-positioning loop.
2684        let __win = self
2685            .windows
2686            .get_mut(&self.active_window)
2687            .expect("active window must exist");
2688        let bulk_edit = __win
2689            .buffers
2690            .with_buffer_and_view_states(buffer_id, |state, vs_map| -> AnyhowResult<Event> {
2691                // Snapshot buffer state for undo (piece tree + buffers)
2692                let old_snapshot = state.buffer.snapshot_buffer_state();
2693
2694                // Convert events to edit tuples: (position, delete_len, insert_text)
2695                let mut edits: Vec<(usize, usize, String)> = Vec::new();
2696                for event in &events {
2697                    match event {
2698                        Event::Insert { position, text, .. } => {
2699                            edits.push((*position, 0, text.clone()));
2700                        }
2701                        Event::Delete { range, .. } => {
2702                            edits.push((range.start, range.len(), String::new()));
2703                        }
2704                        _ => {}
2705                    }
2706                }
2707
2708                // Sort edits by position descending (required by apply_bulk_edits)
2709                edits.sort_by(|a, b| b.0.cmp(&a.0));
2710
2711                // Convert to references for apply_bulk_edits
2712                let edit_refs: Vec<(usize, usize, &str)> = edits
2713                    .iter()
2714                    .map(|(pos, del, text)| (*pos, *del, text.as_str()))
2715                    .collect();
2716
2717                // Snapshot displaced markers before edits so undo can restore them exactly.
2718                let displaced_markers = state.capture_displaced_markers_bulk(&edits);
2719
2720                // Apply bulk edits - O(n) instead of O(n²)
2721                let _delta = state.buffer.apply_bulk_edits(&edit_refs);
2722
2723                // Calculate new cursor positions based on edits
2724                let mut position_deltas: Vec<(usize, isize)> = Vec::new();
2725                for (pos, del_len, text) in &edits {
2726                    let delta = text.len() as isize - *del_len as isize;
2727                    position_deltas.push((*pos, delta));
2728                }
2729                position_deltas.sort_by_key(|(pos, _)| *pos);
2730
2731                let calc_shift = |original_pos: usize| -> isize {
2732                    let mut shift: isize = 0;
2733                    for (edit_pos, delta) in &position_deltas {
2734                        if *edit_pos < original_pos {
2735                            shift += delta;
2736                        }
2737                    }
2738                    shift
2739                };
2740
2741                // Calculate new cursor positions
2742                let buffer_len = state.buffer.len();
2743                let new_cursors: Vec<(CursorId, usize, Option<usize>)> = old_cursors
2744                    .iter()
2745                    .map(|(id, pos, anchor)| {
2746                        let shift = calc_shift(*pos);
2747                        let new_pos = ((*pos as isize + shift).max(0) as usize).min(buffer_len);
2748                        let new_anchor = anchor.map(|a| {
2749                            let anchor_shift = calc_shift(a);
2750                            ((a as isize + anchor_shift).max(0) as usize).min(buffer_len)
2751                        });
2752                        (*id, new_pos, new_anchor)
2753                    })
2754                    .collect();
2755
2756                // Snapshot buffer state after edits (for redo)
2757                let new_snapshot = state.buffer.snapshot_buffer_state();
2758
2759                // Invalidate syntax highlighting
2760                state.highlighter.invalidate_all();
2761
2762                // Apply new cursor positions to split view state
2763                if let Some(vs) = vs_map.get_mut(&split_id_for_cursors) {
2764                    if let Some(bvs) = vs.keyed_states.get_mut(&buffer_id) {
2765                        for (cursor_id, new_pos, new_anchor) in &new_cursors {
2766                            if let Some(cursor) = bvs.cursors.get_mut(*cursor_id) {
2767                                cursor.position = *new_pos;
2768                                cursor.anchor = *new_anchor;
2769                            }
2770                        }
2771                    }
2772                }
2773
2774                // Convert edit list to lengths-only for undo/redo marker replay.
2775                // Merge edits at the same position into a single replacement.
2776                let edit_lengths: Vec<(usize, usize, usize)> = {
2777                    let mut lengths: Vec<(usize, usize, usize)> = Vec::new();
2778                    for (pos, del_len, text) in &edits {
2779                        if let Some(last) = lengths.last_mut() {
2780                            if last.0 == *pos {
2781                                last.1 += del_len;
2782                                last.2 += text.len();
2783                                continue;
2784                            }
2785                        }
2786                        lengths.push((*pos, *del_len, text.len()));
2787                    }
2788                    lengths
2789                };
2790
2791                // Adjust markers using merged net-delta
2792                for &(pos, del_len, ins_len) in &edit_lengths {
2793                    if del_len > 0 && ins_len > 0 {
2794                        if ins_len > del_len {
2795                            state.marker_list.adjust_for_insert(pos, ins_len - del_len);
2796                            state.margins.adjust_for_insert(pos, ins_len - del_len);
2797                        } else if del_len > ins_len {
2798                            state.marker_list.adjust_for_delete(pos, del_len - ins_len);
2799                            state.margins.adjust_for_delete(pos, del_len - ins_len);
2800                        }
2801                    } else if del_len > 0 {
2802                        state.marker_list.adjust_for_delete(pos, del_len);
2803                        state.margins.adjust_for_delete(pos, del_len);
2804                    } else if ins_len > 0 {
2805                        state.marker_list.adjust_for_insert(pos, ins_len);
2806                        state.margins.adjust_for_insert(pos, ins_len);
2807                    }
2808                }
2809
2810                Ok(Event::BulkEdit {
2811                    old_snapshot: Some(old_snapshot),
2812                    new_snapshot: Some(new_snapshot),
2813                    old_cursors,
2814                    new_cursors,
2815                    description,
2816                    edits: edit_lengths,
2817                    displaced_markers,
2818                })
2819            })
2820            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Buffer not found"))??;
2821
2822        // Add to event log
2823        if let Some(event_log) = self.active_window_mut().event_logs.get_mut(&buffer_id) {
2824            event_log.append(bulk_edit);
2825        }
2826
2827        // Notify LSP about the changes using pre-calculated positions
2828        self.active_window_mut()
2829            .send_lsp_changes_for_buffer(buffer_id, lsp_changes);
2830
2831        Ok(())
2832    }
2833
2834    /// Start rename mode - select the symbol at cursor and allow inline editing
2835    pub(crate) fn start_rename(&mut self) -> AnyhowResult<()> {
2836        // If server supports prepareRename, validate first
2837        if self.active_window().server_supports_prepare_rename() {
2838            self.active_window_mut().send_prepare_rename();
2839            return Ok(());
2840        }
2841
2842        self.show_rename_prompt()
2843    }
2844
2845    /// Handle prepareRename response — if valid, show rename prompt; if error, show message.
2846    pub(crate) fn handle_prepare_rename_response(
2847        &mut self,
2848        result: Result<serde_json::Value, String>,
2849    ) {
2850        match result {
2851            Ok(value) if !value.is_null() => {
2852                // prepareRename succeeded — show the rename prompt
2853                if let Err(e) = self.show_rename_prompt() {
2854                    self.set_status_message(format!("Rename failed: {e}"));
2855                }
2856            }
2857            Ok(_) => {
2858                self.set_status_message("Cannot rename at this position".to_string());
2859            }
2860            Err(e) => {
2861                self.set_status_message(format!("Cannot rename: {e}"));
2862            }
2863        }
2864    }
2865
2866    /// Send textDocument/prepareRename to the LSP server
2867    /// Show the rename prompt (called directly or after prepareRename succeeds).
2868    fn show_rename_prompt(&mut self) -> AnyhowResult<()> {
2869        use crate::primitives::word_navigation::{find_word_end, find_word_start};
2870
2871        // Get the current buffer and cursor position
2872        let cursor_pos = self.active_cursors().primary().position;
2873        let (word_start, word_end) = {
2874            let state = self.active_state();
2875
2876            // Find the word boundaries
2877            let word_start = find_word_start(&state.buffer, cursor_pos);
2878            let word_end = find_word_end(&state.buffer, cursor_pos);
2879
2880            // Check if we're on a word
2881            if word_start >= word_end {
2882                self.active_window_mut().status_message =
2883                    Some(t!("lsp.no_symbol_at_cursor").to_string());
2884                return Ok(());
2885            }
2886
2887            (word_start, word_end)
2888        };
2889
2890        // Get the word text
2891        let word_text = self.active_state_mut().get_text_range(word_start, word_end);
2892
2893        // Create an overlay to highlight the symbol being renamed
2894        let overlay_handle = self.add_overlay(
2895            None,
2896            word_start..word_end,
2897            crate::model::event::OverlayFace::Background {
2898                color: (50, 100, 200), // Blue background for rename
2899            },
2900            100,
2901            Some(t!("lsp.popup_renaming").to_string()),
2902        );
2903
2904        // Enter rename mode using the Prompt system
2905        // Store the rename metadata in the PromptType and pre-fill the input with the current name
2906        let mut prompt = Prompt::new(
2907            "Rename to: ".to_string(),
2908            PromptType::LspRename {
2909                original_text: word_text.clone(),
2910                start_pos: word_start,
2911                end_pos: word_end,
2912                overlay_handle,
2913            },
2914        );
2915        // Pre-fill the input with the current name and position cursor at the end
2916        prompt.set_input(word_text);
2917
2918        self.active_window_mut().prompt = Some(prompt);
2919        Ok(())
2920    }
2921
2922    /// Cancel rename mode - removes overlay if the prompt was for LSP rename
2923    pub(crate) fn cancel_rename_overlay(&mut self, handle: &crate::view::overlay::OverlayHandle) {
2924        self.remove_overlay(handle.clone());
2925    }
2926
2927    /// Perform the actual LSP rename request
2928    pub(crate) fn perform_lsp_rename(
2929        &mut self,
2930        new_name: String,
2931        original_text: String,
2932        start_pos: usize,
2933        overlay_handle: crate::view::overlay::OverlayHandle,
2934    ) {
2935        // Remove the overlay first
2936        self.cancel_rename_overlay(&overlay_handle);
2937
2938        // Check if the name actually changed
2939        if new_name == original_text {
2940            self.active_window_mut().status_message = Some(t!("lsp.name_unchanged").to_string());
2941            return;
2942        }
2943
2944        // Use the position from when we entered rename mode, NOT the current cursor position
2945        // This ensures we send the rename request for the correct symbol even if cursor moved
2946        let rename_pos = start_pos;
2947
2948        // Convert byte position to LSP position (line, UTF-16 code units)
2949        // LSP uses UTF-16 code units for character offsets, not byte offsets
2950        let state = self.active_state();
2951        let (line, character) = state.buffer.position_to_lsp_position(rename_pos);
2952        let buffer_id = self.active_buffer();
2953        let request_id = self.active_window_mut().next_lsp_request_id;
2954
2955        // Use helper to ensure didOpen is sent before the request
2956        let sent = self
2957            .with_lsp_for_buffer(buffer_id, LspFeature::Rename, |handle, uri, _language| {
2958                let result = handle.rename(
2959                    request_id,
2960                    uri.as_uri().clone(),
2961                    line as u32,
2962                    character as u32,
2963                    new_name.clone(),
2964                );
2965                if result.is_ok() {
2966                    tracing::info!(
2967                        "Requested rename at {}:{}:{} to '{}'",
2968                        uri.as_str(),
2969                        line,
2970                        character,
2971                        new_name
2972                    );
2973                }
2974                result.is_ok()
2975            })
2976            .unwrap_or(false);
2977
2978        if sent {
2979            self.active_window_mut().next_lsp_request_id += 1;
2980        } else if self
2981            .active_window()
2982            .buffer_metadata
2983            .get(&buffer_id)
2984            .and_then(|m| m.file_path())
2985            .is_none()
2986        {
2987            self.active_window_mut().status_message =
2988                Some(t!("lsp.cannot_rename_unsaved").to_string());
2989        }
2990    }
2991
2992    /// Request inlay hints for the active buffer (if enabled and LSP available)
2993    pub(crate) fn request_inlay_hints_for_active_buffer(&mut self) {
2994        let buffer_id = self.active_buffer();
2995        self.request_inlay_hints_for_buffer(buffer_id);
2996    }
2997
2998    /// Request inlay hints for a specific buffer (if enabled and LSP available)
2999    pub(crate) fn request_inlay_hints_for_buffer(&mut self, buffer_id: BufferId) {
3000        if !self.config.editor.enable_inlay_hints {
3001            return;
3002        }
3003
3004        // Get line count and version from buffer state — both are needed so
3005        // the response handler can drop stale data if the buffer has moved
3006        // on by the time hints arrive.
3007        let (line_count, version) = if let Some(state) = self
3008            .windows
3009            .get(&self.active_window)
3010            .map(|w| &w.buffers)
3011            .expect("active window present")
3012            .get(&buffer_id)
3013        {
3014            (
3015                state.buffer.line_count().unwrap_or(1000),
3016                state.buffer.version(),
3017            )
3018        } else {
3019            return;
3020        };
3021        let last_line = line_count.saturating_sub(1) as u32;
3022        let request_id = self.active_window_mut().next_lsp_request_id;
3023
3024        // Use helper to ensure didOpen is sent before the request
3025        let sent = self
3026            .with_lsp_for_buffer(
3027                buffer_id,
3028                LspFeature::InlayHints,
3029                |handle, uri, _language| {
3030                    let result = handle.inlay_hints(
3031                        request_id,
3032                        uri.as_uri().clone(),
3033                        0,
3034                        0,
3035                        last_line,
3036                        10000,
3037                    );
3038                    if result.is_ok() {
3039                        tracing::info!(
3040                            "Requested inlay hints for {} (request_id={})",
3041                            uri.as_str(),
3042                            request_id
3043                        );
3044                    } else if let Err(e) = &result {
3045                        tracing::debug!("Failed to request inlay hints: {}", e);
3046                    }
3047                    result.is_ok()
3048                },
3049            )
3050            .unwrap_or(false);
3051
3052        if sent {
3053            self.active_window_mut().next_lsp_request_id += 1;
3054            self.active_window_mut()
3055                .pending_inlay_hints_requests
3056                .insert(request_id, super::InlayHintsRequest { buffer_id, version });
3057        }
3058    }
3059
3060    /// Issue a debounced folding range request if the timer has elapsed.
3061    pub(crate) fn maybe_request_folding_ranges_debounced(&mut self, buffer_id: BufferId) {
3062        let Some(ready_at) = self
3063            .active_window()
3064            .folding_ranges_debounce
3065            .get(&buffer_id)
3066            .copied()
3067        else {
3068            return;
3069        };
3070        if Instant::now() < ready_at {
3071            return;
3072        }
3073
3074        self.active_window_mut()
3075            .folding_ranges_debounce
3076            .remove(&buffer_id);
3077        self.request_folding_ranges_for_buffer(buffer_id);
3078    }
3079
3080    /// Request folding ranges for a buffer if supported and needed.
3081    pub(crate) fn request_folding_ranges_for_buffer(&mut self, buffer_id: BufferId) {
3082        if self
3083            .active_window_mut()
3084            .folding_ranges_in_flight
3085            .contains_key(&buffer_id)
3086        {
3087            return;
3088        }
3089
3090        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
3091            return;
3092        };
3093        if !metadata.lsp_enabled {
3094            return;
3095        }
3096        let Some(uri) = metadata.file_uri().cloned() else {
3097            return;
3098        };
3099        let file_path = metadata.file_path().cloned();
3100
3101        let Some(language) = self
3102            .windows
3103            .get(&self.active_window)
3104            .map(|w| &w.buffers)
3105            .expect("active window present")
3106            .get(&buffer_id)
3107            .map(|s| s.language.clone())
3108        else {
3109            return;
3110        };
3111
3112        let __active_id = self.active_window;
3113        // Pre-collect buffer version so we don't re-read self.buffers
3114        // while the &mut lsp borrow is alive.
3115        let __buffer_version_for_request = self
3116            .windows
3117            .get(&__active_id)
3118            .and_then(|w| w.buffers.get(&buffer_id))
3119            .map(|s| s.buffer.version())
3120            .unwrap_or(0);
3121
3122        let Some(__win) = self.windows.get_mut(&__active_id) else {
3123            return;
3124        };
3125        let __next_id = &mut __win.next_lsp_request_id;
3126        let __pending_folding = &mut __win.pending_folding_range_requests;
3127        let __folding_in_flight = &mut __win.folding_ranges_in_flight;
3128        let lsp = &mut __win.lsp;
3129
3130        if !lsp.folding_ranges_supported(&language) {
3131            return;
3132        }
3133
3134        // Ensure there is a running server
3135        use crate::services::lsp::manager::LspSpawnResult;
3136        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
3137            return;
3138        }
3139
3140        let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::FoldingRange) else {
3141            return;
3142        };
3143        let handle = &mut sh.handle;
3144
3145        let request_id = {
3146            let id = *__next_id;
3147            *__next_id += 1;
3148            id
3149        };
3150        let buffer_version = __buffer_version_for_request;
3151        let _ = __folding_in_flight;
3152
3153        match handle.folding_ranges(request_id, uri.as_uri().clone()) {
3154            Ok(()) => {
3155                __pending_folding.insert(
3156                    request_id,
3157                    super::FoldingRangeRequest {
3158                        buffer_id,
3159                        version: buffer_version,
3160                    },
3161                );
3162                __folding_in_flight.insert(buffer_id, (request_id, buffer_version));
3163            }
3164            Err(e) => {
3165                tracing::debug!("Failed to request folding ranges: {}", e);
3166            }
3167        }
3168    }
3169
3170    /// Request semantic tokens for a specific buffer if supported and needed.
3171    pub(crate) fn maybe_request_semantic_tokens(&mut self, buffer_id: BufferId) {
3172        if !self.config.editor.enable_semantic_tokens_full {
3173            return;
3174        }
3175
3176        // Avoid duplicate in-flight requests per buffer
3177        if self
3178            .active_window_mut()
3179            .semantic_tokens_in_flight
3180            .contains_key(&buffer_id)
3181        {
3182            return;
3183        }
3184
3185        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
3186            return;
3187        };
3188        if !metadata.lsp_enabled {
3189            return;
3190        }
3191        let Some(uri) = metadata.file_uri().cloned() else {
3192            return;
3193        };
3194        let file_path_for_spawn = metadata.file_path().cloned();
3195        // Get language from buffer state
3196        let Some(language) = self
3197            .windows
3198            .get(&self.active_window)
3199            .map(|w| &w.buffers)
3200            .expect("active window present")
3201            .get(&buffer_id)
3202            .map(|s| s.language.clone())
3203        else {
3204            return;
3205        };
3206
3207        let __active_id = self.active_window;
3208        // Pre-extract buffer state info so we don't re-borrow self
3209        // while the &mut lsp borrow is alive.
3210        let Some((buffer_version, existing_version, previous_result_id)) = self
3211            .windows
3212            .get(&__active_id)
3213            .and_then(|w| w.buffers.get(&buffer_id))
3214            .map(|state| {
3215                (
3216                    state.buffer.version(),
3217                    state.semantic_tokens.as_ref().map(|s| s.version),
3218                    state
3219                        .semantic_tokens
3220                        .as_ref()
3221                        .and_then(|s| s.result_id.clone()),
3222                )
3223            })
3224        else {
3225            return;
3226        };
3227        if Some(buffer_version) == existing_version {
3228            return; // Already up to date
3229        }
3230
3231        let Some(__win) = self.windows.get_mut(&__active_id) else {
3232            return;
3233        };
3234        let __next_id = &mut __win.next_lsp_request_id;
3235        let __pending_st = &mut __win.pending_semantic_token_requests;
3236        let __st_in_flight = &mut __win.semantic_tokens_in_flight;
3237        let lsp = &mut __win.lsp;
3238
3239        // Ensure there is a running server
3240        use crate::services::lsp::manager::LspSpawnResult;
3241        if lsp.try_spawn(&language, file_path_for_spawn.as_deref()) != LspSpawnResult::Spawned {
3242            return;
3243        }
3244
3245        // Check that a server actually supports full semantic tokens
3246        if !lsp.semantic_tokens_full_supported(&language) {
3247            return;
3248        }
3249        if lsp.semantic_tokens_legend(&language).is_none() {
3250            return;
3251        }
3252
3253        let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::SemanticTokens) else {
3254            return;
3255        };
3256        // Check capabilities on the specific server we'll send to
3257        let supports_delta = sh.capabilities.semantic_tokens_full_delta;
3258        let use_delta = previous_result_id.is_some() && supports_delta;
3259        let handle = &mut sh.handle;
3260
3261        let request_id = {
3262            let id = *__next_id;
3263            *__next_id += 1;
3264            id
3265        };
3266
3267        let request_kind = if use_delta {
3268            super::SemanticTokensFullRequestKind::FullDelta
3269        } else {
3270            super::SemanticTokensFullRequestKind::Full
3271        };
3272
3273        let request_result = if use_delta {
3274            handle.semantic_tokens_full_delta(
3275                request_id,
3276                uri.as_uri().clone(),
3277                previous_result_id.unwrap(),
3278            )
3279        } else {
3280            handle.semantic_tokens_full(request_id, uri.as_uri().clone())
3281        };
3282
3283        match request_result {
3284            Ok(_) => {
3285                __pending_st.insert(
3286                    request_id,
3287                    super::SemanticTokenFullRequest {
3288                        buffer_id,
3289                        version: buffer_version,
3290                        kind: request_kind,
3291                    },
3292                );
3293                __st_in_flight.insert(buffer_id, (request_id, buffer_version, request_kind));
3294            }
3295            Err(e) => {
3296                tracing::debug!("Failed to request semantic tokens: {}", e);
3297            }
3298        }
3299    }
3300
3301    /// Issue a debounced full semantic token request if the timer has elapsed.
3302    pub(crate) fn maybe_request_semantic_tokens_full_debounced(&mut self, buffer_id: BufferId) {
3303        if !self.config.editor.enable_semantic_tokens_full {
3304            self.active_window_mut()
3305                .semantic_tokens_full_debounce
3306                .remove(&buffer_id);
3307            return;
3308        }
3309
3310        let Some(ready_at) = self
3311            .active_window()
3312            .semantic_tokens_full_debounce
3313            .get(&buffer_id)
3314            .copied()
3315        else {
3316            return;
3317        };
3318        if Instant::now() < ready_at {
3319            return;
3320        }
3321
3322        self.active_window_mut()
3323            .semantic_tokens_full_debounce
3324            .remove(&buffer_id);
3325        self.maybe_request_semantic_tokens(buffer_id);
3326    }
3327
3328    /// Request semantic tokens for a viewport range (with padding).
3329    pub(crate) fn maybe_request_semantic_tokens_range(
3330        &mut self,
3331        buffer_id: BufferId,
3332        start_line: usize,
3333        end_line: usize,
3334    ) {
3335        let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
3336            return;
3337        };
3338        if !metadata.lsp_enabled {
3339            return;
3340        }
3341        let Some(uri) = metadata.file_uri().cloned() else {
3342            return;
3343        };
3344        let file_path = metadata.file_path().cloned();
3345        // Get language from buffer state
3346        let Some(language) = self
3347            .windows
3348            .get(&self.active_window)
3349            .map(|w| &w.buffers)
3350            .expect("active window present")
3351            .get(&buffer_id)
3352            .map(|s| s.language.clone())
3353        else {
3354            return;
3355        };
3356
3357        let __active_id = self.active_window;
3358        // Single &mut on the active window — split-borrow into &mut lsp
3359        // and &buffers so we can use both concurrently.
3360        let __win = self
3361            .windows
3362            .get_mut(&__active_id)
3363            .expect("active window must exist");
3364        let __next_id = &mut __win.next_lsp_request_id;
3365        let __pending_st_range = &mut __win.pending_semantic_token_range_requests;
3366        let __st_range_in_flight = &mut __win.semantic_tokens_range_in_flight;
3367        let __st_range_last = &mut __win.semantic_tokens_range_last_request;
3368        let __st_range_applied = &__win.semantic_tokens_range_applied;
3369        let lsp = &mut __win.lsp;
3370        let __buffers_ref: &crate::app::window::WindowBuffers = &__win.buffers;
3371
3372        // Ensure there is a running server
3373        use crate::services::lsp::manager::LspSpawnResult;
3374        if lsp.try_spawn(&language, file_path.as_deref()) != LspSpawnResult::Spawned {
3375            return;
3376        }
3377
3378        if !lsp.semantic_tokens_range_supported(&language) {
3379            // Fall back to full document tokens if no server supports range.
3380            self.maybe_request_semantic_tokens(buffer_id);
3381            return;
3382        }
3383        if lsp.semantic_tokens_legend(&language).is_none() {
3384            return;
3385        }
3386
3387        let Some(sh) = lsp.handle_for_feature_mut(&language, LspFeature::SemanticTokens) else {
3388            return;
3389        };
3390        // The handle_for_feature_mut check ensures has_capability(SemanticTokens) which is
3391        // full || range. Double-check this specific server supports range.
3392        if !sh.capabilities.semantic_tokens_range {
3393            return;
3394        }
3395        let handle = &mut sh.handle;
3396        let Some(state) = __buffers_ref.get(&buffer_id) else {
3397            return;
3398        };
3399
3400        let buffer_version = state.buffer.version();
3401        let mut padded_start = start_line.saturating_sub(SEMANTIC_TOKENS_RANGE_PADDING_LINES);
3402        let mut padded_end = end_line.saturating_add(SEMANTIC_TOKENS_RANGE_PADDING_LINES);
3403
3404        if let Some(line_count) = state.buffer.line_count() {
3405            if line_count == 0 {
3406                return;
3407            }
3408            let max_line = line_count.saturating_sub(1);
3409            padded_start = padded_start.min(max_line);
3410            padded_end = padded_end.min(max_line);
3411        }
3412
3413        let start_byte = state.buffer.line_start_offset(padded_start).unwrap_or(0);
3414        let end_char = state
3415            .buffer
3416            .get_line(padded_end)
3417            .map(|line| String::from_utf8_lossy(&line).encode_utf16().count())
3418            .unwrap_or(0);
3419        let end_byte = if state.buffer.line_start_offset(padded_end).is_some() {
3420            state.buffer.lsp_position_to_byte(padded_end, end_char)
3421        } else {
3422            state.buffer.len()
3423        };
3424
3425        if start_byte >= end_byte {
3426            return;
3427        }
3428
3429        let range = start_byte..end_byte;
3430        if let Some((in_flight_id, in_flight_start, in_flight_end, in_flight_version)) =
3431            __st_range_in_flight.get(&buffer_id).copied()
3432        {
3433            if in_flight_start == padded_start
3434                && in_flight_end == padded_end
3435                && in_flight_version == buffer_version
3436            {
3437                return;
3438            }
3439            if let Err(e) = handle.cancel_request(in_flight_id) {
3440                tracing::debug!("Failed to cancel semantic token range request: {}", e);
3441            }
3442            __pending_st_range.remove(&in_flight_id);
3443            __st_range_in_flight.remove(&buffer_id);
3444        }
3445
3446        if let Some((applied_start, applied_end, applied_version)) =
3447            __st_range_applied.get(&buffer_id).copied()
3448        {
3449            if applied_start == padded_start
3450                && applied_end == padded_end
3451                && applied_version == buffer_version
3452            {
3453                return;
3454            }
3455        }
3456
3457        let now = Instant::now();
3458        if let Some((last_start, last_end, last_version, last_time)) =
3459            __st_range_last.get(&buffer_id).copied()
3460        {
3461            if last_start == padded_start
3462                && last_end == padded_end
3463                && last_version == buffer_version
3464                && now.duration_since(last_time)
3465                    < Duration::from_millis(SEMANTIC_TOKENS_RANGE_DEBOUNCE_MS)
3466            {
3467                return;
3468            }
3469        }
3470
3471        let lsp_range = lsp_types::Range {
3472            start: lsp_types::Position {
3473                line: padded_start as u32,
3474                character: 0,
3475            },
3476            end: lsp_types::Position {
3477                line: padded_end as u32,
3478                character: end_char as u32,
3479            },
3480        };
3481
3482        let request_id = {
3483            let id = *__next_id;
3484            *__next_id += 1;
3485            id
3486        };
3487        let _ = __st_range_applied;
3488
3489        match handle.semantic_tokens_range(request_id, uri.as_uri().clone(), lsp_range) {
3490            Ok(_) => {
3491                __pending_st_range.insert(
3492                    request_id,
3493                    SemanticTokenRangeRequest {
3494                        buffer_id,
3495                        version: buffer_version,
3496                        range: range.clone(),
3497                        start_line: padded_start,
3498                        end_line: padded_end,
3499                    },
3500                );
3501                __st_range_in_flight.insert(
3502                    buffer_id,
3503                    (request_id, padded_start, padded_end, buffer_version),
3504                );
3505                __st_range_last.insert(buffer_id, (padded_start, padded_end, buffer_version, now));
3506            }
3507            Err(e) => {
3508                tracing::debug!("Failed to request semantic token range: {}", e);
3509            }
3510        }
3511    }
3512}
3513
3514#[cfg(test)]
3515mod tests {
3516    use crate::model::filesystem::StdFileSystem;
3517    use std::sync::Arc;
3518
3519    fn test_fs() -> Arc<dyn crate::model::filesystem::FileSystem + Send + Sync> {
3520        Arc::new(StdFileSystem)
3521    }
3522    use super::{lsp_range_contains, lsp_range_overlaps, Editor};
3523
3524    fn range(sl: u32, sc: u32, el: u32, ec: u32) -> lsp_types::Range {
3525        lsp_types::Range {
3526            start: lsp_types::Position {
3527                line: sl,
3528                character: sc,
3529            },
3530            end: lsp_types::Position {
3531                line: el,
3532                character: ec,
3533            },
3534        }
3535    }
3536
3537    #[test]
3538    fn test_lsp_range_contains_inclusive_start_exclusive_end() {
3539        let r = range(3, 10, 3, 20);
3540        // Before start
3541        assert!(!lsp_range_contains(&r, 3, 9));
3542        assert!(!lsp_range_contains(&r, 2, 50));
3543        // At start (inclusive)
3544        assert!(lsp_range_contains(&r, 3, 10));
3545        // Inside
3546        assert!(lsp_range_contains(&r, 3, 15));
3547        // Just before end (inclusive)
3548        assert!(lsp_range_contains(&r, 3, 19));
3549        // At end (exclusive)
3550        assert!(!lsp_range_contains(&r, 3, 20));
3551        // After end
3552        assert!(!lsp_range_contains(&r, 3, 21));
3553        assert!(!lsp_range_contains(&r, 4, 0));
3554    }
3555
3556    #[test]
3557    fn test_lsp_range_contains_multiline() {
3558        let r = range(2, 5, 4, 3);
3559        // Line before start
3560        assert!(!lsp_range_contains(&r, 1, 100));
3561        // On start line, before start character
3562        assert!(!lsp_range_contains(&r, 2, 4));
3563        // On start line, at start character (inclusive)
3564        assert!(lsp_range_contains(&r, 2, 5));
3565        // Interior line — any character is inside.
3566        assert!(lsp_range_contains(&r, 3, 0));
3567        assert!(lsp_range_contains(&r, 3, 9999));
3568        // End line, before end character (inclusive)
3569        assert!(lsp_range_contains(&r, 4, 2));
3570        // End line, at end character (exclusive)
3571        assert!(!lsp_range_contains(&r, 4, 3));
3572        // Line after end
3573        assert!(!lsp_range_contains(&r, 5, 0));
3574    }
3575
3576    #[test]
3577    fn test_lsp_range_contains_zero_length_matches_anchor_only() {
3578        // Point diagnostic: start == end.
3579        let r = range(7, 4, 7, 4);
3580        assert!(lsp_range_contains(&r, 7, 4));
3581        assert!(!lsp_range_contains(&r, 7, 3));
3582        assert!(!lsp_range_contains(&r, 7, 5));
3583        assert!(!lsp_range_contains(&r, 6, 4));
3584        assert!(!lsp_range_contains(&r, 8, 4));
3585    }
3586
3587    #[test]
3588    fn test_lsp_range_overlaps_point_cursor_in_diagnostic_range() {
3589        // Diagnostic at line 3, cols 10..20. A zero-width cursor anywhere
3590        // inside (including at start) overlaps; just outside does not.
3591        let diag = range(3, 10, 3, 20);
3592        // Cursor on the start anchor.
3593        assert!(lsp_range_overlaps(&diag, 3, 10, 3, 10));
3594        // Cursor inside.
3595        assert!(lsp_range_overlaps(&diag, 3, 15, 3, 15));
3596        // Cursor at exclusive end — not contained.
3597        assert!(!lsp_range_overlaps(&diag, 3, 20, 3, 20));
3598        // Cursor before start.
3599        assert!(!lsp_range_overlaps(&diag, 3, 9, 3, 9));
3600        // Cursor on a different line.
3601        assert!(!lsp_range_overlaps(&diag, 4, 15, 4, 15));
3602    }
3603
3604    #[test]
3605    fn test_lsp_range_overlaps_selection_intersects_diagnostic() {
3606        // Diagnostic at line 3, cols 10..20.
3607        let diag = range(3, 10, 3, 20);
3608        // Selection entirely inside diag.
3609        assert!(lsp_range_overlaps(&diag, 3, 12, 3, 18));
3610        // Selection straddling the start of diag.
3611        assert!(lsp_range_overlaps(&diag, 3, 5, 3, 15));
3612        // Selection straddling the end of diag.
3613        assert!(lsp_range_overlaps(&diag, 3, 15, 3, 25));
3614        // Selection entirely covering diag.
3615        assert!(lsp_range_overlaps(&diag, 3, 0, 3, 30));
3616        // Selection adjacent before (touches at start, half-open end).
3617        assert!(!lsp_range_overlaps(&diag, 3, 0, 3, 10));
3618        // Selection adjacent after (starts at exclusive end of diag).
3619        assert!(!lsp_range_overlaps(&diag, 3, 20, 3, 30));
3620        // Selection on the wrong line.
3621        assert!(!lsp_range_overlaps(&diag, 4, 0, 4, 100));
3622    }
3623
3624    #[test]
3625    fn test_lsp_range_overlaps_point_diagnostic_within_selection() {
3626        // Point-style diagnostic at line 3, col 10.
3627        let diag = range(3, 10, 3, 10);
3628        // Selection covering the point.
3629        assert!(lsp_range_overlaps(&diag, 3, 5, 3, 15));
3630        // Selection starting at the point (half-open includes start).
3631        assert!(lsp_range_overlaps(&diag, 3, 10, 3, 15));
3632        // Selection ending at the point (half-open excludes end).
3633        assert!(!lsp_range_overlaps(&diag, 3, 0, 3, 10));
3634        // Zero-width cursor on the exact anchor.
3635        assert!(lsp_range_overlaps(&diag, 3, 10, 3, 10));
3636        // Zero-width cursor not on the anchor.
3637        assert!(!lsp_range_overlaps(&diag, 3, 9, 3, 9));
3638    }
3639
3640    #[test]
3641    fn test_lsp_range_overlaps_multiline() {
3642        // Diagnostic spans line 2 col 5 through line 4 col 3.
3643        let diag = range(2, 5, 4, 3);
3644        // Cursor on an interior line, anywhere.
3645        assert!(lsp_range_overlaps(&diag, 3, 0, 3, 0));
3646        // Selection crossing line boundary into the diagnostic.
3647        assert!(lsp_range_overlaps(&diag, 1, 0, 2, 6));
3648        // Selection that starts at the exclusive end of the diagnostic — no overlap.
3649        assert!(!lsp_range_overlaps(&diag, 4, 3, 4, 10));
3650        // Selection entirely after the diagnostic.
3651        assert!(!lsp_range_overlaps(&diag, 5, 0, 5, 10));
3652    }
3653
3654    use crate::model::buffer::Buffer;
3655    use crate::state::EditorState;
3656    use crate::view::virtual_text::VirtualTextPosition;
3657    use lsp_types::{InlayHint, InlayHintKind, InlayHintLabel, Position};
3658
3659    fn make_hint(line: u32, character: u32, label: &str, kind: Option<InlayHintKind>) -> InlayHint {
3660        InlayHint {
3661            position: Position { line, character },
3662            label: InlayHintLabel::String(label.to_string()),
3663            kind,
3664            text_edits: None,
3665            tooltip: None,
3666            padding_left: None,
3667            padding_right: None,
3668            data: None,
3669        }
3670    }
3671
3672    #[test]
3673    fn test_inlay_hint_inserts_before_character() {
3674        let mut state = EditorState::new(
3675            80,
3676            24,
3677            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3678            test_fs(),
3679        );
3680        state.buffer = Buffer::from_str_test("ab");
3681
3682        if !state.buffer.is_empty() {
3683            state.marker_list.adjust_for_insert(0, state.buffer.len());
3684        }
3685
3686        let hints = vec![make_hint(0, 1, ": i32", Some(InlayHintKind::TYPE))];
3687        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3688
3689        let lookup = state
3690            .virtual_texts
3691            .build_lookup(&state.marker_list, 0, state.buffer.len());
3692        let vtexts = lookup.get(&1).expect("expected hint at byte offset 1");
3693        assert_eq!(vtexts.len(), 1);
3694        assert_eq!(vtexts[0].text, ": i32");
3695        assert_eq!(vtexts[0].position, VirtualTextPosition::BeforeChar);
3696    }
3697
3698    #[test]
3699    fn test_inlay_hint_at_eof_renders_after_last_char() {
3700        let mut state = EditorState::new(
3701            80,
3702            24,
3703            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3704            test_fs(),
3705        );
3706        state.buffer = Buffer::from_str_test("ab");
3707
3708        if !state.buffer.is_empty() {
3709            state.marker_list.adjust_for_insert(0, state.buffer.len());
3710        }
3711
3712        let hints = vec![make_hint(0, 2, ": i32", Some(InlayHintKind::TYPE))];
3713        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3714
3715        let lookup = state
3716            .virtual_texts
3717            .build_lookup(&state.marker_list, 0, state.buffer.len());
3718        let vtexts = lookup.get(&1).expect("expected hint anchored to last byte");
3719        assert_eq!(vtexts.len(), 1);
3720        assert_eq!(vtexts[0].text, ": i32");
3721        assert_eq!(vtexts[0].position, VirtualTextPosition::AfterChar);
3722    }
3723
3724    #[test]
3725    fn test_inlay_hint_empty_buffer_is_ignored() {
3726        let mut state = EditorState::new(
3727            80,
3728            24,
3729            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3730            test_fs(),
3731        );
3732        state.buffer = Buffer::from_str_test("");
3733
3734        let hints = vec![make_hint(0, 0, ": i32", Some(InlayHintKind::TYPE))];
3735        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3736
3737        assert!(state.virtual_texts.is_empty());
3738    }
3739
3740    #[test]
3741    fn test_inlay_hint_uses_theme_key_for_foreground() {
3742        // Verify that apply_inlay_hints_to_state stores the theme key so
3743        // hints follow the active theme rather than a hardcoded color.
3744        let mut state = EditorState::new(
3745            80,
3746            24,
3747            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3748            test_fs(),
3749        );
3750        state.buffer = Buffer::from_str_test("ab");
3751
3752        if !state.buffer.is_empty() {
3753            state.marker_list.adjust_for_insert(0, state.buffer.len());
3754        }
3755
3756        let hints = vec![make_hint(0, 1, ": i32", Some(InlayHintKind::TYPE))];
3757        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3758
3759        let lookup = state
3760            .virtual_texts
3761            .build_lookup(&state.marker_list, 0, state.buffer.len());
3762        let vtexts = lookup.get(&1).expect("expected hint at byte offset 1");
3763        assert_eq!(
3764            vtexts[0].fg_theme_key.as_deref(),
3765            Some("editor.line_number_fg")
3766        );
3767        assert_eq!(vtexts[0].bg_theme_key, None);
3768    }
3769
3770    #[test]
3771    fn test_inlay_hint_removed_when_its_range_is_deleted() {
3772        // Regression: deleting a range that covers the anchor byte of an
3773        // inlay hint used to leave the hint visible (the marker snapped to
3774        // the deletion start). apply_delete now calls
3775        // virtual_texts.remove_in_range before adjusting markers, so the
3776        // hint vanishes immediately. A future LSP refresh can repopulate
3777        // hints elsewhere.
3778        let mut state = EditorState::new(
3779            80,
3780            24,
3781            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3782            test_fs(),
3783        );
3784        state.buffer = Buffer::from_str_test("let x = 42;");
3785        state.marker_list.adjust_for_insert(0, state.buffer.len());
3786
3787        // Hint anchored at byte 5 (after "let x" -> rendered before '=').
3788        let hints = vec![make_hint(0, 5, ": i32", Some(InlayHintKind::TYPE))];
3789        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3790        assert_eq!(state.virtual_texts.len(), 1);
3791
3792        // Simulate user deleting "x = 42" (bytes 4..10, half-open) — the
3793        // hint anchor at byte 5 is inside this range.
3794        let removed = state
3795            .virtual_texts
3796            .remove_in_range(&mut state.marker_list, 4, 10);
3797        assert_eq!(removed, 1, "hint inside deleted range must be removed");
3798        assert!(state.virtual_texts.is_empty());
3799    }
3800
3801    #[test]
3802    fn test_marker_delete_after_repeat_clear_recreate() {
3803        // Regression: simulates what apply_inlay_hints_to_state does on
3804        // every LSP refresh — clear every virtual_text's marker then
3805        // recreate markers at fresh positions. After a few rounds,
3806        // delete one marker and adjust for a deletion and check the
3807        // remaining markers' positions.
3808        use crate::model::marker::MarkerList;
3809        use crate::view::virtual_text::{VirtualTextManager, VirtualTextPosition};
3810        use ratatui::style::Style;
3811
3812        let mut markers = MarkerList::new();
3813        let mut vtexts = VirtualTextManager::new();
3814
3815        // Initial marker layout at six positions (same as the e2e test).
3816        let positions = [200usize, 401, 602, 803, 1205, 1406];
3817        for &p in &positions {
3818            vtexts.add(
3819                &mut markers,
3820                p,
3821                format!("hint-at-{p}"),
3822                Style::default(),
3823                VirtualTextPosition::BeforeChar,
3824                0,
3825            );
3826        }
3827
3828        // Simulate a couple of clear/recreate cycles (each LSP refresh
3829        // goes through this exact path via apply_inlay_hints_to_state).
3830        for _ in 0..3 {
3831            vtexts.clear(&mut markers);
3832            for &p in &positions {
3833                vtexts.add(
3834                    &mut markers,
3835                    p,
3836                    format!("hint-at-{p}"),
3837                    Style::default(),
3838                    VirtualTextPosition::BeforeChar,
3839                    0,
3840                );
3841            }
3842        }
3843
3844        // remove_in_range + adjust_for_delete equivalent to apply_delete.
3845        let removed = vtexts.remove_in_range(&mut markers, 1005, 1206);
3846        assert_eq!(
3847            removed, 1,
3848            "exactly one marker inside [1005, 1206) should be removed"
3849        );
3850        markers.adjust_for_delete(1005, 201);
3851
3852        let lookup = vtexts.build_lookup(&markers, 0, 10_000);
3853        let mut positions: Vec<usize> = lookup.keys().copied().collect();
3854        positions.sort();
3855        assert_eq!(
3856            positions,
3857            vec![200, 401, 602, 803, 1205],
3858            "after delete+adjust, expected marker byte positions {:?}, got {:?}",
3859            vec![200, 401, 602, 803, 1205],
3860            positions
3861        );
3862    }
3863
3864    #[test]
3865    fn test_marker_delete_then_adjust_preserves_last_marker_position() {
3866        // Regression for the user-observed flip of an end-of-line inlay
3867        // hint to the start of its line after a nearby line is deleted.
3868        //
3869        // Scenario (real numbers from the failing e2e test): six markers
3870        // at byte offsets that correspond to the `\n` of each line,
3871        // then delete-one-marker (simulating remove_in_range on the
3872        // line being deleted) followed by adjust_for_delete on the
3873        // remaining markers.
3874        //
3875        // The last marker (at byte 1406) should end up at byte 1205
3876        // after subtracting the 201-byte deleted range. Observed bug:
3877        // it ends up at byte 1005 (the deletion start) — exactly as
3878        // though the delta were applied twice.
3879        use crate::model::marker::MarkerList;
3880
3881        let mut markers = MarkerList::new();
3882        let m0 = markers.create(200, false);
3883        let m1 = markers.create(401, false);
3884        let m2 = markers.create(602, false);
3885        let m3 = markers.create(803, false);
3886        let m5 = markers.create(1205, false);
3887        let m6 = markers.create(1406, false);
3888
3889        // Simulate remove_in_range removing marker m5 inside [1005, 1206).
3890        markers.delete(m5);
3891
3892        // Now simulate adjust_for_delete over that range.
3893        markers.adjust_for_delete(1005, 201);
3894
3895        assert_eq!(markers.get_position(m0), Some(200), "m0 unchanged");
3896        assert_eq!(markers.get_position(m1), Some(401), "m1 unchanged");
3897        assert_eq!(markers.get_position(m2), Some(602), "m2 unchanged");
3898        assert_eq!(markers.get_position(m3), Some(803), "m3 unchanged");
3899        assert_eq!(
3900            markers.get_position(m6),
3901            Some(1205),
3902            "m6 must shift from 1406 to 1205 (1406 - 201), not be clamped to delete-start 1005"
3903        );
3904    }
3905
3906    #[test]
3907    fn test_inlay_hint_outside_deletion_survives() {
3908        // Anchors outside the deleted range must not be collateral damage.
3909        let mut state = EditorState::new(
3910            80,
3911            24,
3912            crate::config::LARGE_FILE_THRESHOLD_BYTES as usize,
3913            test_fs(),
3914        );
3915        state.buffer = Buffer::from_str_test("let x = 42; let y = 0;");
3916        state.marker_list.adjust_for_insert(0, state.buffer.len());
3917
3918        let hints = vec![
3919            make_hint(0, 5, ": i32", Some(InlayHintKind::TYPE)), // byte 5 - inside deletion
3920            make_hint(0, 17, ": i32", Some(InlayHintKind::TYPE)), // byte 17 - outside
3921        ];
3922        Editor::apply_inlay_hints_to_state(&mut state, &hints);
3923        assert_eq!(state.virtual_texts.len(), 2);
3924
3925        let removed = state
3926            .virtual_texts
3927            .remove_in_range(&mut state.marker_list, 4, 10);
3928        assert_eq!(removed, 1);
3929        assert_eq!(state.virtual_texts.len(), 1);
3930    }
3931
3932    #[test]
3933    fn test_space_doc_paragraphs_inserts_blank_lines() {
3934        use super::space_doc_paragraphs;
3935
3936        // Single newlines become double newlines
3937        let input = "sep\n  description.\nend\n  another.";
3938        let result = space_doc_paragraphs(input);
3939        assert_eq!(result, "sep\n\n  description.\n\nend\n\n  another.");
3940    }
3941
3942    #[test]
3943    fn test_space_doc_paragraphs_preserves_existing_blank_lines() {
3944        use super::space_doc_paragraphs;
3945
3946        // Already-double newlines stay double (not quadrupled)
3947        let input = "First paragraph.\n\nSecond paragraph.";
3948        let result = space_doc_paragraphs(input);
3949        assert_eq!(result, "First paragraph.\n\nSecond paragraph.");
3950    }
3951
3952    #[test]
3953    fn test_space_doc_paragraphs_plain_text() {
3954        use super::space_doc_paragraphs;
3955
3956        let input = "Just a single line of docs.";
3957        let result = space_doc_paragraphs(input);
3958        assert_eq!(result, "Just a single line of docs.");
3959    }
3960}