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).