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