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