terraform.rs

  1use anyhow::{anyhow, Context, Result};
  2use async_trait::async_trait;
  3use collections::HashMap;
  4use futures::StreamExt;
  5pub use language::*;
  6use lsp::{CodeActionKind, LanguageServerBinary};
  7use smol::fs::{self, File};
  8use std::{any::Any, ffi::OsString, path::PathBuf};
  9use util::{
 10    async_maybe,
 11    fs::remove_matching,
 12    github::{latest_github_release, GitHubLspBinaryVersion},
 13    ResultExt,
 14};
 15
 16fn terraform_ls_binary_arguments() -> Vec<OsString> {
 17    vec!["serve".into()]
 18}
 19
 20pub struct TerraformLspAdapter;
 21
 22#[async_trait(?Send)]
 23impl LspAdapter for TerraformLspAdapter {
 24    fn name(&self) -> LanguageServerName {
 25        LanguageServerName("terraform-ls".into())
 26    }
 27
 28    async fn fetch_latest_server_version(
 29        &self,
 30        delegate: &dyn LspAdapterDelegate,
 31    ) -> Result<Box<dyn 'static + Send + Any>> {
 32        // TODO: maybe use release API instead
 33        // https://api.releases.hashicorp.com/v1/releases/terraform-ls?limit=1
 34        let release = latest_github_release(
 35            "hashicorp/terraform-ls",
 36            false,
 37            false,
 38            delegate.http_client(),
 39        )
 40        .await?;
 41
 42        Ok(Box::new(GitHubLspBinaryVersion {
 43            name: release.tag_name,
 44            url: Default::default(),
 45        }))
 46    }
 47
 48    async fn fetch_server_binary(
 49        &self,
 50        version: Box<dyn 'static + Send + Any>,
 51        container_dir: PathBuf,
 52        delegate: &dyn LspAdapterDelegate,
 53    ) -> Result<LanguageServerBinary> {
 54        let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
 55        let zip_path = container_dir.join(format!("terraform-ls_{}.zip", version.name));
 56        let version_dir = container_dir.join(format!("terraform-ls_{}", version.name));
 57        let binary_path = version_dir.join("terraform-ls");
 58        let url = build_download_url(version.name)?;
 59
 60        if fs::metadata(&binary_path).await.is_err() {
 61            let mut response = delegate
 62                .http_client()
 63                .get(&url, Default::default(), true)
 64                .await
 65                .context("error downloading release")?;
 66            let mut file = File::create(&zip_path).await?;
 67            if !response.status().is_success() {
 68                Err(anyhow!(
 69                    "download failed with status {}",
 70                    response.status().to_string()
 71                ))?;
 72            }
 73            futures::io::copy(response.body_mut(), &mut file).await?;
 74
 75            let unzip_status = smol::process::Command::new("unzip")
 76                .current_dir(&container_dir)
 77                .arg(&zip_path)
 78                .arg("-d")
 79                .arg(&version_dir)
 80                .output()
 81                .await?
 82                .status;
 83            if !unzip_status.success() {
 84                Err(anyhow!("failed to unzip Terraform LS archive"))?;
 85            }
 86
 87            remove_matching(&container_dir, |entry| entry != version_dir).await;
 88        }
 89
 90        Ok(LanguageServerBinary {
 91            path: binary_path,
 92            env: None,
 93            arguments: terraform_ls_binary_arguments(),
 94        })
 95    }
 96
 97    async fn cached_server_binary(
 98        &self,
 99        container_dir: PathBuf,
100        _: &dyn LspAdapterDelegate,
101    ) -> Option<LanguageServerBinary> {
102        get_cached_server_binary(container_dir).await
103    }
104
105    async fn installation_test_binary(
106        &self,
107        container_dir: PathBuf,
108    ) -> Option<LanguageServerBinary> {
109        get_cached_server_binary(container_dir)
110            .await
111            .map(|mut binary| {
112                binary.arguments = vec!["version".into()];
113                binary
114            })
115    }
116
117    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
118        // TODO: file issue for server supported code actions
119        // TODO: reenable default actions / delete override
120        Some(vec![])
121    }
122
123    fn language_ids(&self) -> HashMap<String, String> {
124        HashMap::from_iter([
125            ("Terraform".into(), "terraform".into()),
126            ("Terraform Vars".into(), "terraform-vars".into()),
127        ])
128    }
129}
130
131fn build_download_url(version: String) -> Result<String> {
132    let v = version.strip_prefix('v').unwrap_or(&version);
133    let os = match std::env::consts::OS {
134        "linux" => "linux",
135        "macos" => "darwin",
136        "win" => "windows",
137        _ => Err(anyhow!("unsupported OS {}", std::env::consts::OS))?,
138    }
139    .to_string();
140    let arch = match std::env::consts::ARCH {
141        "x86" => "386",
142        "x86_64" => "amd64",
143        "arm" => "arm",
144        "aarch64" => "arm64",
145        _ => Err(anyhow!("unsupported ARCH {}", std::env::consts::ARCH))?,
146    }
147    .to_string();
148
149    let url = format!(
150        "https://releases.hashicorp.com/terraform-ls/{v}/terraform-ls_{v}_{os}_{arch}.zip",
151    );
152
153    Ok(url)
154}
155
156async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServerBinary> {
157    async_maybe!({
158        let mut last = None;
159        let mut entries = fs::read_dir(&container_dir).await?;
160        while let Some(entry) = entries.next().await {
161            last = Some(entry?.path());
162        }
163
164        match last {
165            Some(path) if path.is_dir() => {
166                let binary = path.join("terraform-ls");
167                if fs::metadata(&binary).await.is_ok() {
168                    return Ok(LanguageServerBinary {
169                        path: binary,
170                        env: None,
171                        arguments: terraform_ls_binary_arguments(),
172                    });
173                }
174            }
175            _ => {}
176        }
177
178        Err(anyhow!("no cached binary"))
179    })
180    .await
181    .log_err()
182}