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