iced_code_editor/canvas_editor/lsp_process/config.rs
1//! LSP (Language Server Protocol) configuration module.
2//!
3//! This module handles language server detection, configuration, and command resolution
4//! for various programming languages. It maps file extensions to language servers and
5//! provides functionality to resolve the correct server command based on environment
6//! variables and system availability.
7
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11/// Represents a language supported by an LSP server.
12///
13/// Contains the language identifier and the associated server key.
14///
15/// # Examples
16///
17/// ```no_run
18/// use iced_code_editor::lsp_language_for_extension;
19///
20/// if let Some(lang) = lsp_language_for_extension("rs") {
21/// assert_eq!(lang.language_id, "rust");
22/// assert_eq!(lang.server_key, "rust-analyzer");
23/// }
24/// ```
25#[derive(Clone, Copy)]
26pub struct LspLanguage {
27 /// Language identifier (e.g., "rust", "python", "typescript")
28 pub language_id: &'static str,
29 /// Key identifying the LSP server (e.g., "rust-analyzer", "pyright")
30 pub server_key: &'static str,
31}
32
33/// Internal mapping between file extensions and language/server configurations.
34#[derive(Clone, Copy)]
35struct LspLanguageMapping {
36 /// File extensions associated with this language (e.g., ["rs"], ["ts", "tsx"])
37 extensions: &'static [&'static str],
38 /// Language identifier for LSP protocol
39 language_id: &'static str,
40 /// Key to look up the server configuration
41 server_key: &'static str,
42}
43
44/// Configuration for an LSP server.
45///
46/// Defines how to locate and run the language server.
47///
48/// # Examples
49///
50/// ```no_run
51/// use iced_code_editor::lsp_server_config;
52///
53/// if let Some(config) = lsp_server_config("rust-analyzer") {
54/// println!("key: {}", config.key);
55/// }
56/// ```
57#[derive(Clone, Copy)]
58pub struct LspServerConfig {
59 /// Unique identifier for this server configuration
60 pub key: &'static str,
61 /// Environment variables to check for custom server paths (checked in order)
62 pub env_vars: &'static [&'static str],
63 /// Default command and arguments to run the server
64 pub default_command: &'static [&'static str],
65}
66
67/// Resolved command to execute an LSP server.
68///
69/// # Examples
70///
71/// ```no_run
72/// use iced_code_editor::{lsp_server_config, resolve_lsp_command};
73///
74/// if let Some(config) = lsp_server_config("gopls") {
75/// if let Ok(cmd) = resolve_lsp_command(config) {
76/// println!("program: {}", cmd.program);
77/// }
78/// }
79/// ```
80pub struct LspCommand {
81 /// Program path or name
82 pub program: String,
83 /// Command-line arguments
84 pub args: Vec<String>,
85}
86
87/// Supported language mappings: file extensions -> language ID -> server key
88const LSP_LANGUAGE_MAPPINGS: &[LspLanguageMapping] = &[
89 LspLanguageMapping {
90 extensions: &["rs"],
91 language_id: "rust",
92 server_key: "rust-analyzer",
93 },
94 LspLanguageMapping {
95 extensions: &["py"],
96 language_id: "python",
97 server_key: "pyright",
98 },
99 LspLanguageMapping {
100 extensions: &["js", "jsx"],
101 language_id: "javascript",
102 server_key: "typescript-language-server",
103 },
104 LspLanguageMapping {
105 extensions: &["ts", "tsx"],
106 language_id: "typescript",
107 server_key: "typescript-language-server",
108 },
109 LspLanguageMapping {
110 extensions: &["lua"],
111 language_id: "lua",
112 server_key: "lua-language-server",
113 },
114 LspLanguageMapping {
115 extensions: &["go"],
116 language_id: "go",
117 server_key: "gopls",
118 },
119];
120
121/// Server configurations for each supported LSP server.
122/// Defines environment variables and default commands for each server.
123const LSP_SERVER_CONFIGS: &[LspServerConfig] = &[
124 LspServerConfig {
125 key: "rust-analyzer",
126 env_vars: &["RUST_ANALYZER", "RUST_ANALYZER_PATH"],
127 default_command: &["rust-analyzer"],
128 },
129 LspServerConfig {
130 key: "pyright",
131 env_vars: &["PYRIGHT_LANGSERVER", "PYRIGHT_LANGSERVER_PATH"],
132 default_command: &["pyright-langserver", "--stdio"],
133 },
134 LspServerConfig {
135 key: "typescript-language-server",
136 env_vars: &[
137 "TYPESCRIPT_LANGUAGE_SERVER",
138 "TYPESCRIPT_LANGUAGE_SERVER_PATH",
139 ],
140 default_command: &["typescript-language-server", "--stdio"],
141 },
142 LspServerConfig {
143 key: "lua-language-server",
144 env_vars: &["LUA_LANGUAGE_SERVER", "LUA_LANGUAGE_SERVER_PATH"],
145 default_command: &["lua-language-server"],
146 },
147 LspServerConfig {
148 key: "gopls",
149 env_vars: &["GOPLS", "GOPLS_PATH"],
150 default_command: &["gopls"],
151 },
152];
153
154/// Looks up the LSP language configuration for a file extension.
155///
156/// Returns `None` if the extension is not supported.
157///
158/// # Examples
159///
160/// ```
161/// use iced_code_editor::lsp_language_for_extension;
162///
163/// let lang = lsp_language_for_extension("rs");
164/// assert!(lang.is_some());
165///
166/// let unknown = lsp_language_for_extension("xyz");
167/// assert!(unknown.is_none());
168/// ```
169pub fn lsp_language_for_extension(extension: &str) -> Option<LspLanguage> {
170 let extension = extension.to_lowercase();
171 LSP_LANGUAGE_MAPPINGS
172 .iter()
173 .find(|mapping| {
174 mapping
175 .extensions
176 .iter()
177 .any(|ext| ext.eq_ignore_ascii_case(extension.as_str()))
178 })
179 .map(|mapping| LspLanguage {
180 language_id: mapping.language_id,
181 server_key: mapping.server_key,
182 })
183}
184
185/// Looks up the LSP language configuration for a file path.
186///
187/// Extracts the extension and delegates to [`lsp_language_for_extension`].
188///
189/// # Examples
190///
191/// ```
192/// use std::path::Path;
193/// use iced_code_editor::lsp_language_for_path;
194///
195/// let lang = lsp_language_for_path(Path::new("main.rs"));
196/// assert!(lang.is_some());
197///
198/// let none = lsp_language_for_path(Path::new("noext"));
199/// assert!(none.is_none());
200/// ```
201pub fn lsp_language_for_path(path: &Path) -> Option<LspLanguage> {
202 let extension = path.extension()?.to_str()?;
203 lsp_language_for_extension(extension)
204}
205
206/// Retrieves the server configuration for a given server key.
207///
208/// # Examples
209///
210/// ```
211/// use iced_code_editor::lsp_server_config;
212///
213/// let cfg = lsp_server_config("rust-analyzer");
214/// assert!(cfg.is_some());
215///
216/// let missing = lsp_server_config("unknown-server");
217/// assert!(missing.is_none());
218/// ```
219pub fn lsp_server_config(key: &str) -> Option<&'static LspServerConfig> {
220 LSP_SERVER_CONFIGS.iter().find(|config| config.key == key)
221}
222
223/// Resolves the command to execute an LSP server.
224///
225/// Checks environment variables first, then falls back to the default command.
226/// Special handling for rust-analyzer to support rustup-installed versions.
227///
228/// # Errors
229///
230/// Returns an error string if the program cannot be located (e.g. rust-analyzer
231/// or gopls are not installed and not found via their fallback discovery logic).
232///
233/// # Examples
234///
235/// ```no_run
236/// use iced_code_editor::{lsp_server_config, resolve_lsp_command};
237///
238/// if let Some(config) = lsp_server_config("lua-language-server") {
239/// match resolve_lsp_command(config) {
240/// Ok(cmd) => println!("Run: {}", cmd.program),
241/// Err(e) => eprintln!("Not found: {e}"),
242/// }
243/// }
244/// ```
245pub fn resolve_lsp_command(
246 config: &LspServerConfig,
247) -> Result<LspCommand, String> {
248 let program = if config.key == "rust-analyzer" {
249 resolve_rust_analyzer_command()?
250 } else if config.key == "gopls" {
251 resolve_gopls_command()?
252 } else {
253 resolve_program_from_envs(config.env_vars)
254 .unwrap_or_else(|| config.default_command[0].to_string())
255 };
256 let args = config
257 .default_command
258 .iter()
259 .skip(1)
260 .map(|arg| arg.to_string())
261 .collect();
262 Ok(LspCommand { program, args })
263}
264
265/// Resolves a program path from a list of environment variables.
266/// Returns the first non-empty value found, or None if all are unset/empty.
267fn resolve_program_from_envs(env_vars: &[&str]) -> Option<String> {
268 for var in env_vars {
269 if let Ok(path) = std::env::var(var)
270 && !path.trim().is_empty()
271 {
272 return Some(path);
273 }
274 }
275 None
276}
277
278/// Resolves the rust-analyzer command with special handling.
279/// Checks in order:
280/// 1. RUST_ANALYZER environment variable
281/// 2. RUST_ANALYZER_PATH environment variable
282/// 3. Direct rust-analyzer command
283/// 4. rustup which rust-analyzer
284fn resolve_rust_analyzer_command() -> Result<String, String> {
285 if let Ok(path) = std::env::var("RUST_ANALYZER")
286 && !path.trim().is_empty()
287 {
288 return Ok(path);
289 }
290 if let Ok(path) = std::env::var("RUST_ANALYZER_PATH")
291 && !path.trim().is_empty()
292 {
293 return Ok(path);
294 }
295 if Command::new("rust-analyzer").arg("--version").output().is_ok() {
296 return Ok("rust-analyzer".to_string());
297 }
298 if let Ok(output) =
299 Command::new("rustup").args(["which", "rust-analyzer"]).output()
300 && output.status.success()
301 {
302 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
303 if !path.is_empty() {
304 return Ok(path);
305 }
306 }
307 Err(
308 "rust-analyzer not found. Please run rustup component add rust-analyzer or brew install rust-analyzer"
309 .to_string(),
310 )
311}
312
313fn resolve_gopls_command() -> Result<String, String> {
314 if let Some(path) = resolve_program_from_envs(&["GOPLS", "GOPLS_PATH"]) {
315 return Ok(path);
316 }
317 if Command::new("gopls").arg("version").output().is_ok() {
318 return Ok("gopls".to_string());
319 }
320 if let Ok(output) = Command::new("go").args(["env", "GOBIN"]).output()
321 && output.status.success()
322 {
323 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
324 if !path.is_empty() {
325 let candidate = PathBuf::from(path).join("gopls");
326 if candidate.exists() {
327 return Ok(candidate.to_string_lossy().to_string());
328 }
329 }
330 }
331 if let Ok(output) = Command::new("go").args(["env", "GOPATH"]).output()
332 && output.status.success()
333 {
334 let paths = String::from_utf8_lossy(&output.stdout);
335 for path in paths.trim().split(':') {
336 let path = path.trim();
337 if path.is_empty() {
338 continue;
339 }
340 let candidate = PathBuf::from(path).join("bin").join("gopls");
341 if candidate.exists() {
342 return Ok(candidate.to_string_lossy().to_string());
343 }
344 }
345 }
346 Err(
347 "gopls not found. Please set GOPLS/GOPLS_PATH or add GOPATH/bin to PATH"
348 .to_string(),
349 )
350}
351
352/// Ensures rust-analyzer configuration directory exists on macOS.
353///
354/// Creates the configuration directory and an empty config file if they don't exist.
355/// This prevents rust-analyzer from failing on first run on macOS.
356///
357/// # Examples
358///
359/// ```no_run
360/// use iced_code_editor::ensure_rust_analyzer_config;
361///
362/// // Safe to call on any platform; no-op on non-macOS.
363/// ensure_rust_analyzer_config();
364/// ```
365#[cfg(target_os = "macos")]
366pub fn ensure_rust_analyzer_config() {
367 let Some(home) = std::env::var_os("HOME") else { return };
368 let mut path = std::path::PathBuf::from(home);
369 path.push("Library");
370 path.push("Application Support");
371 path.push("rust-analyzer");
372 let _ = std::fs::create_dir_all(&path);
373 path.push("rust-analyzer.toml");
374 if !path.exists() {
375 let _ = std::fs::write(path, "");
376 }
377}
378
379/// No-op on non-macOS platforms.
380///
381/// # Examples
382///
383/// ```no_run
384/// use iced_code_editor::ensure_rust_analyzer_config;
385///
386/// // Safe to call on any platform; no-op on non-macOS.
387/// ensure_rust_analyzer_config();
388/// ```
389#[cfg(not(target_os = "macos"))]
390pub fn ensure_rust_analyzer_config() {}