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