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(®.id)
126 && prev.method != reg.method
127 && let Some(bucket) = self.by_method.get_mut(&prev.method)
128 {
129 bucket.retain(|id| id != ®.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(®.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(®.method) {
149 bucket.retain(|x| x != id);
150 if bucket.is_empty() {
151 self.by_method.remove(®.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}