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}