reovim_plugin_lsp/
lib.rs

1//! LSP integration plugin for reovim.
2//!
3//! This plugin provides Language Server Protocol support:
4//! - Document synchronization (`didOpen`, `didChange`, `didClose`)
5//! - Diagnostics display (errors, warnings)
6//!
7//! # Architecture
8//!
9//! The plugin uses the saturator pattern for non-blocking LSP I/O:
10//! - `LspSaturator` runs in a background tokio task
11//! - `DiagnosticCache` uses `ArcSwap` for lock-free reads
12//! - Event handlers schedule syncs with debouncing
13//! - `LspRenderStage` sends content to the saturator when debounce elapsed
14
15mod command;
16mod document;
17mod health;
18mod hover;
19mod manager;
20mod picker;
21mod stage;
22mod window;
23
24use std::{path::PathBuf, sync::Arc};
25
26use {
27    reovim_core::{
28        bind::{CommandRef, KeymapScope},
29        event_bus::{
30            BufferClosed, BufferModified, CursorMoved, EventBus, EventResult, FileOpened,
31            ModeChanged, RequestOpenFileAtPosition, ShutdownEvent,
32        },
33        keys,
34        plugin::{Plugin, PluginContext, PluginId, PluginStateRegistry},
35    },
36    reovim_lsp::{
37        ClientConfig, GotoDefinitionResponse, HoverContents, Location, LspSaturator, MarkedString,
38    },
39    reovim_plugin_microscope::{MicroscopeOpen, PickerRegistry},
40    tokio::sync::mpsc,
41    tracing::{debug, error, info, warn},
42};
43
44/// Command IDs for LSP commands
45pub mod command_id {
46    use reovim_core::command::id::CommandId;
47
48    pub const GOTO_DEFINITION: CommandId = CommandId::new("lsp_goto_definition");
49    pub const GOTO_REFERENCES: CommandId = CommandId::new("lsp_goto_references");
50    pub const SHOW_HOVER: CommandId = CommandId::new("lsp_show_hover");
51}
52
53pub use {
54    document::{DocumentManager, DocumentState},
55    manager::{LspManager, SharedLspManager},
56    stage::LspRenderStage,
57};
58
59// Re-export events and commands for external use
60pub use command::{
61    LspGotoDefinition, LspGotoDefinitionCommand, LspGotoReferences, LspGotoReferencesCommand,
62    LspHoverDismiss, LspLogOpen, LspShowHover, LspShowHoverCommand,
63};
64
65/// LSP integration plugin.
66///
67/// Manages document synchronization with language servers.
68/// Currently supports rust-analyzer only.
69pub struct LspPlugin {
70    manager: Arc<SharedLspManager>,
71}
72
73impl Default for LspPlugin {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl LspPlugin {
80    /// Create a new LSP plugin.
81    #[must_use]
82    pub fn new() -> Self {
83        Self {
84            manager: Arc::new(SharedLspManager::new()),
85        }
86    }
87
88    /// Get the shared manager (for external access).
89    #[must_use]
90    pub fn manager(&self) -> Arc<SharedLspManager> {
91        Arc::clone(&self.manager)
92    }
93}
94
95impl Plugin for LspPlugin {
96    fn id(&self) -> PluginId {
97        PluginId::new("reovim:lsp")
98    }
99
100    fn name(&self) -> &'static str {
101        "LSP"
102    }
103
104    fn description(&self) -> &'static str {
105        "Language Server Protocol integration for diagnostics and more"
106    }
107
108    fn build(&self, ctx: &mut PluginContext) {
109        // Register render stage for buffer content access and sync
110        let stage = Arc::new(LspRenderStage::new(Arc::clone(&self.manager)));
111        ctx.register_render_stage(stage);
112
113        // Register LSP commands
114        if let Err(e) = ctx.register_command(LspGotoDefinitionCommand) {
115            error!("Failed to register lsp_goto_definition command: {:?}", e);
116        }
117        if let Err(e) = ctx.register_command(LspGotoReferencesCommand) {
118            error!("Failed to register lsp_goto_references command: {:?}", e);
119        }
120        if let Err(e) = ctx.register_command(LspShowHoverCommand) {
121            error!("Failed to register lsp_show_hover command: {:?}", e);
122        }
123        debug!(
124            "LspPlugin: registered commands - gd={}, gr={}, K={}",
125            command_id::GOTO_DEFINITION.as_str(),
126            command_id::GOTO_REFERENCES.as_str(),
127            command_id::SHOW_HOVER.as_str()
128        );
129
130        // Register keybindings in editor normal mode
131        let editor_normal = KeymapScope::editor_normal();
132
133        // gd - Go to definition
134        ctx.bind_key_scoped(
135            editor_normal.clone(),
136            keys!['g' 'd'],
137            CommandRef::Registered(command_id::GOTO_DEFINITION),
138        );
139
140        // gr - Go to references
141        ctx.bind_key_scoped(
142            editor_normal.clone(),
143            keys!['g' 'r'],
144            CommandRef::Registered(command_id::GOTO_REFERENCES),
145        );
146
147        // K (Shift+k) - Show hover
148        ctx.bind_key_scoped(
149            editor_normal,
150            keys!['K'],
151            CommandRef::Registered(command_id::SHOW_HOVER),
152        );
153
154        info!("LspPlugin: registered render stage and keybindings (gd, gr, K)");
155    }
156
157    fn init_state(&self, registry: &PluginStateRegistry) {
158        // Store in plugin state registry for other plugins to access
159        registry.register(Arc::clone(&self.manager));
160
161        // Register hover plugin window
162        registry.register_plugin_window(Arc::new(window::HoverPluginWindow::new(Arc::clone(
163            &self.manager,
164        ))));
165
166        // Register LSP pickers with microscope
167        let references_picker = Arc::new(picker::LspReferencesPicker::new());
168        registry.register(Arc::clone(&references_picker));
169
170        let definitions_picker = Arc::new(picker::LspDefinitionsPicker::new());
171        registry.register(Arc::clone(&definitions_picker));
172
173        registry.with_mut::<PickerRegistry, _, _>(|picker_registry| {
174            picker_registry.register(references_picker);
175            picker_registry.register(definitions_picker);
176        });
177
178        debug!("LspPlugin: initialized state");
179    }
180
181    #[allow(clippy::too_many_lines)]
182    fn subscribe(&self, bus: &EventBus, state: Arc<PluginStateRegistry>) {
183        // Capture tokio runtime handle for use in EventBus handlers
184        // (EventBus handlers run on std::thread, not tokio runtime - see #120)
185        let rt_handle = tokio::runtime::Handle::current();
186
187        // Register LSP health check with health-check plugin
188        {
189            use reovim_plugin_health_check::RegisterHealthCheck;
190
191            let manager = Arc::clone(&self.manager);
192            bus.emit(RegisterHealthCheck::new(Arc::new(health::LspHealthCheck::new(manager))));
193        }
194
195        // Register :LspLog ex-command
196        {
197            use reovim_core::{
198                command_line::ExCommandHandler,
199                event_bus::{DynEvent, core_events::RegisterExCommand},
200            };
201
202            bus.emit(RegisterExCommand::new(
203                "LspLog",
204                ExCommandHandler::ZeroArg {
205                    event_constructor: || DynEvent::new(command::LspLogOpen),
206                    description: "Open the LSP log file",
207                },
208            ));
209        }
210
211        // Handle LspLogOpen - find and open the LSP log file
212        bus.subscribe::<command::LspLogOpen, _>(100, move |_event, ctx| {
213            use reovim_core::event_bus::core_events::RequestOpenFile;
214
215            // Find the latest LSP log file in the data directory
216            let data_dir = dirs::data_local_dir()
217                .unwrap_or_else(|| PathBuf::from("."))
218                .join("reovim");
219
220            // Find latest lsp-*.log file
221            if let Ok(entries) = std::fs::read_dir(&data_dir) {
222                let mut lsp_logs: Vec<_> = entries
223                    .filter_map(std::result::Result::ok)
224                    .filter(|e| {
225                        e.file_name().to_string_lossy().starts_with("lsp-")
226                            && e.file_name().to_string_lossy().ends_with(".log")
227                    })
228                    .collect();
229
230                // Sort by modification time (newest first)
231                lsp_logs.sort_by(|a, b| {
232                    b.metadata()
233                        .and_then(|m| m.modified())
234                        .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
235                        .cmp(
236                            &a.metadata()
237                                .and_then(|m| m.modified())
238                                .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
239                        )
240                });
241
242                if let Some(latest) = lsp_logs.first() {
243                    let log_path = latest.path();
244                    info!("Opening LSP log: {}", log_path.display());
245                    ctx.emit(RequestOpenFile { path: log_path });
246                } else {
247                    warn!("No LSP log files found in {}", data_dir.display());
248                }
249            } else {
250                warn!("Could not read data directory: {}", data_dir.display());
251            }
252
253            EventResult::Handled
254        });
255
256        // Handle file open - register document and schedule sync for render stage
257        // Note: We don't send didOpen here directly. The render stage handles it
258        // via schedule_immediate_sync() to avoid version synchronization bugs.
259        {
260            let state = Arc::clone(&state);
261            bus.subscribe::<FileOpened, _>(100, move |event, _ctx| {
262                info!(
263                    buffer_id = event.buffer_id,
264                    path = %event.path,
265                    "LSP: FileOpened event received"
266                );
267                state.with_mut::<Arc<SharedLspManager>, _, _>(|manager| {
268                    manager.with_mut(|m| {
269                        let path = PathBuf::from(&event.path);
270                        if let Some(doc) = m.documents.open_document(event.buffer_id, path) {
271                            info!(
272                                buffer_id = event.buffer_id,
273                                language_id = %doc.language_id,
274                                "LSP: opened document, scheduling sync for render stage"
275                            );
276                            // Let render stage handle didOpen to ensure version consistency
277                            m.documents.schedule_immediate_sync(event.buffer_id);
278                        }
279                    });
280                });
281                EventResult::Handled
282            });
283        }
284
285        // Handle buffer modifications - schedule sync with debounce
286        {
287            let state = Arc::clone(&state);
288            bus.subscribe::<BufferModified, _>(100, move |event, _ctx| {
289                state.with_mut::<Arc<SharedLspManager>, _, _>(|manager| {
290                    manager.with_mut(|m| {
291                        if m.documents.has_document(event.buffer_id)
292                            && let Some(version) = m.documents.schedule_sync(event.buffer_id)
293                        {
294                            debug!(
295                                buffer_id = event.buffer_id,
296                                version = version,
297                                "LSP: scheduled sync"
298                            );
299                        }
300                    });
301                });
302                EventResult::Handled
303            });
304        }
305
306        // Handle buffer close - send didClose and cleanup
307        {
308            let state = Arc::clone(&state);
309            bus.subscribe::<BufferClosed, _>(100, move |event, _ctx| {
310                state.with_mut::<Arc<SharedLspManager>, _, _>(|manager| {
311                    manager.with_mut(|m| {
312                        if let Some(doc) = m.documents.close_document(event.buffer_id) {
313                            debug!(
314                                buffer_id = event.buffer_id,
315                                uri = ?doc.uri,
316                                "LSP: closed document"
317                            );
318
319                            // Send didClose to the server
320                            if let Some(handle) = &m.handle {
321                                handle.did_close(doc.uri);
322                            }
323                        }
324                    });
325                });
326                EventResult::Handled
327            });
328        }
329
330        // Handle goto definition command
331        {
332            let state = Arc::clone(&state);
333            let event_sender = bus.sender();
334            let rt_handle = rt_handle.clone();
335            bus.subscribe::<LspGotoDefinition, _>(100, move |event, _ctx| {
336                info!(
337                    buffer_id = event.buffer_id,
338                    line = event.line,
339                    column = event.column,
340                    "LSP: LspGotoDefinition event received"
341                );
342                let buffer_id = event.buffer_id;
343                let line = event.line;
344                let column = event.column;
345
346                // Get document URI and handle from manager
347                let request_info = state.with::<Arc<SharedLspManager>, _, _>(|manager| {
348                    manager.with(|m| {
349                        let doc = m.documents.get(buffer_id)?;
350                        let handle = m.handle.as_ref()?;
351                        let uri = doc.uri.clone();
352                        #[allow(clippy::cast_possible_truncation)]
353                        let position = reovim_lsp::Position {
354                            line: line as u32,       // Line numbers won't exceed u32
355                            character: column as u32, // Column numbers won't exceed u32
356                        };
357                        let rx = handle.goto_definition(uri.clone(), position);
358                        Some((uri, position, rx))
359                    })
360                });
361
362                if let Some(Some((uri, _position, Some(rx)))) = request_info {
363                    info!(
364                        buffer_id,
365                        line,
366                        column,
367                        uri = %uri.as_str(),
368                        "LSP: goto definition request sent"
369                    );
370
371                    // Spawn async task to handle response
372                    // Use captured rt_handle since EventBus handlers run on std::thread (#120)
373                    let sender = event_sender.clone();
374                    let state_clone = Arc::clone(&state);
375                    rt_handle.spawn(async move {
376                        match rx.await {
377                            Ok(Ok(Some(response))) => {
378                                // Extract all locations from response
379                                let locations = extract_all_locations(&response);
380
381                                if locations.is_empty() {
382                                    info!("LSP: no definition location in response");
383                                    return;
384                                }
385
386                                if locations.len() == 1 {
387                                    // Single definition - navigate directly
388                                    let loc = &locations[0];
389                                    info!(
390                                        uri = %loc.uri.as_str(),
391                                        line = loc.range.start.line,
392                                        col = loc.range.start.character,
393                                        "LSP: navigating to definition"
394                                    );
395                                    if let Some(path) = uri_to_path(&loc.uri) {
396                                        sender.try_send(RequestOpenFileAtPosition {
397                                            path,
398                                            line: loc.range.start.line as usize,
399                                            column: loc.range.start.character as usize,
400                                        });
401                                    } else {
402                                        warn!(uri = %loc.uri.as_str(), "LSP: cannot convert URI to file path");
403                                    }
404                                } else {
405                                    // Multiple definitions - show picker
406                                    info!(count = locations.len(), "LSP: multiple definitions found, opening picker");
407
408                                    // Store definitions in picker
409                                    state_clone.with::<Arc<picker::LspDefinitionsPicker>, _, _>(
410                                        |definitions_picker| {
411                                            definitions_picker.set_definitions(locations);
412                                        },
413                                    );
414
415                                    // Open microscope with definitions picker
416                                    sender.try_send(MicroscopeOpen::new("lsp_definitions"));
417                                }
418                            }
419                            Ok(Ok(None)) => {
420                                info!("LSP: no definition found");
421                            }
422                            Ok(Err(e)) => {
423                                warn!("LSP: goto definition error: {}", e);
424                            }
425                            Err(_) => {
426                                warn!("LSP: goto definition channel closed");
427                            }
428                        }
429                    });
430                } else {
431                    debug!(buffer_id, "LSP: goto definition - no document or handle");
432                }
433
434                EventResult::Handled
435            });
436        }
437
438        // Handle goto references command
439        {
440            let state = Arc::clone(&state);
441            let event_sender = bus.sender();
442            let rt_handle = rt_handle.clone();
443            bus.subscribe::<LspGotoReferences, _>(100, move |event, _ctx| {
444                let buffer_id = event.buffer_id;
445                let line = event.line;
446                let column = event.column;
447
448                // Get document URI and handle from manager
449                let request_info = state.with::<Arc<SharedLspManager>, _, _>(|manager| {
450                    manager.with(|m| {
451                        let doc = m.documents.get(buffer_id)?;
452                        let handle = m.handle.as_ref()?;
453                        let uri = doc.uri.clone();
454                        #[allow(clippy::cast_possible_truncation)]
455                        let position = reovim_lsp::Position {
456                            line: line as u32,        // Line numbers won't exceed u32
457                            character: column as u32, // Column numbers won't exceed u32
458                        };
459                        let rx = handle.references(uri.clone(), position, true);
460                        Some((uri, position, rx))
461                    })
462                });
463
464                if let Some(Some((uri, _position, Some(rx)))) = request_info {
465                    info!(
466                        buffer_id,
467                        line,
468                        column,
469                        uri = %uri.as_str(),
470                        "LSP: goto references request sent"
471                    );
472
473                    // Spawn async task to handle response
474                    // Use captured rt_handle since EventBus handlers run on std::thread (#120)
475                    let state_clone = Arc::clone(&state);
476                    let sender = event_sender.clone();
477                    rt_handle.spawn(async move {
478                        match rx.await {
479                            Ok(Ok(Some(locations))) => {
480                                info!(count = locations.len(), "LSP: references found");
481
482                                if locations.is_empty() {
483                                    info!("LSP: no references to display");
484                                    return;
485                                }
486
487                                // Store references in picker
488                                state_clone.with::<Arc<picker::LspReferencesPicker>, _, _>(
489                                    |picker| {
490                                        picker.set_references(locations);
491                                    },
492                                );
493
494                                // Open microscope with references picker
495                                sender.try_send(MicroscopeOpen::new("lsp_references"));
496                            }
497                            Ok(Ok(None)) => {
498                                info!("LSP: no references found");
499                            }
500                            Ok(Err(e)) => {
501                                warn!("LSP: references error: {}", e);
502                            }
503                            Err(_) => {
504                                warn!("LSP: references channel closed");
505                            }
506                        }
507                    });
508                } else {
509                    debug!(buffer_id, "LSP: goto references - no document or handle");
510                }
511
512                EventResult::Handled
513            });
514        }
515
516        // Handle show hover command
517        {
518            let state = Arc::clone(&state);
519            // Last use of rt_handle - no clone needed (moved into closure)
520            bus.subscribe::<LspShowHover, _>(100, move |event, _ctx| {
521                let buffer_id = event.buffer_id;
522                let line = event.line;
523                let column = event.column;
524
525                // Get document URI and handle from manager
526                let request_info = state.with::<Arc<SharedLspManager>, _, _>(|manager| {
527                    manager.with(|m| {
528                        let doc = m.documents.get(buffer_id)?;
529                        let handle = m.handle.as_ref()?;
530                        let uri = doc.uri.clone();
531                        #[allow(clippy::cast_possible_truncation)]
532                        let position = reovim_lsp::Position {
533                            line: line as u32,        // Line numbers won't exceed u32
534                            character: column as u32, // Column numbers won't exceed u32
535                        };
536                        let rx = handle.hover(uri.clone(), position);
537                        Some((uri, position, rx))
538                    })
539                });
540
541                if let Some(Some((uri, _position, Some(rx)))) = request_info {
542                    info!(
543                        buffer_id,
544                        line,
545                        column,
546                        uri = %uri.as_str(),
547                        "LSP: hover request sent"
548                    );
549
550                    // Spawn async task to handle response
551                    // Use captured rt_handle since EventBus handlers run on std::thread (#120)
552                    let state_clone = Arc::clone(&state);
553                    rt_handle.spawn(async move {
554                        match rx.await {
555                            Ok(Ok(Some(hover))) => {
556                                // Extract hover text content
557                                let content = extract_hover_text(&hover.contents);
558                                if content.is_empty() {
559                                    info!("LSP: hover response is empty");
560                                } else {
561                                    info!(
562                                        content_lines = content.lines().count(),
563                                        "LSP: hover content received"
564                                    );
565
566                                    // Store hover content in cache for popup display
567                                    state_clone.with_mut::<Arc<SharedLspManager>, _, _>(
568                                        |manager| {
569                                            manager.with_mut(|m| {
570                                                let snapshot = hover::HoverSnapshot::new(
571                                                    content, line, column, buffer_id,
572                                                );
573                                                m.hover_cache.store(snapshot);
574                                                // Trigger re-render to show popup
575                                                m.send_render_signal();
576                                            });
577                                        },
578                                    );
579                                }
580                            }
581                            Ok(Ok(None)) => {
582                                info!("LSP: no hover info");
583                            }
584                            Ok(Err(e)) => {
585                                warn!("LSP: hover error: {}", e);
586                            }
587                            Err(_) => {
588                                warn!("LSP: hover channel closed");
589                            }
590                        }
591                    });
592                } else {
593                    info!(buffer_id, ?request_info, "LSP: hover - no document or handle");
594                }
595
596                EventResult::Handled
597            });
598        }
599
600        // Handle shutdown - gracefully shutdown LSP server
601        {
602            let state = Arc::clone(&state);
603            bus.subscribe::<ShutdownEvent, _>(100, move |_event, _ctx| {
604                state.with_mut::<Arc<SharedLspManager>, _, _>(|manager| {
605                    manager.with_mut(|m| {
606                        if m.is_running() {
607                            info!("LSP: shutting down language server");
608                            m.shutdown();
609                        }
610                    });
611                });
612                EventResult::Handled
613            });
614        }
615
616        // Handle explicit hover dismiss
617        {
618            let state = Arc::clone(&state);
619            bus.subscribe::<command::LspHoverDismiss, _>(100, move |_event, _ctx| {
620                state.with_mut::<Arc<SharedLspManager>, _, _>(|manager| {
621                    manager.with_mut(|m| {
622                        m.hover_cache.clear();
623                    });
624                });
625                EventResult::Handled
626            });
627        }
628
629        // Dismiss hover on cursor movement
630        {
631            let state = Arc::clone(&state);
632            bus.subscribe::<CursorMoved, _>(200, move |_event, _ctx| {
633                let was_active = state
634                    .with_mut::<Arc<SharedLspManager>, _, bool>(|manager| {
635                        manager.with_mut(|m| {
636                            let is_active = m.hover_cache.is_active();
637                            if is_active {
638                                debug!("CursorMoved: clearing active hover");
639                                m.hover_cache.clear();
640                                true
641                            } else {
642                                false
643                            }
644                        })
645                    })
646                    .unwrap_or(false);
647
648                // Request render to dismiss the hover window
649                if was_active {
650                    debug!("CursorMoved: returning NeedsRender to dismiss hover");
651                    EventResult::NeedsRender
652                } else {
653                    EventResult::Handled
654                }
655            });
656        }
657
658        // Dismiss hover on mode change
659        {
660            let state = Arc::clone(&state);
661            bus.subscribe::<ModeChanged, _>(200, move |_event, _ctx| {
662                let was_active = state
663                    .with_mut::<Arc<SharedLspManager>, _, bool>(|manager| {
664                        manager.with_mut(|m| {
665                            let is_active = m.hover_cache.is_active();
666                            if is_active {
667                                debug!("ModeChanged: clearing active hover");
668                                m.hover_cache.clear();
669                                true
670                            } else {
671                                false
672                            }
673                        })
674                    })
675                    .unwrap_or(false);
676
677                // Request render to dismiss the hover window
678                if was_active {
679                    debug!("ModeChanged: returning NeedsRender to dismiss hover");
680                    EventResult::NeedsRender
681                } else {
682                    EventResult::Handled
683                }
684            });
685        }
686
687        // Handle virtual text option changes
688        {
689            use reovim_core::option::{OptionChanged, OptionValue};
690
691            let state = Arc::clone(&state);
692            bus.subscribe::<OptionChanged, _>(100, move |event, _ctx| {
693                let name = event.name.as_str();
694
695                // Only handle virtual_text_* options
696                if !name.starts_with("virtual_text") {
697                    return EventResult::Handled;
698                }
699
700                state.with_mut::<Arc<SharedLspManager>, _, _>(|manager| {
701                    manager.with_mut(|m| {
702                        let config = &mut m.virtual_text_config;
703                        match name {
704                            "virtual_text" => {
705                                if let OptionValue::Bool(enabled) = &event.new_value {
706                                    config.enabled = *enabled;
707                                    debug!("LSP: virtual_text enabled = {}", enabled);
708                                }
709                            }
710                            "virtual_text_prefix" => {
711                                if let OptionValue::String(prefix) = &event.new_value {
712                                    config.prefix.clone_from(prefix);
713                                    debug!("LSP: virtual_text prefix = {:?}", prefix);
714                                }
715                            }
716                            "virtual_text_max_length" => {
717                                if let OptionValue::Integer(len) = &event.new_value {
718                                    #[allow(
719                                        clippy::cast_possible_truncation,
720                                        clippy::cast_sign_loss
721                                    )]
722                                    {
723                                        config.max_length = (*len).clamp(10, 200) as u16;
724                                    }
725                                    debug!("LSP: virtual_text max_length = {}", len);
726                                }
727                            }
728                            "virtual_text_show" => {
729                                if let OptionValue::String(mode) = &event.new_value {
730                                    config.show_mode = match mode.as_str() {
731                                        "highest" => manager::VirtualTextShowMode::Highest,
732                                        "all" => manager::VirtualTextShowMode::All,
733                                        _ => manager::VirtualTextShowMode::First,
734                                    };
735                                    debug!("LSP: virtual_text show_mode = {:?}", config.show_mode);
736                                }
737                            }
738                            _ => {}
739                        }
740                    });
741                });
742
743                EventResult::NeedsRender
744            });
745        }
746    }
747
748    #[allow(clippy::too_many_lines)]
749    fn boot(
750        &self,
751        bus: &EventBus,
752        state: Arc<PluginStateRegistry>,
753        event_tx: Option<tokio::sync::mpsc::Sender<reovim_core::event::RuntimeEvent>>,
754    ) {
755        // Store event_tx in manager for triggering re-renders
756        if let Some(tx) = event_tx {
757            state.with_mut::<Arc<SharedLspManager>, _, _>(|manager| {
758                manager.with_mut(|m| {
759                    m.set_event_tx(tx);
760                });
761            });
762        }
763
764        // Create channel for LSP progress events
765        let (progress_tx, mut progress_rx) =
766            mpsc::unbounded_channel::<Box<dyn std::any::Any + Send>>();
767
768        // Spawn task to forward LSP progress events to notification plugin
769        // This converts LSP progress events to notification plugin events
770        let bus_sender = bus.sender();
771        tokio::spawn(async move {
772            use {
773                reovim_lsp::{LspProgressBegin, LspProgressEnd, LspProgressReport},
774                reovim_plugin_notification::{ProgressComplete, ProgressUpdate},
775            };
776
777            while let Some(event) = progress_rx.recv().await {
778                // Try to downcast to LSP progress event types
779                if let Some(begin) = event.downcast_ref::<LspProgressBegin>() {
780                    let mut progress =
781                        ProgressUpdate::new(begin.id.clone(), begin.title.clone(), "LSP");
782                    if let Some(pct) = begin.percentage {
783                        progress = progress.with_progress(pct);
784                    }
785                    if let Some(msg) = &begin.message {
786                        progress = progress.with_detail(msg.clone());
787                    }
788                    bus_sender.try_send(progress);
789                } else if let Some(report) = event.downcast_ref::<LspProgressReport>() {
790                    let mut progress =
791                        ProgressUpdate::new(report.id.clone(), report.title.clone(), "LSP");
792                    if let Some(pct) = report.percentage {
793                        progress = progress.with_progress(pct);
794                    }
795                    if let Some(msg) = &report.message {
796                        progress = progress.with_detail(msg.clone());
797                    }
798                    bus_sender.try_send(progress);
799                } else if let Some(end) = event.downcast_ref::<LspProgressEnd>() {
800                    let mut complete = ProgressComplete::new(end.id.clone());
801                    if let Some(msg) = &end.message {
802                        complete = complete.with_message(msg.clone());
803                    }
804                    bus_sender.try_send(complete);
805                }
806            }
807        });
808
809        // Start the LSP server in a background task
810        let state_clone = Arc::clone(&state);
811
812        tokio::spawn(async move {
813            // Find the project root (look for Cargo.toml)
814            let root_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
815
816            // Check if rust-analyzer is available
817            if !rust_analyzer_available() {
818                warn!("LSP: rust-analyzer not found in PATH, LSP disabled");
819                return;
820            }
821
822            info!(root = %root_path.display(), "LSP: starting rust-analyzer");
823
824            let config = ClientConfig::rust_analyzer(&root_path);
825
826            // Create a render signal channel (not used yet, but allows triggering re-renders)
827            let (render_tx, _render_rx) = mpsc::channel::<()>(1);
828
829            match LspSaturator::start(config, Some(render_tx), Some(progress_tx)).await {
830                Ok((handle, cache)) => {
831                    info!("LSP: rust-analyzer started successfully");
832
833                    // Set connection and send didOpen for pending documents
834                    state_clone.with_mut::<Arc<SharedLspManager>, _, _>(|manager| {
835                        manager.with_mut(|m| {
836                            m.set_connection(handle, cache);
837
838                            // Send didOpen for all documents that aren't opened yet
839                            // This handles documents opened before the server was ready
840                            let buffer_ids: Vec<usize> = m.documents.buffer_ids();
841                            info!(
842                                count = buffer_ids.len(),
843                                ?buffer_ids,
844                                "LSP: checking pending documents on server ready"
845                            );
846                            for buffer_id in buffer_ids {
847                                info!(buffer_id, "LSP: processing buffer_id");
848                                let Some(doc) = m.documents.get(buffer_id) else {
849                                    warn!(buffer_id, "LSP: document not found for buffer_id");
850                                    continue;
851                                };
852                                info!(
853                                    buffer_id,
854                                    opened = doc.opened,
855                                    has_handle = m.handle.is_some(),
856                                    "LSP: checking doc state"
857                                );
858                                if let (false, Some(handle)) = (doc.opened, &m.handle) {
859                                    // Read content from disk
860                                    let path = doc.path.clone();
861                                    let uri = doc.uri.clone();
862                                    let version = doc.version;
863                                    let language_id = doc.language_id.clone();
864
865                                    if let Ok(content) = std::fs::read_to_string(&path) {
866                                        info!(
867                                            buffer_id,
868                                            version,
869                                            uri = %uri.as_str(),
870                                            "LSP: sending didOpen on server ready"
871                                        );
872                                        handle.did_open(uri, language_id, version, content);
873                                        m.documents.mark_opened(buffer_id);
874                                    }
875                                }
876                            }
877                        });
878                    });
879                }
880                Err(e) => {
881                    error!("LSP: failed to start rust-analyzer: {}", e);
882                }
883            }
884        });
885    }
886}
887
888/// Check if rust-analyzer is available in PATH.
889///
890/// Returns `false` in test environments (when `REOVIM_TEST` is set) to prevent
891/// LSP progress notifications from interfering with tests.
892fn rust_analyzer_available() -> bool {
893    // Skip LSP auto-start in test environments
894    if std::env::var("REOVIM_TEST").is_ok() {
895        return false;
896    }
897
898    std::process::Command::new("rust-analyzer")
899        .arg("--version")
900        .stdout(std::process::Stdio::null())
901        .stderr(std::process::Stdio::null())
902        .status()
903        .is_ok_and(|s| s.success())
904}
905
906/// Extract all locations from a `GotoDefinitionResponse`.
907///
908/// Handles all three variants: Scalar, Array, and Link.
909fn extract_all_locations(response: &GotoDefinitionResponse) -> Vec<Location> {
910    match response {
911        GotoDefinitionResponse::Scalar(loc) => vec![loc.clone()],
912        GotoDefinitionResponse::Array(locs) => locs.clone(),
913        GotoDefinitionResponse::Link(links) => links
914            .iter()
915            .map(|link| Location {
916                uri: link.target_uri.clone(),
917                range: link.target_selection_range,
918            })
919            .collect(),
920    }
921}
922
923/// Convert a file:// URI to a filesystem path.
924fn uri_to_path(uri: &reovim_lsp::Uri) -> Option<PathBuf> {
925    let uri_str = uri.as_str();
926    uri_str.strip_prefix("file://").map(|path_str| {
927        // Handle percent-encoded characters (basic: %20 -> space)
928        let decoded = path_str.replace("%20", " ");
929        PathBuf::from(decoded)
930    })
931}
932
933/// Extract text content from `HoverContents`.
934fn extract_hover_text(contents: &HoverContents) -> String {
935    match contents {
936        HoverContents::Scalar(marked) => marked_string_to_text(marked),
937        HoverContents::Array(items) => items
938            .iter()
939            .map(marked_string_to_text)
940            .collect::<Vec<_>>()
941            .join("\n\n"),
942        HoverContents::Markup(markup) => markup.value.clone(),
943    }
944}
945
946/// Convert a `MarkedString` to plain text.
947fn marked_string_to_text(marked: &MarkedString) -> String {
948    match marked {
949        MarkedString::String(s) => s.clone(),
950        MarkedString::LanguageString(ls) => {
951            // Format as code block header + value
952            format!("```{}\n{}\n```", ls.language, ls.value)
953        }
954    }
955}
956
957#[cfg(test)]
958mod tests {
959    use {
960        super::*,
961        reovim_core::{
962            bind::CommandRef,
963            keys,
964            modd::ModeState,
965            plugin::{Plugin, PluginContext},
966        },
967    };
968
969    #[test]
970    fn test_lsp_keybindings_registered() {
971        let plugin = LspPlugin::new();
972        let mut ctx = PluginContext::new();
973
974        // Build the plugin (registers commands and keybindings)
975        plugin.build(&mut ctx);
976
977        // Get the keymap and bindings for editor normal scope
978        let keymap = ctx.keymap();
979        let scope = KeymapScope::editor_normal();
980        let bindings = keymap.get_scope(&scope);
981
982        // gd, gr, K should all be bound
983        assert!(
984            bindings.get(&keys!['g' 'd']).is_some(),
985            "gd keybinding should be registered. Bindings in scope: {:?}",
986            bindings.keys().collect::<Vec<_>>()
987        );
988        assert!(bindings.get(&keys!['g' 'r']).is_some(), "gr keybinding should be registered");
989        assert!(bindings.get(&keys!['K']).is_some(), "K keybinding should be registered");
990    }
991
992    #[test]
993    fn test_lsp_keybindings_lookup_with_mode() {
994        let plugin = LspPlugin::new();
995        let mut ctx = PluginContext::new();
996
997        // Build the plugin
998        plugin.build(&mut ctx);
999
1000        // Get the keymap
1001        let keymap = ctx.keymap();
1002
1003        // Create a default mode state (editor + normal)
1004        let mode = ModeState::new();
1005
1006        // Test lookup for gd
1007        let goto_def_binding = keymap.lookup_binding(&mode, &keys!['g' 'd']);
1008        assert!(
1009            goto_def_binding.is_some(),
1010            "lookup_binding should find gd. mode.interactor_id={:?}, mode.edit_mode={:?}",
1011            mode.interactor_id,
1012            mode.edit_mode
1013        );
1014        assert!(goto_def_binding.unwrap().command.is_some(), "gd binding should have a command");
1015
1016        // Test lookup for K
1017        let hover_binding = keymap.lookup_binding(&mode, &keys!['K']);
1018        assert!(hover_binding.is_some(), "lookup_binding should find K");
1019        assert!(hover_binding.unwrap().command.is_some(), "K binding should have a command");
1020    }
1021
1022    #[test]
1023    fn test_lsp_commands_registered_and_resolvable() {
1024        let plugin = LspPlugin::new();
1025        let mut ctx = PluginContext::new();
1026
1027        // Build the plugin
1028        plugin.build(&mut ctx);
1029
1030        // Get the command registry
1031        let cmd_registry = ctx.command_registry();
1032
1033        // Test that gd command is registered
1034        let goto_def_cmd = cmd_registry.get(&command_id::GOTO_DEFINITION);
1035        assert!(
1036            goto_def_cmd.is_some(),
1037            "lsp_goto_definition command should be registered. ID = {:?}",
1038            command_id::GOTO_DEFINITION
1039        );
1040        assert_eq!(goto_def_cmd.unwrap().name(), "lsp_goto_definition");
1041
1042        // Test that K command is registered
1043        let show_hover_cmd = cmd_registry.get(&command_id::SHOW_HOVER);
1044        assert!(show_hover_cmd.is_some(), "lsp_show_hover command should be registered");
1045        assert_eq!(show_hover_cmd.unwrap().name(), "lsp_show_hover");
1046
1047        // Test that the keybinding points to the right command
1048        let keymap = ctx.keymap();
1049        let mode = ModeState::new();
1050        let binding = keymap.lookup_binding(&mode, &keys!['g' 'd']).unwrap();
1051
1052        match binding.command.as_ref().unwrap() {
1053            CommandRef::Registered(id) => {
1054                assert_eq!(
1055                    id.as_str(),
1056                    "lsp_goto_definition",
1057                    "gd keybinding should point to lsp_goto_definition"
1058                );
1059                // Verify the command can be resolved
1060                assert!(
1061                    cmd_registry.get(id).is_some(),
1062                    "Command {id} should be resolvable from registry"
1063                );
1064            }
1065            CommandRef::Inline(_) => panic!("gd keybinding should be a Registered command ref"),
1066        }
1067    }
1068}