Skip to main content

lsp_max/composition/
strategy.rs

1//! Composition strategy routing and source state types.
2
3use std::collections::HashMap;
4
5use serde_json::Value;
6
7// ── Composition Strategy ───────────────────────────────────────────────────────
8
9/// The routing/composition strategy for a given method family.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum CompositionStrategy {
12    SingleOwner,
13    OrderedFanout,
14    MergeAttributed,
15    MergeDeduped,
16    FirstSuccess,
17    RankedProviders,
18    TransactionalEditGate,
19    ObserveOnly,
20    Deny,
21}
22
23/// Method routing table: maps LSP method names to composition strategies.
24pub fn method_strategy(method: &str) -> CompositionStrategy {
25    if std::env::var("SABOTAGE_ROUTING_MATRIX").is_ok() && method == "textDocument/hover" {
26        return CompositionStrategy::Deny;
27    }
28    match method {
29        "initialize" | "initialized" | "shutdown" | "exit" => CompositionStrategy::SingleOwner,
30
31        "textDocument/didOpen"
32        | "textDocument/didChange"
33        | "textDocument/didSave"
34        | "textDocument/didClose"
35        | "textDocument/willSave"
36        | "workspace/didChangeConfiguration"
37        | "workspace/didChangeWorkspaceFolders"
38        | "workspace/didCreateFiles"
39        | "workspace/didRenameFiles"
40        | "workspace/didDeleteFiles"
41        | "workspace/didChangeWatchedFiles"
42        | "notebookDocument/didOpen"
43        | "notebookDocument/didChange"
44        | "notebookDocument/didSave"
45        | "notebookDocument/didClose" => CompositionStrategy::OrderedFanout,
46
47        "textDocument/publishDiagnostics" | "textDocument/documentSymbol" | "workspace/symbol" => {
48            CompositionStrategy::MergeAttributed
49        }
50
51        "textDocument/hover"
52        | "textDocument/signatureHelp"
53        | "textDocument/linkedEditingRange"
54        | "documentLink/resolve"
55        | "completionItem/resolve"
56        | "codeLens/resolve"
57        | "workspaceSymbol/resolve"
58        | "inlayHint/resolve"
59        | "textDocument/diagnostic"
60        | "workspace/diagnostic"
61        | "workspace/textDocumentContent" => CompositionStrategy::FirstSuccess,
62
63        "textDocument/definition"
64        | "textDocument/declaration"
65        | "textDocument/implementation"
66        | "textDocument/typeDefinition"
67        | "textDocument/references"
68        | "textDocument/prepareCallHierarchy"
69        | "callHierarchy/incomingCalls"
70        | "callHierarchy/outgoingCalls"
71        | "textDocument/prepareTypeHierarchy"
72        | "typeHierarchy/supertypes"
73        | "typeHierarchy/subtypes"
74        | "textDocument/documentHighlight"
75        | "textDocument/documentLink"
76        | "textDocument/codeLens"
77        | "textDocument/selectionRange"
78        | "textDocument/foldingRange"
79        | "textDocument/documentColor"
80        | "textDocument/colorPresentation"
81        | "textDocument/moniker"
82        | "textDocument/inlayHint"
83        | "textDocument/inlineValue" => CompositionStrategy::MergeDeduped,
84
85        "textDocument/completion" | "textDocument/inlineCompletion" => {
86            CompositionStrategy::RankedProviders
87        }
88
89        "textDocument/semanticTokens/full"
90        | "textDocument/semanticTokens/full/delta"
91        | "textDocument/semanticTokens/range" => CompositionStrategy::SingleOwner,
92
93        "textDocument/formatting"
94        | "textDocument/rangeFormatting"
95        | "textDocument/onTypeFormatting"
96        | "textDocument/rangesFormatting"
97        | "textDocument/rename"
98        | "textDocument/prepareRename"
99        | "textDocument/codeAction"
100        | "codeAction/resolve"
101        | "workspace/applyEdit"
102        | "textDocument/willSaveWaitUntil"
103        | "workspace/willCreateFiles"
104        | "workspace/willRenameFiles"
105        | "workspace/willDeleteFiles"
106        | "workspace/executeCommand" => CompositionStrategy::TransactionalEditGate,
107
108        "$/cancelRequest" | "$/progress" | "window/workDoneProgress/cancel" | "$/setTrace" => {
109            CompositionStrategy::ObserveOnly
110        }
111
112        _ => CompositionStrategy::Deny,
113    }
114}
115
116// ── Source State ───────────────────────────────────────────────────────────────
117
118/// The health state of an upstream source.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum SourceHealth {
121    /// Source is healthy.
122    Healthy,
123    /// Source initialization failed.
124    InitializationFailed,
125    /// Source crashed.
126    Crashed,
127    /// Source connection timed out.
128    TimedOut,
129    /// Source returned an invalid response.
130    InvalidResponse,
131    /// Source is in degraded health state.
132    Degraded,
133}
134
135/// Runtime state for a single upstream source.
136#[derive(Debug)]
137pub struct UpstreamSource {
138    pub id: String,
139    pub address: String,
140    pub health: SourceHealth,
141    pub server_capabilities: Option<lsp_types_max::ServerCapabilities>,
142    pub dynamic_registrations: HashMap<String, Value>,
143}
144
145impl UpstreamSource {
146    pub fn new(id: impl Into<String>, address: impl Into<String>) -> Self {
147        Self {
148            id: id.into(),
149            address: address.into(),
150            health: SourceHealth::Healthy,
151            server_capabilities: None,
152            dynamic_registrations: HashMap::new(),
153        }
154    }
155
156    pub fn is_routable(&self) -> bool {
157        self.health != SourceHealth::InitializationFailed && self.health != SourceHealth::Crashed
158    }
159
160    pub fn supports_method(&self, method: &str) -> bool {
161        if !self.is_routable() {
162            return false;
163        }
164        if method == "initialize"
165            || method == "initialized"
166            || method == "shutdown"
167            || method == "exit"
168        {
169            return true;
170        }
171        if self.dynamic_registrations.contains_key(method) {
172            return true;
173        }
174        if let Some(caps) = &self.server_capabilities {
175            capability_supports_method(caps, method)
176        } else {
177            false
178        }
179    }
180}
181
182/// Derives whether a ServerCapabilities supports the given method.
183pub fn capability_supports_method(caps: &lsp_types_max::ServerCapabilities, method: &str) -> bool {
184    match method {
185        "textDocument/hover" => {
186            if let Some(ref p) = caps.hover_provider {
187                match p {
188                    lsp_types_max::HoverProviderCapability::Simple(b) => *b,
189                    lsp_types_max::HoverProviderCapability::Options(_) => true,
190                }
191            } else {
192                false
193            }
194        }
195        "textDocument/completion" => caps.completion_provider.is_some(),
196        "textDocument/definition" => {
197            if let Some(ref p) = caps.definition_provider {
198                match p {
199                    lsp_types_max::OneOf::Left(b) => *b,
200                    lsp_types_max::OneOf::Right(_) => true,
201                }
202            } else {
203                false
204            }
205        }
206        "textDocument/declaration" => {
207            if let Some(ref p) = caps.declaration_provider {
208                match p {
209                    lsp_types_max::DeclarationCapability::Simple(b) => *b,
210                    _ => true,
211                }
212            } else {
213                false
214            }
215        }
216        "textDocument/implementation" => {
217            if let Some(ref p) = caps.implementation_provider {
218                match p {
219                    lsp_types_max::ImplementationProviderCapability::Simple(b) => *b,
220                    _ => true,
221                }
222            } else {
223                false
224            }
225        }
226        "textDocument/references" => {
227            if let Some(ref p) = caps.references_provider {
228                match p {
229                    lsp_types_max::OneOf::Left(b) => *b,
230                    lsp_types_max::OneOf::Right(_) => true,
231                }
232            } else {
233                false
234            }
235        }
236        "textDocument/documentHighlight" => {
237            if let Some(ref p) = caps.document_highlight_provider {
238                match p {
239                    lsp_types_max::OneOf::Left(b) => *b,
240                    lsp_types_max::OneOf::Right(_) => true,
241                }
242            } else {
243                false
244            }
245        }
246        "textDocument/documentSymbol" | "workspace/symbol" => {
247            if let Some(ref p) = caps.document_symbol_provider {
248                match p {
249                    lsp_types_max::OneOf::Left(b) => *b,
250                    lsp_types_max::OneOf::Right(_) => true,
251                }
252            } else {
253                false
254            }
255        }
256        "textDocument/codeAction" => {
257            if let Some(ref p) = caps.code_action_provider {
258                match p {
259                    lsp_types_max::CodeActionProviderCapability::Simple(b) => *b,
260                    lsp_types_max::CodeActionProviderCapability::Options(_) => true,
261                }
262            } else {
263                false
264            }
265        }
266        "textDocument/codeLens" => caps.code_lens_provider.is_some(),
267        "textDocument/formatting" => {
268            if let Some(ref p) = caps.document_formatting_provider {
269                match p {
270                    lsp_types_max::OneOf::Left(b) => *b,
271                    lsp_types_max::OneOf::Right(_) => true,
272                }
273            } else {
274                false
275            }
276        }
277        "textDocument/rangeFormatting" => {
278            if let Some(ref p) = caps.document_range_formatting_provider {
279                match p {
280                    lsp_types_max::OneOf::Left(b) => *b,
281                    lsp_types_max::OneOf::Right(_) => true,
282                }
283            } else {
284                false
285            }
286        }
287        "textDocument/onTypeFormatting" => caps.document_on_type_formatting_provider.is_some(),
288        "textDocument/rename" => {
289            if let Some(ref p) = caps.rename_provider {
290                match p {
291                    lsp_types_max::OneOf::Left(b) => *b,
292                    lsp_types_max::OneOf::Right(_) => true,
293                }
294            } else {
295                false
296            }
297        }
298
299        "textDocument/semanticTokens/full"
300        | "textDocument/semanticTokens/full/delta"
301        | "textDocument/semanticTokens/range" => caps.semantic_tokens_provider.is_some(),
302        "textDocument/didOpen"
303        | "textDocument/didChange"
304        | "textDocument/didSave"
305        | "textDocument/didClose" => true,
306        _ => false,
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn method_strategy_lifecycle() {
316        assert_eq!(
317            method_strategy("initialize"),
318            CompositionStrategy::SingleOwner
319        );
320        assert_eq!(
321            method_strategy("initialized"),
322            CompositionStrategy::SingleOwner
323        );
324        assert_eq!(
325            method_strategy("shutdown"),
326            CompositionStrategy::SingleOwner
327        );
328        assert_eq!(method_strategy("exit"), CompositionStrategy::SingleOwner);
329    }
330
331    #[test]
332    fn method_strategy_fanout() {
333        assert_eq!(
334            method_strategy("textDocument/didOpen"),
335            CompositionStrategy::OrderedFanout
336        );
337        assert_eq!(
338            method_strategy("textDocument/didChange"),
339            CompositionStrategy::OrderedFanout
340        );
341        assert_eq!(
342            method_strategy("textDocument/didSave"),
343            CompositionStrategy::OrderedFanout
344        );
345    }
346
347    #[test]
348    fn method_strategy_hover_is_first_success() {
349        // Per the routing table, hover uses FirstSuccess
350        assert_eq!(
351            method_strategy("textDocument/hover"),
352            CompositionStrategy::FirstSuccess
353        );
354    }
355
356    #[test]
357    fn method_strategy_merge_attributed() {
358        assert_eq!(
359            method_strategy("textDocument/publishDiagnostics"),
360            CompositionStrategy::MergeAttributed
361        );
362        assert_eq!(
363            method_strategy("textDocument/documentSymbol"),
364            CompositionStrategy::MergeAttributed
365        );
366    }
367
368    #[test]
369    fn method_strategy_merge_deduped() {
370        assert_eq!(
371            method_strategy("textDocument/definition"),
372            CompositionStrategy::MergeDeduped
373        );
374        assert_eq!(
375            method_strategy("textDocument/references"),
376            CompositionStrategy::MergeDeduped
377        );
378    }
379
380    #[test]
381    fn method_strategy_ranked_providers() {
382        assert_eq!(
383            method_strategy("textDocument/completion"),
384            CompositionStrategy::RankedProviders
385        );
386    }
387
388    #[test]
389    fn method_strategy_transactional_edit_gate() {
390        assert_eq!(
391            method_strategy("textDocument/formatting"),
392            CompositionStrategy::TransactionalEditGate
393        );
394        assert_eq!(
395            method_strategy("textDocument/rename"),
396            CompositionStrategy::TransactionalEditGate
397        );
398    }
399
400    #[test]
401    fn method_strategy_observe_only() {
402        assert_eq!(
403            method_strategy("$/cancelRequest"),
404            CompositionStrategy::ObserveOnly
405        );
406        assert_eq!(
407            method_strategy("$/progress"),
408            CompositionStrategy::ObserveOnly
409        );
410    }
411
412    #[test]
413    fn method_strategy_unknown_defaults_to_deny() {
414        assert_eq!(
415            method_strategy("nonexistent/method"),
416            CompositionStrategy::Deny
417        );
418        assert_eq!(method_strategy(""), CompositionStrategy::Deny);
419    }
420
421    #[test]
422    fn upstream_source_new_is_healthy_and_routable() {
423        let src = UpstreamSource::new("test-src", "127.0.0.1:9999");
424        assert_eq!(src.id, "test-src");
425        assert_eq!(src.health, SourceHealth::Healthy);
426        assert!(src.is_routable());
427    }
428
429    #[test]
430    fn upstream_source_initialization_failed_is_not_routable() {
431        let mut src = UpstreamSource::new("src-a", "addr");
432        src.health = SourceHealth::InitializationFailed;
433        assert!(!src.is_routable());
434    }
435
436    #[test]
437    fn upstream_source_crashed_is_not_routable() {
438        let mut src = UpstreamSource::new("src-b", "addr");
439        src.health = SourceHealth::Crashed;
440        assert!(!src.is_routable());
441    }
442
443    #[test]
444    fn upstream_source_degraded_is_still_routable() {
445        let mut src = UpstreamSource::new("src-c", "addr");
446        src.health = SourceHealth::Degraded;
447        assert!(src.is_routable());
448    }
449
450    #[test]
451    fn upstream_source_supports_lifecycle_without_caps() {
452        let src = UpstreamSource::new("src-d", "addr");
453        assert!(src.supports_method("initialize"));
454        assert!(src.supports_method("shutdown"));
455        assert!(src.supports_method("exit"));
456    }
457
458    #[test]
459    fn upstream_source_does_not_support_hover_without_caps() {
460        let src = UpstreamSource::new("src-e", "addr");
461        // No server_capabilities set — hover not supported
462        assert!(!src.supports_method("textDocument/hover"));
463    }
464
465    #[test]
466    fn upstream_source_not_routable_does_not_support_any_method() {
467        let mut src = UpstreamSource::new("src-f", "addr");
468        src.health = SourceHealth::Crashed;
469        assert!(!src.supports_method("initialize"));
470        assert!(!src.supports_method("textDocument/hover"));
471    }
472}