gleam.rs

  1use html_to_markdown::{convert_html_to_markdown, TagHandler};
  2use std::cell::RefCell;
  3use std::fs;
  4use std::rc::Rc;
  5use zed::lsp::CompletionKind;
  6use zed::{
  7    CodeLabel, CodeLabelSpan, HttpRequest, KeyValueStore, LanguageServerId, SlashCommand,
  8    SlashCommandOutput, SlashCommandOutputSection,
  9};
 10use zed_extension_api::{self as zed, Result};
 11
 12struct GleamExtension {
 13    cached_binary_path: Option<String>,
 14}
 15
 16impl GleamExtension {
 17    fn language_server_binary_path(
 18        &mut self,
 19        language_server_id: &LanguageServerId,
 20        worktree: &zed::Worktree,
 21    ) -> Result<String> {
 22        if let Some(path) = worktree.which("gleam") {
 23            return Ok(path);
 24        }
 25
 26        if let Some(path) = &self.cached_binary_path {
 27            if fs::metadata(path).map_or(false, |stat| stat.is_file()) {
 28                return Ok(path.clone());
 29            }
 30        }
 31
 32        zed::set_language_server_installation_status(
 33            &language_server_id,
 34            &zed::LanguageServerInstallationStatus::CheckingForUpdate,
 35        );
 36        let release = zed::latest_github_release(
 37            "gleam-lang/gleam",
 38            zed::GithubReleaseOptions {
 39                require_assets: true,
 40                pre_release: false,
 41            },
 42        )?;
 43
 44        let (platform, arch) = zed::current_platform();
 45        let asset_name = format!(
 46            "gleam-{version}-{arch}-{os}.tar.gz",
 47            version = release.version,
 48            arch = match arch {
 49                zed::Architecture::Aarch64 => "aarch64",
 50                zed::Architecture::X86 => "x86",
 51                zed::Architecture::X8664 => "x86_64",
 52            },
 53            os = match platform {
 54                zed::Os::Mac => "apple-darwin",
 55                zed::Os::Linux => "unknown-linux-musl",
 56                zed::Os::Windows => "pc-windows-msvc",
 57            },
 58        );
 59
 60        let asset = release
 61            .assets
 62            .iter()
 63            .find(|asset| asset.name == asset_name)
 64            .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?;
 65
 66        let version_dir = format!("gleam-{}", release.version);
 67        let binary_path = format!("{version_dir}/gleam");
 68
 69        if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) {
 70            zed::set_language_server_installation_status(
 71                &language_server_id,
 72                &zed::LanguageServerInstallationStatus::Downloading,
 73            );
 74
 75            zed::download_file(
 76                &asset.download_url,
 77                &version_dir,
 78                zed::DownloadedFileType::GzipTar,
 79            )
 80            .map_err(|e| format!("failed to download file: {e}"))?;
 81
 82            let entries =
 83                fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?;
 84            for entry in entries {
 85                let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?;
 86                if entry.file_name().to_str() != Some(&version_dir) {
 87                    fs::remove_dir_all(&entry.path()).ok();
 88                }
 89            }
 90        }
 91
 92        self.cached_binary_path = Some(binary_path.clone());
 93        Ok(binary_path)
 94    }
 95}
 96
 97impl zed::Extension for GleamExtension {
 98    fn new() -> Self {
 99        Self {
100            cached_binary_path: None,
101        }
102    }
103
104    fn language_server_command(
105        &mut self,
106        language_server_id: &LanguageServerId,
107        worktree: &zed::Worktree,
108    ) -> Result<zed::Command> {
109        Ok(zed::Command {
110            command: self.language_server_binary_path(language_server_id, worktree)?,
111            args: vec!["lsp".to_string()],
112            env: Default::default(),
113        })
114    }
115
116    fn label_for_completion(
117        &self,
118        _language_server_id: &LanguageServerId,
119        completion: zed::lsp::Completion,
120    ) -> Option<zed::CodeLabel> {
121        let name = &completion.label;
122        let ty = strip_newlines_from_detail(&completion.detail?);
123        let let_binding = "let a";
124        let colon = ": ";
125        let assignment = " = ";
126        let call = match completion.kind? {
127            CompletionKind::Function | CompletionKind::Constructor => "()",
128            _ => "",
129        };
130        let code = format!("{let_binding}{colon}{ty}{assignment}{name}{call}");
131
132        Some(CodeLabel {
133            spans: vec![
134                CodeLabelSpan::code_range({
135                    let start = let_binding.len() + colon.len() + ty.len() + assignment.len();
136                    start..start + name.len()
137                }),
138                CodeLabelSpan::code_range({
139                    let start = let_binding.len();
140                    start..start + colon.len()
141                }),
142                CodeLabelSpan::code_range({
143                    let start = let_binding.len() + colon.len();
144                    start..start + ty.len()
145                }),
146            ],
147            filter_range: (0..name.len()).into(),
148            code,
149        })
150    }
151
152    fn complete_slash_command_argument(
153        &self,
154        command: SlashCommand,
155        _query: String,
156    ) -> Result<Vec<String>, String> {
157        match command.name.as_str() {
158            "gleam-project" => Ok(vec![
159                "apple".to_string(),
160                "banana".to_string(),
161                "cherry".to_string(),
162            ]),
163            _ => Ok(Vec::new()),
164        }
165    }
166
167    fn run_slash_command(
168        &self,
169        command: SlashCommand,
170        argument: Option<String>,
171        worktree: &zed::Worktree,
172    ) -> Result<SlashCommandOutput, String> {
173        match command.name.as_str() {
174            "gleam-docs" => {
175                let argument = argument.ok_or_else(|| "missing argument".to_string())?;
176
177                let mut components = argument.split('/');
178                let package_name = components
179                    .next()
180                    .ok_or_else(|| "missing package name".to_string())?;
181                let module_path = components.map(ToString::to_string).collect::<Vec<_>>();
182
183                let response = zed::fetch(&HttpRequest {
184                    url: format!(
185                        "https://hexdocs.pm/{package_name}{maybe_path}",
186                        maybe_path = if !module_path.is_empty() {
187                            format!("/{}.html", module_path.join("/"))
188                        } else {
189                            String::new()
190                        }
191                    ),
192                })?;
193
194                let mut handlers: Vec<TagHandler> = vec![
195                    Rc::new(RefCell::new(
196                        html_to_markdown::markdown::WebpageChromeRemover,
197                    )),
198                    Rc::new(RefCell::new(html_to_markdown::markdown::ParagraphHandler)),
199                    Rc::new(RefCell::new(html_to_markdown::markdown::HeadingHandler)),
200                    Rc::new(RefCell::new(html_to_markdown::markdown::ListHandler)),
201                    Rc::new(RefCell::new(html_to_markdown::markdown::TableHandler::new())),
202                    Rc::new(RefCell::new(html_to_markdown::markdown::StyledTextHandler)),
203                ];
204
205                let markdown = convert_html_to_markdown(response.body.as_bytes(), &mut handlers)
206                    .map_err(|err| format!("failed to convert docs to Markdown {err}"))?;
207
208                let mut text = String::new();
209                text.push_str(&markdown);
210
211                Ok(SlashCommandOutput {
212                    sections: vec![SlashCommandOutputSection {
213                        range: (0..text.len()).into(),
214                        label: format!("gleam-docs: {package_name} {}", module_path.join("/")),
215                    }],
216                    text,
217                })
218            }
219            "gleam-project" => {
220                let mut text = String::new();
221                text.push_str("You are in a Gleam project.\n");
222
223                if let Some(gleam_toml) = worktree.read_text_file("gleam.toml").ok() {
224                    text.push_str("The `gleam.toml` is as follows:\n");
225                    text.push_str(&gleam_toml);
226                }
227
228                Ok(SlashCommandOutput {
229                    sections: vec![SlashCommandOutputSection {
230                        range: (0..text.len()).into(),
231                        label: "gleam-project".to_string(),
232                    }],
233                    text,
234                })
235            }
236            command => Err(format!("unknown slash command: \"{command}\"")),
237        }
238    }
239
240    fn index_docs(
241        &self,
242        provider: String,
243        package: String,
244        database: &KeyValueStore,
245    ) -> Result<(), String> {
246        match provider.as_str() {
247            "gleam-hexdocs" => {
248                let response = zed::fetch(&HttpRequest {
249                    url: format!("https://hexdocs.pm/{package}"),
250                })?;
251
252                let mut handlers: Vec<TagHandler> = vec![
253                    Rc::new(RefCell::new(
254                        html_to_markdown::markdown::WebpageChromeRemover,
255                    )),
256                    Rc::new(RefCell::new(html_to_markdown::markdown::ParagraphHandler)),
257                    Rc::new(RefCell::new(html_to_markdown::markdown::HeadingHandler)),
258                    Rc::new(RefCell::new(html_to_markdown::markdown::ListHandler)),
259                    Rc::new(RefCell::new(html_to_markdown::markdown::TableHandler::new())),
260                    Rc::new(RefCell::new(html_to_markdown::markdown::StyledTextHandler)),
261                ];
262
263                let markdown = convert_html_to_markdown(response.body.as_bytes(), &mut handlers)
264                    .map_err(|err| format!("failed to convert docs to Markdown {err}"))?;
265
266                Ok(database.insert(&package, &markdown)?)
267            }
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}