typescript.rs

  1use anyhow::{Context as _, Result};
  2use async_compression::futures::bufread::GzipDecoder;
  3use async_tar::Archive;
  4use async_trait::async_trait;
  5use chrono::{DateTime, Local};
  6use collections::HashMap;
  7use gpui::{App, AppContext, AsyncApp, Task};
  8use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
  9use language::{
 10    ContextLocation, ContextProvider, File, LanguageToolchainStore, LspAdapter, LspAdapterDelegate,
 11};
 12use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
 13use node_runtime::NodeRuntime;
 14use project::{Fs, lsp_store::language_server_settings};
 15use serde_json::{Value, json};
 16use smol::{fs, io::BufReader, lock::RwLock, stream::StreamExt};
 17use std::{
 18    any::Any,
 19    borrow::Cow,
 20    ffi::OsString,
 21    path::{Path, PathBuf},
 22    sync::Arc,
 23};
 24use task::{TaskTemplate, TaskTemplates, TaskVariables, VariableName};
 25use util::archive::extract_zip;
 26use util::merge_json_value_into;
 27use util::{ResultExt, fs::remove_matching, maybe};
 28
 29pub(crate) struct TypeScriptContextProvider {
 30    last_package_json: PackageJsonContents,
 31}
 32
 33const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
 34    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
 35const TYPESCRIPT_JEST_TASK_VARIABLE: VariableName =
 36    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST"));
 37const TYPESCRIPT_MOCHA_TASK_VARIABLE: VariableName =
 38    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA"));
 39
 40const TYPESCRIPT_VITEST_TASK_VARIABLE: VariableName =
 41    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST"));
 42const TYPESCRIPT_JASMINE_TASK_VARIABLE: VariableName =
 43    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE"));
 44const TYPESCRIPT_BUILD_SCRIPT_TASK_VARIABLE: VariableName =
 45    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUILD_SCRIPT"));
 46const TYPESCRIPT_TEST_SCRIPT_TASK_VARIABLE: VariableName =
 47    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_TEST_SCRIPT"));
 48
 49#[derive(Clone, Default)]
 50struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
 51
 52struct PackageJson {
 53    mtime: DateTime<Local>,
 54    data: PackageJsonData,
 55}
 56
 57#[derive(Clone, Copy, Default)]
 58struct PackageJsonData {
 59    jest: bool,
 60    mocha: bool,
 61    vitest: bool,
 62    jasmine: bool,
 63    build_script: bool,
 64    test_script: bool,
 65    runner: Runner,
 66}
 67
 68#[derive(Clone, Copy, Default)]
 69enum Runner {
 70    #[default]
 71    Npm,
 72    Npx,
 73    Pnpm,
 74}
 75
 76impl PackageJsonData {
 77    fn new(package_json: HashMap<String, Value>) -> Self {
 78        let mut build_script = false;
 79        let mut test_script = false;
 80        if let Some(serde_json::Value::Object(scripts)) = package_json.get("scripts") {
 81            build_script |= scripts.contains_key("build");
 82            test_script |= scripts.contains_key("test");
 83        }
 84
 85        let mut jest = false;
 86        let mut mocha = false;
 87        let mut vitest = false;
 88        let mut jasmine = false;
 89        if let Some(serde_json::Value::Object(dependencies)) = package_json.get("devDependencies") {
 90            jest |= dependencies.contains_key("jest");
 91            mocha |= dependencies.contains_key("mocha");
 92            vitest |= dependencies.contains_key("vitest");
 93            jasmine |= dependencies.contains_key("jasmine");
 94        }
 95        if let Some(serde_json::Value::Object(dev_dependencies)) = package_json.get("dependencies")
 96        {
 97            jest |= dev_dependencies.contains_key("jest");
 98            mocha |= dev_dependencies.contains_key("mocha");
 99            vitest |= dev_dependencies.contains_key("vitest");
100            jasmine |= dev_dependencies.contains_key("jasmine");
101        }
102
103        let mut runner = Runner::Npm;
104        if which::which("pnpm").is_ok() {
105            runner = Runner::Pnpm;
106        } else if which::which("npx").is_ok() {
107            runner = Runner::Npx;
108        }
109
110        Self {
111            jest,
112            mocha,
113            vitest,
114            jasmine,
115            build_script,
116            test_script,
117            runner,
118        }
119    }
120
121    fn fill_variables(&self, variables: &mut TaskVariables) {
122        let runner = match self.runner {
123            Runner::Npm => "npm",
124            Runner::Npx => "npx",
125            Runner::Pnpm => "pnpm",
126        };
127        variables.insert(TYPESCRIPT_RUNNER_VARIABLE, runner.to_owned());
128
129        if self.jest {
130            variables.insert(TYPESCRIPT_JEST_TASK_VARIABLE, "jest".to_owned());
131        }
132        if self.mocha {
133            variables.insert(TYPESCRIPT_MOCHA_TASK_VARIABLE, "mocha".to_owned());
134        }
135        if self.vitest {
136            variables.insert(TYPESCRIPT_VITEST_TASK_VARIABLE, "vitest".to_owned());
137        }
138        if self.jasmine {
139            variables.insert(TYPESCRIPT_JASMINE_TASK_VARIABLE, "jasmine".to_owned());
140        }
141        if self.build_script {
142            variables.insert(TYPESCRIPT_BUILD_SCRIPT_TASK_VARIABLE, "build".to_owned());
143        }
144        if self.test_script {
145            variables.insert(TYPESCRIPT_TEST_SCRIPT_TASK_VARIABLE, "test".to_owned());
146        }
147    }
148}
149
150impl TypeScriptContextProvider {
151    pub fn new() -> Self {
152        TypeScriptContextProvider {
153            last_package_json: PackageJsonContents::default(),
154        }
155    }
156}
157
158impl ContextProvider for TypeScriptContextProvider {
159    fn associated_tasks(&self, _: Option<Arc<dyn File>>, _: &App) -> Option<TaskTemplates> {
160        let mut task_templates = TaskTemplates(Vec::new());
161
162        // Jest tasks
163        task_templates.0.push(TaskTemplate {
164            label: format!(
165                "{} file test",
166                TYPESCRIPT_JEST_TASK_VARIABLE.template_value()
167            ),
168            command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
169            args: vec![
170                TYPESCRIPT_JEST_TASK_VARIABLE.template_value(),
171                VariableName::File.template_value(),
172            ],
173            ..TaskTemplate::default()
174        });
175        task_templates.0.push(TaskTemplate {
176            label: format!(
177                "{} test {}",
178                TYPESCRIPT_JEST_TASK_VARIABLE.template_value(),
179                VariableName::Symbol.template_value(),
180            ),
181            command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
182            args: vec![
183                TYPESCRIPT_JEST_TASK_VARIABLE.template_value(),
184                "--testNamePattern".to_owned(),
185                format!("\"{}\"", VariableName::Symbol.template_value()),
186                VariableName::File.template_value(),
187            ],
188            tags: vec![
189                "ts-test".to_owned(),
190                "js-test".to_owned(),
191                "tsx-test".to_owned(),
192            ],
193            ..TaskTemplate::default()
194        });
195
196        // Vitest tasks
197        task_templates.0.push(TaskTemplate {
198            label: format!(
199                "{} file test",
200                TYPESCRIPT_VITEST_TASK_VARIABLE.template_value()
201            ),
202            command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
203            args: vec![
204                TYPESCRIPT_VITEST_TASK_VARIABLE.template_value(),
205                "run".to_owned(),
206                VariableName::File.template_value(),
207            ],
208            ..TaskTemplate::default()
209        });
210        task_templates.0.push(TaskTemplate {
211            label: format!(
212                "{} test {}",
213                TYPESCRIPT_VITEST_TASK_VARIABLE.template_value(),
214                VariableName::Symbol.template_value(),
215            ),
216            command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
217            args: vec![
218                TYPESCRIPT_VITEST_TASK_VARIABLE.template_value(),
219                "run".to_owned(),
220                "--testNamePattern".to_owned(),
221                format!("\"{}\"", VariableName::Symbol.template_value()),
222                VariableName::File.template_value(),
223            ],
224            tags: vec![
225                "ts-test".to_owned(),
226                "js-test".to_owned(),
227                "tsx-test".to_owned(),
228            ],
229            ..TaskTemplate::default()
230        });
231
232        // Mocha tasks
233        task_templates.0.push(TaskTemplate {
234            label: format!(
235                "{} file test",
236                TYPESCRIPT_MOCHA_TASK_VARIABLE.template_value()
237            ),
238            command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
239            args: vec![
240                TYPESCRIPT_MOCHA_TASK_VARIABLE.template_value(),
241                VariableName::File.template_value(),
242            ],
243            ..TaskTemplate::default()
244        });
245        task_templates.0.push(TaskTemplate {
246            label: format!(
247                "{} test {}",
248                TYPESCRIPT_MOCHA_TASK_VARIABLE.template_value(),
249                VariableName::Symbol.template_value(),
250            ),
251            command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
252            args: vec![
253                TYPESCRIPT_MOCHA_TASK_VARIABLE.template_value(),
254                "--grep".to_owned(),
255                format!("\"{}\"", VariableName::Symbol.template_value()),
256                VariableName::File.template_value(),
257            ],
258            tags: vec![
259                "ts-test".to_owned(),
260                "js-test".to_owned(),
261                "tsx-test".to_owned(),
262            ],
263            ..TaskTemplate::default()
264        });
265
266        // Jasmine tasks
267        task_templates.0.push(TaskTemplate {
268            label: format!(
269                "{} file test",
270                TYPESCRIPT_JASMINE_TASK_VARIABLE.template_value()
271            ),
272            command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
273            args: vec![
274                TYPESCRIPT_JASMINE_TASK_VARIABLE.template_value(),
275                VariableName::File.template_value(),
276            ],
277            ..TaskTemplate::default()
278        });
279        task_templates.0.push(TaskTemplate {
280            label: format!(
281                "{} test {}",
282                TYPESCRIPT_JASMINE_TASK_VARIABLE.template_value(),
283                VariableName::Symbol.template_value(),
284            ),
285            command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
286            args: vec![
287                TYPESCRIPT_JASMINE_TASK_VARIABLE.template_value(),
288                format!("--filter={}", VariableName::Symbol.template_value()),
289                VariableName::File.template_value(),
290            ],
291            tags: vec![
292                "ts-test".to_owned(),
293                "js-test".to_owned(),
294                "tsx-test".to_owned(),
295            ],
296            ..TaskTemplate::default()
297        });
298
299        for package_json_script in [
300            TYPESCRIPT_TEST_SCRIPT_TASK_VARIABLE,
301            TYPESCRIPT_BUILD_SCRIPT_TASK_VARIABLE,
302        ] {
303            task_templates.0.push(TaskTemplate {
304                label: format!(
305                    "package.json script {}",
306                    package_json_script.template_value()
307                ),
308                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
309                args: vec![
310                    "--prefix".to_owned(),
311                    VariableName::WorktreeRoot.template_value(),
312                    "run".to_owned(),
313                    package_json_script.template_value(),
314                ],
315                tags: vec!["package-script".into()],
316                ..TaskTemplate::default()
317            });
318        }
319
320        task_templates.0.push(TaskTemplate {
321            label: format!(
322                "execute selection {}",
323                VariableName::SelectedText.template_value()
324            ),
325            command: "node".to_owned(),
326            args: vec![
327                "-e".to_owned(),
328                format!("\"{}\"", VariableName::SelectedText.template_value()),
329            ],
330            ..TaskTemplate::default()
331        });
332
333        Some(task_templates)
334    }
335
336    fn build_context(
337        &self,
338        _variables: &task::TaskVariables,
339        location: ContextLocation<'_>,
340        _project_env: Option<HashMap<String, String>>,
341        _toolchains: Arc<dyn LanguageToolchainStore>,
342        cx: &mut App,
343    ) -> Task<Result<task::TaskVariables>> {
344        let Some((fs, worktree_root)) = location.fs.zip(location.worktree_root) else {
345            return Task::ready(Ok(task::TaskVariables::default()));
346        };
347
348        let package_json_contents = self.last_package_json.clone();
349        cx.background_spawn(async move {
350            let variables = package_json_variables(fs, worktree_root, package_json_contents)
351                .await
352                .context("package.json context retrieval")
353                .log_err()
354                .unwrap_or_else(task::TaskVariables::default);
355            Ok(variables)
356        })
357    }
358}
359
360async fn package_json_variables(
361    fs: Arc<dyn Fs>,
362    worktree_root: PathBuf,
363    package_json_contents: PackageJsonContents,
364) -> anyhow::Result<task::TaskVariables> {
365    let package_json_path = worktree_root.join("package.json");
366    let metadata = fs
367        .metadata(&package_json_path)
368        .await
369        .with_context(|| format!("getting metadata for {package_json_path:?}"))?
370        .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
371    let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
372    let existing_data = {
373        let contents = package_json_contents.0.read().await;
374        contents
375            .get(&package_json_path)
376            .filter(|package_json| package_json.mtime == mtime)
377            .map(|package_json| package_json.data)
378    };
379
380    let mut variables = TaskVariables::default();
381    if let Some(existing_data) = existing_data {
382        existing_data.fill_variables(&mut variables);
383    } else {
384        let package_json_string = fs
385            .load(&package_json_path)
386            .await
387            .with_context(|| format!("loading package.json from {package_json_path:?}"))?;
388        let package_json: HashMap<String, serde_json::Value> =
389            serde_json::from_str(&package_json_string)
390                .with_context(|| format!("parsing package.json from {package_json_path:?}"))?;
391        let new_data = PackageJsonData::new(package_json);
392        new_data.fill_variables(&mut variables);
393        {
394            let mut contents = package_json_contents.0.write().await;
395            contents.insert(
396                package_json_path,
397                PackageJson {
398                    mtime,
399                    data: new_data,
400                },
401            );
402        }
403    }
404
405    Ok(variables)
406}
407
408fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
409    vec![server_path.into(), "--stdio".into()]
410}
411
412fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
413    vec![
414        "--max-old-space-size=8192".into(),
415        server_path.into(),
416        "--stdio".into(),
417    ]
418}
419
420pub struct TypeScriptLspAdapter {
421    node: NodeRuntime,
422}
423
424impl TypeScriptLspAdapter {
425    const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
426    const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
427    const SERVER_NAME: LanguageServerName =
428        LanguageServerName::new_static("typescript-language-server");
429    const PACKAGE_NAME: &str = "typescript";
430    pub fn new(node: NodeRuntime) -> Self {
431        TypeScriptLspAdapter { node }
432    }
433    async fn tsdk_path(fs: &dyn Fs, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
434        let is_yarn = adapter
435            .read_text_file(PathBuf::from(".yarn/sdks/typescript/lib/typescript.js"))
436            .await
437            .is_ok();
438
439        let tsdk_path = if is_yarn {
440            ".yarn/sdks/typescript/lib"
441        } else {
442            "node_modules/typescript/lib"
443        };
444
445        if fs
446            .is_dir(&adapter.worktree_root_path().join(tsdk_path))
447            .await
448        {
449            Some(tsdk_path)
450        } else {
451            None
452        }
453    }
454}
455
456struct TypeScriptVersions {
457    typescript_version: String,
458    server_version: String,
459}
460
461#[async_trait(?Send)]
462impl LspAdapter for TypeScriptLspAdapter {
463    fn name(&self) -> LanguageServerName {
464        Self::SERVER_NAME.clone()
465    }
466
467    async fn fetch_latest_server_version(
468        &self,
469        _: &dyn LspAdapterDelegate,
470    ) -> Result<Box<dyn 'static + Send + Any>> {
471        Ok(Box::new(TypeScriptVersions {
472            typescript_version: self.node.npm_package_latest_version("typescript").await?,
473            server_version: self
474                .node
475                .npm_package_latest_version("typescript-language-server")
476                .await?,
477        }) as Box<_>)
478    }
479
480    async fn check_if_version_installed(
481        &self,
482        version: &(dyn 'static + Send + Any),
483        container_dir: &PathBuf,
484        _: &dyn LspAdapterDelegate,
485    ) -> Option<LanguageServerBinary> {
486        let version = version.downcast_ref::<TypeScriptVersions>().unwrap();
487        let server_path = container_dir.join(Self::NEW_SERVER_PATH);
488
489        let should_install_language_server = self
490            .node
491            .should_install_npm_package(
492                Self::PACKAGE_NAME,
493                &server_path,
494                &container_dir,
495                version.typescript_version.as_str(),
496            )
497            .await;
498
499        if should_install_language_server {
500            None
501        } else {
502            Some(LanguageServerBinary {
503                path: self.node.binary_path().await.ok()?,
504                env: None,
505                arguments: typescript_server_binary_arguments(&server_path),
506            })
507        }
508    }
509
510    async fn fetch_server_binary(
511        &self,
512        latest_version: Box<dyn 'static + Send + Any>,
513        container_dir: PathBuf,
514        _: &dyn LspAdapterDelegate,
515    ) -> Result<LanguageServerBinary> {
516        let latest_version = latest_version.downcast::<TypeScriptVersions>().unwrap();
517        let server_path = container_dir.join(Self::NEW_SERVER_PATH);
518
519        self.node
520            .npm_install_packages(
521                &container_dir,
522                &[
523                    (
524                        Self::PACKAGE_NAME,
525                        latest_version.typescript_version.as_str(),
526                    ),
527                    (
528                        "typescript-language-server",
529                        latest_version.server_version.as_str(),
530                    ),
531                ],
532            )
533            .await?;
534
535        Ok(LanguageServerBinary {
536            path: self.node.binary_path().await?,
537            env: None,
538            arguments: typescript_server_binary_arguments(&server_path),
539        })
540    }
541
542    async fn cached_server_binary(
543        &self,
544        container_dir: PathBuf,
545        _: &dyn LspAdapterDelegate,
546    ) -> Option<LanguageServerBinary> {
547        get_cached_ts_server_binary(container_dir, &self.node).await
548    }
549
550    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
551        Some(vec![
552            CodeActionKind::QUICKFIX,
553            CodeActionKind::REFACTOR,
554            CodeActionKind::REFACTOR_EXTRACT,
555            CodeActionKind::SOURCE,
556        ])
557    }
558
559    async fn label_for_completion(
560        &self,
561        item: &lsp::CompletionItem,
562        language: &Arc<language::Language>,
563    ) -> Option<language::CodeLabel> {
564        use lsp::CompletionItemKind as Kind;
565        let len = item.label.len();
566        let grammar = language.grammar()?;
567        let highlight_id = match item.kind? {
568            Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
569            Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
570            Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
571            Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
572            Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
573            Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
574            _ => None,
575        }?;
576
577        let text = if let Some(description) = item
578            .label_details
579            .as_ref()
580            .and_then(|label_details| label_details.description.as_ref())
581        {
582            format!("{} {}", item.label, description)
583        } else if let Some(detail) = &item.detail {
584            format!("{} {}", item.label, detail)
585        } else {
586            item.label.clone()
587        };
588
589        Some(language::CodeLabel {
590            text,
591            runs: vec![(0..len, highlight_id)],
592            filter_range: 0..len,
593        })
594    }
595
596    async fn initialization_options(
597        self: Arc<Self>,
598        fs: &dyn Fs,
599        adapter: &Arc<dyn LspAdapterDelegate>,
600    ) -> Result<Option<serde_json::Value>> {
601        let tsdk_path = Self::tsdk_path(fs, adapter).await;
602        Ok(Some(json!({
603            "provideFormatter": true,
604            "hostInfo": "zed",
605            "tsserver": {
606                "path": tsdk_path,
607            },
608            "preferences": {
609                "includeInlayParameterNameHints": "all",
610                "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
611                "includeInlayFunctionParameterTypeHints": true,
612                "includeInlayVariableTypeHints": true,
613                "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
614                "includeInlayPropertyDeclarationTypeHints": true,
615                "includeInlayFunctionLikeReturnTypeHints": true,
616                "includeInlayEnumMemberValueHints": true,
617            }
618        })))
619    }
620
621    async fn workspace_configuration(
622        self: Arc<Self>,
623        _: &dyn Fs,
624        delegate: &Arc<dyn LspAdapterDelegate>,
625        _: Arc<dyn LanguageToolchainStore>,
626        cx: &mut AsyncApp,
627    ) -> Result<Value> {
628        let override_options = cx.update(|cx| {
629            language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
630                .and_then(|s| s.settings.clone())
631        })?;
632        if let Some(options) = override_options {
633            return Ok(options);
634        }
635        Ok(json!({
636            "completions": {
637              "completeFunctionCalls": true
638            }
639        }))
640    }
641
642    fn language_ids(&self) -> HashMap<String, String> {
643        HashMap::from_iter([
644            ("TypeScript".into(), "typescript".into()),
645            ("JavaScript".into(), "javascript".into()),
646            ("TSX".into(), "typescriptreact".into()),
647        ])
648    }
649}
650
651async fn get_cached_ts_server_binary(
652    container_dir: PathBuf,
653    node: &NodeRuntime,
654) -> Option<LanguageServerBinary> {
655    maybe!(async {
656        let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
657        let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
658        if new_server_path.exists() {
659            Ok(LanguageServerBinary {
660                path: node.binary_path().await?,
661                env: None,
662                arguments: typescript_server_binary_arguments(&new_server_path),
663            })
664        } else if old_server_path.exists() {
665            Ok(LanguageServerBinary {
666                path: node.binary_path().await?,
667                env: None,
668                arguments: typescript_server_binary_arguments(&old_server_path),
669            })
670        } else {
671            anyhow::bail!("missing executable in directory {container_dir:?}")
672        }
673    })
674    .await
675    .log_err()
676}
677
678pub struct EsLintLspAdapter {
679    node: NodeRuntime,
680}
681
682impl EsLintLspAdapter {
683    const CURRENT_VERSION: &'static str = "2.4.4";
684    const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
685
686    #[cfg(not(windows))]
687    const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
688    #[cfg(windows)]
689    const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
690
691    const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
692    const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
693
694    const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
695        "eslint.config.js",
696        "eslint.config.mjs",
697        "eslint.config.cjs",
698        "eslint.config.ts",
699        "eslint.config.cts",
700        "eslint.config.mts",
701    ];
702
703    pub fn new(node: NodeRuntime) -> Self {
704        EsLintLspAdapter { node }
705    }
706
707    fn build_destination_path(container_dir: &Path) -> PathBuf {
708        container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
709    }
710}
711
712#[async_trait(?Send)]
713impl LspAdapter for EsLintLspAdapter {
714    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
715        Some(vec![
716            CodeActionKind::QUICKFIX,
717            CodeActionKind::new("source.fixAll.eslint"),
718        ])
719    }
720
721    async fn workspace_configuration(
722        self: Arc<Self>,
723        _: &dyn Fs,
724        delegate: &Arc<dyn LspAdapterDelegate>,
725        _: Arc<dyn LanguageToolchainStore>,
726        cx: &mut AsyncApp,
727    ) -> Result<Value> {
728        let workspace_root = delegate.worktree_root_path();
729        let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
730            .iter()
731            .any(|file| workspace_root.join(file).is_file());
732
733        let mut default_workspace_configuration = json!({
734            "validate": "on",
735            "rulesCustomizations": [],
736            "run": "onType",
737            "nodePath": null,
738            "workingDirectory": {
739                "mode": "auto"
740            },
741            "workspaceFolder": {
742                "uri": workspace_root,
743                "name": workspace_root.file_name()
744                    .unwrap_or(workspace_root.as_os_str())
745                    .to_string_lossy(),
746            },
747            "problems": {},
748            "codeActionOnSave": {
749                // We enable this, but without also configuring code_actions_on_format
750                // in the Zed configuration, it doesn't have an effect.
751                "enable": true,
752            },
753            "codeAction": {
754                "disableRuleComment": {
755                    "enable": true,
756                    "location": "separateLine",
757                },
758                "showDocumentation": {
759                    "enable": true
760                }
761            },
762            "experimental": {
763                "useFlatConfig": use_flat_config,
764            },
765        });
766
767        let override_options = cx.update(|cx| {
768            language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
769                .and_then(|s| s.settings.clone())
770        })?;
771
772        if let Some(override_options) = override_options {
773            merge_json_value_into(override_options, &mut default_workspace_configuration);
774        }
775
776        Ok(json!({
777            "": default_workspace_configuration
778        }))
779    }
780
781    fn name(&self) -> LanguageServerName {
782        Self::SERVER_NAME.clone()
783    }
784
785    async fn fetch_latest_server_version(
786        &self,
787        _delegate: &dyn LspAdapterDelegate,
788    ) -> Result<Box<dyn 'static + Send + Any>> {
789        let url = build_asset_url(
790            "zed-industries/vscode-eslint",
791            Self::CURRENT_VERSION_TAG_NAME,
792            Self::GITHUB_ASSET_KIND,
793        )?;
794
795        Ok(Box::new(GitHubLspBinaryVersion {
796            name: Self::CURRENT_VERSION.into(),
797            url,
798        }))
799    }
800
801    async fn fetch_server_binary(
802        &self,
803        version: Box<dyn 'static + Send + Any>,
804        container_dir: PathBuf,
805        delegate: &dyn LspAdapterDelegate,
806    ) -> Result<LanguageServerBinary> {
807        let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
808        let destination_path = Self::build_destination_path(&container_dir);
809        let server_path = destination_path.join(Self::SERVER_PATH);
810
811        if fs::metadata(&server_path).await.is_err() {
812            remove_matching(&container_dir, |entry| entry != destination_path).await;
813
814            let mut response = delegate
815                .http_client()
816                .get(&version.url, Default::default(), true)
817                .await
818                .context("downloading release")?;
819            match Self::GITHUB_ASSET_KIND {
820                AssetKind::TarGz => {
821                    let decompressed_bytes = GzipDecoder::new(BufReader::new(response.body_mut()));
822                    let archive = Archive::new(decompressed_bytes);
823                    archive.unpack(&destination_path).await.with_context(|| {
824                        format!("extracting {} to {:?}", version.url, destination_path)
825                    })?;
826                }
827                AssetKind::Gz => {
828                    let mut decompressed_bytes =
829                        GzipDecoder::new(BufReader::new(response.body_mut()));
830                    let mut file =
831                        fs::File::create(&destination_path).await.with_context(|| {
832                            format!(
833                                "creating a file {:?} for a download from {}",
834                                destination_path, version.url,
835                            )
836                        })?;
837                    futures::io::copy(&mut decompressed_bytes, &mut file)
838                        .await
839                        .with_context(|| {
840                            format!("extracting {} to {:?}", version.url, destination_path)
841                        })?;
842                }
843                AssetKind::Zip => {
844                    extract_zip(&destination_path, response.body_mut())
845                        .await
846                        .with_context(|| {
847                            format!("unzipping {} to {:?}", version.url, destination_path)
848                        })?;
849                }
850            }
851
852            let mut dir = fs::read_dir(&destination_path).await?;
853            let first = dir.next().await.context("missing first file")??;
854            let repo_root = destination_path.join("vscode-eslint");
855            fs::rename(first.path(), &repo_root).await?;
856
857            #[cfg(target_os = "windows")]
858            {
859                handle_symlink(
860                    repo_root.join("$shared"),
861                    repo_root.join("client").join("src").join("shared"),
862                )
863                .await?;
864                handle_symlink(
865                    repo_root.join("$shared"),
866                    repo_root.join("server").join("src").join("shared"),
867                )
868                .await?;
869            }
870
871            self.node
872                .run_npm_subcommand(&repo_root, "install", &[])
873                .await?;
874
875            self.node
876                .run_npm_subcommand(&repo_root, "run-script", &["compile"])
877                .await?;
878        }
879
880        Ok(LanguageServerBinary {
881            path: self.node.binary_path().await?,
882            env: None,
883            arguments: eslint_server_binary_arguments(&server_path),
884        })
885    }
886
887    async fn cached_server_binary(
888        &self,
889        container_dir: PathBuf,
890        _: &dyn LspAdapterDelegate,
891    ) -> Option<LanguageServerBinary> {
892        let server_path =
893            Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
894        Some(LanguageServerBinary {
895            path: self.node.binary_path().await.ok()?,
896            env: None,
897            arguments: eslint_server_binary_arguments(&server_path),
898        })
899    }
900}
901
902#[cfg(target_os = "windows")]
903async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
904    anyhow::ensure!(
905        fs::metadata(&src_dir).await.is_ok(),
906        "Directory {src_dir:?} is not present"
907    );
908    if fs::metadata(&dest_dir).await.is_ok() {
909        fs::remove_file(&dest_dir).await?;
910    }
911    fs::create_dir_all(&dest_dir).await?;
912    let mut entries = fs::read_dir(&src_dir).await?;
913    while let Some(entry) = entries.try_next().await? {
914        let entry_path = entry.path();
915        let entry_name = entry.file_name();
916        let dest_path = dest_dir.join(&entry_name);
917        fs::copy(&entry_path, &dest_path).await?;
918    }
919    Ok(())
920}
921
922#[cfg(test)]
923mod tests {
924    use gpui::{AppContext as _, TestAppContext};
925    use unindent::Unindent;
926
927    #[gpui::test]
928    async fn test_outline(cx: &mut TestAppContext) {
929        let language = crate::language(
930            "typescript",
931            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
932        );
933
934        let text = r#"
935            function a() {
936              // local variables are omitted
937              let a1 = 1;
938              // all functions are included
939              async function a2() {}
940            }
941            // top-level variables are included
942            let b: C
943            function getB() {}
944            // exported variables are included
945            export const d = e;
946        "#
947        .unindent();
948
949        let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
950        let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None).unwrap());
951        assert_eq!(
952            outline
953                .items
954                .iter()
955                .map(|item| (item.text.as_str(), item.depth))
956                .collect::<Vec<_>>(),
957            &[
958                ("function a()", 0),
959                ("async function a2()", 1),
960                ("let b", 0),
961                ("function getB()", 0),
962                ("const d", 0),
963            ]
964        );
965    }
966}