Skip to main content

lean_ctx/lsp/
capabilities.rs

1// SPDX-License-Identifier: Apache-2.0
2//! What a concrete semantic backend can actually do.
3//!
4//! LSP servers advertise their features in the `initialize` handshake; the
5//! router and every semantic consumer ask [`SemanticCapabilities`] instead of
6//! assuming that "an LSP server" supports a given request.
7
8use lsp_types::{OneOf, ServerCapabilities};
9
10/// Which kind of backend answers semantic queries.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SemanticBackendKind {
13    /// Stdio language server managed by lean-ctx (`LspClient`).
14    Lsp,
15    /// Live JetBrains IDE over the plugin's HTTP bridge.
16    JetBrains,
17    /// Editor extension's semantic bridge (VS Code, Cursor, Windsurf).
18    Editor,
19}
20
21impl SemanticBackendKind {
22    pub fn as_str(self) -> &'static str {
23        match self {
24            Self::Lsp => "lsp",
25            Self::JetBrains => "jetbrains",
26            Self::Editor => "editor",
27        }
28    }
29}
30
31/// Negotiated feature set of one backend instance.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub struct SemanticCapabilities {
34    pub definition: bool,
35    pub declaration: bool,
36    pub references: bool,
37    pub implementations: bool,
38    pub type_hierarchy: bool,
39    pub call_hierarchy: bool,
40    pub document_symbols: bool,
41    pub rename: bool,
42}
43
44impl SemanticCapabilities {
45    /// Everything โ€” the JetBrains bridge exposes the full PSI feature set.
46    pub const ALL: Self = Self {
47        definition: true,
48        declaration: true,
49        references: true,
50        implementations: true,
51        type_hierarchy: true,
52        call_hierarchy: true,
53        document_symbols: true,
54        rename: true,
55    };
56
57    /// Maps the server's `initialize` answer. A provider counts as present
58    /// unless the server explicitly answers `false`.
59    pub fn from_server(caps: &ServerCapabilities) -> Self {
60        fn one_of<T>(p: Option<&OneOf<bool, T>>) -> bool {
61            matches!(p, Some(OneOf::Left(true) | OneOf::Right(_)))
62        }
63        Self {
64            definition: one_of(caps.definition_provider.as_ref()),
65            declaration: caps
66                .declaration_provider
67                .as_ref()
68                .is_some_and(|p| !matches!(p, lsp_types::DeclarationCapability::Simple(false))),
69            references: one_of(caps.references_provider.as_ref()),
70            implementations: caps.implementation_provider.as_ref().is_some_and(|p| {
71                !matches!(
72                    p,
73                    lsp_types::ImplementationProviderCapability::Simple(false)
74                )
75            }),
76            // lsp-types has no `typeHierarchyProvider` field: the stdio
77            // client reads it from the raw `initialize` answer.
78            type_hierarchy: false,
79            call_hierarchy: caps.call_hierarchy_provider.as_ref().is_some_and(|p| {
80                !matches!(p, lsp_types::CallHierarchyServerCapability::Simple(false))
81            }),
82            document_symbols: one_of(caps.document_symbol_provider.as_ref()),
83            rename: one_of(caps.rename_provider.as_ref()),
84        }
85    }
86}
87
88/// Identity + capabilities of one backend instance.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct SemanticBackendInfo {
91    pub kind: SemanticBackendKind,
92    /// `serverInfo.name` from `initialize` (e.g. `rust-analyzer`), if reported.
93    pub server_name: Option<String>,
94    pub server_version: Option<String>,
95    pub capabilities: SemanticCapabilities,
96    /// Negotiated LSP position encoding: `true` = UTF-8 byte offsets, `false`
97    /// = UTF-16 code units (the protocol default, also used by the JetBrains
98    /// bridge). Callers convert tree-sitter byte columns accordingly.
99    pub utf8_positions: bool,
100}
101
102/// Converts a 0-based byte column within `line_text` into the character
103/// offset a backend expects. Columns inside a multi-byte char snap back to
104/// that char's start; columns past the end clamp to the line length.
105pub fn encode_column(line_text: &str, byte_col: usize, utf8_positions: bool) -> u32 {
106    let mut col = byte_col.min(line_text.len());
107    while !line_text.is_char_boundary(col) {
108        col -= 1;
109    }
110    let prefix = &line_text[..col];
111    let units = if utf8_positions {
112        prefix.len()
113    } else {
114        prefix.encode_utf16().count()
115    };
116    u32::try_from(units).unwrap_or(u32::MAX)
117}
118
119/// Some servers report build metadata as their version (gopls: a multi-KB
120/// JSON build info). The version ends up in every evidence record and cache
121/// key, so anything that is not a short plain token is reduced to its
122/// release (`Version` field of a JSON object, if present) plus a content
123/// hash โ€” still unique per build, but bounded.
124pub fn compact_server_version(raw: &str) -> String {
125    const MAX: usize = 64;
126    /// Build info beyond this is not parsed, only hashed.
127    const MAX_PARSED: usize = 64 * 1024;
128    let raw = raw.trim();
129    if raw.len() <= MAX && !raw.contains(['{', '\n', '"']) {
130        return raw.to_string();
131    }
132    let digest = blake3::hash(raw.as_bytes()).to_hex();
133    let release = (raw.len() <= MAX_PARSED)
134        .then(|| serde_json::from_str::<serde_json::Value>(raw).ok())
135        .flatten()
136        .and_then(|v| v.get("Version")?.as_str().map(str::to_string))
137        .filter(|v| v.len() <= MAX && !v.contains(['{', '\n', '"']));
138    match release {
139        Some(v) => format!("{v}+{}", &digest[..12]),
140        None => format!("build+{}", &digest[..12]),
141    }
142}
143
144impl SemanticBackendInfo {
145    /// Stable identity used to key cached semantic results: a different server
146    /// or server version may resolve differently, so its results never mix.
147    pub fn identity(&self) -> String {
148        format!(
149            "{}:{}@{}",
150            self.kind.as_str(),
151            self.server_name.as_deref().unwrap_or("unknown"),
152            self.server_version.as_deref().unwrap_or("unknown")
153        )
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use lsp_types::{ImplementationProviderCapability, OneOf, ServerCapabilities};
161
162    #[test]
163    fn negotiated_capabilities_follow_the_server_answer() {
164        let caps = ServerCapabilities {
165            definition_provider: Some(OneOf::Left(true)),
166            references_provider: Some(OneOf::Left(false)),
167            implementation_provider: Some(ImplementationProviderCapability::Simple(true)),
168            ..Default::default()
169        };
170        let negotiated = SemanticCapabilities::from_server(&caps);
171        assert!(negotiated.definition);
172        assert!(!negotiated.references, "explicit false must be honoured");
173        assert!(negotiated.implementations);
174        assert!(!negotiated.rename, "absent provider means unsupported");
175    }
176
177    #[test]
178    fn byte_columns_are_encoded_per_negotiated_encoding() {
179        // `รค` is 2 bytes / 1 UTF-16 unit; `๐Ÿ˜€` is 4 bytes / 2 UTF-16 units.
180        let line = "รค๐Ÿ˜€.save()";
181        let save = line.find("save").unwrap(); // byte 7
182        assert_eq!(encode_column(line, save, true), 7);
183        assert_eq!(encode_column(line, save, false), 4);
184        assert_eq!(
185            encode_column(line, 3, false),
186            1,
187            "inside ๐Ÿ˜€ snaps to its start"
188        );
189        assert_eq!(encode_column(line, 999, true), line.len() as u32);
190    }
191
192    #[test]
193    fn server_versions_are_bounded_but_stay_unique_per_build() {
194        assert_eq!(compact_server_version("1.15.0"), "1.15.0");
195        // gopls reports its JSON build info as the version.
196        let a =
197            r#"{"GoVersion":"go1.26","Deps":[{"Path":"x","Version":"v1"}],"Version":"v0.23.0"}"#;
198        let b =
199            r#"{"GoVersion":"go1.27","Deps":[{"Path":"x","Version":"v1"}],"Version":"v0.23.0"}"#;
200        let (ca, cb) = (compact_server_version(a), compact_server_version(b));
201        assert!(ca.starts_with("v0.23.0+") && ca.len() <= 24, "{ca}");
202        assert_ne!(ca, cb, "a different build keeps a different identity");
203        let blob = "x".repeat(500);
204        assert!(compact_server_version(&blob).starts_with("build+"));
205    }
206}