Skip to main content

mcpls_core/bridge/translator/
support.rs

1//! Advisory per-tool support snapshot behind the `get_tool_support` MCP tool.
2//!
3//! The snapshot answers "would a call to this tool be dispatched to a server
4//! that advertises its capability?" through the same pure decision functions
5//! enforcement uses ([`lookup_route`], [`lookup_workspace_route`]) and the
6//! same per-capability predicate ([`Capability::is_supported`], folded into a
7//! [`CapabilitySet`] per server), so the report and the per-call
8//! `require_capability` gate cannot drift apart. `require_capability` stays
9//! the authoritative enforcement point.
10
11use std::collections::{HashMap, HashSet};
12use std::sync::Arc;
13
14use lsp_types::ServerCapabilities;
15use schemars::JsonSchema;
16use serde::Serialize;
17
18use super::Translator;
19use super::pull_support::PullSupport;
20use super::routing::{
21    Capability, LanguageCandidates, RouteLookup, WorkspaceRouteLookup, lookup_route,
22    lookup_workspace_route,
23};
24use crate::bridge::ClientPath;
25use crate::config::{LanguageId, LanguageMap, ServerId, ToolKind, ToolRouter};
26use crate::error::Result;
27use crate::util::lock_std;
28
29/// Whether one route of a tool would be dispatched to a capable server.
30///
31/// `Supported` means "will be dispatched", not "will succeed": push-only
32/// diagnostics, respawn backoff and indexing can still fail the call.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
34#[serde(tag = "status", rename_all = "snake_case")]
35pub enum RouteSupport {
36    /// The routed server is registered and advertises the tool's capability.
37    Supported {
38        /// The server the call would be dispatched to.
39        server: ServerId,
40    },
41    /// The routed server is registered and publishes diagnostics but advertises
42    /// no pull provider, so `get_diagnostics` answers from the push cache.
43    PushOnly {
44        /// The server the call would be dispatched to.
45        server: ServerId,
46    },
47    /// The routed server is registered but does not advertise the capability.
48    CapabilityNotAdvertised {
49        /// The server the call would be dispatched to.
50        server: ServerId,
51        /// The capability the server does not advertise.
52        capability: Capability,
53    },
54    /// The routed server has not finished initializing, or its capabilities
55    /// are not known yet.
56    Initializing,
57    /// No server is routed for this tool.
58    NoServer,
59}
60
61/// The set of [`Capability`] values one server advertises, one bit each.
62///
63/// Computed once per snapshot through [`Capability::is_supported`], so the
64/// snapshot never clones a server's full `ServerCapabilities`.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub struct CapabilitySet(u32);
67
68impl CapabilitySet {
69    /// The capabilities `caps` advertises.
70    pub fn of(caps: &ServerCapabilities) -> Self {
71        Capability::ALL
72            .into_iter()
73            .filter(|capability| capability.is_supported(caps))
74            .fold(Self::default(), |set, capability| {
75                Self(set.0 | 1 << capability as u32)
76            })
77    }
78
79    /// Whether `capability` is in the set.
80    pub const fn contains(self, capability: Capability) -> bool {
81        self.0 & (1 << capability as u32) != 0
82    }
83}
84
85/// A point-in-time copy of the registries needed to answer
86/// [`RouteSupport`] queries without holding any `Translator` lock.
87///
88/// Dynamic capability registrations are invisible to both this snapshot and
89/// `require_capability`; see `Translator::require_capability`.
90#[derive(Debug)]
91pub struct ToolSupportSnapshot {
92    language_map: Arc<LanguageMap>,
93    router: Arc<ToolRouter>,
94    expected: HashSet<ServerId>,
95    running: HashMap<ServerId, RunningSupport>,
96    registered: HashSet<ServerId>,
97}
98
99/// What a running server's capabilities and pull behavior allow, read in one
100/// pass so an id never has one without the other.
101#[derive(Debug, Clone, Copy)]
102struct RunningSupport {
103    caps: CapabilitySet,
104    pull: PullSupport,
105}
106
107impl ToolSupportSnapshot {
108    /// Every language a file can be detected as that reaches a configured
109    /// route, sorted: a mapped language counts when it, or its React base
110    /// language, has a server. A configured language no extension or name maps
111    /// to is not listed, since no file can be routed to it.
112    pub(crate) fn languages(&self) -> Vec<LanguageId> {
113        let configured = self.router.configured_languages();
114        let mut languages: Vec<LanguageId> = self
115            .language_map
116            .languages()
117            .filter(|language| {
118                LanguageCandidates::new((*language).clone())
119                    .iter()
120                    .any(|candidate| configured.contains(candidate))
121            })
122            .cloned()
123            .collect();
124        languages.sort_unstable();
125        languages.dedup();
126        languages
127    }
128
129    /// Support for a per-document `tool` on files of `language`, gated on the
130    /// tool's primary capability.
131    #[cfg(test)]
132    pub(crate) fn document_support(&self, language: &LanguageId, tool: ToolKind) -> RouteSupport {
133        self.document_support_gated(language, tool, Capability::for_tool(tool))
134    }
135
136    /// Support for a per-document `tool` on files of `language`, gated on
137    /// `capability` (`None` for an ungated tool). A tool that shares another
138    /// tool's route but needs a different capability, such as `prepare_rename`
139    /// on the `Rename` route, passes its own.
140    pub(crate) fn document_support_gated(
141        &self,
142        language: &LanguageId,
143        tool: ToolKind,
144        capability: Option<Capability>,
145    ) -> RouteSupport {
146        let lookup = lookup_route(
147            &LanguageCandidates::new(language.clone()),
148            |lang| self.router.resolve(lang, tool).cloned(),
149            |id| self.registered.contains(id).then_some(()),
150            |id| self.expected.contains(id),
151            |lang| {
152                self.router
153                    .catch_all_for(lang)
154                    .filter(|id| self.expected.contains(*id))
155                    .cloned()
156            },
157            |_| None,
158        );
159        match lookup {
160            RouteLookup::Registered(server, ()) => {
161                match (tool, self.registered_support(server, capability)) {
162                    (ToolKind::Diagnostics, RouteSupport::Supported { server })
163                        if self.answers_from_push_cache(&server) =>
164                    {
165                        RouteSupport::PushOnly { server }
166                    }
167                    (_, support) => support,
168                }
169            }
170            RouteLookup::Initializing(_) => RouteSupport::Initializing,
171            RouteLookup::Failed(_) | RouteLookup::Dangling { .. } | RouteLookup::Unrouted => {
172                RouteSupport::NoServer
173            }
174        }
175    }
176
177    /// Support for a workspace-wide `tool` (no document, hence no language).
178    pub(crate) fn workspace_support(&self, tool: ToolKind) -> RouteSupport {
179        let lookup = lookup_workspace_route(
180            || self.router.resolve_any(tool).cloned(),
181            |id| self.registered.contains(id),
182            |id| self.expected.contains(id),
183            || self.expected.is_empty(),
184        );
185        match lookup {
186            WorkspaceRouteLookup::Registered(server) => {
187                self.registered_support(server, Capability::for_tool(tool))
188            }
189            WorkspaceRouteLookup::Initializing(_) | WorkspaceRouteLookup::AllInitializing => {
190                RouteSupport::Initializing
191            }
192            WorkspaceRouteLookup::Dangling(_)
193            | WorkspaceRouteLookup::NothingConfigured
194            | WorkspaceRouteLookup::NoClaimant => RouteSupport::NoServer,
195        }
196    }
197
198    /// Whether `get_diagnostics` reads `server`'s push cache because it
199    /// advertises no pull provider and has not shown that it answers pulls.
200    fn answers_from_push_cache(&self, server: &ServerId) -> bool {
201        matches!(
202            self.running.get(server).map(|running| running.pull),
203            Some(PullSupport::Probing | PullSupport::Unsupported)
204        )
205    }
206
207    fn registered_support(&self, server: ServerId, capability: Option<Capability>) -> RouteSupport {
208        let Some(RunningSupport { caps, .. }) = self.running.get(&server) else {
209            return RouteSupport::Initializing;
210        };
211        match capability {
212            Some(capability) if !caps.contains(capability) => {
213                RouteSupport::CapabilityNotAdvertised { server, capability }
214            }
215            _ => RouteSupport::Supported { server },
216        }
217    }
218}
219
220impl Translator {
221    /// Copy the registries `get_tool_support` reads: one `servers` guard, then
222    /// one router clone. A server is a single slot, so a healthy server is
223    /// seen as registered or expected, never as neither (which would be
224    /// misreported as `no_server`), whatever registration or restart is
225    /// doing at the same moment.
226    pub(crate) fn tool_support_snapshot(&self) -> ToolSupportSnapshot {
227        let servers = lock_std(&self.servers);
228        let expected = servers.expected();
229        let running = servers
230            .running_servers()
231            .map(|(id, server)| {
232                (
233                    id.clone(),
234                    RunningSupport {
235                        caps: CapabilitySet::of(server.capabilities()),
236                        pull: servers.pull_support(id),
237                    },
238                )
239            })
240            .collect();
241        let registered = servers
242            .ids()
243            .filter(|id| servers.client(id).is_some())
244            .cloned()
245            .collect();
246        drop(servers);
247        // Read after the registry: a settlement writes the registry first and
248        // the router after, so a router that is newer than the registry only
249        // moves routes toward servers that are already registered or expected.
250        let router = self.router_snapshot();
251        ToolSupportSnapshot {
252            language_map: Arc::clone(&self.language_map),
253            router,
254            expected,
255            running,
256            registered,
257        }
258    }
259
260    /// The language of the file at `path`, after workspace-root validation.
261    ///
262    /// # Errors
263    ///
264    /// Returns the same path-validation errors as every document tool.
265    pub(crate) async fn language_for_path(&self, path: &ClientPath) -> Result<LanguageId> {
266        let validated = self.validate_path(path).await?;
267        Ok(self
268            .language_candidates(validated.as_path())
269            .language()
270            .clone())
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use tempfile::TempDir;
277
278    use super::super::testing::translator_with_capabilities;
279    use super::*;
280    use crate::config::FileExtension;
281    use crate::test_lsp::client_path;
282
283    fn rust_language_map() -> Arc<LanguageMap> {
284        Arc::new(LanguageMap::from(HashMap::from([(
285            FileExtension::from_static("rs"),
286            LanguageId::from_static("rust"),
287        )])))
288    }
289
290    fn rust_caps(hover: bool) -> ServerCapabilities {
291        ServerCapabilities {
292            hover_provider: hover.then_some(lsp_types::HoverProvider::Bool(true)),
293            ..Default::default()
294        }
295    }
296
297    fn snapshot(
298        registered: &[&str],
299        expected: &[&str],
300        caps: &[(&str, ServerCapabilities)],
301    ) -> ToolSupportSnapshot {
302        let ids = |names: &[&str]| names.iter().map(|n| ServerId::new(*n).unwrap()).collect();
303        ToolSupportSnapshot {
304            language_map: rust_language_map(),
305            router: Arc::new(ToolRouter::catch_all([(
306                ServerId::from_static("rust"),
307                LanguageId::from_static("rust"),
308            )])),
309            expected: ids(expected),
310            running: caps
311                .iter()
312                .map(|(id, caps)| {
313                    (
314                        ServerId::new(*id).unwrap(),
315                        RunningSupport {
316                            caps: CapabilitySet::of(caps),
317                            pull: PullSupport::Advertised,
318                        },
319                    )
320                })
321                .collect(),
322            registered: ids(registered),
323        }
324    }
325
326    #[test]
327    fn capability_tool_kind_round_trips() {
328        for capability in Capability::ALL {
329            if capability.is_primary() {
330                assert_eq!(
331                    Capability::for_tool(capability.tool_kind()),
332                    Some(capability)
333                );
334            } else {
335                assert_ne!(
336                    Capability::for_tool(capability.tool_kind()),
337                    Some(capability)
338                );
339            }
340        }
341        assert_eq!(Capability::for_tool(ToolKind::Diagnostics), None);
342        for tool in ToolKind::ALL {
343            let expected = usize::from(*tool != ToolKind::Diagnostics);
344            assert_eq!(
345                Capability::ALL
346                    .iter()
347                    .filter(|c| c.tool_kind() == *tool && c.is_primary())
348                    .count(),
349                expected,
350                "{tool}"
351            );
352        }
353    }
354
355    #[test]
356    fn prepare_rename_shares_the_rename_route_with_its_own_capability() {
357        let rename_only = ServerCapabilities {
358            rename_provider: Some(lsp_types::RenameProvider::Bool(true)),
359            ..Default::default()
360        };
361        let with_prepare = ServerCapabilities {
362            rename_provider: Some(lsp_types::RenameProvider::RenameOptions(
363                lsp_types::RenameOptions {
364                    prepare_provider: Some(true),
365                    ..Default::default()
366                },
367            )),
368            ..Default::default()
369        };
370        let server = ServerId::from_static("rust");
371        let snap = snapshot(&["rust"], &[], &[("rust", rename_only)]);
372        assert_eq!(
373            snap.document_support(&LanguageId::from_static("rust"), ToolKind::Rename),
374            RouteSupport::Supported {
375                server: server.clone()
376            }
377        );
378        assert_eq!(
379            snap.document_support_gated(
380                &LanguageId::from_static("rust"),
381                ToolKind::Rename,
382                Some(Capability::PrepareRename)
383            ),
384            RouteSupport::CapabilityNotAdvertised {
385                server: server.clone(),
386                capability: Capability::PrepareRename,
387            }
388        );
389        let snap = snapshot(&["rust"], &[], &[("rust", with_prepare)]);
390        assert_eq!(
391            snap.document_support_gated(
392                &LanguageId::from_static("rust"),
393                ToolKind::Rename,
394                Some(Capability::PrepareRename)
395            ),
396            RouteSupport::Supported { server }
397        );
398    }
399
400    #[test]
401    fn capability_set_fits_every_capability() {
402        assert!(Capability::ALL.len() > 16);
403        let caps = ServerCapabilities {
404            document_range_formatting_provider: Some(
405                lsp_types::DocumentRangeFormattingProvider::Bool(true),
406            ),
407            ..Default::default()
408        };
409        assert!(CapabilitySet::of(&caps).contains(Capability::FormatRange));
410    }
411
412    #[test]
413    fn registered_server_with_capability_is_supported() {
414        let snap = snapshot(&["rust"], &[], &[("rust", rust_caps(true))]);
415        assert_eq!(
416            snap.document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
417            RouteSupport::Supported {
418                server: ServerId::from_static("rust")
419            }
420        );
421    }
422
423    #[test]
424    fn registered_server_without_capability_is_not_advertised() {
425        let snap = snapshot(&["rust"], &[], &[("rust", rust_caps(false))]);
426        assert_eq!(
427            snap.document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
428            RouteSupport::CapabilityNotAdvertised {
429                server: ServerId::from_static("rust"),
430                capability: Capability::Hover,
431            }
432        );
433        assert_eq!(
434            snap.document_support(&LanguageId::from_static("rust"), ToolKind::Diagnostics),
435            RouteSupport::Supported {
436                server: ServerId::from_static("rust")
437            }
438        );
439    }
440
441    fn snapshot_with_pull(pull: PullSupport) -> ToolSupportSnapshot {
442        ToolSupportSnapshot {
443            language_map: rust_language_map(),
444            running: HashMap::from([(
445                ServerId::from_static("rust"),
446                RunningSupport {
447                    caps: CapabilitySet::of(&rust_caps(true)),
448                    pull,
449                },
450            )]),
451            ..snapshot(&["rust"], &[], &[("rust", rust_caps(true))])
452        }
453    }
454
455    /// #666: a server that advertises no pull provider, and has not shown it
456    /// answers pulls, is reported as answering `get_diagnostics` from the push
457    /// cache; one that advertises or answers is plain supported.
458    #[test]
459    fn diagnostics_route_is_push_only_unless_pull_is_advertised_or_answered() {
460        let language = LanguageId::from_static("rust");
461        let server = ServerId::from_static("rust");
462        for (pull, push_only) in [
463            (PullSupport::Advertised, false),
464            (PullSupport::Answers, false),
465            (PullSupport::Probing, true),
466            (PullSupport::Unsupported, true),
467        ] {
468            let support =
469                snapshot_with_pull(pull).document_support(&language, ToolKind::Diagnostics);
470            let expected = if push_only {
471                RouteSupport::PushOnly {
472                    server: server.clone(),
473                }
474            } else {
475                RouteSupport::Supported {
476                    server: server.clone(),
477                }
478            };
479            assert_eq!(support, expected, "{pull:?}");
480        }
481        assert_eq!(
482            snapshot_with_pull(PullSupport::Unsupported)
483                .document_support(&language, ToolKind::Hover),
484            RouteSupport::Supported { server },
485            "only the diagnostics route is affected"
486        );
487    }
488
489    #[test]
490    fn client_without_server_capabilities_is_initializing() {
491        let snap = snapshot(&["rust"], &[], &[]);
492        assert_eq!(
493            snap.document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
494            RouteSupport::Initializing
495        );
496    }
497
498    #[test]
499    fn expected_but_unregistered_server_is_initializing() {
500        let snap = snapshot(&[], &["rust"], &[]);
501        assert_eq!(
502            snap.document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
503            RouteSupport::Initializing
504        );
505        assert_eq!(
506            snap.workspace_support(ToolKind::WorkspaceSymbols),
507            RouteSupport::Initializing
508        );
509    }
510
511    #[test]
512    fn capability_set_holds_exactly_the_advertised_capabilities() {
513        let set = CapabilitySet::of(&rust_caps(true));
514        for capability in Capability::ALL {
515            assert_eq!(
516                set.contains(capability),
517                capability == Capability::Hover,
518                "{capability:?}"
519            );
520        }
521        assert_eq!(
522            CapabilitySet::of(&ServerCapabilities::default()),
523            CapabilitySet::default()
524        );
525    }
526
527    /// A server's whole lifecycle, step by step (expected, registered, taken
528    /// out for a restart, restored): at no step does a snapshot read it as
529    /// `no_server`, because each step is one slot transition.
530    #[tokio::test]
531    async fn server_is_never_misreported_across_registration_and_restart_steps() {
532        use crate::lsp::LspServer;
533
534        let id = ServerId::from_static("rust");
535        let translator = Translator::new().with_router(ToolRouter::catch_all([(
536            id.clone(),
537            LanguageId::from_static("rust"),
538        )]));
539        let check = |step: &str| {
540            let snap = translator.tool_support_snapshot();
541            assert_ne!(
542                snap.document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
543                RouteSupport::NoServer,
544                "document route, {step}"
545            );
546            assert_ne!(
547                snap.workspace_support(ToolKind::WorkspaceSymbols),
548                RouteSupport::NoServer,
549                "workspace route, {step}"
550            );
551        };
552
553        translator.set_expected_servers(HashSet::from([id.clone()]));
554        check("expected");
555        translator.register_server(id.clone(), LspServer::new_for_test(rust_caps(true)));
556        check("registered");
557        let held = lock_std(&translator.servers).take_for_restart(&id);
558        check("taken for restart");
559        let refused = lock_std(&translator.servers).restore(&id, held.unwrap());
560        assert!(refused.is_none());
561        check("restored");
562        translator.clear_expected_servers();
563        check("expectation cleared");
564        assert_eq!(
565            translator
566                .tool_support_snapshot()
567                .document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
568            RouteSupport::Supported { server: id }
569        );
570    }
571
572    #[test]
573    fn language_without_live_route_is_listed_as_no_server() {
574        let mut router = ToolRouter::catch_all([(
575            ServerId::from_static("rust"),
576            LanguageId::from_static("rust"),
577        )]);
578        router.rebind_to_registered(&HashSet::new());
579        let snap = ToolSupportSnapshot {
580            language_map: rust_language_map(),
581            router: Arc::new(router),
582            expected: HashSet::new(),
583            running: HashMap::new(),
584            registered: HashSet::new(),
585        };
586        assert_eq!(snap.languages(), ["rust"]);
587        assert_eq!(
588            snap.document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
589            RouteSupport::NoServer
590        );
591    }
592
593    #[test]
594    fn test_languages_list_mapped_languages_with_a_route_including_the_react_base() {
595        let mut language_map = LanguageMap::from(HashMap::from([
596            (
597                FileExtension::from_static("ts"),
598                LanguageId::from_static("typescript"),
599            ),
600            (
601                FileExtension::from_static("tsx"),
602                LanguageId::from_static("typescriptreact"),
603            ),
604            (
605                FileExtension::from_static("py"),
606                LanguageId::from_static("python"),
607            ),
608        ]));
609        language_map.insert(
610            crate::config::PatternTarget::Name(crate::config::FileName::from_static("Makefile")),
611            LanguageId::from_static("make"),
612        );
613        let snap = ToolSupportSnapshot {
614            language_map: Arc::new(language_map),
615            router: Arc::new(ToolRouter::catch_all([
616                (
617                    ServerId::from_static("ts"),
618                    LanguageId::from_static("typescript"),
619                ),
620                (
621                    ServerId::from_static("make-lsp"),
622                    LanguageId::from_static("make"),
623                ),
624                (
625                    ServerId::from_static("unmapped"),
626                    LanguageId::from_static("zig"),
627                ),
628            ])),
629            expected: HashSet::new(),
630            running: HashMap::new(),
631            registered: HashSet::new(),
632        };
633        assert_eq!(snap.languages(), ["make", "typescript", "typescriptreact"]);
634    }
635
636    #[test]
637    fn nothing_configured_workspace_support_is_no_server() {
638        let snap = ToolSupportSnapshot {
639            language_map: rust_language_map(),
640            router: Arc::new(ToolRouter::default()),
641            expected: HashSet::new(),
642            running: HashMap::new(),
643            registered: HashSet::new(),
644        };
645        assert!(snap.languages().is_empty());
646        assert_eq!(
647            snap.workspace_support(ToolKind::WorkspaceSymbols),
648            RouteSupport::NoServer
649        );
650    }
651
652    #[tokio::test]
653    async fn translator_snapshot_reflects_registered_capabilities() {
654        let dir = TempDir::new().unwrap();
655        let id = ServerId::from_static("rust");
656        let (translator, _fake) = translator_with_capabilities(&dir, &id, rust_caps(true));
657        let snap = translator.tool_support_snapshot();
658        assert_eq!(
659            snap.document_support(&LanguageId::from_static("rust"), ToolKind::Hover),
660            RouteSupport::Supported { server: id.clone() }
661        );
662        assert_eq!(
663            snap.document_support(&LanguageId::from_static("rust"), ToolKind::Rename),
664            RouteSupport::CapabilityNotAdvertised {
665                server: id,
666                capability: Capability::Rename,
667            }
668        );
669    }
670
671    #[tokio::test]
672    async fn language_for_path_validates_and_detects() {
673        let dir = TempDir::new().unwrap();
674        let (translator, _fake) = translator_with_capabilities(
675            &dir,
676            &ServerId::from_static("rust"),
677            ServerCapabilities::default(),
678        );
679        let file = dir.path().join("a.rs");
680        std::fs::write(&file, "").unwrap();
681        assert_eq!(
682            translator
683                .language_for_path(&client_path(&file))
684                .await
685                .unwrap(),
686            "rust"
687        );
688        assert!(
689            translator
690                .language_for_path(&client_path("/definitely/missing.rs"))
691                .await
692                .is_err()
693        );
694    }
695}