Skip to main content

aft/lsp/
registry.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use crate::config::{Config, UserServerDef};
6
7/// Unique identifier for a language server kind.
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub enum ServerKind {
10    TypeScript,
11    Python,
12    Rust,
13    Go,
14    Bash,
15    Yaml,
16    Ty,
17    Custom(Arc<str>),
18}
19
20impl ServerKind {
21    pub fn id_str(&self) -> &str {
22        match self {
23            Self::TypeScript => "typescript",
24            Self::Python => "python",
25            Self::Rust => "rust",
26            Self::Go => "go",
27            Self::Bash => "bash",
28            Self::Yaml => "yaml",
29            Self::Ty => "ty",
30            Self::Custom(id) => id.as_ref(),
31        }
32    }
33}
34
35/// Definition of a language server.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ServerDef {
38    pub kind: ServerKind,
39    /// Display name.
40    pub name: String,
41    /// File extensions this server handles.
42    pub extensions: Vec<String>,
43    /// Binary name to look up on PATH.
44    pub binary: String,
45    /// Arguments to pass when spawning.
46    pub args: Vec<String>,
47    /// Root marker files — presence indicates a workspace root.
48    pub root_markers: Vec<String>,
49    /// Extra environment variables for this server process.
50    pub env: HashMap<String, String>,
51    /// Optional JSON initializationOptions for the initialize request.
52    pub initialization_options: Option<serde_json::Value>,
53}
54
55impl ServerDef {
56    /// Check if this server handles a given file extension.
57    pub fn matches_extension(&self, ext: &str) -> bool {
58        self.extensions
59            .iter()
60            .any(|candidate| candidate.eq_ignore_ascii_case(ext))
61    }
62
63    /// Check if the server binary is available on PATH.
64    pub fn is_available(&self) -> bool {
65        which::which(&self.binary).is_ok()
66    }
67}
68
69/// Built-in server definitions.
70pub fn builtin_servers() -> Vec<ServerDef> {
71    vec![
72        builtin_server(
73            ServerKind::TypeScript,
74            "TypeScript Language Server",
75            &["ts", "tsx", "js", "jsx", "mjs", "cjs"],
76            "typescript-language-server",
77            &["--stdio"],
78            &["tsconfig.json", "jsconfig.json", "package.json"],
79        ),
80        builtin_server(
81            ServerKind::Python,
82            "Pyright",
83            &["py", "pyi"],
84            "pyright-langserver",
85            &["--stdio"],
86            &[
87                "pyproject.toml",
88                "setup.py",
89                "setup.cfg",
90                "pyrightconfig.json",
91                "requirements.txt",
92            ],
93        ),
94        builtin_server(
95            ServerKind::Rust,
96            "rust-analyzer",
97            &["rs"],
98            "rust-analyzer",
99            &[],
100            &["Cargo.toml"],
101        ),
102        // gopls requires opt-in for `textDocument/diagnostic` (LSP 3.17 pull)
103        // via the `pullDiagnostics` initializationOption. Without this the
104        // server still publishes via push but ignores pull requests.
105        // See https://github.com/golang/tools/blob/master/gopls/doc/settings.md
106        builtin_server_with_init(
107            ServerKind::Go,
108            "gopls",
109            &["go"],
110            "gopls",
111            &["serve"],
112            &["go.mod"],
113            serde_json::json!({ "pullDiagnostics": true }),
114        ),
115        builtin_server(
116            ServerKind::Bash,
117            "bash-language-server",
118            &["sh", "bash", "zsh"],
119            "bash-language-server",
120            &["start"],
121            &["package.json", ".git"],
122        ),
123        builtin_server(
124            ServerKind::Yaml,
125            "yaml-language-server",
126            &["yaml", "yml"],
127            "yaml-language-server",
128            &["--stdio"],
129            &["package.json", ".git"],
130        ),
131        builtin_server(
132            ServerKind::Ty,
133            "ty",
134            &["py", "pyi"],
135            "ty",
136            &["server"],
137            &[
138                "pyproject.toml",
139                "ty.toml",
140                "setup.py",
141                "setup.cfg",
142                "requirements.txt",
143                "Pipfile",
144                "pyrightconfig.json",
145            ],
146        ),
147    ]
148}
149
150/// Find all server definitions that handle a given file path.
151pub fn servers_for_file(path: &Path, config: &Config) -> Vec<ServerDef> {
152    let extension = path
153        .extension()
154        .and_then(|ext| ext.to_str())
155        .unwrap_or_default();
156
157    builtin_servers()
158        .into_iter()
159        .chain(config.lsp_servers.iter().filter_map(custom_server))
160        .filter(|server| !is_disabled(server, config))
161        .filter(|server| config.experimental_lsp_ty || server.kind != ServerKind::Ty)
162        .filter(|server| server.matches_extension(extension))
163        .collect()
164}
165
166fn builtin_server(
167    kind: ServerKind,
168    name: &str,
169    extensions: &[&str],
170    binary: &str,
171    args: &[&str],
172    root_markers: &[&str],
173) -> ServerDef {
174    ServerDef {
175        kind,
176        name: name.to_string(),
177        extensions: strings(extensions),
178        binary: binary.to_string(),
179        args: strings(args),
180        root_markers: strings(root_markers),
181        env: HashMap::new(),
182        initialization_options: None,
183    }
184}
185
186/// Builder variant of [`builtin_server`] that includes a default
187/// `initializationOptions` payload — used for servers that need server-specific
188/// settings to enable LSP features (e.g., gopls's `pullDiagnostics`).
189fn builtin_server_with_init(
190    kind: ServerKind,
191    name: &str,
192    extensions: &[&str],
193    binary: &str,
194    args: &[&str],
195    root_markers: &[&str],
196    initialization_options: serde_json::Value,
197) -> ServerDef {
198    let mut def = builtin_server(kind, name, extensions, binary, args, root_markers);
199    def.initialization_options = Some(initialization_options);
200    def
201}
202
203fn custom_server(server: &UserServerDef) -> Option<ServerDef> {
204    if server.disabled {
205        return None;
206    }
207
208    Some(ServerDef {
209        kind: ServerKind::Custom(Arc::from(server.id.as_str())),
210        name: server.id.clone(),
211        extensions: server.extensions.clone(),
212        binary: server.binary.clone(),
213        args: server.args.clone(),
214        root_markers: server.root_markers.clone(),
215        env: server.env.clone(),
216        initialization_options: server.initialization_options.clone(),
217    })
218}
219
220fn is_disabled(server: &ServerDef, config: &Config) -> bool {
221    config
222        .disabled_lsp
223        .contains(&server.kind.id_str().to_ascii_lowercase())
224}
225
226fn strings(values: &[&str]) -> Vec<String> {
227    values.iter().map(|value| (*value).to_string()).collect()
228}
229
230#[cfg(test)]
231mod tests {
232    use std::path::Path;
233    use std::sync::Arc;
234
235    use crate::config::{Config, UserServerDef};
236
237    use super::{servers_for_file, ServerKind};
238
239    fn matching_kinds(path: &str, config: &Config) -> Vec<ServerKind> {
240        servers_for_file(Path::new(path), config)
241            .into_iter()
242            .map(|server| server.kind)
243            .collect()
244    }
245
246    #[test]
247    fn test_servers_for_typescript_file() {
248        assert_eq!(
249            matching_kinds("/tmp/file.ts", &Config::default()),
250            vec![ServerKind::TypeScript]
251        );
252    }
253
254    #[test]
255    fn test_servers_for_python_file() {
256        assert_eq!(
257            matching_kinds("/tmp/file.py", &Config::default()),
258            vec![ServerKind::Python]
259        );
260    }
261
262    #[test]
263    fn test_servers_for_rust_file() {
264        assert_eq!(
265            matching_kinds("/tmp/file.rs", &Config::default()),
266            vec![ServerKind::Rust]
267        );
268    }
269
270    #[test]
271    fn test_servers_for_go_file() {
272        assert_eq!(
273            matching_kinds("/tmp/file.go", &Config::default()),
274            vec![ServerKind::Go]
275        );
276    }
277
278    #[test]
279    fn test_servers_for_unknown_file() {
280        assert!(matching_kinds("/tmp/file.txt", &Config::default()).is_empty());
281    }
282
283    #[test]
284    fn test_tsx_matches_typescript() {
285        assert_eq!(
286            matching_kinds("/tmp/file.tsx", &Config::default()),
287            vec![ServerKind::TypeScript]
288        );
289    }
290
291    #[test]
292    fn test_case_insensitive_extension() {
293        assert_eq!(
294            matching_kinds("/tmp/file.TS", &Config::default()),
295            vec![ServerKind::TypeScript]
296        );
297    }
298
299    #[test]
300    fn test_bash_and_yaml_builtins() {
301        assert_eq!(
302            matching_kinds("/tmp/file.sh", &Config::default()),
303            vec![ServerKind::Bash]
304        );
305        assert_eq!(
306            matching_kinds("/tmp/file.yaml", &Config::default()),
307            vec![ServerKind::Yaml]
308        );
309    }
310
311    #[test]
312    fn test_ty_requires_experimental_flag() {
313        assert_eq!(
314            matching_kinds("/tmp/file.py", &Config::default()),
315            vec![ServerKind::Python]
316        );
317
318        let config = Config {
319            experimental_lsp_ty: true,
320            ..Config::default()
321        };
322        assert_eq!(
323            matching_kinds("/tmp/file.py", &config),
324            vec![ServerKind::Python, ServerKind::Ty]
325        );
326    }
327
328    #[test]
329    fn test_custom_server_matches_extension() {
330        let config = Config {
331            lsp_servers: vec![UserServerDef {
332                id: "tinymist".to_string(),
333                extensions: vec!["typ".to_string()],
334                binary: "tinymist".to_string(),
335                root_markers: vec!["typst.toml".to_string()],
336                ..UserServerDef::default()
337            }],
338            ..Config::default()
339        };
340
341        assert_eq!(
342            matching_kinds("/tmp/file.typ", &config),
343            vec![ServerKind::Custom(Arc::from("tinymist"))]
344        );
345    }
346}