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}