gleam.rs

  1mod hexdocs;
  2
  3use std::fs;
  4use std::sync::LazyLock;
  5use zed::lsp::CompletionKind;
  6use zed::{
  7    CodeLabel, CodeLabelSpan, KeyValueStore, LanguageServerId, SlashCommand, SlashCommandOutput,
  8    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 run_slash_command(
153        &self,
154        command: SlashCommand,
155        _args: Vec<String>,
156        worktree: Option<&zed::Worktree>,
157    ) -> Result<SlashCommandOutput, String> {
158        match command.name.as_str() {
159            "gleam-project" => {
160                let worktree = worktree.ok_or("no worktree")?;
161
162                let mut text = String::new();
163                text.push_str("You are in a Gleam project.\n");
164
165                if let Ok(gleam_toml) = worktree.read_text_file("gleam.toml") {
166                    text.push_str("The `gleam.toml` is as follows:\n");
167                    text.push_str(&gleam_toml);
168                }
169
170                Ok(SlashCommandOutput {
171                    sections: vec![SlashCommandOutputSection {
172                        range: (0..text.len()).into(),
173                        label: "gleam-project".to_string(),
174                    }],
175                    text,
176                })
177            }
178            command => Err(format!("unknown slash command: \"{command}\"")),
179        }
180    }
181
182    fn suggest_docs_packages(&self, provider: String) -> Result<Vec<String>, String> {
183        match provider.as_str() {
184            "gleam-hexdocs" => {
185                static GLEAM_PACKAGES: LazyLock<Vec<String>> = LazyLock::new(|| {
186                    include_str!("../packages.txt")
187                        .lines()
188                        .filter(|line| !line.starts_with('#'))
189                        .map(|line| line.trim().to_owned())
190                        .collect()
191                });
192
193                Ok(GLEAM_PACKAGES.clone())
194            }
195            _ => Ok(Vec::new()),
196        }
197    }
198
199    fn index_docs(
200        &self,
201        provider: String,
202        package: String,
203        database: &KeyValueStore,
204    ) -> Result<(), String> {
205        match provider.as_str() {
206            "gleam-hexdocs" => hexdocs::index(package, database),
207            _ => Ok(()),
208        }
209    }
210}
211
212zed::register_extension!(GleamExtension);
213
214/// Removes newlines from the completion detail.
215///
216/// The Gleam LSP can return types containing newlines, which causes formatting
217/// issues within the Zed completions menu.
218fn strip_newlines_from_detail(detail: &str) -> String {
219    let without_newlines = detail
220        .replace("->\n  ", "-> ")
221        .replace("\n  ", "")
222        .replace(",\n", "");
223
224    let comma_delimited_parts = without_newlines.split(',');
225    comma_delimited_parts
226        .map(|part| part.trim())
227        .collect::<Vec<_>>()
228        .join(", ")
229}
230
231#[cfg(test)]
232mod tests {
233    use crate::strip_newlines_from_detail;
234
235    #[test]
236    fn test_strip_newlines_from_detail() {
237        let detail = "fn(\n  Selector(a),\n  b,\n  fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a,\n) -> Selector(a)";
238        let expected = "fn(Selector(a), b, fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a) -> Selector(a)";
239        assert_eq!(strip_newlines_from_detail(detail), expected);
240
241        let detail = "fn(Selector(a), b, fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a) ->\n  Selector(a)";
242        let expected = "fn(Selector(a), b, fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a) -> Selector(a)";
243        assert_eq!(strip_newlines_from_detail(detail), expected);
244
245        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)";
246        let expected = "fn(Method, List(#(String, String)), a, Scheme, String, Option(Int), String, Option(String)) -> Request(a)";
247        assert_eq!(strip_newlines_from_detail(detail), expected);
248    }
249}