Skip to main content

lattice_lsp/
apply_edit.rs

1//! Server-initiated `workspace/applyEdit` plumbing (Phase 4.3; BC.8d reshape).
2//!
3//! When a language server sends `workspace/applyEdit` (most
4//! commonly during `workspace/executeCommand` callbacks for
5//! code actions) the client must:
6//!
7//! 1. Apply the supplied [`lsp_types::WorkspaceEdit`] to the
8//!    affected buffers.
9//! 2. Reply with `ApplyWorkspaceEditResponse { applied,
10//!    failure_reason, failed_change }` so the server knows
11//!    whether to roll back its own state.
12//!
13//! Step 1 needs the editor's mutable buffer state (`&mut Editor`);
14//! step 2 must come back on the tokio actor's task. The bridge is
15//! a channel: the actor receives the request, packages it into
16//! [`InboundApplyEdit`] (with a oneshot for the response), and
17//! sends it through [`ApplyEditBus`]. The host drains the receiver
18//! each tick, applies the edit, and writes the [`ApplyEditOutcome`]
19//! back through the oneshot. The actor's spawned response-task
20//! reads the oneshot, builds the LSP `Response`, and ferries it to
21//! the wire.
22//!
23//! **BC.8d (2026-06-24): reshaped onto the generic inbound primitive.**
24//! `ApplyEditBus` is now a type alias for the generic
25//! [`InboundBus`](lattice_mode::inbound::InboundBus) built via
26//! [`make_inbound_raw`](lattice_mode::inbound::make_inbound_raw): its `send`
27//! **wakes the editor**, so a server-initiated edit is applied off-keystroke
28//! (was: no wake — it only landed on the next keypress). Unlike the
29//! configuration / show-document buses, this is the *host-drained* variant: the
30//! apply (`Editor::apply_inbound_workspace_edit`) is irreducibly `&mut Editor`
31//! and carries `lsp_types`, which cannot cross the [`Effect`] boundary into a
32//! mode-owned handler — so the host keeps the receiver
33//! (`Editor::pending_apply_edit_rx`) and drains it in `run_tick_pending`, while
34//! the bus contributes only the structural wake. This keeps the irreducible
35//! apply as documented host residue (the diff-lifecycle / multibuffer
36//! Effect-arm class) without introducing an internal-pump `Effect`. The
37//! real-outcome reply (`applied` reflects what actually landed) is preserved.
38//!
39//! [`Effect`]: lattice_grammar::effect::Effect
40
41use std::sync::Arc;
42
43use tokio::sync::oneshot;
44
45/// The bus the supervisor fans out to each actor -- the generic inbound
46/// primitive specialised to the apply-edit payload, built host-drained via
47/// [`make_inbound_raw`](lattice_mode::inbound::make_inbound_raw). `send` wakes
48/// the editor; the host owns the matching receiver and drains it. (Was the
49/// bespoke `ApplyEditBus` struct before BC.8d.)
50pub type ApplyEditBus = lattice_mode::inbound::InboundBus<InboundApplyEdit>;
51
52/// One server-initiated `workspace/applyEdit` request, ferried
53/// from the LSP actor's task to the host's drain. Carries the
54/// untyped LSP `WorkspaceEdit` (the host reuses its existing
55/// flatten + apply path) plus a oneshot the host fills with the
56/// outcome.
57#[derive(Debug)]
58pub struct InboundApplyEdit {
59    /// Server that sent the request. Used by the host's echo /
60    /// log so the user can tell which language server is
61    /// asking. Cheap to clone (`Arc<str>`).
62    pub server_id: Arc<str>,
63    /// Workspace root the originating actor was spawned against
64    /// (B'.2). Pairs with `server_id` to form the canonical
65    /// `(server_id, workspace)` instance key.
66    pub workspace: Arc<std::path::Path>,
67    /// Optional descriptive label the server attached to the
68    /// edit (e.g. `"organize imports"`). Spec field; we surface
69    /// it in the host's echo and the log entry.
70    pub label: Option<String>,
71    /// The edit to apply. The host's existing
72    /// `flatten_workspace_edit` path turns this into per-file
73    /// edit batches.
74    pub edit: lsp_types::WorkspaceEdit,
75    /// Oneshot the host fills after applying. The actor task
76    /// awaits this and converts the outcome into the LSP
77    /// `Response`.
78    pub response: oneshot::Sender<ApplyEditOutcome>,
79}
80
81/// Result the host reports back to the actor's response task.
82/// Mirrors `ApplyWorkspaceEditResponse` minus the `failed_change`
83/// index which the host doesn't track today (every per-file edit
84/// is a separate batch; partial-apply with a failure echoes a
85/// warning but doesn't roll back). Future atomic-rollback work
86/// fills `failed_change`.
87#[derive(Debug, Clone)]
88pub struct ApplyEditOutcome {
89    /// Whether ANY edit was applied. `false` when nothing
90    /// landed (parse error, no buffer matched the URI, etc.);
91    /// `true` for full success AND for partial success.
92    pub applied: bool,
93    /// Optional human-readable description -- spec lets the
94    /// client send this when `applied: false` and surfaces it
95    /// in the server's failure handling. We also send it on
96    /// partial success so server logs explain the situation.
97    pub failure_reason: Option<String>,
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    // BC.8d: the bespoke `ApplyEditBus::new()`/`dispatch()` round-trip + dropped-
105    // receiver tests are retired — the bus is now the generic `InboundBus`, whose
106    // send/wake/dropped-receiver behaviour is pinned in `lattice-mode`'s inbound
107    // tests (incl. `raw_send_wakes_and_receiver_gets_item`). The host-side apply
108    // + real-outcome reply stays exercised by `lattice-ui-tui`'s
109    // `drain_inbound_apply_edits_*` tests against `Editor::apply_inbound_workspace_edit`.
110
111    /// The outcome still round-trips through the request's oneshot (the shape
112    /// the actor's response task awaits).
113    #[tokio::test]
114    async fn outcome_response_round_trips_via_oneshot() {
115        let (resp_tx, resp_rx) = oneshot::channel();
116        let outcome = ApplyEditOutcome {
117            applied: true,
118            failure_reason: None,
119        };
120        resp_tx.send(outcome).expect("rx alive");
121        let got = resp_rx.await.expect("oneshot completes");
122        assert!(got.applied);
123        assert!(got.failure_reason.is_none());
124    }
125}