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::{
 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    fn workspace_configuration(&self, _workspace_root: &Path, cx: &mut AppContext) -> Value {
282        let settings = ProjectSettings::get_global(cx)
283            .lsp
284            .get("elixir-ls")
285            .and_then(|s| s.settings.clone())
286            .unwrap_or_default();
287
288        serde_json::json!({
289            "elixirLS": settings
290        })
291    }
292}
293
294async fn get_cached_server_binary_elixir_ls(
295    container_dir: PathBuf,
296) -> Option<LanguageServerBinary> {
297    let server_path = container_dir.join("elixir-ls/language_server.sh");
298    if server_path.exists() {
299        Some(LanguageServerBinary {
300            path: server_path,
301            env: None,
302            arguments: vec![],
303        })
304    } else {
305        log::error!("missing executable in directory {:?}", server_path);
306        None
307    }
308}
309
310pub struct NextLspAdapter;
311
312#[async_trait(?Send)]
313impl LspAdapter for NextLspAdapter {
314    fn name(&self) -> LanguageServerName {
315        LanguageServerName("next-ls".into())
316    }
317
318    async fn fetch_latest_server_version(
319        &self,
320        delegate: &dyn LspAdapterDelegate,
321    ) -> Result<Box<dyn 'static + Send + Any>> {
322        let platform = match consts::ARCH {
323            "x86_64" => "darwin_amd64",
324            "aarch64" => "darwin_arm64",
325            other => bail!("Running on unsupported platform: {other}"),
326        };
327        let release =
328            latest_github_release("elixir-tools/next-ls", true, false, delegate.http_client())
329                .await?;
330        let version = release.tag_name;
331        let asset_name = format!("next_ls_{platform}");
332        let asset = release
333            .assets
334            .iter()
335            .find(|asset| asset.name == asset_name)
336            .with_context(|| format!("no asset found matching {asset_name:?}"))?;
337        let version = GitHubLspBinaryVersion {
338            name: version,
339            url: asset.browser_download_url.clone(),
340        };
341        Ok(Box::new(version) as Box<_>)
342    }
343
344    async fn fetch_server_binary(
345        &self,
346        version: Box<dyn 'static + Send + Any>,
347        container_dir: PathBuf,
348        delegate: &dyn LspAdapterDelegate,
349    ) -> Result<LanguageServerBinary> {
350        let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
351
352        let binary_path = container_dir.join("next-ls");
353
354        if fs::metadata(&binary_path).await.is_err() {
355            let mut response = delegate
356                .http_client()
357                .get(&version.url, Default::default(), true)
358                .await
359                .map_err(|err| anyhow!("error downloading release: {}", err))?;
360
361            let mut file = smol::fs::File::create(&binary_path).await?;
362            if !response.status().is_success() {
363                Err(anyhow!(
364                    "download failed with status {}",
365                    response.status().to_string()
366                ))?;
367            }
368            futures::io::copy(response.body_mut(), &mut file).await?;
369
370            // todo("windows")
371            #[cfg(not(windows))]
372            {
373                fs::set_permissions(
374                    &binary_path,
375                    <fs::Permissions as fs::unix::PermissionsExt>::from_mode(0o755),
376                )
377                .await?;
378            }
379        }
380
381        Ok(LanguageServerBinary {
382            path: binary_path,
383            env: None,
384            arguments: vec!["--stdio".into()],
385        })
386    }
387
388    async fn cached_server_binary(
389        &self,
390        container_dir: PathBuf,
391        _: &dyn LspAdapterDelegate,
392    ) -> Option<LanguageServerBinary> {
393        get_cached_server_binary_next(container_dir)
394            .await
395            .map(|mut binary| {
396                binary.arguments = vec!["--stdio".into()];
397                binary
398            })
399    }
400
401    async fn installation_test_binary(
402        &self,
403        container_dir: PathBuf,
404    ) -> Option<LanguageServerBinary> {
405        get_cached_server_binary_next(container_dir)
406            .await
407            .map(|mut binary| {
408                binary.arguments = vec!["--help".into()];
409                binary
410            })
411    }
412
413    async fn label_for_completion(
414        &self,
415        completion: &lsp::CompletionItem,
416        language: &Arc<Language>,
417    ) -> Option<CodeLabel> {
418        label_for_completion_elixir(completion, language)
419    }
420
421    async fn label_for_symbol(
422        &self,
423        name: &str,
424        symbol_kind: SymbolKind,
425        language: &Arc<Language>,
426    ) -> Option<CodeLabel> {
427        label_for_symbol_elixir(name, symbol_kind, language)
428    }
429}
430
431async fn get_cached_server_binary_next(container_dir: PathBuf) -> Option<LanguageServerBinary> {
432    maybe!(async {
433        let mut last_binary_path = None;
434        let mut entries = fs::read_dir(&container_dir).await?;
435        while let Some(entry) = entries.next().await {
436            let entry = entry?;
437            if entry.file_type().await?.is_file()
438                && entry
439                    .file_name()
440                    .to_str()
441                    .map_or(false, |name| name == "next-ls")
442            {
443                last_binary_path = Some(entry.path());
444            }
445        }
446
447        if let Some(path) = last_binary_path {
448            Ok(LanguageServerBinary {
449                path,
450                env: None,
451                arguments: Vec::new(),
452            })
453        } else {
454            Err(anyhow!("no cached binary"))
455        }
456    })
457    .await
458    .log_err()
459}
460
461pub struct LocalLspAdapter {
462    pub path: String,
463    pub arguments: Vec<String>,
464}
465
466#[async_trait(?Send)]
467impl LspAdapter for LocalLspAdapter {
468    fn name(&self) -> LanguageServerName {
469        LanguageServerName("local-ls".into())
470    }
471
472    async fn fetch_latest_server_version(
473        &self,
474        _: &dyn LspAdapterDelegate,
475    ) -> Result<Box<dyn 'static + Send + Any>> {
476        Ok(Box::new(()) as Box<_>)
477    }
478
479    async fn fetch_server_binary(
480        &self,
481        _: Box<dyn 'static + Send + Any>,
482        _: PathBuf,
483        _: &dyn LspAdapterDelegate,
484    ) -> Result<LanguageServerBinary> {
485        let path = shellexpand::full(&self.path)?;
486        Ok(LanguageServerBinary {
487            path: PathBuf::from(path.deref()),
488            env: None,
489            arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
490        })
491    }
492
493    async fn cached_server_binary(
494        &self,
495        _: PathBuf,
496        _: &dyn LspAdapterDelegate,
497    ) -> Option<LanguageServerBinary> {
498        let path = shellexpand::full(&self.path).ok()?;
499        Some(LanguageServerBinary {
500            path: PathBuf::from(path.deref()),
501            env: None,
502            arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
503        })
504    }
505
506    async fn installation_test_binary(&self, _: PathBuf) -> Option<LanguageServerBinary> {
507        let path = shellexpand::full(&self.path).ok()?;
508        Some(LanguageServerBinary {
509            path: PathBuf::from(path.deref()),
510            env: None,
511            arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
512        })
513    }
514
515    async fn label_for_completion(
516        &self,
517        completion: &lsp::CompletionItem,
518        language: &Arc<Language>,
519    ) -> Option<CodeLabel> {
520        label_for_completion_elixir(completion, language)
521    }
522
523    async fn label_for_symbol(
524        &self,
525        name: &str,
526        symbol: SymbolKind,
527        language: &Arc<Language>,
528    ) -> Option<CodeLabel> {
529        label_for_symbol_elixir(name, symbol, language)
530    }
531}
532
533fn label_for_completion_elixir(
534    completion: &lsp::CompletionItem,
535    language: &Arc<Language>,
536) -> Option<CodeLabel> {
537    return Some(CodeLabel {
538        runs: language.highlight_text(&completion.label.clone().into(), 0..completion.label.len()),
539        text: completion.label.clone(),
540        filter_range: 0..completion.label.len(),
541    });
542}
543
544fn label_for_symbol_elixir(
545    name: &str,
546    _: SymbolKind,
547    language: &Arc<Language>,
548) -> Option<CodeLabel> {
549    Some(CodeLabel {
550        runs: language.highlight_text(&name.into(), 0..name.len()),
551        text: name.to_string(),
552        filter_range: 0..name.len(),
553    })
554}
555
556pub(super) fn elixir_task_context() -> ContextProviderWithTasks {
557    // Taken from https://gist.github.com/josevalim/2e4f60a14ccd52728e3256571259d493#gistcomment-4995881
558    ContextProviderWithTasks::new(TaskDefinitions(vec![
559        Definition {
560            label: "Elixir: test suite".to_owned(),
561            command: "mix".to_owned(),
562            args: vec!["test".to_owned()],
563            ..Definition::default()
564        },
565        Definition {
566            label: "Elixir: failed tests suite".to_owned(),
567            command: "mix".to_owned(),
568            args: vec!["test".to_owned(), "--failed".to_owned()],
569            ..Definition::default()
570        },
571        Definition {
572            label: "Elixir: test file".to_owned(),
573            command: "mix".to_owned(),
574            args: vec!["test".to_owned(), VariableName::Symbol.template_value()],
575            ..Definition::default()
576        },
577        Definition {
578            label: "Elixir: test at current line".to_owned(),
579            command: "mix".to_owned(),
580            args: vec![
581                "test".to_owned(),
582                format!(
583                    "{}:{}",
584                    VariableName::File.template_value(),
585                    VariableName::Row.template_value()
586                ),
587            ],
588            ..Definition::default()
589        },
590        Definition {
591            label: "Elixir: break line".to_owned(),
592            command: "iex".to_owned(),
593            args: vec![
594                "-S".to_owned(),
595                "mix".to_owned(),
596                "test".to_owned(),
597                "-b".to_owned(),
598                format!(
599                    "{}:{}",
600                    VariableName::File.template_value(),
601                    VariableName::Row.template_value()
602                ),
603            ],
604            ..Definition::default()
605        },
606    ]))
607}