Skip to main content

fresh/services/
async_bridge.rs

1//! Async Bridge: Communication between async Tokio runtime and sync main loop
2//!
3//! This module implements the hybrid architecture described in TOKIO_ANALYSIS.md:
4//! - Tokio runtime handles I/O tasks (LSP, file watching, git, etc.)
5//! - Main UI loop stays synchronous (rendering, input, buffer manipulation)
6//! - std::sync::mpsc channels bridge the two worlds
7//!
8//! Philosophy:
9//! - I/O should be async (LSP, filesystem, network)
10//! - Computation should be sync (editing, rendering)
11//! - Main loop remains responsive and simple
12
13use crate::services::terminal::TerminalId;
14use crate::view::file_tree::{FileTreeView, NodeId};
15use lsp_types::{
16    CodeActionOrCommand, CompletionItem, Diagnostic, FoldingRange, InlayHint, Location,
17    SemanticTokensFullDeltaResult, SemanticTokensRangeResult, SemanticTokensResult, SignatureHelp,
18};
19use serde_json::Value;
20use std::sync::mpsc;
21
22/// Semantic token responses grouped by request type.
23#[derive(Debug)]
24pub enum LspSemanticTokensResponse {
25    Full(Result<Option<SemanticTokensResult>, String>),
26    FullDelta(Result<Option<SemanticTokensFullDeltaResult>, String>),
27    Range(Result<Option<SemanticTokensRangeResult>, String>),
28}
29
30/// Messages sent from async tasks to the synchronous main loop
31#[derive(Debug)]
32pub enum AsyncMessage {
33    /// LSP diagnostics received for a file
34    LspDiagnostics {
35        uri: String,
36        diagnostics: Vec<Diagnostic>,
37        /// Name of the server that sent these diagnostics (for per-server tracking)
38        server_name: String,
39    },
40
41    /// LSP server initialized successfully
42    LspInitialized {
43        language: String,
44        /// Name of the specific server (for per-server capability tracking)
45        server_name: String,
46        /// Capabilities reported by this server
47        capabilities: crate::services::lsp::manager::ServerCapabilitySummary,
48    },
49
50    /// LSP server crashed or failed
51    LspError {
52        language: String,
53        error: String,
54        /// Path to the stderr log file for this LSP session
55        stderr_log_path: Option<std::path::PathBuf>,
56    },
57
58    /// LSP completion response
59    LspCompletion {
60        request_id: u64,
61        items: Vec<CompletionItem>,
62    },
63
64    /// LSP go-to-definition response
65    LspGotoDefinition {
66        request_id: u64,
67        locations: Vec<Location>,
68    },
69
70    /// LSP rename response
71    LspRename {
72        request_id: u64,
73        result: Result<lsp_types::WorkspaceEdit, String>,
74    },
75
76    /// LSP hover response
77    LspHover {
78        request_id: u64,
79        /// Hover contents as a single string (joined if multiple parts)
80        contents: String,
81        /// Whether the content is markdown (true) or plaintext (false)
82        is_markdown: bool,
83        /// Optional range of the symbol that was hovered over (LSP line/character positions)
84        /// Used to highlight the hovered symbol
85        range: Option<((u32, u32), (u32, u32))>,
86    },
87
88    /// LSP find references response
89    LspReferences {
90        request_id: u64,
91        locations: Vec<Location>,
92    },
93
94    /// LSP signature help response
95    LspSignatureHelp {
96        request_id: u64,
97        signature_help: Option<SignatureHelp>,
98    },
99
100    /// LSP code actions response
101    LspCodeActions {
102        request_id: u64,
103        actions: Vec<CodeActionOrCommand>,
104    },
105
106    /// LSP completionItem/resolve response
107    LspCompletionResolved {
108        request_id: u64,
109        item: Result<lsp_types::CompletionItem, String>,
110    },
111
112    /// LSP textDocument/formatting response
113    LspFormatting {
114        request_id: u64,
115        uri: String,
116        edits: Vec<lsp_types::TextEdit>,
117    },
118
119    /// LSP textDocument/prepareRename response
120    LspPrepareRename {
121        request_id: u64,
122        result: Result<serde_json::Value, String>,
123    },
124
125    /// LSP pulled diagnostics response (textDocument/diagnostic)
126    LspPulledDiagnostics {
127        request_id: u64,
128        uri: String,
129        /// New result_id for incremental updates (None if server doesn't support)
130        result_id: Option<String>,
131        /// Diagnostics (empty if unchanged)
132        diagnostics: Vec<Diagnostic>,
133        /// True if diagnostics haven't changed since previous_result_id
134        unchanged: bool,
135    },
136
137    /// LSP inlay hints response (textDocument/inlayHint)
138    LspInlayHints {
139        request_id: u64,
140        uri: String,
141        /// Inlay hints for the requested range
142        hints: Vec<InlayHint>,
143    },
144
145    /// LSP folding ranges response (textDocument/foldingRange)
146    LspFoldingRanges {
147        request_id: u64,
148        uri: String,
149        ranges: Vec<FoldingRange>,
150    },
151
152    /// LSP semantic tokens response (full, full/delta, or range)
153    LspSemanticTokens {
154        request_id: u64,
155        uri: String,
156        response: LspSemanticTokensResponse,
157    },
158
159    /// LSP server status became quiescent (project fully loaded)
160    /// This is a rust-analyzer specific notification (experimental/serverStatus)
161    LspServerQuiescent { language: String },
162
163    /// LSP server requests diagnostic refresh (workspace/diagnostic/refresh)
164    /// Client should re-pull diagnostics for all open documents
165    LspDiagnosticRefresh { language: String },
166
167    /// File changed externally (future: file watching)
168    FileChanged { path: String },
169
170    /// Git status updated (future: git integration)
171    GitStatusChanged { status: String },
172
173    /// File explorer initialized with tree view
174    FileExplorerInitialized(FileTreeView),
175
176    /// File explorer node toggle completed
177    FileExplorerToggleNode(NodeId),
178
179    /// File explorer node refresh completed
180    FileExplorerRefreshNode(NodeId),
181
182    /// File explorer expand to path completed
183    /// Contains the updated FileTreeView with the path expanded and selected
184    FileExplorerExpandedToPath(FileTreeView),
185
186    /// Plugin-related async messages
187    Plugin(fresh_core::api::PluginAsyncMessage),
188
189    /// File open dialog: directory listing completed
190    FileOpenDirectoryLoaded(std::io::Result<Vec<crate::services::fs::DirEntry>>),
191
192    /// File open dialog: async shortcuts (Windows drive letters) loaded
193    FileOpenShortcutsLoaded(Vec<crate::app::file_open::NavigationShortcut>),
194
195    /// Terminal output received (triggers redraw)
196    TerminalOutput { terminal_id: TerminalId },
197
198    /// File watcher delivered an event for a path under a
199    /// `WatchPath`-registered watcher. Routed to the
200    /// `path_changed` plugin hook by the main loop.
201    PathChanged {
202        /// Watch handle the event came from (matches the value
203        /// returned by `WatchPath`).
204        handle: u64,
205        path: std::path::PathBuf,
206        /// Conservative bucketing of `notify::EventKind`.
207        kind: PathChangeKind,
208    },
209
210    /// Terminal process exited.
211    ///
212    /// `exit_code` is `None` when the editor cannot determine a status
213    /// (the wait happens in a separate thread, signal exits, kill
214    /// before wait, etc.). Populated end-to-end is a follow-up; the
215    /// initial wiring sends `None` so plugin handlers see the variant
216    /// shape that matches `HookArgs::TerminalExited`.
217    TerminalExited {
218        terminal_id: TerminalId,
219        exit_code: Option<i32>,
220    },
221
222    /// LSP progress notification ($/progress)
223    LspProgress {
224        language: String,
225        token: String,
226        value: LspProgressValue,
227    },
228
229    /// LSP window message (window/showMessage)
230    LspWindowMessage {
231        language: String,
232        message_type: LspMessageType,
233        message: String,
234    },
235
236    /// LSP log message (window/logMessage)
237    LspLogMessage {
238        language: String,
239        message_type: LspMessageType,
240        message: String,
241    },
242
243    /// LSP workspace/applyEdit (server -> client request)
244    /// Server asks client to apply a workspace edit (during executeCommand, etc.)
245    LspApplyEdit {
246        edit: lsp_types::WorkspaceEdit,
247        label: Option<String>,
248    },
249
250    /// LSP codeAction/resolve response
251    LspCodeActionResolved {
252        request_id: u64,
253        action: Result<lsp_types::CodeAction, String>,
254    },
255
256    /// LSP server request (server -> client)
257    /// Used for custom/extension methods that plugins can handle
258    LspServerRequest {
259        language: String,
260        server_command: String,
261        method: String,
262        params: Option<Value>,
263    },
264
265    /// Response for a plugin-initiated LSP request
266    PluginLspResponse {
267        language: String,
268        request_id: u64,
269        result: Result<Value, String>,
270    },
271
272    /// Plugin process completed with output
273    PluginProcessOutput {
274        /// Unique ID for this process (to match with callback)
275        process_id: u64,
276        /// Standard output
277        stdout: String,
278        /// Standard error
279        stderr: String,
280        /// Exit code
281        exit_code: i32,
282    },
283
284    /// LSP server status update (progress, messages, etc.)
285    LspStatusUpdate {
286        language: String,
287        /// Name of the specific server (for multi-server status tracking)
288        server_name: String,
289        status: LspServerStatus,
290        message: Option<String>,
291    },
292
293    /// Background grammar build completed — swap in the new registry.
294    /// `callback_ids` contains plugin callbacks to resolve (empty for the
295    /// initial startup build).
296    GrammarRegistryBuilt {
297        registry: std::sync::Arc<crate::primitives::grammar::GrammarRegistry>,
298        callback_ids: Vec<fresh_core::api::JsCallbackId>,
299    },
300
301    /// Quick Open file list loaded by a background task.
302    /// `complete` is `true` when the scan is finished, `false` for incremental
303    /// partial updates sent while the walk is still in progress.
304    QuickOpenFilesLoaded {
305        /// The working directory the files were enumerated under. Lets
306        /// the editor drop results that arrive after the user has
307        /// switched windows/projects (the cache is keyed by cwd).
308        cwd: String,
309        files: std::sync::Arc<Vec<crate::input::quick_open::providers::FileEntry>>,
310        complete: bool,
311    },
312
313    /// Startup-async: a single plugin directory finished loading on the
314    /// plugin thread. Carries the same payload as the blocking
315    /// `load_plugins_from_dir_with_config` return value.
316    PluginsDirLoaded {
317        dir: std::path::PathBuf,
318        errors: Vec<String>,
319        discovered_plugins: std::collections::HashMap<String, fresh_core::config::PluginConfig>,
320    },
321
322    /// Startup-async: every directory in the startup batch has loaded and
323    /// the resulting `.d.ts` declarations have been collected from the
324    /// plugin runtime. Triggers `init_script::write_plugin_declarations`.
325    PluginDeclarationsReady { declarations: Vec<(String, String)> },
326
327    /// Startup-async: `init.ts` (auto-loaded source plugin) finished
328    /// running its top level and has either succeeded, failed, or was
329    /// skipped/fused. The handler logs and applies the corresponding
330    /// status message, and (on `Loaded`) clears the crash fuse.
331    PluginInitScriptLoaded(PluginInitScriptOutcome),
332}
333
334/// Async equivalent of `init_script::InitOutcome`. Wraps the same set
335/// of states but is plain data so it can travel across the bridge.
336#[derive(Debug, Clone)]
337pub enum PluginInitScriptOutcome {
338    NotFound,
339    Disabled,
340    CrashFused { failures: u32 },
341    Loaded,
342    Failed { message: String },
343}
344
345/// Conservative bucketing of `notify::EventKind`. We don't expose
346/// the full notify enum to plugins because the kind set varies by
347/// platform and changes between notify releases. Plugins switch on
348/// these strings; refining requires a new variant + a new string
349/// (additive, no breakage).
350#[derive(Debug, Clone, Copy)]
351pub enum PathChangeKind {
352    Modify,
353    Create,
354    Delete,
355    Rename,
356    Other,
357}
358
359impl PathChangeKind {
360    pub fn as_str(&self) -> &'static str {
361        match self {
362            PathChangeKind::Modify => "modify",
363            PathChangeKind::Create => "create",
364            PathChangeKind::Delete => "delete",
365            PathChangeKind::Rename => "rename",
366            PathChangeKind::Other => "other",
367        }
368    }
369}
370
371/// LSP progress value types
372#[derive(Debug, Clone)]
373pub enum LspProgressValue {
374    Begin {
375        title: String,
376        message: Option<String>,
377        percentage: Option<u32>,
378    },
379    Report {
380        message: Option<String>,
381        percentage: Option<u32>,
382    },
383    End {
384        message: Option<String>,
385    },
386}
387
388/// LSP message type (corresponds to MessageType in LSP spec)
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub enum LspMessageType {
391    Error = 1,
392    Warning = 2,
393    Info = 3,
394    Log = 4,
395}
396
397/// LSP server status
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub enum LspServerStatus {
400    Starting,
401    Initializing,
402    Running,
403    Error,
404    Shutdown,
405}
406
407/// Bridge between async Tokio runtime and sync main loop
408///
409/// Design:
410/// - Lightweight, cloneable sender that can be passed to async tasks
411/// - Non-blocking receiver checked each frame in main loop
412/// - No locks needed in main loop (channel handles synchronization)
413#[derive(Clone)]
414pub struct AsyncBridge {
415    sender: mpsc::Sender<AsyncMessage>,
416    // Receiver wrapped in Arc<Mutex<>> to allow cloning
417    receiver: std::sync::Arc<std::sync::Mutex<mpsc::Receiver<AsyncMessage>>>,
418}
419
420impl AsyncBridge {
421    /// Create a new async bridge with an unbounded channel
422    ///
423    /// Unbounded is appropriate here because:
424    /// 1. Main loop processes messages every 16ms (60fps)
425    /// 2. LSP messages are infrequent (< 100/sec typically)
426    /// 3. Memory usage is bounded by message rate × frame time
427    pub fn new() -> Self {
428        let (sender, receiver) = mpsc::channel();
429        Self {
430            sender,
431            receiver: std::sync::Arc::new(std::sync::Mutex::new(receiver)),
432        }
433    }
434
435    /// Get a cloneable sender for async tasks
436    ///
437    /// This sender can be:
438    /// - Cloned freely (cheap Arc internally)
439    /// - Sent to async tasks
440    /// - Stored in LspClient instances
441    pub fn sender(&self) -> mpsc::Sender<AsyncMessage> {
442        self.sender.clone()
443    }
444
445    /// Try to receive pending messages (non-blocking)
446    ///
447    /// Called each frame in the main loop to process async messages.
448    /// Returns all pending messages without blocking.
449    pub fn try_recv_all(&self) -> Vec<AsyncMessage> {
450        let mut messages = Vec::new();
451
452        // Lock the receiver and drain all pending messages
453        if let Ok(receiver) = self.receiver.lock() {
454            while let Ok(msg) = receiver.try_recv() {
455                messages.push(msg);
456            }
457        }
458
459        messages
460    }
461
462    /// Check if there are pending messages (non-blocking)
463    pub fn has_messages(&self) -> bool {
464        // Note: This is racy but safe - only used for optimization
465        if let Ok(receiver) = self.receiver.lock() {
466            receiver.try_recv().is_ok()
467        } else {
468            false
469        }
470    }
471}
472
473impl Default for AsyncBridge {
474    fn default() -> Self {
475        Self::new()
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn test_async_bridge_send_receive() {
485        let bridge = AsyncBridge::new();
486        let sender = bridge.sender();
487
488        // Send a message
489        sender
490            .send(AsyncMessage::LspInitialized {
491                language: "rust".to_string(),
492                server_name: "test".to_string(),
493                capabilities: Default::default(),
494            })
495            .unwrap();
496
497        // Receive it
498        let messages = bridge.try_recv_all();
499        assert_eq!(messages.len(), 1);
500
501        match &messages[0] {
502            AsyncMessage::LspInitialized {
503                language,
504                server_name,
505                ..
506            } => {
507                assert_eq!(language, "rust");
508                assert_eq!(server_name, "test");
509            }
510            _ => panic!("Wrong message type"),
511        }
512    }
513
514    #[test]
515    fn test_async_bridge_multiple_messages() {
516        let bridge = AsyncBridge::new();
517        let sender = bridge.sender();
518
519        // Send multiple messages
520        sender
521            .send(AsyncMessage::LspInitialized {
522                language: "rust".to_string(),
523                server_name: "test".to_string(),
524                capabilities: Default::default(),
525            })
526            .unwrap();
527        sender
528            .send(AsyncMessage::LspInitialized {
529                language: "typescript".to_string(),
530                server_name: "test".to_string(),
531                capabilities: Default::default(),
532            })
533            .unwrap();
534
535        // Receive all at once
536        let messages = bridge.try_recv_all();
537        assert_eq!(messages.len(), 2);
538    }
539
540    #[test]
541    fn test_async_bridge_no_messages() {
542        let bridge = AsyncBridge::new();
543
544        // Try to receive with no messages
545        let messages = bridge.try_recv_all();
546        assert_eq!(messages.len(), 0);
547    }
548
549    #[test]
550    fn test_async_bridge_clone_sender() {
551        let bridge = AsyncBridge::new();
552        let sender1 = bridge.sender();
553        let sender2 = sender1.clone();
554
555        // Both senders work
556        sender1
557            .send(AsyncMessage::LspInitialized {
558                language: "rust".to_string(),
559                server_name: "test".to_string(),
560                capabilities: Default::default(),
561            })
562            .unwrap();
563        sender2
564            .send(AsyncMessage::LspInitialized {
565                language: "typescript".to_string(),
566                server_name: "test".to_string(),
567                capabilities: Default::default(),
568            })
569            .unwrap();
570
571        let messages = bridge.try_recv_all();
572        assert_eq!(messages.len(), 2);
573    }
574
575    #[test]
576    fn test_async_bridge_diagnostics() {
577        let bridge = AsyncBridge::new();
578        let sender = bridge.sender();
579
580        // Send diagnostic message
581        let diagnostics = vec![lsp_types::Diagnostic {
582            range: lsp_types::Range {
583                start: lsp_types::Position {
584                    line: 0,
585                    character: 0,
586                },
587                end: lsp_types::Position {
588                    line: 0,
589                    character: 5,
590                },
591            },
592            severity: Some(lsp_types::DiagnosticSeverity::ERROR),
593            code: None,
594            code_description: None,
595            source: Some("rust-analyzer".to_string()),
596            message: "test error".to_string(),
597            related_information: None,
598            tags: None,
599            data: None,
600        }];
601
602        sender
603            .send(AsyncMessage::LspDiagnostics {
604                uri: "file:///test.rs".to_string(),
605                diagnostics: diagnostics.clone(),
606                server_name: "rust-analyzer".to_string(),
607            })
608            .unwrap();
609
610        let messages = bridge.try_recv_all();
611        assert_eq!(messages.len(), 1);
612
613        match &messages[0] {
614            AsyncMessage::LspDiagnostics {
615                uri,
616                diagnostics: diags,
617                server_name,
618            } => {
619                assert_eq!(uri, "file:///test.rs");
620                assert_eq!(diags.len(), 1);
621                assert_eq!(diags[0].message, "test error");
622                assert_eq!(server_name, "rust-analyzer");
623            }
624            _ => panic!("Expected LspDiagnostics message"),
625        }
626    }
627
628    #[test]
629    fn test_async_bridge_error_message() {
630        let bridge = AsyncBridge::new();
631        let sender = bridge.sender();
632
633        sender
634            .send(AsyncMessage::LspError {
635                language: "rust".to_string(),
636                error: "Failed to initialize".to_string(),
637                stderr_log_path: None,
638            })
639            .unwrap();
640
641        let messages = bridge.try_recv_all();
642        assert_eq!(messages.len(), 1);
643
644        match &messages[0] {
645            AsyncMessage::LspError {
646                language,
647                error,
648                stderr_log_path,
649            } => {
650                assert_eq!(language, "rust");
651                assert_eq!(error, "Failed to initialize");
652                assert!(stderr_log_path.is_none());
653            }
654            _ => panic!("Expected LspError message"),
655        }
656    }
657
658    #[test]
659    fn test_async_bridge_clone_bridge() {
660        let bridge = AsyncBridge::new();
661        let bridge_clone = bridge.clone();
662        let sender = bridge.sender();
663
664        // Send via original bridge's sender
665        sender
666            .send(AsyncMessage::LspInitialized {
667                language: "rust".to_string(),
668                server_name: "test".to_string(),
669                capabilities: Default::default(),
670            })
671            .unwrap();
672
673        // Receive via cloned bridge
674        let messages = bridge_clone.try_recv_all();
675        assert_eq!(messages.len(), 1);
676    }
677
678    #[test]
679    fn test_async_bridge_multiple_calls_to_try_recv_all() {
680        let bridge = AsyncBridge::new();
681        let sender = bridge.sender();
682
683        sender
684            .send(AsyncMessage::LspInitialized {
685                language: "rust".to_string(),
686                server_name: "test".to_string(),
687                capabilities: Default::default(),
688            })
689            .unwrap();
690
691        // First call gets the message
692        let messages1 = bridge.try_recv_all();
693        assert_eq!(messages1.len(), 1);
694
695        // Second call gets nothing
696        let messages2 = bridge.try_recv_all();
697        assert_eq!(messages2.len(), 0);
698    }
699
700    #[test]
701    fn test_async_bridge_ordering() {
702        let bridge = AsyncBridge::new();
703        let sender = bridge.sender();
704
705        // Send messages in order
706        sender
707            .send(AsyncMessage::LspInitialized {
708                language: "rust".to_string(),
709                server_name: "test".to_string(),
710                capabilities: Default::default(),
711            })
712            .unwrap();
713        sender
714            .send(AsyncMessage::LspInitialized {
715                language: "typescript".to_string(),
716                server_name: "test".to_string(),
717                capabilities: Default::default(),
718            })
719            .unwrap();
720        sender
721            .send(AsyncMessage::LspInitialized {
722                language: "python".to_string(),
723                server_name: "test".to_string(),
724                capabilities: Default::default(),
725            })
726            .unwrap();
727
728        // Messages should be received in same order
729        let messages = bridge.try_recv_all();
730        assert_eq!(messages.len(), 3);
731
732        match (&messages[0], &messages[1], &messages[2]) {
733            (
734                AsyncMessage::LspInitialized { language: l1, .. },
735                AsyncMessage::LspInitialized { language: l2, .. },
736                AsyncMessage::LspInitialized { language: l3, .. },
737            ) => {
738                assert_eq!(l1, "rust");
739                assert_eq!(l2, "typescript");
740                assert_eq!(l3, "python");
741            }
742            _ => panic!("Expected ordered LspInitialized messages"),
743        }
744    }
745}