1use std::collections::HashMap;
4
5use serde_json::Value;
6
7#[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
23pub 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#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum SourceHealth {
121 Healthy,
123 InitializationFailed,
125 Crashed,
127 TimedOut,
129 InvalidResponse,
131 Degraded,
133}
134
135#[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
182pub 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 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 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}