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}