elixir.rs

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