Skip to main content

lattice_lsp/
dynamic_registration.rs

1//! Dynamic capability tracking (LSP §3.18.10.3 / 4.4.n).
2//!
3//! After `initialize`, a server may send `client/registerCapability`
4//! to announce capabilities it didn't list in the initial
5//! `ServerCapabilities` blob, or to attach method-specific options
6//! that the static shape can't express (the canonical example is
7//! `workspace/didChangeWatchedFiles` -- the glob patterns to watch
8//! depend on the active project and aren't known at handshake).
9//! `client/unregisterCapability` reverses an earlier registration.
10//!
11//! Before 4.4.n the actor accepted both requests with `null` and
12//! threw the payload away; feature dispatch saw only the static
13//! capability set. This module owns the "dynamic layer" the
14//! server adds on top -- the [`Capabilities`](crate::Capabilities)
15//! aggregate carries one of these and every `supports_*` probe
16//! that cares about dynamic-only registrations consults it
17//! alongside the static `ServerCapabilities` field.
18//!
19//! ## Indexing
20//!
21//! The registry indexes registrations two ways:
22//!
23//! 1. `by_id` (`HashMap<String, DynamicRegistration>`) -- so
24//!    `unregisterCapability` can find an entry by the id the
25//!    server picked at register time and evict it in O(1).
26//! 2. `by_method` (`HashMap<String, Vec<String>>`) -- so feature
27//!    dispatch can ask "is `textDocument/completion` registered
28//!    dynamically?" without scanning the whole table. The vec
29//!    holds registration ids; the registry stays a single source
30//!    of truth (the actual `DynamicRegistration` lives only in
31//!    `by_id`).
32//!
33//! ## Snapshot model
34//!
35//! [`Capabilities`](crate::Capabilities) is published through an
36//! `arc_swap::ArcSwap` cell on the [`ServerHandle`]; readers see
37//! the union of static + dynamic atomically. Mutations are
38//! per-actor (only the actor task issues the swap), so writes
39//! don't race with each other. The cloning cost on update is
40//! the size of the registry's two HashMaps -- typically
41//! single-digit entries for the lifetime of a server, so the
42//! arithmetic is cheap.
43//!
44//! [`ServerHandle`]: crate::ServerHandle
45
46use std::collections::HashMap;
47
48use serde_json::Value;
49
50/// One server-issued capability registration. The `register_options`
51/// blob's interpretation is method-specific; the registry treats it
52/// as opaque JSON and hands it to the consumer when they fetch the
53/// entry. For methods we wire (e.g. `workspace/didChangeWatchedFiles`)
54/// the consumer parses it into the lsp_types shape on demand.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct DynamicRegistration {
57    /// Server-chosen id. Stable for the registration's lifetime;
58    /// `unregisterCapability` references it.
59    pub id: String,
60    /// LSP method the registration applies to
61    /// (e.g. `"textDocument/completion"`,
62    /// `"workspace/didChangeWatchedFiles"`).
63    pub method: String,
64    /// Method-specific options blob the server attached. `None`
65    /// when the server registers without options (the entry then
66    /// just means "I support this method dynamically").
67    pub register_options: Option<Value>,
68}
69
70/// In-memory index of every active dynamic registration for one
71/// server actor. Empty on a fresh server; mutated by the actor on
72/// `client/(un)registerCapability` and snapshotted into the
73/// published [`Capabilities`](crate::Capabilities) on every
74/// change.
75///
76/// Two-way index (`by_id` + `by_method`) keeps both register
77/// (O(1) append) and unregister (O(1) lookup by id) cheap, with
78/// the "is method X registered?" probe also O(1) via the
79/// per-method bucket.
80#[derive(Debug, Clone, Default)]
81pub struct DynamicRegistry {
82    by_id: HashMap<String, DynamicRegistration>,
83    /// `method` → list of registration ids. The vec lets one
84    /// method carry multiple simultaneous registrations
85    /// (servers occasionally register the same method twice
86    /// with different option blobs, e.g. one watcher set per
87    /// language).
88    by_method: HashMap<String, Vec<String>>,
89}
90
91impl DynamicRegistry {
92    /// Construct an empty registry. Used at handshake and
93    /// whenever the actor restarts (each restart starts clean;
94    /// the server replays its registrations).
95    pub fn new() -> Self {
96        Self::default()
97    }
98
99    /// True iff the registry has no entries. Cheap shortcut for
100    /// the common steady-state case -- most probes ask "is this
101    /// dynamically registered?" and the registry is usually
102    /// empty, so we want the false branch to be O(1).
103    pub fn is_empty(&self) -> bool {
104        self.by_id.is_empty()
105    }
106
107    /// Number of distinct registrations currently active. Each
108    /// `Registration` from a `RegistrationParams` batch counts
109    /// once -- duplicate ids (server error) are deduplicated by
110    /// [`Self::register`].
111    pub fn len(&self) -> usize {
112        self.by_id.len()
113    }
114
115    /// Add one registration. If the id is already present, the
116    /// new entry replaces the old (servers shouldn't reuse ids
117    /// without unregistering first, but if they do we honour
118    /// the latest). The `by_method` index is updated to remove
119    /// the stale entry from the old method's bucket before
120    /// inserting under the new method.
121    pub fn register(&mut self, reg: DynamicRegistration) {
122        // If the id was registered before, clean up the old
123        // method bucket so the new entry's method is the only
124        // place its id appears.
125        if let Some(prev) = self.by_id.get(&reg.id)
126            && prev.method != reg.method
127            && let Some(bucket) = self.by_method.get_mut(&prev.method)
128        {
129            bucket.retain(|id| id != &reg.id);
130            if bucket.is_empty() {
131                self.by_method.remove(&prev.method);
132            }
133        }
134        let bucket = self.by_method.entry(reg.method.clone()).or_default();
135        if !bucket.contains(&reg.id) {
136            bucket.push(reg.id.clone());
137        }
138        self.by_id.insert(reg.id.clone(), reg);
139    }
140
141    /// Evict the registration matching `id`. Silently no-op when
142    /// the id is absent (server may unregister speculatively
143    /// after a restart; rejecting would just spam the log).
144    pub fn unregister(&mut self, id: &str) {
145        let Some(reg) = self.by_id.remove(id) else {
146            return;
147        };
148        if let Some(bucket) = self.by_method.get_mut(&reg.method) {
149            bucket.retain(|x| x != id);
150            if bucket.is_empty() {
151                self.by_method.remove(&reg.method);
152            }
153        }
154    }
155
156    /// True iff at least one active registration targets the
157    /// given LSP method. The probe `supports_*` family calls
158    /// here to OR into their static `ServerCapabilities` check.
159    pub fn has(&self, method: &str) -> bool {
160        self.by_method.get(method).is_some_and(|v| !v.is_empty())
161    }
162
163    /// Borrow every registration for the given method in
164    /// insertion order. Used by feature consumers that need
165    /// the `register_options` blob -- e.g. the file-watcher
166    /// pump (4.4.l) reads each `DidChangeWatchedFilesRegistrationOptions`
167    /// to know which glob patterns to subscribe to.
168    pub fn registrations_for<'a>(
169        &'a self,
170        method: &str,
171    ) -> impl Iterator<Item = &'a DynamicRegistration> + 'a {
172        self.by_method
173            .get(method)
174            .into_iter()
175            .flat_map(|ids| ids.iter())
176            .filter_map(|id| self.by_id.get(id))
177    }
178
179    /// Borrow one registration by id. Used by tests and the
180    /// log surface (4.4.n's `:lsp-status` extension will dump
181    /// every active dynamic registration for diagnosis).
182    pub fn get(&self, id: &str) -> Option<&DynamicRegistration> {
183        self.by_id.get(id)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use serde_json::json;
191
192    fn reg(id: &str, method: &str) -> DynamicRegistration {
193        DynamicRegistration {
194            id: id.into(),
195            method: method.into(),
196            register_options: None,
197        }
198    }
199
200    fn reg_opts(id: &str, method: &str, opts: Value) -> DynamicRegistration {
201        DynamicRegistration {
202            id: id.into(),
203            method: method.into(),
204            register_options: Some(opts),
205        }
206    }
207
208    #[test]
209    fn empty_registry_has_nothing() {
210        let r = DynamicRegistry::new();
211        assert!(r.is_empty());
212        assert_eq!(r.len(), 0);
213        assert!(!r.has("textDocument/completion"));
214        assert_eq!(r.registrations_for("textDocument/completion").count(), 0);
215    }
216
217    #[test]
218    fn register_and_query_by_method() {
219        let mut r = DynamicRegistry::new();
220        r.register(reg("a", "textDocument/completion"));
221        r.register(reg("b", "textDocument/completion"));
222        r.register(reg("c", "workspace/didChangeWatchedFiles"));
223        assert_eq!(r.len(), 3);
224        assert!(r.has("textDocument/completion"));
225        assert!(r.has("workspace/didChangeWatchedFiles"));
226        assert!(!r.has("textDocument/hover"));
227        let comp: Vec<&str> = r
228            .registrations_for("textDocument/completion")
229            .map(|x| x.id.as_str())
230            .collect();
231        assert_eq!(comp, vec!["a", "b"]);
232    }
233
234    #[test]
235    fn unregister_evicts_from_both_indexes() {
236        let mut r = DynamicRegistry::new();
237        r.register(reg("a", "textDocument/completion"));
238        r.register(reg("b", "textDocument/completion"));
239        r.unregister("a");
240        assert_eq!(r.len(), 1);
241        assert!(r.has("textDocument/completion"));
242        let comp: Vec<&str> = r
243            .registrations_for("textDocument/completion")
244            .map(|x| x.id.as_str())
245            .collect();
246        assert_eq!(comp, vec!["b"]);
247        r.unregister("b");
248        assert!(!r.has("textDocument/completion"));
249        assert!(r.is_empty());
250    }
251
252    #[test]
253    fn unregister_unknown_id_is_noop() {
254        let mut r = DynamicRegistry::new();
255        r.register(reg("a", "textDocument/completion"));
256        r.unregister("does-not-exist");
257        assert_eq!(r.len(), 1);
258        assert!(r.has("textDocument/completion"));
259    }
260
261    /// Some servers re-register an id with a new method without
262    /// an intermediate unregister (sloppy but legal per spec).
263    /// The registry honours the latest entry and clears the
264    /// stale method's bucket so probes don't claim phantom
265    /// support.
266    #[test]
267    fn re_register_same_id_moves_methods() {
268        let mut r = DynamicRegistry::new();
269        r.register(reg("a", "textDocument/completion"));
270        r.register(reg("a", "textDocument/hover"));
271        assert_eq!(r.len(), 1);
272        assert!(!r.has("textDocument/completion"));
273        assert!(r.has("textDocument/hover"));
274    }
275
276    #[test]
277    fn register_options_round_trip() {
278        let mut r = DynamicRegistry::new();
279        let opts = json!({ "watchers": [{ "globPattern": "**/*.rs" }] });
280        r.register(reg_opts(
281            "watch-rs",
282            "workspace/didChangeWatchedFiles",
283            opts.clone(),
284        ));
285        let got = r.get("watch-rs").expect("registered");
286        assert_eq!(got.register_options, Some(opts));
287    }
288}