diff --git a/README.md b/README.md index 7ecdfb9..c6fe567 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,86 @@ A [Lua](https://www.lua.org/) extension for [Zed](https://zed.dev). +## Language servers + +The extension supports multiple language servers. + +| Name | GitHub | Settings ID | Binary | +| --------- | ------------------------------------------------------------------------------------- | --------------------- | --------------------- | +| LuaLS | [LuaLS/lua-language-server](https://github.com/LuaLS/lua-language-server) | `lua-language-server` | `lua-language-server` | +| EmmyLuaLS | [EmmyLuaLs/emmylua-analyzer-rust](https://github.com/EmmyLuaLs/emmylua-analyzer-rust) | `emmylua_ls` | `emmylua_ls` | + +If Zed finds the binary for an LSP in your PATH (e.g. `which emmylua_ls` or `which lua-language-server`) it will use that version, if not it will automatically download managed copies of the newest version via GitHub releases. + +### Language server selection + +You should only enable one language server at a time. For backwards compatability, Zed defaults to `LuaLS`. + +If you would like to use EmmyLuaLS instead, you can specify your preferred language server via Zed Settings: + +```json +{ + "languages": { + "Lua": { + "language_servers": ["emmylua_ls", "!lua-language-server", "..."] + } + } +} +``` + +Explicitly use LuaLS: + +```json +{ + "languages": { + "Lua": { + "language_servers": ["lua-language-server", "!emmylua_ls", "..."] + } + } +} +``` + +Or to use no language server: + +```json +{ + "languages": { + "Lua": { + "enable_language_server": false + } + } +} +``` + + +### Advanced CLI Settings + +You can also specify optional `binary` configuration per language-server if required: + +```json +{ + "lsp": { + "emmylua_ls": { + "binary": { + "path": "/home/somebody/.cargo/bin/emmylua_ls", + "arguments": ["--log-level", "debug"], + "env": { + "RUST_LOG": "debug" + } + } + } + } +} +``` + +## Links + +- Official [Zed Lua Language Docs](https://zed.dev/docs/languages/lua) +- [`.luarc.json` Documentation](https://luals.github.io/wiki/configuration/#luarcjson-file) and [`.luarc.json` Schema](https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json) +- [`.emmyrc.json` Docucmentation](https://github.com/EmmyLuaLs/emmylua-analyzer-rust/blob/main/docs/config/emmyrc_json_EN.md) and [`.emmyrc.json` Schema](https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json) +- Luau Extension: [4teapo/zed-luau ](https://github.com/4teapo/zed-luau) +- Fennel Extension: [notpeter/fennel](https://github.com/notpeter/fennel-zed/) + ## Development To develop this extension, see the [Developing Extensions](https://zed.dev/docs/extensions/developing-extensions) section of the Zed docs. diff --git a/extension.toml b/extension.toml index 7f9bf14..84e9c56 100644 --- a/extension.toml +++ b/extension.toml @@ -10,6 +10,10 @@ repository = "https://github.com/zed-extensions/lua" name = "LuaLS" languages = ["Lua", "EmmyLuadoc"] +[language_servers.emmylua_ls] +name = "EmmyLuaLS" +languages = ["Lua", "EmmyLuadoc"] + [grammars.lua] repository = "https://github.com/tree-sitter-grammars/tree-sitter-lua" commit = "d76023017f7485eae629cb60d406c7a1ca0f40c9" diff --git a/src/language_servers/emmylua_ls.rs b/src/language_servers/emmylua_ls.rs new file mode 100644 index 0000000..3df7e6f --- /dev/null +++ b/src/language_servers/emmylua_ls.rs @@ -0,0 +1,152 @@ +use super::remove_old_server_versions; +use std::fs; +use zed_extension_api::{self as zed, settings::LspSettings, LanguageServerId, Result}; + +pub struct EmmyLuaLs { + cached_binary_path: Option, +} + +impl EmmyLuaLs { + pub const SERVER_ID: &str = "emmylua_ls"; + const BINARY_NAME: &str = "emmylua_ls"; + + pub fn new() -> Self { + Self { + cached_binary_path: None, + } + } + + pub fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + let binary = LspSettings::for_worktree(language_server_id.as_ref(), worktree)?.binary; + let args = binary + .as_ref() + .and_then(|binary| binary.arguments.clone()) + .unwrap_or_default(); + let env = binary + .as_ref() + .and_then(|binary| binary.env.clone()) + .map(|env| env.into_iter().collect()) + .unwrap_or_default(); + let configured_or_system_path = binary + .and_then(|binary| binary.path) + .or_else(|| worktree.which(Self::BINARY_NAME)); + let command = match configured_or_system_path { + Some(path) => path, + None => self.zed_managed_binary_path(language_server_id)?, + }; + + Ok(zed::Command { command, args, env }) + } + + fn zed_managed_binary_path(&mut self, language_server_id: &LanguageServerId) -> Result { + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { + return Ok(path.clone()); + } + } + + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + let release = zed::latest_github_release( + "EmmyLuaLs/emmylua-analyzer-rust", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (platform, arch) = zed::current_platform(); + let asset_name = Self::asset_name(platform, arch)?; + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| format!("no asset found matching {asset_name:?}"))?; + + let version_dir = format!("emmylua_ls-{}", release.version); + let binary_path = format!( + "{version_dir}/emmylua_ls{extension}", + extension = match platform { + zed::Os::Mac | zed::Os::Linux => "", + zed::Os::Windows => ".exe", + }, + ); + + if !fs::metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &version_dir, + match platform { + zed::Os::Mac | zed::Os::Linux => zed::DownloadedFileType::GzipTar, + zed::Os::Windows => zed::DownloadedFileType::Zip, + }, + ) + .map_err(|e| format!("failed to download file: {e}"))?; + + remove_old_server_versions("emmylua_ls-", &version_dir)?; + } + + self.cached_binary_path = Some(binary_path.clone()); + Ok(binary_path) + } + + fn asset_name(platform: zed::Os, arch: zed::Architecture) -> Result { + let platform_name = match (platform, arch) { + (zed::Os::Mac, zed::Architecture::Aarch64) => "darwin-arm64", + (zed::Os::Mac, zed::Architecture::X8664) => "darwin-x64", + (zed::Os::Mac, zed::Architecture::X86) => { + return Err("unsupported macOS architecture x86".into()); + } + (zed::Os::Linux, zed::Architecture::Aarch64) => "linux-aarch64-glibc.2.17", + (zed::Os::Linux, zed::Architecture::X8664) => "linux-x64-glibc.2.17", + (zed::Os::Linux, zed::Architecture::X86) => { + return Err("unsupported Linux architecture x86".into()); + } + (zed::Os::Windows, zed::Architecture::Aarch64) => "win32-arm64", + (zed::Os::Windows, zed::Architecture::X8664) => "win32-x64", + (zed::Os::Windows, zed::Architecture::X86) => "win32-ia32", + }; + let extension = match platform { + zed::Os::Mac | zed::Os::Linux => "tar.gz", + zed::Os::Windows => "zip", + }; + Ok(format!("emmylua_ls-{platform_name}.{extension}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uses_its_own_server_id() { + assert_eq!(EmmyLuaLs::SERVER_ID, "emmylua_ls"); + } + + #[test] + fn maps_supported_release_assets() { + assert_eq!( + EmmyLuaLs::asset_name(zed::Os::Mac, zed::Architecture::Aarch64).unwrap(), + "emmylua_ls-darwin-arm64.tar.gz" + ); + assert_eq!( + EmmyLuaLs::asset_name(zed::Os::Linux, zed::Architecture::X8664).unwrap(), + "emmylua_ls-linux-x64-glibc.2.17.tar.gz" + ); + assert_eq!( + EmmyLuaLs::asset_name(zed::Os::Windows, zed::Architecture::X86).unwrap(), + "emmylua_ls-win32-ia32.zip" + ); + } +} diff --git a/src/language_servers/lua_ls.rs b/src/language_servers/lua_ls.rs new file mode 100644 index 0000000..21addcb --- /dev/null +++ b/src/language_servers/lua_ls.rs @@ -0,0 +1,131 @@ +use super::remove_old_server_versions; +use std::fs; +use zed_extension_api::{self as zed, settings::LspSettings, LanguageServerId, Result}; + +pub struct LuaLs { + cached_binary_path: Option, +} + +impl LuaLs { + pub const SERVER_ID: &str = "lua-language-server"; + const BINARY_NAME: &str = "lua-language-server"; + + pub fn new() -> Self { + Self { + cached_binary_path: None, + } + } + + pub fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + let binary = LspSettings::for_worktree(language_server_id.as_ref(), worktree)?.binary; + let args = binary + .as_ref() + .and_then(|binary| binary.arguments.clone()) + .unwrap_or_default(); + let env = binary + .as_ref() + .and_then(|binary| binary.env.clone()) + .map(|env| env.into_iter().collect()) + .unwrap_or_default(); + let configured_or_system_path = binary + .and_then(|binary| binary.path) + .or_else(|| worktree.which(Self::BINARY_NAME)); + let command = match configured_or_system_path { + Some(path) => path, + None => self.zed_managed_binary_path(language_server_id)?, + }; + + Ok(zed::Command { command, args, env }) + } + + fn zed_managed_binary_path(&mut self, language_server_id: &LanguageServerId) -> Result { + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { + return Ok(path.clone()); + } + } + + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + let release = zed::latest_github_release( + "LuaLS/lua-language-server", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (platform, arch) = zed::current_platform(); + let asset_name = format!( + "lua-language-server-{version}-{os}-{arch}.{extension}", + version = release.version, + os = match platform { + zed::Os::Mac => "darwin", + zed::Os::Linux => "linux", + zed::Os::Windows => "win32", + }, + arch = match arch { + zed::Architecture::Aarch64 => "arm64", + zed::Architecture::X8664 => "x64", + zed::Architecture::X86 => return Err("unsupported platform x86".into()), + }, + extension = match platform { + zed::Os::Mac | zed::Os::Linux => "tar.gz", + zed::Os::Windows => "zip", + }, + ); + + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| format!("no asset found matching {asset_name:?}"))?; + + let version_dir = format!("lua-language-server-{}", release.version); + let binary_path = format!( + "{version_dir}/bin/lua-language-server{extension}", + extension = match platform { + zed::Os::Mac | zed::Os::Linux => "", + zed::Os::Windows => ".exe", + }, + ); + + if !fs::metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &version_dir, + match platform { + zed::Os::Mac | zed::Os::Linux => zed::DownloadedFileType::GzipTar, + zed::Os::Windows => zed::DownloadedFileType::Zip, + }, + ) + .map_err(|e| format!("failed to download file: {e}"))?; + + remove_old_server_versions("lua-language-server-", &version_dir)?; + } + + self.cached_binary_path = Some(binary_path.clone()); + Ok(binary_path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uses_its_own_server_id() { + assert_eq!(LuaLs::SERVER_ID, "lua-language-server"); + } +} diff --git a/src/language_servers/mod.rs b/src/language_servers/mod.rs new file mode 100644 index 0000000..9bb0490 --- /dev/null +++ b/src/language_servers/mod.rs @@ -0,0 +1,52 @@ +mod emmylua_ls; +mod lua_ls; + +pub use emmylua_ls::EmmyLuaLs; +pub use lua_ls::LuaLs; + +use std::fs; +use zed_extension_api::Result; + +fn remove_old_server_versions(prefix: &str, current_version_dir: &str) -> Result<()> { + let entries = + fs::read_dir(".").map_err(|e| format!("failed to list working directory: {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry: {e}"))?; + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + if is_old_server_version(file_name, prefix, current_version_dir) { + fs::remove_dir_all(entry.path()).ok(); + } + } + Ok(()) +} + +fn is_old_server_version(file_name: &str, prefix: &str, current_version_dir: &str) -> bool { + file_name.starts_with(prefix) && file_name != current_version_dir +} + +#[cfg(test)] +mod tests { + use super::is_old_server_version; + + #[test] + fn old_version_cleanup_is_scoped_to_one_server() { + assert!(is_old_server_version( + "lua-language-server-3.0.0", + "lua-language-server-", + "lua-language-server-3.1.0" + )); + assert!(!is_old_server_version( + "lua-language-server-3.1.0", + "lua-language-server-", + "lua-language-server-3.1.0" + )); + assert!(!is_old_server_version( + "emmylua_ls-0.25.1", + "lua-language-server-", + "lua-language-server-3.1.0" + )); + } +} diff --git a/src/lua.rs b/src/lua.rs index 8c3f909..fbe5f95 100644 --- a/src/lua.rs +++ b/src/lua.rs @@ -1,123 +1,21 @@ -use std::fs; +mod language_servers; + +use language_servers::{EmmyLuaLs, LuaLs}; use zed_extension_api::{ self as zed, lsp::CompletionKind, settings::LspSettings, CodeLabel, CodeLabelSpan, LanguageServerId, Result, }; -struct LuaBinary { - path: String, - args: Option>, -} - struct LuaExtension { - cached_binary_path: Option, -} - -impl LuaExtension { - fn language_server_binary( - &mut self, - language_server_id: &LanguageServerId, - worktree: &zed::Worktree, - ) -> Result { - let settings = LspSettings::for_worktree(language_server_id.as_ref(), worktree); - let binary = settings.ok().and_then(|settings| settings.binary); - let args = binary.as_ref().and_then(|binary| binary.arguments.clone()); - let path = binary - .and_then(|binary| binary.path) - .or_else(|| worktree.which("lua-language-server")) - .unwrap_or(self.zed_managed_binary_path(language_server_id)?); - Ok(LuaBinary { path, args }) - } - - fn zed_managed_binary_path(&mut self, language_server_id: &LanguageServerId) -> Result { - if let Some(path) = &self.cached_binary_path { - if fs::metadata(path).is_ok_and(|stat| stat.is_file()) { - return Ok(path.clone()); - } - } - - zed::set_language_server_installation_status( - language_server_id, - &zed::LanguageServerInstallationStatus::CheckingForUpdate, - ); - let release = zed::latest_github_release( - "LuaLS/lua-language-server", - zed::GithubReleaseOptions { - require_assets: true, - pre_release: false, - }, - )?; - - let (platform, arch) = zed::current_platform(); - let asset_name = format!( - "lua-language-server-{version}-{os}-{arch}.{extension}", - version = release.version, - os = match platform { - zed::Os::Mac => "darwin", - zed::Os::Linux => "linux", - zed::Os::Windows => "win32", - }, - arch = match arch { - zed::Architecture::Aarch64 => "arm64", - zed::Architecture::X8664 => "x64", - zed::Architecture::X86 => return Err("unsupported platform x86".into()), - }, - extension = match platform { - zed::Os::Mac | zed::Os::Linux => "tar.gz", - zed::Os::Windows => "zip", - }, - ); - - let asset = release - .assets - .iter() - .find(|asset| asset.name == asset_name) - .ok_or_else(|| format!("no asset found matching {asset_name:?}"))?; - - let version_dir = format!("lua-language-server-{}", release.version); - let binary_path = format!( - "{version_dir}/bin/lua-language-server{extension}", - extension = match platform { - zed::Os::Mac | zed::Os::Linux => "", - zed::Os::Windows => ".exe", - }, - ); - - if !fs::metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { - zed::set_language_server_installation_status( - language_server_id, - &zed::LanguageServerInstallationStatus::Downloading, - ); - - zed::download_file( - &asset.download_url, - &version_dir, - match platform { - zed::Os::Mac | zed::Os::Linux => zed::DownloadedFileType::GzipTar, - zed::Os::Windows => zed::DownloadedFileType::Zip, - }, - ) - .map_err(|e| format!("failed to download file: {e}"))?; - - let entries = - fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; - for entry in entries { - let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; - if entry.file_name().to_str() != Some(&version_dir) { - fs::remove_dir_all(entry.path()).ok(); - } - } - } - - self.cached_binary_path = Some(binary_path.clone()); - Ok(binary_path) - } + lua_ls: LuaLs, + emmylua_ls: EmmyLuaLs, } impl zed::Extension for LuaExtension { fn new() -> Self { Self { - cached_binary_path: None, + lua_ls: LuaLs::new(), + emmylua_ls: EmmyLuaLs::new(), } } @@ -126,12 +24,15 @@ impl zed::Extension for LuaExtension { language_server_id: &LanguageServerId, worktree: &zed::Worktree, ) -> Result { - let lua_binary = self.language_server_binary(language_server_id, worktree)?; - Ok(zed::Command { - command: lua_binary.path, - args: lua_binary.args.unwrap_or_else(std::vec::Vec::new), - env: vec![], - }) + match language_server_id.as_ref() { + LuaLs::SERVER_ID => self + .lua_ls + .language_server_command(language_server_id, worktree), + EmmyLuaLs::SERVER_ID => self + .emmylua_ls + .language_server_command(language_server_id, worktree), + server_id => Err(format!("unknown language server: {server_id}")), + } } fn label_for_completion( @@ -139,25 +40,55 @@ impl zed::Extension for LuaExtension { _language_server_id: &LanguageServerId, completion: zed::lsp::Completion, ) -> Option { - match completion.kind? { + let label_len = completion.label.len(); + let (label_detail, label_description) = completion + .label_details + .map(|details| (details.detail, details.description)) + .unwrap_or_default(); + let detail = completion.detail.filter(|detail| { + detail != &completion.label + && label_detail.as_ref() != Some(detail) + && label_description.as_ref() != Some(detail) + }); + let (code, mut spans, filter_len) = match completion.kind? { CompletionKind::Method | CompletionKind::Function => { let name_len = completion.label.find('(').unwrap_or(completion.label.len()); - Some(CodeLabel { - spans: vec![CodeLabelSpan::code_range(0..completion.label.len())], - filter_range: (0..name_len).into(), - code: completion.label, - }) + ( + completion.label, + vec![CodeLabelSpan::code_range(0..label_len)], + name_len, + ) } - CompletionKind::Field => Some(CodeLabel { - spans: vec![CodeLabelSpan::literal( - completion.label.clone(), + CompletionKind::Field | CompletionKind::Property => ( + Default::default(), + vec![CodeLabelSpan::literal( + completion.label, Some("property".into()), )], - filter_range: (0..completion.label.len()).into(), - code: Default::default(), - }), - _ => None, + label_len, + ), + _ => return None, + }; + + if let Some(label_detail) = label_detail { + spans.push(CodeLabelSpan::literal(label_detail, None)); } + + if let Some(detail) = detail { + spans.push(CodeLabelSpan::literal(" ", None)); + spans.push(CodeLabelSpan::literal(detail, None)); + } + + if let Some(label_description) = label_description { + spans.push(CodeLabelSpan::literal(" ", None)); + spans.push(CodeLabelSpan::literal(label_description, None)); + } + + Some(CodeLabel { + spans, + filter_range: (0..filter_len).into(), + code, + }) } fn label_for_symbol( @@ -165,18 +96,43 @@ impl zed::Extension for LuaExtension { _language_server_id: &LanguageServerId, symbol: zed::lsp::Symbol, ) -> Option { - let prefix = "let a = "; - let suffix = match symbol.kind { - zed::lsp::SymbolKind::Method => "()", - _ => "", + // TODO: Include server-provided symbol detail (such as function parameters + // from LuaLS and EmmyLuaLS) when zed_extension_api exposes it on `lsp::Symbol`. + let highlight = match symbol.kind { + zed::lsp::SymbolKind::Method => Some("function.method"), + zed::lsp::SymbolKind::Function => Some("function"), + zed::lsp::SymbolKind::Property + | zed::lsp::SymbolKind::Field + | zed::lsp::SymbolKind::Key + | zed::lsp::SymbolKind::EnumMember => Some("property"), + zed::lsp::SymbolKind::Class + | zed::lsp::SymbolKind::Interface + | zed::lsp::SymbolKind::Enum + | zed::lsp::SymbolKind::Struct + | zed::lsp::SymbolKind::TypeParameter + | zed::lsp::SymbolKind::Module + | zed::lsp::SymbolKind::Namespace + | zed::lsp::SymbolKind::Package => Some("type"), + zed::lsp::SymbolKind::Constructor => Some("constructor"), + zed::lsp::SymbolKind::Constant => Some("constant"), + zed::lsp::SymbolKind::Variable + | zed::lsp::SymbolKind::String + | zed::lsp::SymbolKind::Number + | zed::lsp::SymbolKind::Boolean + | zed::lsp::SymbolKind::Array + | zed::lsp::SymbolKind::Object + | zed::lsp::SymbolKind::Null => Some("variable"), + zed::lsp::SymbolKind::Operator => Some("operator"), + _ => None, }; - let code = format!("{prefix}{}{suffix}", symbol.name); + let name_len = symbol.name.len(); Some(CodeLabel { - spans: vec![CodeLabelSpan::code_range( - prefix.len()..code.len() - suffix.len(), + spans: vec![CodeLabelSpan::literal( + symbol.name, + highlight.map(str::to_string), )], - filter_range: (0..symbol.name.len()).into(), - code, + filter_range: (0..name_len).into(), + code: Default::default(), }) }