diff --git a/Cargo.toml b/Cargo.toml index 7286ec1..79ea1d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ publish = false [lib] crate-type = ["cdylib"] +path = "src/dockerfile.rs" [dependencies] serde = {version = "1.0", features = ["derive"]} diff --git a/extension.toml b/extension.toml index 6b5be78..03b8023 100644 --- a/extension.toml +++ b/extension.toml @@ -10,6 +10,15 @@ repository = "https://github.com/zed-extensions/dockerfile" name = "Dockerfile Language Server" language = "Dockerfile" +[language_servers.docker-language-server] +name = "Docker Language Server" +language = "Dockerfile" +languages = ["Dockerfile", "Docker Compose"] + +[language_servers.docker-language-server.language_ids] +"Dockerfile" = "dockerfile" +"Docker Compose" = "docker-compose" + [grammars.dockerfile] repository = "https://github.com/camdencheek/tree-sitter-dockerfile" commit = "868e44ce378deb68aac902a9db68ff82d2299dd0" diff --git a/src/lib.rs b/src/dockerfile.rs similarity index 69% rename from src/lib.rs rename to src/dockerfile.rs index 293076e..ea30a68 100644 --- a/src/lib.rs +++ b/src/dockerfile.rs @@ -1,14 +1,10 @@ +mod language_servers; + use serde::{Deserialize, Serialize}; use std::path::Path; -use std::{env, fs}; -use zed_extension_api::{self as zed, Result}; - -const SERVER_PATH: &str = "node_modules/dockerfile-language-server-nodejs/bin/docker-langserver"; -const PACKAGE_NAME: &str = "dockerfile-language-server-nodejs"; +use zed_extension_api::{self as zed, settings::LspSettings, Result}; -struct DockerfileExtension { - did_find_server: bool, -} +use crate::language_servers::{DockerLanguageServer, DockerfileLs}; #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -28,77 +24,55 @@ struct DockerfileDebugConfig { target: Option, } -impl DockerfileExtension { - fn server_exists(&self) -> bool { - fs::metadata(SERVER_PATH).map_or(false, |stat| stat.is_file()) - } - - fn server_script_path(&mut self, language_server_id: &zed::LanguageServerId) -> Result { - let server_exists = self.server_exists(); - if self.did_find_server && server_exists { - return Ok(SERVER_PATH.to_string()); - } - - zed::set_language_server_installation_status( - language_server_id, - &zed::LanguageServerInstallationStatus::CheckingForUpdate, - ); - let version = zed::npm_package_latest_version(PACKAGE_NAME)?; - - if !server_exists - || zed::npm_package_installed_version(PACKAGE_NAME)?.as_ref() != Some(&version) - { - zed::set_language_server_installation_status( - language_server_id, - &zed::LanguageServerInstallationStatus::Downloading, - ); - let result = zed::npm_install_package(PACKAGE_NAME, &version); - match result { - Ok(()) => { - if !self.server_exists() { - Err(format!( - "installed package '{PACKAGE_NAME}' did not contain expected path '{SERVER_PATH}'", - ))?; - } - } - Err(error) => { - if !self.server_exists() { - Err(error)?; - } - } - } - } - - self.did_find_server = true; - Ok(SERVER_PATH.to_string()) - } +struct DockerfileExtension { + dockerfile_ls: Option, + docker_language_server: Option, } impl zed::Extension for DockerfileExtension { fn new() -> Self { Self { - did_find_server: false, + dockerfile_ls: None, + docker_language_server: None, } } fn language_server_command( &mut self, - language_server_id: &zed_extension_api::LanguageServerId, - _worktree: &zed::Worktree, + language_server_id: &zed::LanguageServerId, + worktree: &zed::Worktree, ) -> Result { - let server_path = self.server_script_path(language_server_id)?; - Ok(zed::Command { - command: zed::node_binary_path()?, - args: vec![ - env::current_dir() - .unwrap() - .join(&server_path) - .to_string_lossy() - .to_string(), - "--stdio".to_string(), - ], - env: Default::default(), - }) + match language_server_id.as_ref() { + DockerfileLs::LANGUAGE_SERVER_ID => { + let dockerfile_ls = self.dockerfile_ls.get_or_insert_with(DockerfileLs::new); + dockerfile_ls.language_server_command(language_server_id, worktree) + } + DockerLanguageServer::LANGUAGE_SERVER_ID => { + let docker_ls = self + .docker_language_server + .get_or_insert_with(DockerLanguageServer::new); + docker_ls.language_server_command(language_server_id, worktree) + } + language_server_id => Err(format!("unknown language server: {language_server_id}")), + } + } + + fn language_server_initialization_options( + &mut self, + language_server_id: &zed_extension_api::LanguageServerId, + worktree: &zed_extension_api::Worktree, + ) -> Result> { + LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .map(|settings| settings.initialization_options) + } + + fn language_server_workspace_configuration( + &mut self, + language_server_id: &zed_extension_api::LanguageServerId, + worktree: &zed_extension_api::Worktree, + ) -> Result> { + LspSettings::for_worktree(language_server_id.as_ref(), worktree) + .map(|settings| settings.settings) } fn dap_request_kind( @@ -154,7 +128,7 @@ impl zed::Extension for DockerfileExtension { }) } zed::DebugRequest::Attach(_) => { - return Err("attaching to a running build is not supported".to_string()); + Err("attaching to a running build is not supported".to_string()) } } } diff --git a/src/language_servers.rs b/src/language_servers.rs new file mode 100644 index 0000000..309d18d --- /dev/null +++ b/src/language_servers.rs @@ -0,0 +1,6 @@ +mod docker_language_server; +mod dockerfile_language_server_nodejs; +mod util; + +pub use docker_language_server::*; +pub use dockerfile_language_server_nodejs::*; diff --git a/src/language_servers/docker_language_server.rs b/src/language_servers/docker_language_server.rs new file mode 100644 index 0000000..d40efde --- /dev/null +++ b/src/language_servers/docker_language_server.rs @@ -0,0 +1,140 @@ +use std::fs; + +use zed::LanguageServerId; +use zed_extension_api::settings::LspSettings; +use zed_extension_api::{self as zed, Result}; + +use crate::language_servers::util; + +pub struct DockerLanguageServer { + cached_binary_path: Option, +} + +impl DockerLanguageServer { + pub const LANGUAGE_SERVER_ID: &'static str = "docker-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_settings = LspSettings::for_worktree(Self::LANGUAGE_SERVER_ID, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let binary_args = binary_settings + .as_ref() + .and_then(|settings| settings.arguments.clone()) + .unwrap_or_else(|| vec!["start".to_string(), "--stdio".to_string()]); + + let env = binary_settings + .as_ref() + .and_then(|settings| settings.env.clone()) + .map(|env| env.into_iter().collect()) + .unwrap_or_default(); + + if let Some(path) = binary_settings.and_then(|settings| settings.path) { + return Ok(zed::Command { + command: path, + args: binary_args, + env, + }); + } + + let binary_path = self.language_server_binary_path(language_server_id, worktree)?; + Ok(zed::Command { + command: binary_path, + args: binary_args, + env, + }) + } + + fn language_server_binary_path( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + if let Some(path) = worktree.which("docker-language-server") { + return Ok(path); + } + + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).map_or(false, |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( + "docker/docker-language-server", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (platform, arch) = zed::current_platform(); + let os = match platform { + zed::Os::Mac => "darwin", + zed::Os::Linux => "linux", + zed::Os::Windows => "windows", + }; + let arch = match arch { + zed::Architecture::Aarch64 => "arm64", + zed::Architecture::X8664 => "amd64", + zed::Architecture::X86 => { + return Err("unsupported architecture: x86".to_string()); + } + }; + let extension = match platform { + zed::Os::Mac | zed::Os::Linux => "", + zed::Os::Windows => ".exe", + }; + + let asset_name = format!( + "docker-language-server-{os}-{arch}-{version}{extension}", + version = release.version, + ); + + 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!("{}-{}", Self::LANGUAGE_SERVER_ID, release.version); + fs::create_dir_all(&version_dir).map_err(|e| format!("failed to create directory: {e}"))?; + + let binary_path = format!("{version_dir}/docker-language-server{extension}"); + + if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) { + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &binary_path, + zed::DownloadedFileType::Uncompressed, + ) + .map_err(|e| format!("failed to download file: {e}"))?; + + zed::make_file_executable(&binary_path)?; + + util::remove_outdated_versions(Self::LANGUAGE_SERVER_ID, &version_dir)?; + } + + self.cached_binary_path = Some(binary_path.clone()); + Ok(binary_path) + } +} diff --git a/src/language_servers/dockerfile_language_server_nodejs.rs b/src/language_servers/dockerfile_language_server_nodejs.rs new file mode 100644 index 0000000..c9ea1c8 --- /dev/null +++ b/src/language_servers/dockerfile_language_server_nodejs.rs @@ -0,0 +1,116 @@ +use std::{env, fs}; + +use zed_extension_api::settings::LspSettings; +use zed_extension_api::{self as zed, Result}; + +const SERVER_PATH: &str = "node_modules/dockerfile-language-server-nodejs/bin/docker-langserver"; +const PACKAGE_NAME: &str = "dockerfile-language-server-nodejs"; + +pub struct DockerfileLs { + did_find_server: bool, +} + +impl DockerfileLs { + pub const LANGUAGE_SERVER_ID: &'static str = "dockerfile-language-server"; + + pub fn new() -> Self { + Self { + did_find_server: false, + } + } + + fn server_exists(&self) -> bool { + fs::metadata(SERVER_PATH).is_ok_and(|stat| stat.is_file()) + } + + pub fn language_server_command( + &mut self, + language_server_id: &zed::LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + let binary_settings = LspSettings::for_worktree(Self::LANGUAGE_SERVER_ID, worktree) + .ok() + .and_then(|lsp_settings| lsp_settings.binary); + + let env = binary_settings + .as_ref() + .and_then(|s| s.env.as_ref()) + .map(|env| env.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); + + if let Some(path) = binary_settings.as_ref().and_then(|s| s.path.clone()) { + let args = binary_settings + .as_ref() + .and_then(|s| s.arguments.clone()) + .unwrap_or_else(|| vec!["--stdio".to_string()]); + + return Ok(zed::Command { + command: path, + args, + env, + }); + } + + let server_path = self.server_script_path(language_server_id)?; + + let default_args = vec![ + env::current_dir() + .unwrap() + .join(&server_path) + .to_string_lossy() + .to_string(), + "--stdio".to_string(), + ]; + + let args = binary_settings + .as_ref() + .and_then(|s| s.arguments.clone()) + .unwrap_or(default_args); + + Ok(zed::Command { + command: zed::node_binary_path()?, + args, + env, + }) + } + + fn server_script_path(&mut self, language_server_id: &zed::LanguageServerId) -> Result { + let server_exists = self.server_exists(); + if self.did_find_server && server_exists { + return Ok(SERVER_PATH.to_string()); + } + + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + let version = zed::npm_package_latest_version(PACKAGE_NAME)?; + + if !server_exists + || zed::npm_package_installed_version(PACKAGE_NAME)?.as_ref() != Some(&version) + { + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + let result = zed::npm_install_package(PACKAGE_NAME, &version); + match result { + Ok(()) => { + if !self.server_exists() { + Err(format!( + "installed package '{PACKAGE_NAME}' did not contain expected path '{SERVER_PATH}'", + ))?; + } + } + Err(error) => { + if !self.server_exists() { + Err(error)?; + } + } + } + } + + self.did_find_server = true; + Ok(SERVER_PATH.to_string()) + } +} diff --git a/src/language_servers/util.rs b/src/language_servers/util.rs new file mode 100644 index 0000000..3036c9b --- /dev/null +++ b/src/language_servers/util.rs @@ -0,0 +1,19 @@ +use std::fs; + +use zed_extension_api::Result; + +pub(super) fn remove_outdated_versions( + language_server_id: &'static str, + 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}"))?; + if entry.file_name().to_str().is_none_or(|file_name| { + file_name.starts_with(language_server_id) && file_name != version_dir + }) { + fs::remove_dir_all(entry.path()).ok(); + } + } + Ok(()) +}