From bbc4ed9cab27489373148bef589cedfab8b910fd Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Tue, 27 Feb 2024 02:08:49 +0100 Subject: [PATCH] Add language server for Terraform (#7657) * Depends on: https://github.com/zed-industries/zed/pull/7449 * Closes: https://github.com/zed-industries/zed/issues/5098 --- This PR adds support for downloading and running the Terraform language server for `*.tf` and `*.tfvars` files. I've verified that the code works for `aarch64` and `x86_64` macOS. Downloading new language server versions on release also works as expected. Furthermore this PR adds: - A short docs page for Terraform - An icon for `*.tf` and `*.tfvars` files ## UX ### File Icons ![CleanShot 2024-02-10 at 23 10 13@2x](https://github.com/zed-industries/zed/assets/45985/6f7cd4f0-e94c-4cfb-b3e9-64b0e33c8a43) ### Completion ![CleanShot 2024-02-13 at 20 54 15@2x](https://github.com/zed-industries/zed/assets/45985/18fafa3b-cb50-4f51-b071-ca9eee3521a6) ### Hover ![CleanShot 2024-02-13 at 20 53 40@2x](https://github.com/zed-industries/zed/assets/45985/4d215315-e019-4d3d-b23c-2691db1803e3) ### Go to definition ![2024-02-13 20 56 28](https://github.com/zed-industries/zed/assets/45985/c21d562f-eb0b-4df9-9175-c53b9923344e) ### Formatting ![2024-02-13 20 59 06](https://github.com/zed-industries/zed/assets/45985/0cdf4ec5-e231-4c8a-a257-cae30a8edc8b) and more! ## Known issue(s) @fdionisi discovered that sometimes completion results are inserted with the wrong indentation. Or rather, if you look closely, they are inserted with the correct indentation and then something shifts the closing `}`. I don't think this is related to LSP support and can be addressed in a separate PR. ![2024-02-13 20 58 16](https://github.com/zed-industries/zed/assets/45985/94a118dd-95f5-4e38-8f83-75fec7a0dddf) Release Notes: - Add language server support for Terraform ([#5098](https://github.com/zed-industries/zed/issues/5098)). --------- Co-authored-by: Max Brunsfeld --- assets/icons/file_icons/file_types.json | 5 + assets/icons/file_icons/terraform.svg | 6 + crates/languages/src/lib.rs | 8 +- crates/languages/src/terraform.rs | 186 ++++++++++++++++++++++++ docs/src/languages/terraform.md | 24 +++ 5 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 assets/icons/file_icons/terraform.svg create mode 100644 crates/languages/src/terraform.rs create mode 100644 docs/src/languages/terraform.md diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json index 2ce6ccc157408e0177b2ab9f1c2feb03a1edb83f..75719fb4966091764018837c2877a74ad9a0e56d 100644 --- a/assets/icons/file_icons/file_types.json +++ b/assets/icons/file_icons/file_types.json @@ -133,6 +133,8 @@ "svelte": "template", "svg": "image", "swift": "swift", + "tf": "terraform", + "tfvars": "terraform", "tiff": "image", "toml": "toml", "ts": "typescript", @@ -280,6 +282,9 @@ "template": { "icon": "icons/file_icons/html.svg" }, + "terraform": { + "icon": "icons/file_icons/terraform.svg" + }, "terminal": { "icon": "icons/file_icons/terminal.svg" }, diff --git a/assets/icons/file_icons/terraform.svg b/assets/icons/file_icons/terraform.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c8efc33b665c2ca0a3df5cc4e64b3756fa40dd4 --- /dev/null +++ b/assets/icons/file_icons/terraform.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index b65cf39fe2a59a808e6e7d20aa63a3132801bde5..6bbaa8090d8b79d48885407883598d3e92cb08dc 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -36,6 +36,7 @@ mod ruby; mod rust; mod svelte; mod tailwind; +mod terraform; mod toml; mod typescript; mod uiua; @@ -312,8 +313,11 @@ pub fn init( ); language("uiua", vec![Arc::new(uiua::UiuaLanguageServer {})]); language("proto", vec![]); - language("terraform", vec![]); - language("terraform-vars", vec![]); + language("terraform", vec![Arc::new(terraform::TerraformLspAdapter)]); + language( + "terraform-vars", + vec![Arc::new(terraform::TerraformLspAdapter)], + ); language("hcl", vec![]); language( "prisma", diff --git a/crates/languages/src/terraform.rs b/crates/languages/src/terraform.rs new file mode 100644 index 0000000000000000000000000000000000000000..dba2fdcc1a48337ce51c0db1a5b6b9a235644d5b --- /dev/null +++ b/crates/languages/src/terraform.rs @@ -0,0 +1,186 @@ +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use collections::HashMap; +use futures::StreamExt; +pub use language::*; +use lsp::{CodeActionKind, LanguageServerBinary}; +use smol::fs::{self, File}; +use std::{any::Any, ffi::OsString, path::PathBuf, str}; +use util::{ + async_maybe, + fs::remove_matching, + github::{latest_github_release, GitHubLspBinaryVersion}, + ResultExt, +}; + +fn terraform_ls_binary_arguments() -> Vec { + vec!["serve".into()] +} + +pub struct TerraformLspAdapter; + +#[async_trait] +impl LspAdapter for TerraformLspAdapter { + fn name(&self) -> LanguageServerName { + LanguageServerName("terraform-ls".into()) + } + + fn short_name(&self) -> &'static str { + "terraform-ls" + } + + async fn fetch_latest_server_version( + &self, + delegate: &dyn LspAdapterDelegate, + ) -> Result> { + // TODO: maybe use release API instead + // https://api.releases.hashicorp.com/v1/releases/terraform-ls?limit=1 + let release = latest_github_release( + "hashicorp/terraform-ls", + false, + false, + delegate.http_client(), + ) + .await?; + + Ok(Box::new(GitHubLspBinaryVersion { + name: release.tag_name, + url: Default::default(), + })) + } + + async fn fetch_server_binary( + &self, + version: Box, + container_dir: PathBuf, + delegate: &dyn LspAdapterDelegate, + ) -> Result { + let version = version.downcast::().unwrap(); + let zip_path = container_dir.join(format!("terraform-ls_{}.zip", version.name)); + let version_dir = container_dir.join(format!("terraform-ls_{}", version.name)); + let binary_path = version_dir.join("terraform-ls"); + let url = build_download_url(version.name)?; + + if fs::metadata(&binary_path).await.is_err() { + let mut response = delegate + .http_client() + .get(&url, Default::default(), true) + .await + .context("error downloading release")?; + let mut file = File::create(&zip_path).await?; + if !response.status().is_success() { + Err(anyhow!( + "download failed with status {}", + response.status().to_string() + ))?; + } + futures::io::copy(response.body_mut(), &mut file).await?; + + let unzip_status = smol::process::Command::new("unzip") + .current_dir(&container_dir) + .arg(&zip_path) + .arg("-d") + .arg(&version_dir) + .output() + .await? + .status; + if !unzip_status.success() { + Err(anyhow!("failed to unzip Terraform LS archive"))?; + } + + remove_matching(&container_dir, |entry| entry != version_dir).await; + } + + Ok(LanguageServerBinary { + path: binary_path, + env: None, + arguments: terraform_ls_binary_arguments(), + }) + } + + async fn cached_server_binary( + &self, + container_dir: PathBuf, + _: &dyn LspAdapterDelegate, + ) -> Option { + get_cached_server_binary(container_dir).await + } + + async fn installation_test_binary( + &self, + container_dir: PathBuf, + ) -> Option { + get_cached_server_binary(container_dir) + .await + .map(|mut binary| { + binary.arguments = vec!["version".into()]; + binary + }) + } + + fn code_action_kinds(&self) -> Option> { + // TODO: file issue for server supported code actions + // TODO: reenable default actions / delete override + Some(vec![]) + } + + fn language_ids(&self) -> HashMap { + HashMap::from_iter([ + ("Terraform".into(), "terraform".into()), + ("Terraform Vars".into(), "terraform-vars".into()), + ]) + } +} + +fn build_download_url(version: String) -> Result { + let v = version.strip_prefix("v").unwrap_or(&version); + let os = match std::env::consts::OS { + "linux" => "linux", + "macos" => "darwin", + "win" => "windows", + _ => Err(anyhow!("unsupported OS {}", std::env::consts::OS))?, + } + .to_string(); + let arch = match std::env::consts::ARCH { + "x86" => "386", + "x86_64" => "amd64", + "arm" => "arm", + "aarch64" => "arm64", + _ => Err(anyhow!("unsupported ARCH {}", std::env::consts::ARCH))?, + } + .to_string(); + + let url = format!( + "https://releases.hashicorp.com/terraform-ls/{v}/terraform-ls_{v}_{os}_{arch}.zip", + ); + + Ok(url) +} + +async fn get_cached_server_binary(container_dir: PathBuf) -> Option { + async_maybe!({ + let mut last = None; + let mut entries = fs::read_dir(&container_dir).await?; + while let Some(entry) = entries.next().await { + last = Some(entry?.path()); + } + + match last { + Some(path) if path.is_dir() => { + let binary = path.join("terraform-ls"); + if fs::metadata(&binary).await.is_ok() { + return Ok(LanguageServerBinary { + path: binary, + env: None, + arguments: terraform_ls_binary_arguments(), + }); + } + } + _ => {} + } + + Err(anyhow!("no cached binary")) + }) + .await + .log_err() +} diff --git a/docs/src/languages/terraform.md b/docs/src/languages/terraform.md new file mode 100644 index 0000000000000000000000000000000000000000..32bfe9b4b1dd499980a2d7ba07210643b766e119 --- /dev/null +++ b/docs/src/languages/terraform.md @@ -0,0 +1,24 @@ +# Terraform + +- Tree Sitter: [tree-sitter-hcl](https://github.com/MichaHoffmann/tree-sitter-hcl) +- Language Server: [terraform-ls](https://github.com/hashicorp/terraform-ls) + +### Configuration + +The Terraform language server can be configured in your `settings.json`, e.g.: + +```json +{ + "lsp": { + "terraform-ls": { + "initialization_options": { + "experimentalFeatures": { + "prefillRequiredFields": true + } + } + } + } +} +``` + +See the [full list of server settings here](https://github.com/hashicorp/terraform-ls/blob/main/docs/SETTINGS.md).