typescript.rs

   1use anyhow::{Context as _, Result};
   2use async_trait::async_trait;
   3use chrono::{DateTime, Local};
   4use collections::HashMap;
   5use futures::future::join_all;
   6use gpui::{App, AppContext, AsyncApp, Task};
   7use http_client::github::{AssetKind, GitHubLspBinaryVersion, build_asset_url};
   8use http_client::github_download::download_server_binary;
   9use itertools::Itertools as _;
  10use language::{
  11    ContextLocation, ContextProvider, File, LanguageName, LanguageToolchainStore, LspAdapter,
  12    LspAdapterDelegate, LspInstaller, Toolchain,
  13};
  14use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName};
  15use node_runtime::{NodeRuntime, VersionStrategy};
  16use project::{Fs, lsp_store::language_server_settings};
  17use serde_json::{Value, json};
  18use smol::{fs, lock::RwLock, stream::StreamExt};
  19use std::{
  20    borrow::Cow,
  21    ffi::OsString,
  22    path::{Path, PathBuf},
  23    sync::{Arc, LazyLock},
  24};
  25use task::{TaskTemplate, TaskTemplates, VariableName};
  26use util::{ResultExt, fs::remove_matching, maybe};
  27use util::{merge_json_value_into, rel_path::RelPath};
  28
  29use crate::{PackageJson, PackageJsonData};
  30
  31pub(crate) struct TypeScriptContextProvider {
  32    fs: Arc<dyn Fs>,
  33    last_package_json: PackageJsonContents,
  34}
  35
  36const TYPESCRIPT_RUNNER_VARIABLE: VariableName =
  37    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_RUNNER"));
  38
  39const TYPESCRIPT_JEST_TEST_NAME_VARIABLE: VariableName =
  40    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_TEST_NAME"));
  41
  42const TYPESCRIPT_VITEST_TEST_NAME_VARIABLE: VariableName =
  43    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_TEST_NAME"));
  44
  45const TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE: VariableName =
  46    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JEST_PACKAGE_PATH"));
  47
  48const TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE: VariableName =
  49    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_MOCHA_PACKAGE_PATH"));
  50
  51const TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE: VariableName =
  52    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_VITEST_PACKAGE_PATH"));
  53
  54const TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE: VariableName =
  55    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_JASMINE_PACKAGE_PATH"));
  56
  57const TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE: VariableName =
  58    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_BUN_PACKAGE_PATH"));
  59
  60const TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE: VariableName =
  61    VariableName::Custom(Cow::Borrowed("TYPESCRIPT_NODE_PACKAGE_PATH"));
  62
  63#[derive(Clone, Debug, Default)]
  64struct PackageJsonContents(Arc<RwLock<HashMap<PathBuf, PackageJson>>>);
  65
  66impl PackageJsonData {
  67    fn fill_task_templates(&self, task_templates: &mut TaskTemplates) {
  68        if self.jest_package_path.is_some() {
  69            task_templates.0.push(TaskTemplate {
  70                label: "jest file test".to_owned(),
  71                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
  72                args: vec![
  73                    "exec".to_owned(),
  74                    "--".to_owned(),
  75                    "jest".to_owned(),
  76                    "--runInBand".to_owned(),
  77                    VariableName::File.template_value(),
  78                ],
  79                cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
  80                ..TaskTemplate::default()
  81            });
  82            task_templates.0.push(TaskTemplate {
  83                label: format!("jest test {}", VariableName::Symbol.template_value()),
  84                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
  85                args: vec![
  86                    "exec".to_owned(),
  87                    "--".to_owned(),
  88                    "jest".to_owned(),
  89                    "--runInBand".to_owned(),
  90                    "--testNamePattern".to_owned(),
  91                    format!(
  92                        "\"{}\"",
  93                        TYPESCRIPT_JEST_TEST_NAME_VARIABLE.template_value()
  94                    ),
  95                    VariableName::File.template_value(),
  96                ],
  97                tags: vec![
  98                    "ts-test".to_owned(),
  99                    "js-test".to_owned(),
 100                    "tsx-test".to_owned(),
 101                ],
 102                cwd: Some(TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE.template_value()),
 103                ..TaskTemplate::default()
 104            });
 105        }
 106
 107        if self.vitest_package_path.is_some() {
 108            task_templates.0.push(TaskTemplate {
 109                label: format!("{} file test", "vitest".to_owned()),
 110                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
 111                args: vec![
 112                    "exec".to_owned(),
 113                    "--".to_owned(),
 114                    "vitest".to_owned(),
 115                    "run".to_owned(),
 116                    "--poolOptions.forks.minForks=0".to_owned(),
 117                    "--poolOptions.forks.maxForks=1".to_owned(),
 118                    VariableName::File.template_value(),
 119                ],
 120                cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
 121                ..TaskTemplate::default()
 122            });
 123            task_templates.0.push(TaskTemplate {
 124                label: format!(
 125                    "{} test {}",
 126                    "vitest".to_owned(),
 127                    VariableName::Symbol.template_value(),
 128                ),
 129                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
 130                args: vec![
 131                    "exec".to_owned(),
 132                    "--".to_owned(),
 133                    "vitest".to_owned(),
 134                    "run".to_owned(),
 135                    "--poolOptions.forks.minForks=0".to_owned(),
 136                    "--poolOptions.forks.maxForks=1".to_owned(),
 137                    "--testNamePattern".to_owned(),
 138                    format!(
 139                        "\"{}\"",
 140                        TYPESCRIPT_VITEST_TEST_NAME_VARIABLE.template_value()
 141                    ),
 142                    VariableName::File.template_value(),
 143                ],
 144                tags: vec![
 145                    "ts-test".to_owned(),
 146                    "js-test".to_owned(),
 147                    "tsx-test".to_owned(),
 148                ],
 149                cwd: Some(TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE.template_value()),
 150                ..TaskTemplate::default()
 151            });
 152        }
 153
 154        if self.mocha_package_path.is_some() {
 155            task_templates.0.push(TaskTemplate {
 156                label: format!("{} file test", "mocha".to_owned()),
 157                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
 158                args: vec![
 159                    "exec".to_owned(),
 160                    "--".to_owned(),
 161                    "mocha".to_owned(),
 162                    VariableName::File.template_value(),
 163                ],
 164                cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
 165                ..TaskTemplate::default()
 166            });
 167            task_templates.0.push(TaskTemplate {
 168                label: format!(
 169                    "{} test {}",
 170                    "mocha".to_owned(),
 171                    VariableName::Symbol.template_value(),
 172                ),
 173                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
 174                args: vec![
 175                    "exec".to_owned(),
 176                    "--".to_owned(),
 177                    "mocha".to_owned(),
 178                    "--grep".to_owned(),
 179                    format!("\"{}\"", VariableName::Symbol.template_value()),
 180                    VariableName::File.template_value(),
 181                ],
 182                tags: vec![
 183                    "ts-test".to_owned(),
 184                    "js-test".to_owned(),
 185                    "tsx-test".to_owned(),
 186                ],
 187                cwd: Some(TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE.template_value()),
 188                ..TaskTemplate::default()
 189            });
 190        }
 191
 192        if self.jasmine_package_path.is_some() {
 193            task_templates.0.push(TaskTemplate {
 194                label: format!("{} file test", "jasmine".to_owned()),
 195                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
 196                args: vec![
 197                    "exec".to_owned(),
 198                    "--".to_owned(),
 199                    "jasmine".to_owned(),
 200                    VariableName::File.template_value(),
 201                ],
 202                cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
 203                ..TaskTemplate::default()
 204            });
 205            task_templates.0.push(TaskTemplate {
 206                label: format!(
 207                    "{} test {}",
 208                    "jasmine".to_owned(),
 209                    VariableName::Symbol.template_value(),
 210                ),
 211                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
 212                args: vec![
 213                    "exec".to_owned(),
 214                    "--".to_owned(),
 215                    "jasmine".to_owned(),
 216                    format!("--filter={}", VariableName::Symbol.template_value()),
 217                    VariableName::File.template_value(),
 218                ],
 219                tags: vec![
 220                    "ts-test".to_owned(),
 221                    "js-test".to_owned(),
 222                    "tsx-test".to_owned(),
 223                ],
 224                cwd: Some(TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE.template_value()),
 225                ..TaskTemplate::default()
 226            });
 227        }
 228
 229        if self.bun_package_path.is_some() {
 230            task_templates.0.push(TaskTemplate {
 231                label: format!("{} file test", "bun test".to_owned()),
 232                command: "bun".to_owned(),
 233                args: vec!["test".to_owned(), VariableName::File.template_value()],
 234                cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
 235                ..TaskTemplate::default()
 236            });
 237            task_templates.0.push(TaskTemplate {
 238                label: format!("bun test {}", VariableName::Symbol.template_value(),),
 239                command: "bun".to_owned(),
 240                args: vec![
 241                    "test".to_owned(),
 242                    "--test-name-pattern".to_owned(),
 243                    format!("\"{}\"", VariableName::Symbol.template_value()),
 244                    VariableName::File.template_value(),
 245                ],
 246                tags: vec![
 247                    "ts-test".to_owned(),
 248                    "js-test".to_owned(),
 249                    "tsx-test".to_owned(),
 250                ],
 251                cwd: Some(TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE.template_value()),
 252                ..TaskTemplate::default()
 253            });
 254        }
 255
 256        if self.node_package_path.is_some() {
 257            task_templates.0.push(TaskTemplate {
 258                label: format!("{} file test", "node test".to_owned()),
 259                command: "node".to_owned(),
 260                args: vec!["--test".to_owned(), VariableName::File.template_value()],
 261                tags: vec![
 262                    "ts-test".to_owned(),
 263                    "js-test".to_owned(),
 264                    "tsx-test".to_owned(),
 265                ],
 266                cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
 267                ..TaskTemplate::default()
 268            });
 269            task_templates.0.push(TaskTemplate {
 270                label: format!("node test {}", VariableName::Symbol.template_value()),
 271                command: "node".to_owned(),
 272                args: vec![
 273                    "--test".to_owned(),
 274                    "--test-name-pattern".to_owned(),
 275                    format!("\"{}\"", VariableName::Symbol.template_value()),
 276                    VariableName::File.template_value(),
 277                ],
 278                tags: vec![
 279                    "ts-test".to_owned(),
 280                    "js-test".to_owned(),
 281                    "tsx-test".to_owned(),
 282                ],
 283                cwd: Some(TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE.template_value()),
 284                ..TaskTemplate::default()
 285            });
 286        }
 287
 288        let script_name_counts: HashMap<_, usize> =
 289            self.scripts
 290                .iter()
 291                .fold(HashMap::default(), |mut acc, (_, script)| {
 292                    *acc.entry(script).or_default() += 1;
 293                    acc
 294                });
 295        for (path, script) in &self.scripts {
 296            let label = if script_name_counts.get(script).copied().unwrap_or_default() > 1
 297                && let Some(parent) = path.parent().and_then(|parent| parent.file_name())
 298            {
 299                let parent = parent.to_string_lossy();
 300                format!("{parent}/package.json > {script}")
 301            } else {
 302                format!("package.json > {script}")
 303            };
 304            task_templates.0.push(TaskTemplate {
 305                label,
 306                command: TYPESCRIPT_RUNNER_VARIABLE.template_value(),
 307                args: vec!["run".to_owned(), script.to_owned()],
 308                tags: vec!["package-script".into()],
 309                cwd: Some(
 310                    path.parent()
 311                        .unwrap_or(Path::new("/"))
 312                        .to_string_lossy()
 313                        .to_string(),
 314                ),
 315                ..TaskTemplate::default()
 316            });
 317        }
 318    }
 319}
 320
 321impl TypeScriptContextProvider {
 322    pub fn new(fs: Arc<dyn Fs>) -> Self {
 323        Self {
 324            fs,
 325            last_package_json: PackageJsonContents::default(),
 326        }
 327    }
 328
 329    fn combined_package_json_data(
 330        &self,
 331        fs: Arc<dyn Fs>,
 332        worktree_root: &Path,
 333        file_relative_path: &RelPath,
 334        cx: &App,
 335    ) -> Task<anyhow::Result<PackageJsonData>> {
 336        let new_json_data = file_relative_path
 337            .ancestors()
 338            .map(|path| worktree_root.join(path.as_std_path()))
 339            .map(|parent_path| {
 340                self.package_json_data(&parent_path, self.last_package_json.clone(), fs.clone(), cx)
 341            })
 342            .collect::<Vec<_>>();
 343
 344        cx.background_spawn(async move {
 345            let mut package_json_data = PackageJsonData::default();
 346            for new_data in join_all(new_json_data).await.into_iter().flatten() {
 347                package_json_data.merge(new_data);
 348            }
 349            Ok(package_json_data)
 350        })
 351    }
 352
 353    fn package_json_data(
 354        &self,
 355        directory_path: &Path,
 356        existing_package_json: PackageJsonContents,
 357        fs: Arc<dyn Fs>,
 358        cx: &App,
 359    ) -> Task<anyhow::Result<PackageJsonData>> {
 360        let package_json_path = directory_path.join("package.json");
 361        let metadata_check_fs = fs.clone();
 362        cx.background_spawn(async move {
 363            let metadata = metadata_check_fs
 364                .metadata(&package_json_path)
 365                .await
 366                .with_context(|| format!("getting metadata for {package_json_path:?}"))?
 367                .with_context(|| format!("missing FS metadata for {package_json_path:?}"))?;
 368            let mtime = DateTime::<Local>::from(metadata.mtime.timestamp_for_user());
 369            let existing_data = {
 370                let contents = existing_package_json.0.read().await;
 371                contents
 372                    .get(&package_json_path)
 373                    .filter(|package_json| package_json.mtime == mtime)
 374                    .map(|package_json| package_json.data.clone())
 375            };
 376            match existing_data {
 377                Some(existing_data) => Ok(existing_data),
 378                None => {
 379                    let package_json_string =
 380                        fs.load(&package_json_path).await.with_context(|| {
 381                            format!("loading package.json from {package_json_path:?}")
 382                        })?;
 383                    let package_json: HashMap<String, serde_json_lenient::Value> =
 384                        serde_json_lenient::from_str(&package_json_string).with_context(|| {
 385                            format!("parsing package.json from {package_json_path:?}")
 386                        })?;
 387                    let new_data =
 388                        PackageJsonData::new(package_json_path.as_path().into(), package_json);
 389                    {
 390                        let mut contents = existing_package_json.0.write().await;
 391                        contents.insert(
 392                            package_json_path,
 393                            PackageJson {
 394                                mtime,
 395                                data: new_data.clone(),
 396                            },
 397                        );
 398                    }
 399                    Ok(new_data)
 400                }
 401            }
 402        })
 403    }
 404}
 405
 406async fn detect_package_manager(
 407    worktree_root: PathBuf,
 408    fs: Arc<dyn Fs>,
 409    package_json_data: Option<PackageJsonData>,
 410) -> &'static str {
 411    if let Some(package_json_data) = package_json_data
 412        && let Some(package_manager) = package_json_data.package_manager
 413    {
 414        return package_manager;
 415    }
 416    if fs.is_file(&worktree_root.join("pnpm-lock.yaml")).await {
 417        return "pnpm";
 418    }
 419    if fs.is_file(&worktree_root.join("yarn.lock")).await {
 420        return "yarn";
 421    }
 422    "npm"
 423}
 424
 425impl ContextProvider for TypeScriptContextProvider {
 426    fn associated_tasks(
 427        &self,
 428        file: Option<Arc<dyn File>>,
 429        cx: &App,
 430    ) -> Task<Option<TaskTemplates>> {
 431        let Some(file) = project::File::from_dyn(file.as_ref()).cloned() else {
 432            return Task::ready(None);
 433        };
 434        let Some(worktree_root) = file.worktree.read(cx).root_dir() else {
 435            return Task::ready(None);
 436        };
 437        let file_relative_path = file.path().clone();
 438        let package_json_data = self.combined_package_json_data(
 439            self.fs.clone(),
 440            &worktree_root,
 441            &file_relative_path,
 442            cx,
 443        );
 444
 445        cx.background_spawn(async move {
 446            let mut task_templates = TaskTemplates(Vec::new());
 447            task_templates.0.push(TaskTemplate {
 448                label: format!(
 449                    "execute selection {}",
 450                    VariableName::SelectedText.template_value()
 451                ),
 452                command: "node".to_owned(),
 453                args: vec![
 454                    "-e".to_owned(),
 455                    format!("\"{}\"", VariableName::SelectedText.template_value()),
 456                ],
 457                ..TaskTemplate::default()
 458            });
 459
 460            match package_json_data.await {
 461                Ok(package_json) => {
 462                    package_json.fill_task_templates(&mut task_templates);
 463                }
 464                Err(e) => {
 465                    log::error!(
 466                        "Failed to read package.json for worktree {file_relative_path:?}: {e:#}"
 467                    );
 468                }
 469            }
 470
 471            Some(task_templates)
 472        })
 473    }
 474
 475    fn build_context(
 476        &self,
 477        current_vars: &task::TaskVariables,
 478        location: ContextLocation<'_>,
 479        _project_env: Option<HashMap<String, String>>,
 480        _toolchains: Arc<dyn LanguageToolchainStore>,
 481        cx: &mut App,
 482    ) -> Task<Result<task::TaskVariables>> {
 483        let mut vars = task::TaskVariables::default();
 484
 485        if let Some(symbol) = current_vars.get(&VariableName::Symbol) {
 486            vars.insert(
 487                TYPESCRIPT_JEST_TEST_NAME_VARIABLE,
 488                replace_test_name_parameters(symbol),
 489            );
 490            vars.insert(
 491                TYPESCRIPT_VITEST_TEST_NAME_VARIABLE,
 492                replace_test_name_parameters(symbol),
 493            );
 494        }
 495        let file_path = location
 496            .file_location
 497            .buffer
 498            .read(cx)
 499            .file()
 500            .map(|file| file.path());
 501
 502        let args = location.worktree_root.zip(location.fs).zip(file_path).map(
 503            |((worktree_root, fs), file_path)| {
 504                (
 505                    self.combined_package_json_data(fs.clone(), &worktree_root, file_path, cx),
 506                    worktree_root,
 507                    fs,
 508                )
 509            },
 510        );
 511        cx.background_spawn(async move {
 512            if let Some((task, worktree_root, fs)) = args {
 513                let package_json_data = task.await.log_err();
 514                vars.insert(
 515                    TYPESCRIPT_RUNNER_VARIABLE,
 516                    detect_package_manager(worktree_root, fs, package_json_data.clone())
 517                        .await
 518                        .to_owned(),
 519                );
 520
 521                if let Some(package_json_data) = package_json_data {
 522                    if let Some(path) = package_json_data.jest_package_path {
 523                        vars.insert(
 524                            TYPESCRIPT_JEST_PACKAGE_PATH_VARIABLE,
 525                            path.parent()
 526                                .unwrap_or(Path::new(""))
 527                                .to_string_lossy()
 528                                .to_string(),
 529                        );
 530                    }
 531
 532                    if let Some(path) = package_json_data.mocha_package_path {
 533                        vars.insert(
 534                            TYPESCRIPT_MOCHA_PACKAGE_PATH_VARIABLE,
 535                            path.parent()
 536                                .unwrap_or(Path::new(""))
 537                                .to_string_lossy()
 538                                .to_string(),
 539                        );
 540                    }
 541
 542                    if let Some(path) = package_json_data.vitest_package_path {
 543                        vars.insert(
 544                            TYPESCRIPT_VITEST_PACKAGE_PATH_VARIABLE,
 545                            path.parent()
 546                                .unwrap_or(Path::new(""))
 547                                .to_string_lossy()
 548                                .to_string(),
 549                        );
 550                    }
 551
 552                    if let Some(path) = package_json_data.jasmine_package_path {
 553                        vars.insert(
 554                            TYPESCRIPT_JASMINE_PACKAGE_PATH_VARIABLE,
 555                            path.parent()
 556                                .unwrap_or(Path::new(""))
 557                                .to_string_lossy()
 558                                .to_string(),
 559                        );
 560                    }
 561
 562                    if let Some(path) = package_json_data.bun_package_path {
 563                        vars.insert(
 564                            TYPESCRIPT_BUN_PACKAGE_PATH_VARIABLE,
 565                            path.parent()
 566                                .unwrap_or(Path::new(""))
 567                                .to_string_lossy()
 568                                .to_string(),
 569                        );
 570                    }
 571
 572                    if let Some(path) = package_json_data.node_package_path {
 573                        vars.insert(
 574                            TYPESCRIPT_NODE_PACKAGE_PATH_VARIABLE,
 575                            path.parent()
 576                                .unwrap_or(Path::new(""))
 577                                .to_string_lossy()
 578                                .to_string(),
 579                        );
 580                    }
 581                }
 582            }
 583            Ok(vars)
 584        })
 585    }
 586}
 587
 588fn typescript_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
 589    vec![server_path.into(), "--stdio".into()]
 590}
 591
 592fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
 593    vec![
 594        "--max-old-space-size=8192".into(),
 595        server_path.into(),
 596        "--stdio".into(),
 597    ]
 598}
 599
 600fn replace_test_name_parameters(test_name: &str) -> String {
 601    static PATTERN: LazyLock<regex::Regex> =
 602        LazyLock::new(|| regex::Regex::new(r"(\$([A-Za-z0-9_\.]+|[\#])|%[psdifjo#\$%])").unwrap());
 603    PATTERN.split(test_name).map(regex::escape).join("(.+?)")
 604}
 605
 606pub struct TypeScriptLspAdapter {
 607    fs: Arc<dyn Fs>,
 608    node: NodeRuntime,
 609}
 610
 611impl TypeScriptLspAdapter {
 612    const OLD_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.js";
 613    const NEW_SERVER_PATH: &'static str = "node_modules/typescript-language-server/lib/cli.mjs";
 614    const SERVER_NAME: LanguageServerName =
 615        LanguageServerName::new_static("typescript-language-server");
 616    const PACKAGE_NAME: &str = "typescript";
 617    pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
 618        TypeScriptLspAdapter { fs, node }
 619    }
 620    async fn tsdk_path(&self, adapter: &Arc<dyn LspAdapterDelegate>) -> Option<&'static str> {
 621        let is_yarn = adapter
 622            .read_text_file(RelPath::unix(".yarn/sdks/typescript/lib/typescript.js").unwrap())
 623            .await
 624            .is_ok();
 625
 626        let tsdk_path = if is_yarn {
 627            ".yarn/sdks/typescript/lib"
 628        } else {
 629            "node_modules/typescript/lib"
 630        };
 631
 632        if self
 633            .fs
 634            .is_dir(&adapter.worktree_root_path().join(tsdk_path))
 635            .await
 636        {
 637            Some(tsdk_path)
 638        } else {
 639            None
 640        }
 641    }
 642}
 643
 644pub struct TypeScriptVersions {
 645    typescript_version: String,
 646    server_version: String,
 647}
 648
 649impl LspInstaller for TypeScriptLspAdapter {
 650    type BinaryVersion = TypeScriptVersions;
 651
 652    async fn fetch_latest_server_version(
 653        &self,
 654        _: &dyn LspAdapterDelegate,
 655        _: bool,
 656        _: &mut AsyncApp,
 657    ) -> Result<TypeScriptVersions> {
 658        Ok(TypeScriptVersions {
 659            typescript_version: self.node.npm_package_latest_version("typescript").await?,
 660            server_version: self
 661                .node
 662                .npm_package_latest_version("typescript-language-server")
 663                .await?,
 664        })
 665    }
 666
 667    async fn check_if_version_installed(
 668        &self,
 669        version: &TypeScriptVersions,
 670        container_dir: &PathBuf,
 671        _: &dyn LspAdapterDelegate,
 672    ) -> Option<LanguageServerBinary> {
 673        let server_path = container_dir.join(Self::NEW_SERVER_PATH);
 674
 675        let should_install_language_server = self
 676            .node
 677            .should_install_npm_package(
 678                Self::PACKAGE_NAME,
 679                &server_path,
 680                container_dir,
 681                VersionStrategy::Latest(version.typescript_version.as_str()),
 682            )
 683            .await;
 684
 685        if should_install_language_server {
 686            None
 687        } else {
 688            Some(LanguageServerBinary {
 689                path: self.node.binary_path().await.ok()?,
 690                env: None,
 691                arguments: typescript_server_binary_arguments(&server_path),
 692            })
 693        }
 694    }
 695
 696    async fn fetch_server_binary(
 697        &self,
 698        latest_version: TypeScriptVersions,
 699        container_dir: PathBuf,
 700        _: &dyn LspAdapterDelegate,
 701    ) -> Result<LanguageServerBinary> {
 702        let server_path = container_dir.join(Self::NEW_SERVER_PATH);
 703
 704        self.node
 705            .npm_install_packages(
 706                &container_dir,
 707                &[
 708                    (
 709                        Self::PACKAGE_NAME,
 710                        latest_version.typescript_version.as_str(),
 711                    ),
 712                    (
 713                        "typescript-language-server",
 714                        latest_version.server_version.as_str(),
 715                    ),
 716                ],
 717            )
 718            .await?;
 719
 720        Ok(LanguageServerBinary {
 721            path: self.node.binary_path().await?,
 722            env: None,
 723            arguments: typescript_server_binary_arguments(&server_path),
 724        })
 725    }
 726
 727    async fn cached_server_binary(
 728        &self,
 729        container_dir: PathBuf,
 730        _: &dyn LspAdapterDelegate,
 731    ) -> Option<LanguageServerBinary> {
 732        get_cached_ts_server_binary(container_dir, &self.node).await
 733    }
 734}
 735
 736#[async_trait(?Send)]
 737impl LspAdapter for TypeScriptLspAdapter {
 738    fn name(&self) -> LanguageServerName {
 739        Self::SERVER_NAME
 740    }
 741
 742    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
 743        Some(vec![
 744            CodeActionKind::QUICKFIX,
 745            CodeActionKind::REFACTOR,
 746            CodeActionKind::REFACTOR_EXTRACT,
 747            CodeActionKind::SOURCE,
 748        ])
 749    }
 750
 751    async fn label_for_completion(
 752        &self,
 753        item: &lsp::CompletionItem,
 754        language: &Arc<language::Language>,
 755    ) -> Option<language::CodeLabel> {
 756        use lsp::CompletionItemKind as Kind;
 757        let len = item.label.len();
 758        let grammar = language.grammar()?;
 759        let highlight_id = match item.kind? {
 760            Kind::CLASS | Kind::INTERFACE | Kind::ENUM => grammar.highlight_id_for_name("type"),
 761            Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"),
 762            Kind::CONSTANT => grammar.highlight_id_for_name("constant"),
 763            Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"),
 764            Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"),
 765            Kind::VARIABLE => grammar.highlight_id_for_name("variable"),
 766            _ => None,
 767        }?;
 768
 769        let text = if let Some(description) = item
 770            .label_details
 771            .as_ref()
 772            .and_then(|label_details| label_details.description.as_ref())
 773        {
 774            format!("{} {}", item.label, description)
 775        } else if let Some(detail) = &item.detail {
 776            format!("{} {}", item.label, detail)
 777        } else {
 778            item.label.clone()
 779        };
 780        Some(language::CodeLabel::filtered(
 781            text,
 782            item.filter_text.as_deref(),
 783            vec![(0..len, highlight_id)],
 784        ))
 785    }
 786
 787    async fn initialization_options(
 788        self: Arc<Self>,
 789        adapter: &Arc<dyn LspAdapterDelegate>,
 790    ) -> Result<Option<serde_json::Value>> {
 791        let tsdk_path = self.tsdk_path(adapter).await;
 792        Ok(Some(json!({
 793            "provideFormatter": true,
 794            "hostInfo": "zed",
 795            "tsserver": {
 796                "path": tsdk_path,
 797            },
 798            "preferences": {
 799                "includeInlayParameterNameHints": "all",
 800                "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
 801                "includeInlayFunctionParameterTypeHints": true,
 802                "includeInlayVariableTypeHints": true,
 803                "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
 804                "includeInlayPropertyDeclarationTypeHints": true,
 805                "includeInlayFunctionLikeReturnTypeHints": true,
 806                "includeInlayEnumMemberValueHints": true,
 807            }
 808        })))
 809    }
 810
 811    async fn workspace_configuration(
 812        self: Arc<Self>,
 813
 814        delegate: &Arc<dyn LspAdapterDelegate>,
 815        _: Option<Toolchain>,
 816        cx: &mut AsyncApp,
 817    ) -> Result<Value> {
 818        let override_options = cx.update(|cx| {
 819            language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
 820                .and_then(|s| s.settings.clone())
 821        })?;
 822        if let Some(options) = override_options {
 823            return Ok(options);
 824        }
 825        Ok(json!({
 826            "completions": {
 827              "completeFunctionCalls": true
 828            }
 829        }))
 830    }
 831
 832    fn language_ids(&self) -> HashMap<LanguageName, String> {
 833        HashMap::from_iter([
 834            (LanguageName::new("TypeScript"), "typescript".into()),
 835            (LanguageName::new("JavaScript"), "javascript".into()),
 836            (LanguageName::new("TSX"), "typescriptreact".into()),
 837        ])
 838    }
 839}
 840
 841async fn get_cached_ts_server_binary(
 842    container_dir: PathBuf,
 843    node: &NodeRuntime,
 844) -> Option<LanguageServerBinary> {
 845    maybe!(async {
 846        let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
 847        let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
 848        if new_server_path.exists() {
 849            Ok(LanguageServerBinary {
 850                path: node.binary_path().await?,
 851                env: None,
 852                arguments: typescript_server_binary_arguments(&new_server_path),
 853            })
 854        } else if old_server_path.exists() {
 855            Ok(LanguageServerBinary {
 856                path: node.binary_path().await?,
 857                env: None,
 858                arguments: typescript_server_binary_arguments(&old_server_path),
 859            })
 860        } else {
 861            anyhow::bail!("missing executable in directory {container_dir:?}")
 862        }
 863    })
 864    .await
 865    .log_err()
 866}
 867
 868pub struct EsLintLspAdapter {
 869    node: NodeRuntime,
 870}
 871
 872impl EsLintLspAdapter {
 873    const CURRENT_VERSION: &'static str = "2.4.4";
 874    const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
 875
 876    #[cfg(not(windows))]
 877    const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
 878    #[cfg(windows)]
 879    const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
 880
 881    const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
 882    const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
 883
 884    const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
 885        "eslint.config.js",
 886        "eslint.config.mjs",
 887        "eslint.config.cjs",
 888        "eslint.config.ts",
 889        "eslint.config.cts",
 890        "eslint.config.mts",
 891    ];
 892
 893    pub const fn new(node: NodeRuntime) -> Self {
 894        EsLintLspAdapter { node }
 895    }
 896
 897    fn build_destination_path(container_dir: &Path) -> PathBuf {
 898        container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
 899    }
 900}
 901
 902impl LspInstaller for EsLintLspAdapter {
 903    type BinaryVersion = GitHubLspBinaryVersion;
 904
 905    async fn fetch_latest_server_version(
 906        &self,
 907        _delegate: &dyn LspAdapterDelegate,
 908        _: bool,
 909        _: &mut AsyncApp,
 910    ) -> Result<GitHubLspBinaryVersion> {
 911        let url = build_asset_url(
 912            "zed-industries/vscode-eslint",
 913            Self::CURRENT_VERSION_TAG_NAME,
 914            Self::GITHUB_ASSET_KIND,
 915        )?;
 916
 917        Ok(GitHubLspBinaryVersion {
 918            name: Self::CURRENT_VERSION.into(),
 919            digest: None,
 920            url,
 921        })
 922    }
 923
 924    async fn fetch_server_binary(
 925        &self,
 926        version: GitHubLspBinaryVersion,
 927        container_dir: PathBuf,
 928        delegate: &dyn LspAdapterDelegate,
 929    ) -> Result<LanguageServerBinary> {
 930        let destination_path = Self::build_destination_path(&container_dir);
 931        let server_path = destination_path.join(Self::SERVER_PATH);
 932
 933        if fs::metadata(&server_path).await.is_err() {
 934            remove_matching(&container_dir, |_| true).await;
 935
 936            download_server_binary(
 937                &*delegate.http_client(),
 938                &version.url,
 939                None,
 940                &destination_path,
 941                Self::GITHUB_ASSET_KIND,
 942            )
 943            .await?;
 944
 945            let mut dir = fs::read_dir(&destination_path).await?;
 946            let first = dir.next().await.context("missing first file")??;
 947            let repo_root = destination_path.join("vscode-eslint");
 948            fs::rename(first.path(), &repo_root).await?;
 949
 950            #[cfg(target_os = "windows")]
 951            {
 952                handle_symlink(
 953                    repo_root.join("$shared"),
 954                    repo_root.join("client").join("src").join("shared"),
 955                )
 956                .await?;
 957                handle_symlink(
 958                    repo_root.join("$shared"),
 959                    repo_root.join("server").join("src").join("shared"),
 960                )
 961                .await?;
 962            }
 963
 964            self.node
 965                .run_npm_subcommand(&repo_root, "install", &[])
 966                .await?;
 967
 968            self.node
 969                .run_npm_subcommand(&repo_root, "run-script", &["compile"])
 970                .await?;
 971        }
 972
 973        Ok(LanguageServerBinary {
 974            path: self.node.binary_path().await?,
 975            env: None,
 976            arguments: eslint_server_binary_arguments(&server_path),
 977        })
 978    }
 979
 980    async fn cached_server_binary(
 981        &self,
 982        container_dir: PathBuf,
 983        _: &dyn LspAdapterDelegate,
 984    ) -> Option<LanguageServerBinary> {
 985        let server_path =
 986            Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
 987        Some(LanguageServerBinary {
 988            path: self.node.binary_path().await.ok()?,
 989            env: None,
 990            arguments: eslint_server_binary_arguments(&server_path),
 991        })
 992    }
 993}
 994
 995#[async_trait(?Send)]
 996impl LspAdapter for EsLintLspAdapter {
 997    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
 998        Some(vec![
 999            CodeActionKind::QUICKFIX,
1000            CodeActionKind::new("source.fixAll.eslint"),
1001        ])
1002    }
1003
1004    async fn workspace_configuration(
1005        self: Arc<Self>,
1006        delegate: &Arc<dyn LspAdapterDelegate>,
1007        _: Option<Toolchain>,
1008        cx: &mut AsyncApp,
1009    ) -> Result<Value> {
1010        let workspace_root = delegate.worktree_root_path();
1011        let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
1012            .iter()
1013            .any(|file| workspace_root.join(file).is_file());
1014
1015        let mut default_workspace_configuration = json!({
1016            "validate": "on",
1017            "rulesCustomizations": [],
1018            "run": "onType",
1019            "nodePath": null,
1020            "workingDirectory": {
1021                "mode": "auto"
1022            },
1023            "workspaceFolder": {
1024                "uri": workspace_root,
1025                "name": workspace_root.file_name()
1026                    .unwrap_or(workspace_root.as_os_str())
1027                    .to_string_lossy(),
1028            },
1029            "problems": {},
1030            "codeActionOnSave": {
1031                // We enable this, but without also configuring code_actions_on_format
1032                // in the Zed configuration, it doesn't have an effect.
1033                "enable": true,
1034            },
1035            "codeAction": {
1036                "disableRuleComment": {
1037                    "enable": true,
1038                    "location": "separateLine",
1039                },
1040                "showDocumentation": {
1041                    "enable": true
1042                }
1043            },
1044            "experimental": {
1045                "useFlatConfig": use_flat_config,
1046            }
1047        });
1048
1049        let override_options = cx.update(|cx| {
1050            language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
1051                .and_then(|s| s.settings.clone())
1052        })?;
1053
1054        if let Some(override_options) = override_options {
1055            merge_json_value_into(override_options, &mut default_workspace_configuration);
1056        }
1057
1058        Ok(json!({
1059            "": default_workspace_configuration
1060        }))
1061    }
1062
1063    fn name(&self) -> LanguageServerName {
1064        Self::SERVER_NAME
1065    }
1066}
1067
1068#[cfg(target_os = "windows")]
1069async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1070    anyhow::ensure!(
1071        fs::metadata(&src_dir).await.is_ok(),
1072        "Directory {src_dir:?} is not present"
1073    );
1074    if fs::metadata(&dest_dir).await.is_ok() {
1075        fs::remove_file(&dest_dir).await?;
1076    }
1077    fs::create_dir_all(&dest_dir).await?;
1078    let mut entries = fs::read_dir(&src_dir).await?;
1079    while let Some(entry) = entries.try_next().await? {
1080        let entry_path = entry.path();
1081        let entry_name = entry.file_name();
1082        let dest_path = dest_dir.join(&entry_name);
1083        fs::copy(&entry_path, &dest_path).await?;
1084    }
1085    Ok(())
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090    use std::path::Path;
1091
1092    use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1093    use language::language_settings;
1094    use project::{FakeFs, Project};
1095    use serde_json::json;
1096    use task::TaskTemplates;
1097    use unindent::Unindent;
1098    use util::{path, rel_path::rel_path};
1099
1100    use crate::typescript::{
1101        PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
1102    };
1103
1104    #[gpui::test]
1105    async fn test_outline(cx: &mut TestAppContext) {
1106        let language = crate::language(
1107            "typescript",
1108            tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1109        );
1110
1111        let text = r#"
1112            function a() {
1113              // local variables are omitted
1114              let a1 = 1;
1115              // all functions are included
1116              async function a2() {}
1117            }
1118            // top-level variables are included
1119            let b: C
1120            function getB() {}
1121            // exported variables are included
1122            export const d = e;
1123        "#
1124        .unindent();
1125
1126        let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1127        let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1128        assert_eq!(
1129            outline
1130                .items
1131                .iter()
1132                .map(|item| (item.text.as_str(), item.depth))
1133                .collect::<Vec<_>>(),
1134            &[
1135                ("function a()", 0),
1136                ("async function a2()", 1),
1137                ("let b", 0),
1138                ("function getB()", 0),
1139                ("const d", 0),
1140            ]
1141        );
1142    }
1143
1144    #[gpui::test]
1145    async fn test_generator_function_outline(cx: &mut TestAppContext) {
1146        let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1147
1148        let text = r#"
1149            function normalFunction() {
1150                console.log("normal");
1151            }
1152
1153            function* simpleGenerator() {
1154                yield 1;
1155                yield 2;
1156            }
1157
1158            async function* asyncGenerator() {
1159                yield await Promise.resolve(1);
1160            }
1161
1162            function* generatorWithParams(start, end) {
1163                for (let i = start; i <= end; i++) {
1164                    yield i;
1165                }
1166            }
1167
1168            class TestClass {
1169                *methodGenerator() {
1170                    yield "method";
1171                }
1172
1173                async *asyncMethodGenerator() {
1174                    yield "async method";
1175                }
1176            }
1177        "#
1178        .unindent();
1179
1180        let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1181        let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1182        assert_eq!(
1183            outline
1184                .items
1185                .iter()
1186                .map(|item| (item.text.as_str(), item.depth))
1187                .collect::<Vec<_>>(),
1188            &[
1189                ("function normalFunction()", 0),
1190                ("function* simpleGenerator()", 0),
1191                ("async function* asyncGenerator()", 0),
1192                ("function* generatorWithParams( )", 0),
1193                ("class TestClass", 0),
1194                ("*methodGenerator()", 1),
1195                ("async *asyncMethodGenerator()", 1),
1196            ]
1197        );
1198    }
1199
1200    #[gpui::test]
1201    async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1202        cx.update(|cx| {
1203            settings::init(cx);
1204            Project::init_settings(cx);
1205            language_settings::init(cx);
1206        });
1207
1208        let package_json_1 = json!({
1209            "dependencies": {
1210                "mocha": "1.0.0",
1211                "vitest": "1.0.0"
1212            },
1213            "scripts": {
1214                "test": ""
1215            }
1216        })
1217        .to_string();
1218
1219        let package_json_2 = json!({
1220            "devDependencies": {
1221                "vitest": "2.0.0"
1222            },
1223            "scripts": {
1224                "test": ""
1225            }
1226        })
1227        .to_string();
1228
1229        let fs = FakeFs::new(executor);
1230        fs.insert_tree(
1231            path!("/root"),
1232            json!({
1233                "package.json": package_json_1,
1234                "sub": {
1235                    "package.json": package_json_2,
1236                    "file.js": "",
1237                }
1238            }),
1239        )
1240        .await;
1241
1242        let provider = TypeScriptContextProvider::new(fs.clone());
1243        let package_json_data = cx
1244            .update(|cx| {
1245                provider.combined_package_json_data(
1246                    fs.clone(),
1247                    path!("/root").as_ref(),
1248                    rel_path("sub/file1.js"),
1249                    cx,
1250                )
1251            })
1252            .await
1253            .unwrap();
1254        pretty_assertions::assert_eq!(
1255            package_json_data,
1256            PackageJsonData {
1257                jest_package_path: None,
1258                mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1259                vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1260                jasmine_package_path: None,
1261                bun_package_path: None,
1262                node_package_path: None,
1263                scripts: [
1264                    (
1265                        Path::new(path!("/root/package.json")).into(),
1266                        "test".to_owned()
1267                    ),
1268                    (
1269                        Path::new(path!("/root/sub/package.json")).into(),
1270                        "test".to_owned()
1271                    )
1272                ]
1273                .into_iter()
1274                .collect(),
1275                package_manager: None,
1276            }
1277        );
1278
1279        let mut task_templates = TaskTemplates::default();
1280        package_json_data.fill_task_templates(&mut task_templates);
1281        let task_templates = task_templates
1282            .0
1283            .into_iter()
1284            .map(|template| (template.label, template.cwd))
1285            .collect::<Vec<_>>();
1286        pretty_assertions::assert_eq!(
1287            task_templates,
1288            [
1289                (
1290                    "vitest file test".into(),
1291                    Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1292                ),
1293                (
1294                    "vitest test $ZED_SYMBOL".into(),
1295                    Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1296                ),
1297                (
1298                    "mocha file test".into(),
1299                    Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1300                ),
1301                (
1302                    "mocha test $ZED_SYMBOL".into(),
1303                    Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1304                ),
1305                (
1306                    "root/package.json > test".into(),
1307                    Some(path!("/root").into())
1308                ),
1309                (
1310                    "sub/package.json > test".into(),
1311                    Some(path!("/root/sub").into())
1312                ),
1313            ]
1314        );
1315    }
1316
1317    #[test]
1318    fn test_escaping_name() {
1319        let cases = [
1320            ("plain test name", "plain test name"),
1321            ("test name with $param_name", "test name with (.+?)"),
1322            ("test name with $nested.param.name", "test name with (.+?)"),
1323            ("test name with $#", "test name with (.+?)"),
1324            ("test name with $##", "test name with (.+?)\\#"),
1325            ("test name with %p", "test name with (.+?)"),
1326            ("test name with %s", "test name with (.+?)"),
1327            ("test name with %d", "test name with (.+?)"),
1328            ("test name with %i", "test name with (.+?)"),
1329            ("test name with %f", "test name with (.+?)"),
1330            ("test name with %j", "test name with (.+?)"),
1331            ("test name with %o", "test name with (.+?)"),
1332            ("test name with %#", "test name with (.+?)"),
1333            ("test name with %$", "test name with (.+?)"),
1334            ("test name with %%", "test name with (.+?)"),
1335            ("test name with %q", "test name with %q"),
1336            (
1337                "test name with regex chars .*+?^${}()|[]\\",
1338                "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1339            ),
1340            (
1341                "test name with multiple $params and %pretty and %b and (.+?)",
1342                "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1343            ),
1344        ];
1345
1346        for (input, expected) in cases {
1347            assert_eq!(replace_test_name_parameters(input), expected);
1348        }
1349    }
1350
1351    // The order of test runner tasks is based on inferred user preference:
1352    // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1353    // 2. Bun's built-in test runner (`bun test`) comes next.
1354    // 3. Node.js's built-in test runner (`node --test`) is last.
1355    // This hierarchy assumes that if a dedicated test framework is installed, it is the
1356    // preferred testing mechanism. Between runtime-specific options, `bun test` is
1357    // typically preferred over `node --test` when @types/bun is present.
1358    #[gpui::test]
1359    async fn test_task_ordering_with_multiple_test_runners(
1360        executor: BackgroundExecutor,
1361        cx: &mut TestAppContext,
1362    ) {
1363        cx.update(|cx| {
1364            settings::init(cx);
1365            Project::init_settings(cx);
1366            language_settings::init(cx);
1367        });
1368
1369        // Test case with all test runners present
1370        let package_json_all_runners = json!({
1371            "devDependencies": {
1372                "@types/bun": "1.0.0",
1373                "@types/node": "^20.0.0",
1374                "jest": "29.0.0",
1375                "mocha": "10.0.0",
1376                "vitest": "1.0.0",
1377                "jasmine": "5.0.0",
1378            },
1379            "scripts": {
1380                "test": "jest"
1381            }
1382        })
1383        .to_string();
1384
1385        let fs = FakeFs::new(executor);
1386        fs.insert_tree(
1387            path!("/root"),
1388            json!({
1389                "package.json": package_json_all_runners,
1390                "file.js": "",
1391            }),
1392        )
1393        .await;
1394
1395        let provider = TypeScriptContextProvider::new(fs.clone());
1396
1397        let package_json_data = cx
1398            .update(|cx| {
1399                provider.combined_package_json_data(
1400                    fs.clone(),
1401                    path!("/root").as_ref(),
1402                    rel_path("file.js"),
1403                    cx,
1404                )
1405            })
1406            .await
1407            .unwrap();
1408
1409        assert!(package_json_data.jest_package_path.is_some());
1410        assert!(package_json_data.mocha_package_path.is_some());
1411        assert!(package_json_data.vitest_package_path.is_some());
1412        assert!(package_json_data.jasmine_package_path.is_some());
1413        assert!(package_json_data.bun_package_path.is_some());
1414        assert!(package_json_data.node_package_path.is_some());
1415
1416        let mut task_templates = TaskTemplates::default();
1417        package_json_data.fill_task_templates(&mut task_templates);
1418
1419        let test_tasks: Vec<_> = task_templates
1420            .0
1421            .iter()
1422            .filter(|template| {
1423                template.tags.contains(&"ts-test".to_owned())
1424                    || template.tags.contains(&"js-test".to_owned())
1425            })
1426            .map(|template| &template.label)
1427            .collect();
1428
1429        let node_test_index = test_tasks
1430            .iter()
1431            .position(|label| label.contains("node test"));
1432        let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1433        let bun_test_index = test_tasks
1434            .iter()
1435            .position(|label| label.contains("bun test"));
1436
1437        assert!(
1438            node_test_index.is_some(),
1439            "Node test tasks should be present"
1440        );
1441        assert!(
1442            jest_test_index.is_some(),
1443            "Jest test tasks should be present"
1444        );
1445        assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1446
1447        assert!(
1448            jest_test_index.unwrap() < bun_test_index.unwrap(),
1449            "Jest should come before Bun"
1450        );
1451        assert!(
1452            bun_test_index.unwrap() < node_test_index.unwrap(),
1453            "Bun should come before Node"
1454        );
1455    }
1456}