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