Skip to main content

lattice_lsp/
show_document.rs

1//! Server-initiated `window/showDocument` plumbing (4.4.b; BC.8c reshape).
2//!
3//! Spec (LSP §3.16): the server asks the client to open a URI.
4//! The URI can be:
5//!
6//! - A `file://` URI -- the editor opens it in a buffer.
7//! - An `http://` / `https://` URI -- the editor delegates to
8//!   the OS browser when `external == true`.
9//! - Any other scheme -- best-effort; a server that asks the
10//!   client to open a non-file, non-web URI without `external`
11//!   gets `success: false`.
12//!
13//! Optional fields:
14//!
15//! - `external: bool` -- prefer the OS handler over an in-buffer
16//!   open. Servers usually set this for `http*` URIs.
17//! - `take_focus: bool` -- give the new buffer / window focus.
18//! - `selection: Range` -- after opening, place the cursor.
19//!
20//! **BC.8c (2026-06-24): reshaped onto the generic inbound primitive.**
21//! The bespoke `ShowDocumentBus` (an mpsc sender with no wake, drained by a
22//! host `Editor::drain_inbound_show_documents` method that itself ran
23//! `do_edit`) is gone. The supervisor now holds an
24//! `InboundBus<InboundShowDocument>` ([`lattice_mode::inbound`]) whose `send`
25//! **wakes the editor** so a server-initiated request is answered
26//! off-keystroke, and whose per-tick drain runs the **mode-owned**
27//! [`make_handler`] below.
28//!
29//! Unlike the configuration handler (a pure read), this handler maps each
30//! request to a host-applied open [`Effect`] and resolves the oneshot
31//! optimistically (`success: true` once the request maps to a valid open;
32//! `false` on a non-file / malformed URI). The open effects
33//! ([`Effect::OpenExternalUri`], [`Effect::OpenBufferAtColumn`]) are
34//! **host-applied** in `Editor::handle_effect` -- they MUST run host-side
35//! because this bus drains off-keystroke through the generic inbound
36//! tick-callback, where peer-applied effects (`OpenBuffer` / `OpenBufferAt`)
37//! are not forwarded. The Effect-boundary layering holds: the handler emits
38//! generic effects + resolves its own oneshot; no `lsp_types` crosses into
39//! `lattice-grammar`.
40
41use std::sync::Arc;
42
43use lattice_grammar::Utf16Pos;
44use lattice_grammar::effect::Effect;
45use tokio::sync::oneshot;
46
47use crate::logging::{InstanceKey, LogLevel, LogSource, LspLogger};
48
49/// The bus the supervisor fans out to each actor -- the generic inbound
50/// primitive specialised to the show-document payload. `send` wakes the
51/// editor; the per-tick drain runs [`make_handler`]. (Was the bespoke
52/// `ShowDocumentBus` struct before BC.8c.)
53pub type ShowDocumentBus = lattice_mode::inbound::InboundBus<InboundShowDocument>;
54
55/// One server-initiated `window/showDocument` request, ferried
56/// from the LSP actor to the editor's per-tick drain.
57#[derive(Debug)]
58pub struct InboundShowDocument {
59    /// Server that sent the request. Used by the handler'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 so the handler's log
66    /// routes the show-document trail to the correct
67    /// `*lsp:<server>:<workspace>*` ring.
68    pub workspace: Arc<std::path::Path>,
69    /// URI to open. The handler inspects the scheme to decide
70    /// between in-editor open vs. external-handler delegation.
71    pub uri: lsp_types::Uri,
72    /// True iff the server prefers an external handler (OS
73    /// browser / shell). Spec defaults to false; we keep the
74    /// server's wire value.
75    pub external: bool,
76    /// True iff the new buffer / external window should take
77    /// focus. Single-window today, so this is recorded but not
78    /// yet acted on (parity with the retired drain).
79    pub take_focus: bool,
80    /// Optional selection range to place after opening (LSP
81    /// positions; the host converts the UTF-16 column to a byte
82    /// offset against the opened line).
83    pub selection: Option<lsp_types::Range>,
84    /// Oneshot the handler fills after mapping the open. The
85    /// actor task awaits this and converts the outcome into the
86    /// LSP `Response`.
87    pub response: oneshot::Sender<ShowDocumentOutcome>,
88}
89
90/// Result the handler reports back to the actor's response task.
91/// Mirrors `ShowDocumentResult`.
92#[derive(Debug, Clone)]
93pub struct ShowDocumentOutcome {
94    pub success: bool,
95}
96
97/// BC.8c: the mode-owned handler for server-initiated
98/// `window/showDocument`, registered via `boot.inbound::<…>()`.
99///
100/// Maps each request to zero-or-one host-applied open [`Effect`] and resolves
101/// its oneshot (optimistic-ack):
102///
103/// - `external` → [`Effect::OpenExternalUri`], `success: true` (the host arm
104///   spawns the OS handler; the spawn result can't be awaited here, so the
105///   ack is optimistic). The trail is recorded on the per-instance ring.
106/// - non-`file://` without `external` → log + `success: false`, no effect.
107/// - `file://` → [`Effect::OpenBufferAtColumn`] (`column = Some` iff a
108///   selection was given), `success: true`.
109/// - malformed `file://` → log + `success: false`, no effect.
110///
111/// `logger` is captured so the reject / external-trail logs route to the
112/// correct `(server_id, workspace)` ring (mode-owned: the LSP logger + the
113/// instance key both live in `lattice-lsp`).
114pub fn make_handler(
115    logger: LspLogger,
116) -> impl FnMut(InboundShowDocument) -> Vec<Effect> + Send + 'static {
117    move |req| {
118        let instance = InstanceKey::new(Arc::clone(&req.server_id), Arc::clone(&req.workspace));
119        let uri_str = req.uri.as_str().to_string();
120        let (effect, success) = if req.external {
121            // Optimistic ack: we dispatch the OS-handler open and report
122            // success; the host arm spawns + logs any failure (it can't be
123            // awaited here). Record the trail on the per-instance ring.
124            logger.log(
125                Some(&instance),
126                LogLevel::Info,
127                LogSource::Client,
128                format!("showDocument(external): {uri_str}"),
129            );
130            (Some(Effect::OpenExternalUri { uri: uri_str }), true)
131        } else if !uri_str.starts_with("file://") {
132            logger.log(
133                Some(&instance),
134                LogLevel::Warn,
135                LogSource::Client,
136                format!("showDocument: refusing non-file URI {uri_str:?} without `external`"),
137            );
138            (None, false)
139        } else if let Some(path) = crate::actor::uri_to_path(&req.uri) {
140            // `take_focus` is recorded but a no-op today (single window),
141            // matching the retired host drain.
142            let _take_focus = req.take_focus;
143            // The selection's UTF-16 column travels unconverted; the host
144            // resolves it to a byte offset against the opened line.
145            let column = req.selection.map(|range| Utf16Pos {
146                line: range.start.line,
147                col: range.start.character,
148            });
149            (
150                Some(Effect::OpenBufferAtColumn {
151                    path: Some(path),
152                    column,
153                    force: false,
154                }),
155                true,
156            )
157        } else {
158            logger.log(
159                Some(&instance),
160                LogLevel::Warn,
161                LogSource::Client,
162                format!("showDocument: malformed file URI {uri_str:?}"),
163            );
164            (None, false)
165        };
166        // A dropped response receiver (server gone) is fine — log-and-skip.
167        let _ = req.response.send(ShowDocumentOutcome { success });
168        effect.into_iter().collect()
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use lattice_mode::inbound::make_inbound;
176    use std::str::FromStr;
177    use tokio::sync::Notify;
178
179    fn req(
180        uri: &str,
181        external: bool,
182        selection: Option<lsp_types::Range>,
183        response: oneshot::Sender<ShowDocumentOutcome>,
184    ) -> InboundShowDocument {
185        InboundShowDocument {
186            server_id: Arc::from("rust"),
187            workspace: Arc::<std::path::Path>::from(std::path::Path::new("/tmp")),
188            uri: lsp_types::Uri::from_str(uri).expect("valid uri"),
189            external,
190            take_focus: false,
191            selection,
192            response,
193        }
194    }
195
196    /// A `file://` URI without a selection maps to a host-applied
197    /// `OpenBufferAtColumn { column: None }` (open only) + replies success.
198    #[test]
199    fn file_uri_no_selection_opens_and_acks() {
200        let mut handler = make_handler(LspLogger::with_defaults());
201        let (tx, mut rx) = oneshot::channel();
202        let effects = handler(req("file:///tmp/x.rs", false, None, tx));
203        assert_eq!(effects.len(), 1);
204        match &effects[0] {
205            Effect::OpenBufferAtColumn {
206                path,
207                column,
208                force,
209            } => {
210                assert_eq!(path.as_deref(), Some(std::path::Path::new("/tmp/x.rs")));
211                assert!(column.is_none(), "no selection → open only, no cursor move");
212                assert!(!force);
213            }
214            other => panic!("expected OpenBufferAtColumn, got {other:?}"),
215        }
216        assert!(rx.try_recv().expect("reply landed").success);
217    }
218
219    /// A `file://` URI with a selection carries the UTF-16 column unconverted
220    /// for the host to resolve post-open.
221    #[test]
222    fn file_uri_with_selection_carries_utf16_column() {
223        let mut handler = make_handler(LspLogger::with_defaults());
224        let (tx, mut rx) = oneshot::channel();
225        let sel = lsp_types::Range {
226            start: lsp_types::Position {
227                line: 3,
228                character: 9,
229            },
230            end: lsp_types::Position {
231                line: 3,
232                character: 9,
233            },
234        };
235        let effects = handler(req("file:///tmp/x.rs", false, Some(sel), tx));
236        match &effects[0] {
237            Effect::OpenBufferAtColumn {
238                column: Some(Utf16Pos { line, col }),
239                ..
240            } => {
241                assert_eq!((*line, *col), (3, 9));
242            }
243            other => panic!("expected OpenBufferAtColumn with column, got {other:?}"),
244        }
245        assert!(rx.try_recv().expect("reply landed").success);
246    }
247
248    /// `external: true` maps to `OpenExternalUri` + an optimistic success ack.
249    #[test]
250    fn external_uri_maps_to_open_external_and_acks() {
251        let mut handler = make_handler(LspLogger::with_defaults());
252        let (tx, mut rx) = oneshot::channel();
253        let effects = handler(req("https://example.com/x", true, None, tx));
254        match &effects[0] {
255            Effect::OpenExternalUri { uri } => assert_eq!(uri, "https://example.com/x"),
256            other => panic!("expected OpenExternalUri, got {other:?}"),
257        }
258        assert!(rx.try_recv().expect("reply landed").success);
259    }
260
261    /// A non-file URI without `external` is refused: no effect, `success:false`.
262    #[test]
263    fn non_file_uri_without_external_is_refused() {
264        let mut handler = make_handler(LspLogger::with_defaults());
265        let (tx, mut rx) = oneshot::channel();
266        let effects = handler(req("https://example.com/x", false, None, tx));
267        assert!(effects.is_empty(), "refused → no effect");
268        assert!(!rx.try_recv().expect("reply landed").success);
269    }
270
271    /// A dropped response receiver (server gone) does not panic the handler.
272    #[test]
273    fn tolerates_dropped_receiver() {
274        let mut handler = make_handler(LspLogger::with_defaults());
275        let (tx, drop_rx) = oneshot::channel();
276        drop(drop_rx);
277        let effects = handler(req("file:///tmp/x.rs", false, None, tx));
278        assert_eq!(
279            effects.len(),
280            1,
281            "effect still emitted; only the reply is dropped"
282        );
283    }
284
285    /// `send` over the generic bus wakes the editor (the wake is baked into
286    /// the primitive) and the drain runs the handler over each item.
287    #[tokio::test]
288    async fn send_wakes_and_drain_runs_handler() {
289        let wake = Arc::new(Notify::new());
290        let (bus, mut drain) =
291            make_inbound(Arc::clone(&wake), make_handler(LspLogger::with_defaults()));
292        let (tx, _rx) = oneshot::channel();
293        bus.send(req("file:///tmp/x.rs", false, None, tx))
294            .expect("receiver alive");
295        let woke =
296            tokio::time::timeout(std::time::Duration::from_millis(200), wake.notified()).await;
297        assert!(woke.is_ok(), "send must wake the editor");
298        assert_eq!(drain().len(), 1, "drain runs the handler → one open effect");
299    }
300}