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}