gleam.rs

  1mod hexdocs;
  2
  3use std::{fs, io};
  4use zed::http_client::{HttpMethod, HttpRequest, RedirectPolicy};
  5use zed::lsp::CompletionKind;
  6use zed::{
  7    CodeLabel, CodeLabelSpan, KeyValueStore, LanguageServerId, SlashCommand,
  8    SlashCommandArgumentCompletion, SlashCommandOutput, SlashCommandOutputSection,
  9};
 10use zed_extension_api::{self as zed, Result};
 11
 12use crate::hexdocs::convert_hexdocs_to_markdown;
 13
 14struct GleamExtension {
 15    cached_binary_path: Option<String>,
 16}
 17
 18impl GleamExtension {
 19    fn language_server_binary_path(
 20        &mut self,
 21        language_server_id: &LanguageServerId,
 22        worktree: &zed::Worktree,
 23    ) -> Result<String> {
 24        if let Some(path) = worktree.which("gleam") {
 25            return Ok(path);
 26        }
 27
 28        if let Some(path) = &self.cached_binary_path {
 29            if fs::metadata(path).map_or(false, |stat| stat.is_file()) {
 30                return Ok(path.clone());
 31            }
 32        }
 33
 34        zed::set_language_server_installation_status(
 35            &language_server_id,
 36            &zed::LanguageServerInstallationStatus::CheckingForUpdate,
 37        );
 38        let release = zed::latest_github_release(
 39            "gleam-lang/gleam",
 40            zed::GithubReleaseOptions {
 41                require_assets: true,
 42                pre_release: false,
 43            },
 44        )?;
 45
 46        let (platform, arch) = zed::current_platform();
 47        let asset_name = format!(
 48            "gleam-{version}-{arch}-{os}.tar.gz",
 49            version = release.version,
 50            arch = match arch {
 51                zed::Architecture::Aarch64 => "aarch64",
 52                zed::Architecture::X86 => "x86",
 53                zed::Architecture::X8664 => "x86_64",
 54            },
 55            os = match platform {
 56                zed::Os::Mac => "apple-darwin",
 57                zed::Os::Linux => "unknown-linux-musl",
 58                zed::Os::Windows => "pc-windows-msvc",
 59            },
 60        );
 61
 62        let asset = release
 63            .assets
 64            .iter()
 65            .find(|asset| asset.name == asset_name)
 66            .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?;
 67
 68        let version_dir = format!("gleam-{}", release.version);
 69        let binary_path = format!("{version_dir}/gleam");
 70
 71        if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) {
 72            zed::set_language_server_installation_status(
 73                &language_server_id,
 74                &zed::LanguageServerInstallationStatus::Downloading,
 75            );
 76
 77            zed::download_file(
 78                &asset.download_url,
 79                &version_dir,
 80                zed::DownloadedFileType::GzipTar,
 81            )
 82            .map_err(|e| format!("failed to download file: {e}"))?;
 83
 84            let entries =
 85                fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?;
 86            for entry in entries {
 87                let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?;
 88                if entry.file_name().to_str() != Some(&version_dir) {
 89                    fs::remove_dir_all(&entry.path()).ok();
 90                }
 91            }
 92        }
 93
 94        self.cached_binary_path = Some(binary_path.clone());
 95        Ok(binary_path)
 96    }
 97}
 98
 99impl zed::Extension for GleamExtension {
100    fn new() -> Self {
101        Self {
102            cached_binary_path: None,
103        }
104    }
105
106    fn language_server_command(
107        &mut self,
108        language_server_id: &LanguageServerId,
109        worktree: &zed::Worktree,
110    ) -> Result<zed::Command> {
111        Ok(zed::Command {
112            command: self.language_server_binary_path(language_server_id, worktree)?,
113            args: vec!["lsp".to_string()],
114            env: Default::default(),
115        })
116    }
117
118    fn label_for_completion(
119        &self,
120        _language_server_id: &LanguageServerId,
121        completion: zed::lsp::Completion,
122    ) -> Option<zed::CodeLabel> {
123        let name = &completion.label;
124        let ty = strip_newlines_from_detail(&completion.detail?);
125        let let_binding = "let a";
126        let colon = ": ";
127        let assignment = " = ";
128        let call = match completion.kind? {
129            CompletionKind::Function | CompletionKind::Constructor => "()",
130            _ => "",
131        };
132        let code = format!("{let_binding}{colon}{ty}{assignment}{name}{call}");
133
134        Some(CodeLabel {
135            spans: vec![
136                CodeLabelSpan::code_range({
137                    let start = let_binding.len() + colon.len() + ty.len() + assignment.len();
138                    start..start + name.len()
139                }),
140                CodeLabelSpan::code_range({
141                    let start = let_binding.len();
142                    start..start + colon.len()
143                }),
144                CodeLabelSpan::code_range({
145                    let start = let_binding.len() + colon.len();
146                    start..start + ty.len()
147                }),
148            ],
149            filter_range: (0..name.len()).into(),
150            code,
151        })
152    }
153
154    fn complete_slash_command_argument(
155        &self,
156        command: SlashCommand,
157        _query: String,
158    ) -> Result<Vec<SlashCommandArgumentCompletion>, String> {
159        match command.name.as_str() {
160            "gleam-project" => Ok(vec![
161                SlashCommandArgumentCompletion {
162                    label: "apple".to_string(),
163                    new_text: "Apple".to_string(),
164                    run_command: false,
165                },
166                SlashCommandArgumentCompletion {
167                    label: "banana".to_string(),
168                    new_text: "Banana".to_string(),
169                    run_command: false,
170                },
171                SlashCommandArgumentCompletion {
172                    label: "cherry".to_string(),
173                    new_text: "Cherry".to_string(),
174                    run_command: true,
175                },
176            ]),
177            _ => Ok(Vec::new()),
178        }
179    }
180
181    fn run_slash_command(
182        &self,
183        command: SlashCommand,
184        argument: Option<String>,
185        worktree: Option<&zed::Worktree>,
186    ) -> Result<SlashCommandOutput, String> {
187        match command.name.as_str() {
188            "gleam-docs" => {
189                let argument = argument.ok_or_else(|| "missing argument".to_string())?;
190
191                let mut components = argument.split('/');
192                let package_name = components
193                    .next()
194                    .ok_or_else(|| "missing package name".to_string())?;
195                let module_path = components.map(ToString::to_string).collect::<Vec<_>>();
196
197                let response = HttpRequest::builder()
198                    .method(HttpMethod::Get)
199                    .url(format!(
200                        "https://hexdocs.pm/{package_name}{maybe_path}",
201                        maybe_path = if !module_path.is_empty() {
202                            format!("/{}.html", module_path.join("/"))
203                        } else {
204                            String::new()
205                        }
206                    ))
207                    .header("User-Agent", "Zed (Gleam Extension)")
208                    .redirect_policy(RedirectPolicy::FollowAll)
209                    .build()?
210                    .fetch()?;
211
212                let (markdown, _modules) =
213                    convert_hexdocs_to_markdown(&mut io::Cursor::new(response.body))?;
214
215                let mut text = String::new();
216                text.push_str(&markdown);
217
218                Ok(SlashCommandOutput {
219                    sections: vec![SlashCommandOutputSection {
220                        range: (0..text.len()).into(),
221                        label: format!("gleam-docs: {package_name} {}", module_path.join("/")),
222                    }],
223                    text,
224                })
225            }
226            "gleam-project" => {
227                let worktree = worktree.ok_or_else(|| "no worktree")?;
228
229                let mut text = String::new();
230                text.push_str("You are in a Gleam project.\n");
231
232                if let Some(gleam_toml) = worktree.read_text_file("gleam.toml").ok() {
233                    text.push_str("The `gleam.toml` is as follows:\n");
234                    text.push_str(&gleam_toml);
235                }
236
237                Ok(SlashCommandOutput {
238                    sections: vec![SlashCommandOutputSection {
239                        range: (0..text.len()).into(),
240                        label: "gleam-project".to_string(),
241                    }],
242                    text,
243                })
244            }
245            command => Err(format!("unknown slash command: \"{command}\"")),
246        }
247    }
248
249    fn suggest_docs_packages(&self, provider: String) -> Result<Vec<String>, String> {
250        match provider.as_str() {
251            "gleam-hexdocs" => Ok(vec![
252                "gleam_stdlib".to_string(),
253                "birdie".to_string(),
254                "startest".to_string(),
255            ]),
256            _ => Ok(Vec::new()),
257        }
258    }
259
260    fn index_docs(
261        &self,
262        provider: String,
263        package: String,
264        database: &KeyValueStore,
265    ) -> Result<(), String> {
266        match provider.as_str() {
267            "gleam-hexdocs" => hexdocs::index(package, database),
268            _ => Ok(()),
269        }
270    }
271}
272
273zed::register_extension!(GleamExtension);
274
275/// Removes newlines from the completion detail.
276///
277/// The Gleam LSP can return types containing newlines, which causes formatting
278/// issues within the Zed completions menu.
279fn strip_newlines_from_detail(detail: &str) -> String {
280    let without_newlines = detail
281        .replace("->\n  ", "-> ")
282        .replace("\n  ", "")
283        .replace(",\n", "");
284
285    let comma_delimited_parts = without_newlines.split(',');
286    comma_delimited_parts
287        .map(|part| part.trim())
288        .collect::<Vec<_>>()
289        .join(", ")
290}
291
292#[cfg(test)]
293mod tests {
294    use crate::strip_newlines_from_detail;
295
296    #[test]
297    fn test_strip_newlines_from_detail() {
298        let detail = "fn(\n  Selector(a),\n  b,\n  fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a,\n) -> Selector(a)";
299        let expected = "fn(Selector(a), b, fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a) -> Selector(a)";
300        assert_eq!(strip_newlines_from_detail(detail), expected);
301
302        let detail = "fn(Selector(a), b, fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a) ->\n  Selector(a)";
303        let expected = "fn(Selector(a), b, fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a) -> Selector(a)";
304        assert_eq!(strip_newlines_from_detail(detail), expected);
305
306        let detail = "fn(\n  Method,\n  List(#(String, String)),\n  a,\n  Scheme,\n  String,\n  Option(Int),\n  String,\n  Option(String),\n) -> Request(a)";
307        let expected = "fn(Method, List(#(String, String)), a, Scheme, String, Option(Int), String, Option(String)) -> Request(a)";
308        assert_eq!(strip_newlines_from_detail(&detail), expected);
309    }
310}