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::view::file_tree::{FileTreeView, NodeId};
14use lsp_types::{
15    CodeActionOrCommand, CompletionItem, Diagnostic, FoldingRange, InlayHint, Location,
16    SemanticTokensFullDeltaResult, SemanticTokensRangeResult, SemanticTokensResult, SignatureHelp,
17};
18use serde_json::Value;
19use std::sync::mpsc;
20
21/// Semantic token responses grouped by request type.
22#[derive(Debug)]
23pub enum LspSemanticTokensResponse {
24    Full(Result<Option<SemanticTokensResult>, String>),
25    FullDelta(Result<Option<SemanticTokensFullDeltaResult>, String>),
26    Range(Result<Option<SemanticTokensRangeResult>, String>),
27}
28
29/// How a completed remote attach is installed.
30pub enum RemoteAttachMode {
31    /// Global: replace the editor's single authority and restart the whole
32    /// editor around the remote backend (the original `setAuthority`-style
33    /// destructive transition). Every window becomes remote.
34    Restart,
35    /// Born-attached: spawn a *new window* whose authority is the remote
36    /// backend, leaving existing (local / other-remote) windows untouched.
37    /// The session coexists warm beside them; switching windows retargets the
38    /// active authority (see `set_active_window` / Gap A). `command` is the
39    /// optional agent argv for the window's seed terminal.
40    Window {
41        label: String,
42        command: Option<Vec<String>>,
43    },
44    /// Reconnect an **existing dormant** session: a remote session restored
45    /// from disk (its backend spec known, but its live authority still the
46    /// local placeholder) whose user just switched to it. Re-point *that
47    /// window's* authority at the freshly-connected backend and park the
48    /// keepalive — no new window, no editor restart.
49    Reconnect { window_id: fresh_core::WindowId },
50}
51
52/// A completed remote-agent attach: the assembled authority plus the
53/// keepalive that must outlive it. Carried back from the async connect
54/// task to the main loop, which installs it per `mode`. Manual `Debug`
55/// because the fields are not `Debug`.
56pub struct RemoteAttachReady {
57    pub authority: crate::services::authority::Authority,
58    pub keepalive: Box<dyn std::any::Any + Send>,
59    /// Pod-side root to re-open the editor at (the remote workspace, e.g.
60    /// `/workspace`). Without this the editor keeps the *local* working
61    /// directory after attach, so the explorer / quick-open / open-file all
62    /// look at a host path that doesn't exist in the pod. `None` falls back to
63    /// the remote home directory.
64    pub working_dir: Option<std::path::PathBuf>,
65    /// Restart (global) vs. born-attached new window.
66    pub mode: RemoteAttachMode,
67    /// Declarative spec for *how to reconnect* this backend — stored on the
68    /// session so a restart / relaunch can bring it back (dormant) and
69    /// reconnect it, rather than degrading it to local.
70    pub spec: crate::services::authority::SessionAuthoritySpec,
71    /// JS callback id of the `attachRemoteAgent` promise to settle once the
72    /// session (authority + window) is fully constructed. The main loop
73    /// resolves it on success and rejects it if window creation fails, so the
74    /// plugin's dialog only closes when there is a real session to show.
75    pub request_id: u64,
76}
77
78impl std::fmt::Debug for RemoteAttachReady {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        f.debug_struct("RemoteAttachReady")
81            .field("label", &self.authority.display_label)
82            .finish_non_exhaustive()
83    }
84}
85
86/// Messages sent from async tasks to the synchronous main loop
87#[derive(Debug)]
88pub enum AsyncMessage {
89    /// An async `attachRemoteAgent` connect succeeded — install the
90    /// authority + keepalive and restart.
91    RemoteAttachReady(RemoteAttachReady),
92
93    /// A remote agent channel's transport was silently hot-swapped back in by
94    /// the background reconnect task (`spawn_reconnect_task`). Carries the
95    /// channel's stable id (`AgentChannel::id`); the editor maps it to the
96    /// owning window and reattaches — respawning the embedded terminals that
97    /// died with the dropped carrier. This is the event-driven counterpart to
98    /// the app-level `RemoteAttachMode::Reconnect` rebuild path.
99    RemoteReconnected { connection_id: u64 },
100
101    /// An async `attachRemoteAgent` connect failed — reject the plugin's
102    /// promise with `error` (the plugin shows it and creates no window); the
103    /// editor stays on its current authority. `reconnect_window` is `Some(id)`
104    /// only when the failed connect was a *dive-triggered reconnect* of an
105    /// existing dormant session (`RemoteAttachMode::Reconnect`); the handler
106    /// records the error on that window so the status-bar remote indicator can
107    /// show `FailedAttach` for it. `None` for born-attached / restart attaches,
108    /// whose failure the launching plugin surfaces via the rejected promise.
109    RemoteAttachFailed {
110        error: String,
111        request_id: u64,
112        reconnect_window: Option<fresh_core::WindowId>,
113    },
114
115    /// LSP diagnostics received for a file
116    LspDiagnostics {
117        uri: String,
118        diagnostics: Vec<Diagnostic>,
119        /// Name of the server that sent these diagnostics (for per-server tracking)
120        server_name: String,
121    },
122
123    /// LSP server initialized successfully
124    LspInitialized {
125        language: String,
126        /// Name of the specific server (for per-server capability tracking)
127        server_name: String,
128        /// Capabilities reported by this server
129        capabilities: crate::services::lsp::manager::ServerCapabilitySummary,
130    },
131
132    /// LSP server crashed or failed
133    LspError {
134        language: String,
135        error: String,
136        /// Path to the stderr log file for this LSP session
137        stderr_log_path: Option<std::path::PathBuf>,
138    },
139
140    /// LSP completion response
141    LspCompletion {
142        request_id: u64,
143        items: Vec<CompletionItem>,
144    },
145
146    /// LSP go-to-definition response
147    LspGotoDefinition {
148        request_id: u64,
149        locations: Vec<Location>,
150    },
151
152    /// LSP go-to-implementation response
153    LspImplementation {
154        request_id: u64,
155        locations: Vec<Location>,
156    },
157
158    /// LSP rename response
159    LspRename {
160        request_id: u64,
161        result: Result<lsp_types::WorkspaceEdit, String>,
162    },
163
164    /// LSP hover response
165    LspHover {
166        request_id: u64,
167        /// Hover contents as a single string (joined if multiple parts)
168        contents: String,
169        /// Whether the content is markdown (true) or plaintext (false)
170        is_markdown: bool,
171        /// Optional range of the symbol that was hovered over (LSP line/character positions)
172        /// Used to highlight the hovered symbol
173        range: Option<((u32, u32), (u32, u32))>,
174    },
175
176    /// LSP find references response
177    LspReferences {
178        request_id: u64,
179        locations: Vec<Location>,
180    },
181
182    /// LSP signature help response
183    LspSignatureHelp {
184        request_id: u64,
185        signature_help: Option<SignatureHelp>,
186    },
187
188    /// LSP code actions response
189    LspCodeActions {
190        request_id: u64,
191        actions: Vec<CodeActionOrCommand>,
192    },
193
194    /// LSP completionItem/resolve response
195    LspCompletionResolved {
196        request_id: u64,
197        item: Result<lsp_types::CompletionItem, String>,
198    },
199
200    /// LSP textDocument/formatting response
201    LspFormatting {
202        request_id: u64,
203        uri: String,
204        edits: Vec<lsp_types::TextEdit>,
205    },
206
207    /// LSP textDocument/prepareRename response
208    LspPrepareRename {
209        request_id: u64,
210        result: Result<serde_json::Value, String>,
211    },
212
213    /// LSP pulled diagnostics response (textDocument/diagnostic)
214    LspPulledDiagnostics {
215        request_id: u64,
216        uri: String,
217        /// New result_id for incremental updates (None if server doesn't support)
218        result_id: Option<String>,
219        /// Diagnostics (empty if unchanged)
220        diagnostics: Vec<Diagnostic>,
221        /// True if diagnostics haven't changed since previous_result_id
222        unchanged: bool,
223    },
224
225    /// LSP inlay hints response (textDocument/inlayHint)
226    LspInlayHints {
227        request_id: u64,
228        uri: String,
229        /// Inlay hints for the requested range
230        hints: Vec<InlayHint>,
231    },
232
233    /// LSP folding ranges response (textDocument/foldingRange)
234    LspFoldingRanges {
235        request_id: u64,
236        uri: String,
237        ranges: Vec<FoldingRange>,
238    },
239
240    /// LSP semantic tokens response (full, full/delta, or range)
241    LspSemanticTokens {
242        request_id: u64,
243        uri: String,
244        response: LspSemanticTokensResponse,
245    },
246
247    /// LSP server status became quiescent (project fully loaded)
248    /// This is a rust-analyzer specific notification (experimental/serverStatus)
249    LspServerQuiescent { language: String },
250
251    /// LSP server requests diagnostic refresh (workspace/diagnostic/refresh)
252    /// Client should re-pull diagnostics for all open documents
253    LspDiagnosticRefresh { language: String },
254
255    /// LSP server requests an inlay-hint refresh (workspace/inlayHint/refresh).
256    /// Client should re-pull inlay hints for all open documents — used when the
257    /// server learns more later (e.g. a change in file A alters inferred types
258    /// in file B, which the user never edited so was never otherwise re-pulled).
259    LspInlayHintRefresh { language: String },
260
261    /// LSP server requests a semantic-tokens refresh
262    /// (workspace/semanticTokens/refresh). Client should re-pull semantic
263    /// tokens for all open documents.
264    LspSemanticTokensRefresh { language: String },
265
266    /// LSP server registered (`client/registerCapability`) or unregistered
267    /// (`client/unregisterCapability`) one or more capabilities dynamically.
268    /// Many servers advertise little or nothing statically in their
269    /// `initialize` result and instead register providers afterwards, so these
270    /// must update the stored `ServerCapabilities` or the features stay gated
271    /// off for the whole session. `register == false` means unregister.
272    /// Each entry is `(method, register_options)`.
273    LspDynamicCapabilities {
274        language: String,
275        server_name: String,
276        register: bool,
277        registrations: Vec<(String, Option<Value>)>,
278    },
279
280    /// File changed externally (future: file watching)
281    FileChanged { path: String },
282
283    /// Git status updated (future: git integration)
284    GitStatusChanged { status: String },
285
286    /// File explorer initialized with tree view. Carries the id of the window
287    /// that requested it: a background preview/materialize can init a
288    /// *non-active* window's explorer, so the view must land on that window —
289    /// applying it to whatever is active would clobber an unrelated explorer.
290    FileExplorerInitialized {
291        window: fresh_core::WindowId,
292        view: FileTreeView,
293    },
294
295    /// File explorer node toggle completed
296    FileExplorerToggleNode(NodeId),
297
298    /// File explorer node refresh completed
299    FileExplorerRefreshNode(NodeId),
300
301    /// File explorer expand to path completed. Carries the requesting window id
302    /// (see `FileExplorerInitialized`) so the expanded view returns to its own
303    /// window rather than the active one.
304    FileExplorerExpandedToPath {
305        window: fresh_core::WindowId,
306        view: FileTreeView,
307    },
308
309    /// Plugin-related async messages
310    Plugin(fresh_core::api::PluginAsyncMessage),
311
312    /// File open dialog: directory listing completed
313    FileOpenDirectoryLoaded(std::io::Result<Vec<crate::services::fs::DirEntry>>),
314
315    /// File open dialog: async shortcuts (Windows drive letters) loaded
316    FileOpenShortcutsLoaded(Vec<crate::app::file_open::NavigationShortcut>),
317
318    /// Terminal output received (triggers redraw). Tagged with the
319    /// owning window: terminal ids are only unique within a window, so a
320    /// bare id can't be attributed to a session without guessing.
321    TerminalOutput {
322        terminal: fresh_core::WindowTerminalId,
323    },
324
325    /// Result of an asynchronous system-clipboard read. The main loop
326    /// blocks input dispatch while a paste is in flight; the matching
327    /// `request_id` ensures a late result that arrived after the
328    /// timeout fallback fired is discarded as stale. `text` is `None`
329    /// when the read errored, returned empty, or was cancelled by the
330    /// deadline.
331    ClipboardPasteResult {
332        request_id: u64,
333        text: Option<String>,
334    },
335
336    /// File watcher delivered an event for a path under a
337    /// `WatchPath`-registered watcher. Routed to the
338    /// `path_changed` plugin hook by the main loop.
339    PathChanged {
340        /// Watch handle the event came from (matches the value
341        /// returned by `WatchPath`).
342        handle: u64,
343        path: std::path::PathBuf,
344        /// Conservative bucketing of `notify::EventKind`.
345        kind: PathChangeKind,
346    },
347
348    /// Terminal process exited.
349    ///
350    /// `exit_code` is `None` when the editor cannot determine a status
351    /// (the wait happens in a separate thread, signal exits, kill
352    /// before wait, etc.). Populated end-to-end is a follow-up; the
353    /// initial wiring sends `None` so plugin handlers see the variant
354    /// shape that matches `HookArgs::TerminalExited`.
355    TerminalExited {
356        terminal: fresh_core::WindowTerminalId,
357        exit_code: Option<i32>,
358    },
359
360    /// LSP progress notification ($/progress)
361    LspProgress {
362        language: String,
363        token: String,
364        value: LspProgressValue,
365    },
366
367    /// LSP window message (window/showMessage)
368    LspWindowMessage {
369        language: String,
370        message_type: LspMessageType,
371        message: String,
372    },
373
374    /// LSP log message (window/logMessage)
375    LspLogMessage {
376        language: String,
377        message_type: LspMessageType,
378        message: String,
379    },
380
381    /// LSP workspace/applyEdit (server -> client request)
382    /// Server asks client to apply a workspace edit (during executeCommand, etc.)
383    LspApplyEdit {
384        edit: lsp_types::WorkspaceEdit,
385        label: Option<String>,
386    },
387
388    /// LSP codeAction/resolve response
389    LspCodeActionResolved {
390        request_id: u64,
391        action: Result<lsp_types::CodeAction, String>,
392    },
393
394    /// LSP server request (server -> client)
395    /// Used for custom/extension methods that plugins can handle
396    LspServerRequest {
397        language: String,
398        server_command: String,
399        method: String,
400        params: Option<Value>,
401    },
402
403    /// Response for a plugin-initiated LSP request
404    PluginLspResponse {
405        language: String,
406        request_id: u64,
407        result: Result<Value, String>,
408    },
409
410    /// Plugin process completed with output
411    PluginProcessOutput {
412        /// Unique ID for this process (to match with callback)
413        process_id: u64,
414        /// Standard output
415        stdout: String,
416        /// Standard error
417        stderr: String,
418        /// Exit code
419        exit_code: i32,
420    },
421
422    /// LSP server status update (progress, messages, etc.)
423    LspStatusUpdate {
424        language: String,
425        /// Name of the specific server (for multi-server status tracking)
426        server_name: String,
427        status: LspServerStatus,
428        message: Option<String>,
429    },
430
431    /// Background grammar build completed — swap in the new registry.
432    /// `callback_ids` contains plugin callbacks to resolve (empty for the
433    /// initial startup build).
434    GrammarRegistryBuilt {
435        registry: std::sync::Arc<crate::primitives::grammar::GrammarRegistry>,
436        callback_ids: Vec<fresh_core::api::JsCallbackId>,
437    },
438
439    /// Quick Open file list loaded by a background task.
440    /// `complete` is `true` when the scan is finished, `false` for incremental
441    /// partial updates sent while the walk is still in progress.
442    QuickOpenFilesLoaded {
443        /// The working directory the files were enumerated under. Lets
444        /// the editor drop results that arrive after the user has
445        /// switched windows/projects (the cache is keyed by cwd).
446        cwd: String,
447        files: std::sync::Arc<Vec<crate::input::quick_open::providers::FileEntry>>,
448        complete: bool,
449    },
450
451    /// Startup-async: a single plugin directory finished loading on the
452    /// plugin thread. Carries the same payload as the blocking
453    /// `load_plugins_from_dir_with_config` return value.
454    PluginsDirLoaded {
455        dir: std::path::PathBuf,
456        errors: Vec<String>,
457        discovered_plugins: std::collections::HashMap<String, fresh_core::config::PluginConfig>,
458    },
459
460    /// Startup-async: every directory in the startup batch has loaded and
461    /// the resulting `.d.ts` declarations have been collected from the
462    /// plugin runtime. Triggers `init_script::write_plugin_declarations`.
463    PluginDeclarationsReady { declarations: Vec<(String, String)> },
464
465    /// Startup-async: `init.ts` (auto-loaded source plugin) finished
466    /// running its top level and has either succeeded, failed, or was
467    /// skipped/fused. The handler logs and applies the corresponding
468    /// status message, and (on `Loaded`) clears the crash fuse.
469    PluginInitScriptLoaded(PluginInitScriptOutcome),
470}
471
472/// Async equivalent of `init_script::InitOutcome`. Wraps the same set
473/// of states but is plain data so it can travel across the bridge.
474#[derive(Debug, Clone)]
475pub enum PluginInitScriptOutcome {
476    NotFound,
477    Disabled,
478    CrashFused { failures: u32 },
479    Loaded,
480    Failed { message: String },
481}
482
483/// Conservative bucketing of `notify::EventKind`. We don't expose
484/// the full notify enum to plugins because the kind set varies by
485/// platform and changes between notify releases. Plugins switch on
486/// these strings; refining requires a new variant + a new string
487/// (additive, no breakage).
488#[derive(Debug, Clone, Copy)]
489pub enum PathChangeKind {
490    Modify,
491    Create,
492    Delete,
493    Rename,
494    Other,
495}
496
497impl PathChangeKind {
498    pub fn as_str(&self) -> &'static str {
499        match self {
500            PathChangeKind::Modify => "modify",
501            PathChangeKind::Create => "create",
502            PathChangeKind::Delete => "delete",
503            PathChangeKind::Rename => "rename",
504            PathChangeKind::Other => "other",
505        }
506    }
507}
508
509/// LSP progress value types
510#[derive(Debug, Clone)]
511pub enum LspProgressValue {
512    Begin {
513        title: String,
514        message: Option<String>,
515        percentage: Option<u32>,
516    },
517    Report {
518        message: Option<String>,
519        percentage: Option<u32>,
520    },
521    End {
522        message: Option<String>,
523    },
524}
525
526/// LSP message type (corresponds to MessageType in LSP spec)
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub enum LspMessageType {
529    Error = 1,
530    Warning = 2,
531    Info = 3,
532    Log = 4,
533}
534
535/// LSP server status
536#[derive(Debug, Clone, Copy, PartialEq, Eq)]
537pub enum LspServerStatus {
538    Starting,
539    Initializing,
540    Running,
541    Error,
542    Shutdown,
543}
544
545/// Bridge between async Tokio runtime and sync main loop
546///
547/// Design:
548/// - Lightweight, cloneable sender that can be passed to async tasks
549/// - Non-blocking receiver checked each frame in main loop
550/// - No locks needed in main loop (channel handles synchronization)
551#[derive(Clone)]
552pub struct AsyncBridge {
553    sender: mpsc::Sender<AsyncMessage>,
554    // Receiver wrapped in Arc<Mutex<>> to allow cloning
555    receiver: std::sync::Arc<std::sync::Mutex<mpsc::Receiver<AsyncMessage>>>,
556}
557
558impl AsyncBridge {
559    /// Create a new async bridge with an unbounded channel
560    ///
561    /// Unbounded is appropriate here because:
562    /// 1. Main loop processes messages every 16ms (60fps)
563    /// 2. LSP messages are infrequent (< 100/sec typically)
564    /// 3. Memory usage is bounded by message rate × frame time
565    pub fn new() -> Self {
566        let (sender, receiver) = mpsc::channel();
567        Self {
568            sender,
569            receiver: std::sync::Arc::new(std::sync::Mutex::new(receiver)),
570        }
571    }
572
573    /// Get a cloneable sender for async tasks
574    ///
575    /// This sender can be:
576    /// - Cloned freely (cheap Arc internally)
577    /// - Sent to async tasks
578    /// - Stored in LspClient instances
579    pub fn sender(&self) -> mpsc::Sender<AsyncMessage> {
580        self.sender.clone()
581    }
582
583    /// Try to receive pending messages (non-blocking)
584    ///
585    /// Called each frame in the main loop to process async messages.
586    /// Returns all pending messages without blocking.
587    pub fn try_recv_all(&self) -> Vec<AsyncMessage> {
588        let mut messages = Vec::new();
589
590        // Lock the receiver and drain all pending messages
591        if let Ok(receiver) = self.receiver.lock() {
592            while let Ok(msg) = receiver.try_recv() {
593                messages.push(msg);
594            }
595        }
596
597        messages
598    }
599
600    /// Check if there are pending messages (non-blocking)
601    pub fn has_messages(&self) -> bool {
602        // Note: This is racy but safe - only used for optimization
603        if let Ok(receiver) = self.receiver.lock() {
604            receiver.try_recv().is_ok()
605        } else {
606            false
607        }
608    }
609}
610
611impl Default for AsyncBridge {
612    fn default() -> Self {
613        Self::new()
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn test_async_bridge_send_receive() {
623        let bridge = AsyncBridge::new();
624        let sender = bridge.sender();
625
626        // Send a message
627        sender
628            .send(AsyncMessage::LspInitialized {
629                language: "rust".to_string(),
630                server_name: "test".to_string(),
631                capabilities: Default::default(),
632            })
633            .unwrap();
634
635        // Receive it
636        let messages = bridge.try_recv_all();
637        assert_eq!(messages.len(), 1);
638
639        match &messages[0] {
640            AsyncMessage::LspInitialized {
641                language,
642                server_name,
643                ..
644            } => {
645                assert_eq!(language, "rust");
646                assert_eq!(server_name, "test");
647            }
648            _ => panic!("Wrong message type"),
649        }
650    }
651
652    #[test]
653    fn test_async_bridge_multiple_messages() {
654        let bridge = AsyncBridge::new();
655        let sender = bridge.sender();
656
657        // Send multiple messages
658        sender
659            .send(AsyncMessage::LspInitialized {
660                language: "rust".to_string(),
661                server_name: "test".to_string(),
662                capabilities: Default::default(),
663            })
664            .unwrap();
665        sender
666            .send(AsyncMessage::LspInitialized {
667                language: "typescript".to_string(),
668                server_name: "test".to_string(),
669                capabilities: Default::default(),
670            })
671            .unwrap();
672
673        // Receive all at once
674        let messages = bridge.try_recv_all();
675        assert_eq!(messages.len(), 2);
676    }
677
678    #[test]
679    fn test_async_bridge_no_messages() {
680        let bridge = AsyncBridge::new();
681
682        // Try to receive with no messages
683        let messages = bridge.try_recv_all();
684        assert_eq!(messages.len(), 0);
685    }
686
687    #[test]
688    fn test_async_bridge_clone_sender() {
689        let bridge = AsyncBridge::new();
690        let sender1 = bridge.sender();
691        let sender2 = sender1.clone();
692
693        // Both senders work
694        sender1
695            .send(AsyncMessage::LspInitialized {
696                language: "rust".to_string(),
697                server_name: "test".to_string(),
698                capabilities: Default::default(),
699            })
700            .unwrap();
701        sender2
702            .send(AsyncMessage::LspInitialized {
703                language: "typescript".to_string(),
704                server_name: "test".to_string(),
705                capabilities: Default::default(),
706            })
707            .unwrap();
708
709        let messages = bridge.try_recv_all();
710        assert_eq!(messages.len(), 2);
711    }
712
713    #[test]
714    fn test_async_bridge_diagnostics() {
715        let bridge = AsyncBridge::new();
716        let sender = bridge.sender();
717
718        // Send diagnostic message
719        let diagnostics = vec![lsp_types::Diagnostic {
720            range: lsp_types::Range {
721                start: lsp_types::Position {
722                    line: 0,
723                    character: 0,
724                },
725                end: lsp_types::Position {
726                    line: 0,
727                    character: 5,
728                },
729            },
730            severity: Some(lsp_types::DiagnosticSeverity::ERROR),
731            code: None,
732            code_description: None,
733            source: Some("rust-analyzer".to_string()),
734            message: "test error".to_string(),
735            related_information: None,
736            tags: None,
737            data: None,
738        }];
739
740        sender
741            .send(AsyncMessage::LspDiagnostics {
742                uri: "file:///test.rs".to_string(),
743                diagnostics: diagnostics.clone(),
744                server_name: "rust-analyzer".to_string(),
745            })
746            .unwrap();
747
748        let messages = bridge.try_recv_all();
749        assert_eq!(messages.len(), 1);
750
751        match &messages[0] {
752            AsyncMessage::LspDiagnostics {
753                uri,
754                diagnostics: diags,
755                server_name,
756            } => {
757                assert_eq!(uri, "file:///test.rs");
758                assert_eq!(diags.len(), 1);
759                assert_eq!(diags[0].message, "test error");
760                assert_eq!(server_name, "rust-analyzer");
761            }
762            _ => panic!("Expected LspDiagnostics message"),
763        }
764    }
765
766    #[test]
767    fn test_async_bridge_error_message() {
768        let bridge = AsyncBridge::new();
769        let sender = bridge.sender();
770
771        sender
772            .send(AsyncMessage::LspError {
773                language: "rust".to_string(),
774                error: "Failed to initialize".to_string(),
775                stderr_log_path: None,
776            })
777            .unwrap();
778
779        let messages = bridge.try_recv_all();
780        assert_eq!(messages.len(), 1);
781
782        match &messages[0] {
783            AsyncMessage::LspError {
784                language,
785                error,
786                stderr_log_path,
787            } => {
788                assert_eq!(language, "rust");
789                assert_eq!(error, "Failed to initialize");
790                assert!(stderr_log_path.is_none());
791            }
792            _ => panic!("Expected LspError message"),
793        }
794    }
795
796    #[test]
797    fn test_async_bridge_clone_bridge() {
798        let bridge = AsyncBridge::new();
799        let bridge_clone = bridge.clone();
800        let sender = bridge.sender();
801
802        // Send via original bridge's sender
803        sender
804            .send(AsyncMessage::LspInitialized {
805                language: "rust".to_string(),
806                server_name: "test".to_string(),
807                capabilities: Default::default(),
808            })
809            .unwrap();
810
811        // Receive via cloned bridge
812        let messages = bridge_clone.try_recv_all();
813        assert_eq!(messages.len(), 1);
814    }
815
816    #[test]
817    fn test_async_bridge_multiple_calls_to_try_recv_all() {
818        let bridge = AsyncBridge::new();
819        let sender = bridge.sender();
820
821        sender
822            .send(AsyncMessage::LspInitialized {
823                language: "rust".to_string(),
824                server_name: "test".to_string(),
825                capabilities: Default::default(),
826            })
827            .unwrap();
828
829        // First call gets the message
830        let messages1 = bridge.try_recv_all();
831        assert_eq!(messages1.len(), 1);
832
833        // Second call gets nothing
834        let messages2 = bridge.try_recv_all();
835        assert_eq!(messages2.len(), 0);
836    }
837
838    #[test]
839    fn test_async_bridge_ordering() {
840        let bridge = AsyncBridge::new();
841        let sender = bridge.sender();
842
843        // Send messages in order
844        sender
845            .send(AsyncMessage::LspInitialized {
846                language: "rust".to_string(),
847                server_name: "test".to_string(),
848                capabilities: Default::default(),
849            })
850            .unwrap();
851        sender
852            .send(AsyncMessage::LspInitialized {
853                language: "typescript".to_string(),
854                server_name: "test".to_string(),
855                capabilities: Default::default(),
856            })
857            .unwrap();
858        sender
859            .send(AsyncMessage::LspInitialized {
860                language: "python".to_string(),
861                server_name: "test".to_string(),
862                capabilities: Default::default(),
863            })
864            .unwrap();
865
866        // Messages should be received in same order
867        let messages = bridge.try_recv_all();
868        assert_eq!(messages.len(), 3);
869
870        match (&messages[0], &messages[1], &messages[2]) {
871            (
872                AsyncMessage::LspInitialized { language: l1, .. },
873                AsyncMessage::LspInitialized { language: l2, .. },
874                AsyncMessage::LspInitialized { language: l3, .. },
875            ) => {
876                assert_eq!(l1, "rust");
877                assert_eq!(l2, "typescript");
878                assert_eq!(l3, "python");
879            }
880            _ => panic!("Expected ordered LspInitialized messages"),
881        }
882    }
883}