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