elixir.rs

  1use anyhow::{anyhow, bail, Context, Result};
  2use async_trait::async_trait;
  3use futures::StreamExt;
  4use gpui::{AppContext, AsyncAppContext, Task};
  5pub use language::*;
  6use lsp::{CompletionItemKind, LanguageServerBinary, SymbolKind};
  7use project::project_settings::ProjectSettings;
  8use schemars::JsonSchema;
  9use serde_derive::{Deserialize, Serialize};
 10use serde_json::Value;
 11use settings::Settings;
 12use smol::fs::{self, File};
 13use std::{
 14    any::Any,
 15    env::consts,
 16    ops::Deref,
 17    path::{Path, PathBuf},
 18    sync::{
 19        atomic::{AtomicBool, Ordering::SeqCst},
 20        Arc,
 21    },
 22};
 23use task::static_source::{Definition, TaskDefinitions};
 24use util::{
 25    fs::remove_matching,
 26    github::{latest_github_release, GitHubLspBinaryVersion},
 27    maybe, ResultExt,
 28};
 29
 30#[derive(Clone, Serialize, Deserialize, JsonSchema)]
 31pub struct ElixirSettings {
 32    pub lsp: ElixirLspSetting,
 33}
 34
 35#[derive(Clone, Serialize, Deserialize, JsonSchema)]
 36#[serde(rename_all = "snake_case")]
 37pub enum ElixirLspSetting {
 38    ElixirLs,
 39    NextLs,
 40    Local {
 41        path: String,
 42        arguments: Vec<String>,
 43    },
 44}
 45
 46#[derive(Clone, Serialize, Default, Deserialize, JsonSchema)]
 47pub struct ElixirSettingsContent {
 48    lsp: Option<ElixirLspSetting>,
 49}
 50
 51impl Settings for ElixirSettings {
 52    const KEY: Option<&'static str> = Some("elixir");
 53
 54    type FileContent = ElixirSettingsContent;
 55
 56    fn load(
 57        default_value: &Self::FileContent,
 58        user_values: &[&Self::FileContent],
 59        _: &mut gpui::AppContext,
 60    ) -> Result<Self>
 61    where
 62        Self: Sized,
 63    {
 64        Self::load_via_json_merge(default_value, user_values)
 65    }
 66}
 67
 68pub struct ElixirLspAdapter;
 69
 70#[async_trait(?Send)]
 71impl LspAdapter for ElixirLspAdapter {
 72    fn name(&self) -> LanguageServerName {
 73        LanguageServerName("elixir-ls".into())
 74    }
 75
 76    fn will_start_server(
 77        &self,
 78        delegate: &Arc<dyn LspAdapterDelegate>,
 79        cx: &mut AsyncAppContext,
 80    ) -> Option<Task<Result<()>>> {
 81        static DID_SHOW_NOTIFICATION: AtomicBool = AtomicBool::new(false);
 82
 83        const NOTIFICATION_MESSAGE: &str = "Could not run the elixir language server, `elixir-ls`, because `elixir` was not found.";
 84
 85        let delegate = delegate.clone();
 86        Some(cx.spawn(|cx| async move {
 87            let elixir_output = smol::process::Command::new("elixir")
 88                .args(["--version"])
 89                .output()
 90                .await;
 91            if elixir_output.is_err() {
 92                if DID_SHOW_NOTIFICATION
 93                    .compare_exchange(false, true, SeqCst, SeqCst)
 94                    .is_ok()
 95                {
 96                    cx.update(|cx| {
 97                        delegate.show_notification(NOTIFICATION_MESSAGE, cx);
 98                    })?
 99                }
100                return Err(anyhow!("cannot run elixir-ls"));
101            }
102
103            Ok(())
104        }))
105    }
106
107    async fn fetch_latest_server_version(
108        &self,
109        delegate: &dyn LspAdapterDelegate,
110    ) -> Result<Box<dyn 'static + Send + Any>> {
111        let http = delegate.http_client();
112        let release = latest_github_release("elixir-lsp/elixir-ls", true, false, http).await?;
113
114        let asset_name = format!("elixir-ls-{}.zip", &release.tag_name);
115        let asset = release
116            .assets
117            .iter()
118            .find(|asset| asset.name == asset_name)
119            .ok_or_else(|| anyhow!("no asset found matching {asset_name:?}"))?;
120
121        let version = GitHubLspBinaryVersion {
122            name: release.tag_name.clone(),
123            url: asset.browser_download_url.clone(),
124        };
125        Ok(Box::new(version) as Box<_>)
126    }
127
128    async fn fetch_server_binary(
129        &self,
130        version: Box<dyn 'static + Send + Any>,
131        container_dir: PathBuf,
132        delegate: &dyn LspAdapterDelegate,
133    ) -> Result<LanguageServerBinary> {
134        let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
135        let zip_path = container_dir.join(format!("elixir-ls_{}.zip", version.name));
136        let folder_path = container_dir.join("elixir-ls");
137        let binary_path = folder_path.join("language_server.sh");
138
139        if fs::metadata(&binary_path).await.is_err() {
140            let mut response = delegate
141                .http_client()
142                .get(&version.url, Default::default(), true)
143                .await
144                .context("error downloading release")?;
145            let mut file = File::create(&zip_path)
146                .await
147                .with_context(|| format!("failed to create file {}", zip_path.display()))?;
148            if !response.status().is_success() {
149                Err(anyhow!(
150                    "download failed with status {}",
151                    response.status().to_string()
152                ))?;
153            }
154            futures::io::copy(response.body_mut(), &mut file).await?;
155
156            fs::create_dir_all(&folder_path)
157                .await
158                .with_context(|| format!("failed to create directory {}", folder_path.display()))?;
159            let unzip_status = smol::process::Command::new("unzip")
160                .arg(&zip_path)
161                .arg("-d")
162                .arg(&folder_path)
163                .output()
164                .await?
165                .status;
166            if !unzip_status.success() {
167                Err(anyhow!("failed to unzip elixir-ls archive"))?;
168            }
169
170            remove_matching(&container_dir, |entry| entry != folder_path).await;
171        }
172
173        Ok(LanguageServerBinary {
174            path: binary_path,
175            env: None,
176            arguments: vec![],
177        })
178    }
179
180    async fn cached_server_binary(
181        &self,
182        container_dir: PathBuf,
183        _: &dyn LspAdapterDelegate,
184    ) -> Option<LanguageServerBinary> {
185        get_cached_server_binary_elixir_ls(container_dir).await
186    }
187
188    async fn installation_test_binary(
189        &self,
190        container_dir: PathBuf,
191    ) -> Option<LanguageServerBinary> {
192        get_cached_server_binary_elixir_ls(container_dir).await
193    }
194
195    async fn label_for_completion(
196        &self,
197        completion: &lsp::CompletionItem,
198        language: &Arc<Language>,
199    ) -> Option<CodeLabel> {
200        match completion.kind.zip(completion.detail.as_ref()) {
201            Some((_, detail)) if detail.starts_with("(function)") => {
202                let text = detail.strip_prefix("(function) ")?;
203                let filter_range = 0..text.find('(').unwrap_or(text.len());
204                let source = Rope::from(format!("def {text}").as_str());
205                let runs = language.highlight_text(&source, 4..4 + text.len());
206                return Some(CodeLabel {
207                    text: text.to_string(),
208                    runs,
209                    filter_range,
210                });
211            }
212            Some((_, detail)) if detail.starts_with("(macro)") => {
213                let text = detail.strip_prefix("(macro) ")?;
214                let filter_range = 0..text.find('(').unwrap_or(text.len());
215                let source = Rope::from(format!("defmacro {text}").as_str());
216                let runs = language.highlight_text(&source, 9..9 + text.len());
217                return Some(CodeLabel {
218                    text: text.to_string(),
219                    runs,
220                    filter_range,
221                });
222            }
223            Some((
224                CompletionItemKind::CLASS
225                | CompletionItemKind::MODULE
226                | CompletionItemKind::INTERFACE
227                | CompletionItemKind::STRUCT,
228                _,
229            )) => {
230                let filter_range = 0..completion
231                    .label
232                    .find(" (")
233                    .unwrap_or(completion.label.len());
234                let text = &completion.label[filter_range.clone()];
235                let source = Rope::from(format!("defmodule {text}").as_str());
236                let runs = language.highlight_text(&source, 10..10 + text.len());
237                return Some(CodeLabel {
238                    text: completion.label.clone(),
239                    runs,
240                    filter_range,
241                });
242            }
243            _ => {}
244        }
245
246        None
247    }
248
249    async fn label_for_symbol(
250        &self,
251        name: &str,
252        kind: SymbolKind,
253        language: &Arc<Language>,
254    ) -> Option<CodeLabel> {
255        let (text, filter_range, display_range) = match kind {
256            SymbolKind::METHOD | SymbolKind::FUNCTION => {
257                let text = format!("def {}", name);
258                let filter_range = 4..4 + name.len();
259                let display_range = 0..filter_range.end;
260                (text, filter_range, display_range)
261            }
262            SymbolKind::CLASS | SymbolKind::MODULE | SymbolKind::INTERFACE | SymbolKind::STRUCT => {
263                let text = format!("defmodule {}", name);
264                let filter_range = 10..10 + name.len();
265                let display_range = 0..filter_range.end;
266                (text, filter_range, display_range)
267            }
268            _ => return None,
269        };
270
271        Some(CodeLabel {
272            runs: language.highlight_text(&text.as_str().into(), display_range.clone()),
273            text: text[display_range].to_string(),
274            filter_range,
275        })
276    }
277
278    fn workspace_configuration(&self, _workspace_root: &Path, cx: &mut AppContext) -> Value {
279        let settings = ProjectSettings::get_global(cx)
280            .lsp
281            .get("elixir-ls")
282            .and_then(|s| s.settings.clone())
283            .unwrap_or_default();
284
285        serde_json::json!({
286            "elixirLS": settings
287        })
288    }
289}
290
291async fn get_cached_server_binary_elixir_ls(
292    container_dir: PathBuf,
293) -> Option<LanguageServerBinary> {
294    let server_path = container_dir.join("elixir-ls/language_server.sh");
295    if server_path.exists() {
296        Some(LanguageServerBinary {
297            path: server_path,
298            env: None,
299            arguments: vec![],
300        })
301    } else {
302        log::error!("missing executable in directory {:?}", server_path);
303        None
304    }
305}
306
307pub struct NextLspAdapter;
308
309#[async_trait(?Send)]
310impl LspAdapter for NextLspAdapter {
311    fn name(&self) -> LanguageServerName {
312        LanguageServerName("next-ls".into())
313    }
314
315    async fn fetch_latest_server_version(
316        &self,
317        delegate: &dyn LspAdapterDelegate,
318    ) -> Result<Box<dyn 'static + Send + Any>> {
319        let platform = match consts::ARCH {
320            "x86_64" => "darwin_amd64",
321            "aarch64" => "darwin_arm64",
322            other => bail!("Running on unsupported platform: {other}"),
323        };
324        let release =
325            latest_github_release("elixir-tools/next-ls", true, false, delegate.http_client())
326                .await?;
327        let version = release.tag_name;
328        let asset_name = format!("next_ls_{platform}");
329        let asset = release
330            .assets
331            .iter()
332            .find(|asset| asset.name == asset_name)
333            .with_context(|| format!("no asset found matching {asset_name:?}"))?;
334        let version = GitHubLspBinaryVersion {
335            name: version,
336            url: asset.browser_download_url.clone(),
337        };
338        Ok(Box::new(version) as Box<_>)
339    }
340
341    async fn fetch_server_binary(
342        &self,
343        version: Box<dyn 'static + Send + Any>,
344        container_dir: PathBuf,
345        delegate: &dyn LspAdapterDelegate,
346    ) -> Result<LanguageServerBinary> {
347        let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
348
349        let binary_path = container_dir.join("next-ls");
350
351        if fs::metadata(&binary_path).await.is_err() {
352            let mut response = delegate
353                .http_client()
354                .get(&version.url, Default::default(), true)
355                .await
356                .map_err(|err| anyhow!("error downloading release: {}", err))?;
357
358            let mut file = smol::fs::File::create(&binary_path).await?;
359            if !response.status().is_success() {
360                Err(anyhow!(
361                    "download failed with status {}",
362                    response.status().to_string()
363                ))?;
364            }
365            futures::io::copy(response.body_mut(), &mut file).await?;
366
367            // todo("windows")
368            #[cfg(not(windows))]
369            {
370                fs::set_permissions(
371                    &binary_path,
372                    <fs::Permissions as fs::unix::PermissionsExt>::from_mode(0o755),
373                )
374                .await?;
375            }
376        }
377
378        Ok(LanguageServerBinary {
379            path: binary_path,
380            env: None,
381            arguments: vec!["--stdio".into()],
382        })
383    }
384
385    async fn cached_server_binary(
386        &self,
387        container_dir: PathBuf,
388        _: &dyn LspAdapterDelegate,
389    ) -> Option<LanguageServerBinary> {
390        get_cached_server_binary_next(container_dir)
391            .await
392            .map(|mut binary| {
393                binary.arguments = vec!["--stdio".into()];
394                binary
395            })
396    }
397
398    async fn installation_test_binary(
399        &self,
400        container_dir: PathBuf,
401    ) -> Option<LanguageServerBinary> {
402        get_cached_server_binary_next(container_dir)
403            .await
404            .map(|mut binary| {
405                binary.arguments = vec!["--help".into()];
406                binary
407            })
408    }
409
410    async fn label_for_completion(
411        &self,
412        completion: &lsp::CompletionItem,
413        language: &Arc<Language>,
414    ) -> Option<CodeLabel> {
415        label_for_completion_elixir(completion, language)
416    }
417
418    async fn label_for_symbol(
419        &self,
420        name: &str,
421        symbol_kind: SymbolKind,
422        language: &Arc<Language>,
423    ) -> Option<CodeLabel> {
424        label_for_symbol_elixir(name, symbol_kind, language)
425    }
426}
427
428async fn get_cached_server_binary_next(container_dir: PathBuf) -> Option<LanguageServerBinary> {
429    maybe!(async {
430        let mut last_binary_path = None;
431        let mut entries = fs::read_dir(&container_dir).await?;
432        while let Some(entry) = entries.next().await {
433            let entry = entry?;
434            if entry.file_type().await?.is_file()
435                && entry
436                    .file_name()
437                    .to_str()
438                    .map_or(false, |name| name == "next-ls")
439            {
440                last_binary_path = Some(entry.path());
441            }
442        }
443
444        if let Some(path) = last_binary_path {
445            Ok(LanguageServerBinary {
446                path,
447                env: None,
448                arguments: Vec::new(),
449            })
450        } else {
451            Err(anyhow!("no cached binary"))
452        }
453    })
454    .await
455    .log_err()
456}
457
458pub struct LocalLspAdapter {
459    pub path: String,
460    pub arguments: Vec<String>,
461}
462
463#[async_trait(?Send)]
464impl LspAdapter for LocalLspAdapter {
465    fn name(&self) -> LanguageServerName {
466        LanguageServerName("local-ls".into())
467    }
468
469    async fn fetch_latest_server_version(
470        &self,
471        _: &dyn LspAdapterDelegate,
472    ) -> Result<Box<dyn 'static + Send + Any>> {
473        Ok(Box::new(()) as Box<_>)
474    }
475
476    async fn fetch_server_binary(
477        &self,
478        _: Box<dyn 'static + Send + Any>,
479        _: PathBuf,
480        _: &dyn LspAdapterDelegate,
481    ) -> Result<LanguageServerBinary> {
482        let path = shellexpand::full(&self.path)?;
483        Ok(LanguageServerBinary {
484            path: PathBuf::from(path.deref()),
485            env: None,
486            arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
487        })
488    }
489
490    async fn cached_server_binary(
491        &self,
492        _: PathBuf,
493        _: &dyn LspAdapterDelegate,
494    ) -> Option<LanguageServerBinary> {
495        let path = shellexpand::full(&self.path).ok()?;
496        Some(LanguageServerBinary {
497            path: PathBuf::from(path.deref()),
498            env: None,
499            arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
500        })
501    }
502
503    async fn installation_test_binary(&self, _: PathBuf) -> Option<LanguageServerBinary> {
504        let path = shellexpand::full(&self.path).ok()?;
505        Some(LanguageServerBinary {
506            path: PathBuf::from(path.deref()),
507            env: None,
508            arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
509        })
510    }
511
512    async fn label_for_completion(
513        &self,
514        completion: &lsp::CompletionItem,
515        language: &Arc<Language>,
516    ) -> Option<CodeLabel> {
517        label_for_completion_elixir(completion, language)
518    }
519
520    async fn label_for_symbol(
521        &self,
522        name: &str,
523        symbol: SymbolKind,
524        language: &Arc<Language>,
525    ) -> Option<CodeLabel> {
526        label_for_symbol_elixir(name, symbol, language)
527    }
528}
529
530fn label_for_completion_elixir(
531    completion: &lsp::CompletionItem,
532    language: &Arc<Language>,
533) -> Option<CodeLabel> {
534    return Some(CodeLabel {
535        runs: language.highlight_text(&completion.label.clone().into(), 0..completion.label.len()),
536        text: completion.label.clone(),
537        filter_range: 0..completion.label.len(),
538    });
539}
540
541fn label_for_symbol_elixir(
542    name: &str,
543    _: SymbolKind,
544    language: &Arc<Language>,
545) -> Option<CodeLabel> {
546    Some(CodeLabel {
547        runs: language.highlight_text(&name.into(), 0..name.len()),
548        text: name.to_string(),
549        filter_range: 0..name.len(),
550    })
551}
552
553pub(super) fn elixir_task_context() -> ContextProviderWithTasks {
554    // Taken from https://gist.github.com/josevalim/2e4f60a14ccd52728e3256571259d493#gistcomment-4995881
555    ContextProviderWithTasks::new(TaskDefinitions(vec![
556        Definition {
557            label: "Elixir: test suite".to_owned(),
558            command: "mix".to_owned(),
559            args: vec!["test".to_owned()],
560            ..Default::default()
561        },
562        Definition {
563            label: "Elixir: failed tests suite".to_owned(),
564            command: "mix".to_owned(),
565            args: vec!["test".to_owned(), "--failed".to_owned()],
566            ..Default::default()
567        },
568        Definition {
569            label: "Elixir: test file".to_owned(),
570            command: "mix".to_owned(),
571            args: vec!["test".to_owned(), "$ZED_FILE".to_owned()],
572            ..Default::default()
573        },
574        Definition {
575            label: "Elixir: test at current line".to_owned(),
576            command: "mix".to_owned(),
577            args: vec!["test".to_owned(), "$ZED_FILE:$ZED_ROW".to_owned()],
578            ..Default::default()
579        },
580        Definition {
581            label: "Elixir: break line".to_owned(),
582            command: "iex".to_owned(),
583            args: vec![
584                "-S".to_owned(),
585                "mix".to_owned(),
586                "test".to_owned(),
587                "-b".to_owned(),
588                "$ZED_FILE:$ZED_ROW".to_owned(),
589            ],
590            ..Default::default()
591        },
592    ]))
593}