Skip to main content

lattice_lsp/
configuration.rs

1//! Server-initiated `workspace/configuration` plumbing
2//! (Phase 4.1 follow-up; BC.8b reshape).
3//!
4//! When a language server sends `workspace/configuration` (most
5//! commonly during its post-`initialize` startup to pull
6//! per-server settings) the client must reply with one value
7//! per requested item:
8//!
9//! ```json
10//! {"items": [
11//!   {"section": "rust-analyzer.cargo.features", "scopeUri": null},
12//!   {"section": "rust-analyzer.checkOnSave",    "scopeUri": null}
13//! ]}
14//! ```
15//!
16//! Pre-this-module the actor responded with `[null, null]` for
17//! every request -- spec-compliant but functionally useless.
18//! With the §5.12 typed-options registry shipped + the TOML
19//! loader from 4.2.g.5 (3a/3) caching the merged user + project
20//! tree, the App can now look up each `section` against the
21//! cached tree and surface the user's actual config.
22//!
23//! **BC.8b (2026-06-24): reshaped onto the generic inbound primitive.**
24//! The bespoke `ConfigurationBus` (an mpsc sender with no wake, drained by a
25//! host `Editor::drain_inbound_configuration_requests` method) is gone. The
26//! supervisor now holds an `InboundBus<InboundConfigurationRequest>`
27//! ([`lattice_mode::inbound`]) whose `send` **wakes the editor** so a
28//! server-initiated request is answered off-keystroke, and whose per-tick drain
29//! runs the **mode-owned** [`make_handler`] below. The handler is a *pure read*:
30//! it walks each requested section in the shared `lsp.*` config tree and
31//! resolves the request's oneshot — it emits no [`Effect`]. This is the cleanest
32//! of the four LSP inbound buses (no `&mut Editor` work), so it sets the BC.8
33//! reshape pattern for show-document / apply-edit / show-message-request.
34
35use std::sync::Arc;
36
37use arc_swap::ArcSwap;
38use lattice_grammar::effect::Effect;
39use serde_json::Value;
40use tokio::sync::oneshot;
41
42/// The bus the supervisor fans out to each actor — the generic inbound
43/// primitive specialised to the configuration payload. `send` wakes the editor;
44/// the per-tick drain runs [`make_handler`]. (Was the bespoke `ConfigurationBus`
45/// before BC.8b.)
46pub type ConfigurationBus = lattice_mode::inbound::InboundBus<InboundConfigurationRequest>;
47
48/// One server-initiated `workspace/configuration` request,
49/// ferried from the LSP actor to the editor's per-tick drain.
50/// Carries the requested section paths verbatim; the handler
51/// walks each in the shared TOML tree and writes the per-section
52/// values back via the embedded oneshot in the same order.
53#[derive(Debug)]
54pub struct InboundConfigurationRequest {
55    /// Server that sent the request -- recorded for the App's
56    /// log entry. Cheap to clone (`Arc<str>`).
57    pub server_id: Arc<str>,
58    /// Workspace root the originating actor was spawned against
59    /// (B'.2). Pairs with `server_id` to form the canonical
60    /// `(server_id, workspace)` instance key.
61    pub workspace: Arc<std::path::Path>,
62    /// One section path per requested item. Spec lets `section`
63    /// be `null`/missing (server wants all config); we coerce
64    /// those to an empty string upstream so the app always sees
65    /// a string.
66    pub sections: Vec<String>,
67    /// Oneshot the handler fills with one `serde_json::Value` per
68    /// section (in input order). Missing sections come back as
69    /// `Value::Null`.
70    pub response: oneshot::Sender<Vec<Value>>,
71}
72
73/// BC.8b: the mode-owned handler for server-initiated
74/// `workspace/configuration`, registered via `boot.inbound::<…>()`.
75///
76/// For each request it reads every requested `section` from the shared `lsp.*`
77/// config tree (the same merged user+project tree the host edits on reload) and
78/// resolves the request's oneshot with one `serde_json::Value` per section, in
79/// input order. A dropped receiver (server gone) is fine — log-and-skip. It is a
80/// pure read, so it emits **no** [`Effect`]: the `Vec<Effect>` it returns is
81/// always empty.
82///
83/// `config_tree` is shared (`Arc<ArcSwap<…>>`) so the handler always reads the
84/// *current* config — the host re-`store`s it on `:set` / config reload.
85pub fn make_handler(
86    config_tree: Arc<ArcSwap<toml::Table>>,
87) -> impl FnMut(InboundConfigurationRequest) -> Vec<Effect> + Send + 'static {
88    move |req| {
89        let tree = config_tree.load();
90        let values: Vec<Value> = req
91            .sections
92            .iter()
93            .map(|section| lookup_section(&tree, section))
94            .collect();
95        // A dropped response receiver (server gone) is fine — log-and-skip.
96        let _ = req.response.send(values);
97        Vec::new()
98    }
99}
100
101/// Resolve one `workspace/configuration` section against the merged `lsp.*`
102/// tree. An empty section means "all of `lsp`"; otherwise `lsp.<section>`.
103/// Missing → `Value::Null` (the spec-compliant fallback).
104fn lookup_section(tree: &toml::Table, section: &str) -> Value {
105    let path = if section.is_empty() {
106        "lsp".to_string()
107    } else {
108        format!("lsp.{section}")
109    };
110    match lattice_config::lookup_dotted_path(tree, &path) {
111        // toml::Value -> serde_json::Value via the serde round-trip.
112        Some(v) => serde_json::to_value(v).unwrap_or(Value::Null),
113        None => Value::Null,
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    /// The handler resolves the request's oneshot with one value per requested
122    /// section, in input order, reading the shared tree.
123    #[tokio::test]
124    async fn handler_resolves_sections_in_order() {
125        let toml_src = "[lsp.rust-analyzer.cargo]\nfeatures = \"all\"\n";
126        let tree: toml::Table = toml::from_str(toml_src).expect("valid toml");
127        let shared = Arc::new(ArcSwap::from_pointee(tree));
128        let mut handler = make_handler(shared);
129
130        let (tx, resp_rx) = oneshot::channel();
131        let effects = handler(InboundConfigurationRequest {
132            server_id: Arc::from("test"),
133            workspace: Arc::<std::path::Path>::from(std::path::Path::new("/tmp")),
134            sections: vec!["rust-analyzer.cargo".into(), "does.not.exist".into()],
135            response: tx,
136        });
137        assert!(
138            effects.is_empty(),
139            "configuration is a pure read — no effects"
140        );
141        let values = resp_rx.await.expect("handler resolved the oneshot");
142        assert_eq!(values.len(), 2);
143        assert_eq!(values[0]["features"], serde_json::json!("all"));
144        assert_eq!(values[1], serde_json::Value::Null);
145    }
146
147    /// An empty section means "all of `lsp`" — the whole sub-tree comes back as
148    /// a JSON object (the convention the retired TUI test pinned).
149    #[tokio::test]
150    async fn handler_empty_section_returns_whole_lsp_subtree() {
151        let tree: toml::Table =
152            toml::from_str("[lsp.rust-analyzer]\nchecker = \"clippy\"\n").expect("valid toml");
153        let shared = Arc::new(ArcSwap::from_pointee(tree));
154        let mut handler = make_handler(shared);
155        let (tx, resp_rx) = oneshot::channel();
156        handler(InboundConfigurationRequest {
157            server_id: Arc::from("rust-analyzer"),
158            workspace: Arc::<std::path::Path>::from(std::path::Path::new("/tmp")),
159            sections: vec![String::new()],
160            response: tx,
161        });
162        let values = resp_rx.await.expect("handler resolved");
163        let obj = values[0]
164            .as_object()
165            .expect("whole lsp subtree is a JSON object");
166        assert!(obj.contains_key("rust-analyzer"));
167    }
168
169    /// A dropped response receiver (server gone) does not panic the handler.
170    #[tokio::test]
171    async fn handler_tolerates_dropped_receiver() {
172        let shared = Arc::new(ArcSwap::from_pointee(toml::Table::new()));
173        let mut handler = make_handler(shared);
174        let (tx, resp_rx) = oneshot::channel::<Vec<Value>>();
175        drop(resp_rx);
176        let effects = handler(InboundConfigurationRequest {
177            server_id: Arc::from("test"),
178            workspace: Arc::<std::path::Path>::from(std::path::Path::new("/tmp")),
179            sections: Vec::new(),
180            response: tx,
181        });
182        assert!(effects.is_empty());
183    }
184}