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 label_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            label_len,
 783            item.filter_text.as_deref(),
 784            vec![(0..label_len, highlight_id)],
 785        ))
 786    }
 787
 788    async fn initialization_options(
 789        self: Arc<Self>,
 790        adapter: &Arc<dyn LspAdapterDelegate>,
 791    ) -> Result<Option<serde_json::Value>> {
 792        let tsdk_path = self.tsdk_path(adapter).await;
 793        Ok(Some(json!({
 794            "provideFormatter": true,
 795            "hostInfo": "zed",
 796            "tsserver": {
 797                "path": tsdk_path,
 798            },
 799            "preferences": {
 800                "includeInlayParameterNameHints": "all",
 801                "includeInlayParameterNameHintsWhenArgumentMatchesName": true,
 802                "includeInlayFunctionParameterTypeHints": true,
 803                "includeInlayVariableTypeHints": true,
 804                "includeInlayVariableTypeHintsWhenTypeMatchesName": true,
 805                "includeInlayPropertyDeclarationTypeHints": true,
 806                "includeInlayFunctionLikeReturnTypeHints": true,
 807                "includeInlayEnumMemberValueHints": true,
 808            }
 809        })))
 810    }
 811
 812    async fn workspace_configuration(
 813        self: Arc<Self>,
 814
 815        delegate: &Arc<dyn LspAdapterDelegate>,
 816        _: Option<Toolchain>,
 817        cx: &mut AsyncApp,
 818    ) -> Result<Value> {
 819        let override_options = cx.update(|cx| {
 820            language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
 821                .and_then(|s| s.settings.clone())
 822        })?;
 823        if let Some(options) = override_options {
 824            return Ok(options);
 825        }
 826        Ok(json!({
 827            "completions": {
 828              "completeFunctionCalls": true
 829            }
 830        }))
 831    }
 832
 833    fn language_ids(&self) -> HashMap<LanguageName, String> {
 834        HashMap::from_iter([
 835            (LanguageName::new("TypeScript"), "typescript".into()),
 836            (LanguageName::new("JavaScript"), "javascript".into()),
 837            (LanguageName::new("TSX"), "typescriptreact".into()),
 838        ])
 839    }
 840}
 841
 842async fn get_cached_ts_server_binary(
 843    container_dir: PathBuf,
 844    node: &NodeRuntime,
 845) -> Option<LanguageServerBinary> {
 846    maybe!(async {
 847        let old_server_path = container_dir.join(TypeScriptLspAdapter::OLD_SERVER_PATH);
 848        let new_server_path = container_dir.join(TypeScriptLspAdapter::NEW_SERVER_PATH);
 849        if new_server_path.exists() {
 850            Ok(LanguageServerBinary {
 851                path: node.binary_path().await?,
 852                env: None,
 853                arguments: typescript_server_binary_arguments(&new_server_path),
 854            })
 855        } else if old_server_path.exists() {
 856            Ok(LanguageServerBinary {
 857                path: node.binary_path().await?,
 858                env: None,
 859                arguments: typescript_server_binary_arguments(&old_server_path),
 860            })
 861        } else {
 862            anyhow::bail!("missing executable in directory {container_dir:?}")
 863        }
 864    })
 865    .await
 866    .log_err()
 867}
 868
 869pub struct EsLintLspAdapter {
 870    node: NodeRuntime,
 871}
 872
 873impl EsLintLspAdapter {
 874    const CURRENT_VERSION: &'static str = "2.4.4";
 875    const CURRENT_VERSION_TAG_NAME: &'static str = "release/2.4.4";
 876
 877    #[cfg(not(windows))]
 878    const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
 879    #[cfg(windows)]
 880    const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
 881
 882    const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
 883    const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
 884
 885    const FLAT_CONFIG_FILE_NAMES: &'static [&'static str] = &[
 886        "eslint.config.js",
 887        "eslint.config.mjs",
 888        "eslint.config.cjs",
 889        "eslint.config.ts",
 890        "eslint.config.cts",
 891        "eslint.config.mts",
 892    ];
 893
 894    pub fn new(node: NodeRuntime) -> Self {
 895        EsLintLspAdapter { node }
 896    }
 897
 898    fn build_destination_path(container_dir: &Path) -> PathBuf {
 899        container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
 900    }
 901}
 902
 903impl LspInstaller for EsLintLspAdapter {
 904    type BinaryVersion = GitHubLspBinaryVersion;
 905
 906    async fn fetch_latest_server_version(
 907        &self,
 908        _delegate: &dyn LspAdapterDelegate,
 909        _: bool,
 910        _: &mut AsyncApp,
 911    ) -> Result<GitHubLspBinaryVersion> {
 912        let url = build_asset_url(
 913            "zed-industries/vscode-eslint",
 914            Self::CURRENT_VERSION_TAG_NAME,
 915            Self::GITHUB_ASSET_KIND,
 916        )?;
 917
 918        Ok(GitHubLspBinaryVersion {
 919            name: Self::CURRENT_VERSION.into(),
 920            digest: None,
 921            url,
 922        })
 923    }
 924
 925    async fn fetch_server_binary(
 926        &self,
 927        version: GitHubLspBinaryVersion,
 928        container_dir: PathBuf,
 929        delegate: &dyn LspAdapterDelegate,
 930    ) -> Result<LanguageServerBinary> {
 931        let destination_path = Self::build_destination_path(&container_dir);
 932        let server_path = destination_path.join(Self::SERVER_PATH);
 933
 934        if fs::metadata(&server_path).await.is_err() {
 935            remove_matching(&container_dir, |_| true).await;
 936
 937            download_server_binary(
 938                &*delegate.http_client(),
 939                &version.url,
 940                None,
 941                &destination_path,
 942                Self::GITHUB_ASSET_KIND,
 943            )
 944            .await?;
 945
 946            let mut dir = fs::read_dir(&destination_path).await?;
 947            let first = dir.next().await.context("missing first file")??;
 948            let repo_root = destination_path.join("vscode-eslint");
 949            fs::rename(first.path(), &repo_root).await?;
 950
 951            #[cfg(target_os = "windows")]
 952            {
 953                handle_symlink(
 954                    repo_root.join("$shared"),
 955                    repo_root.join("client").join("src").join("shared"),
 956                )
 957                .await?;
 958                handle_symlink(
 959                    repo_root.join("$shared"),
 960                    repo_root.join("server").join("src").join("shared"),
 961                )
 962                .await?;
 963            }
 964
 965            self.node
 966                .run_npm_subcommand(&repo_root, "install", &[])
 967                .await?;
 968
 969            self.node
 970                .run_npm_subcommand(&repo_root, "run-script", &["compile"])
 971                .await?;
 972        }
 973
 974        Ok(LanguageServerBinary {
 975            path: self.node.binary_path().await?,
 976            env: None,
 977            arguments: eslint_server_binary_arguments(&server_path),
 978        })
 979    }
 980
 981    async fn cached_server_binary(
 982        &self,
 983        container_dir: PathBuf,
 984        _: &dyn LspAdapterDelegate,
 985    ) -> Option<LanguageServerBinary> {
 986        let server_path =
 987            Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
 988        Some(LanguageServerBinary {
 989            path: self.node.binary_path().await.ok()?,
 990            env: None,
 991            arguments: eslint_server_binary_arguments(&server_path),
 992        })
 993    }
 994}
 995
 996#[async_trait(?Send)]
 997impl LspAdapter for EsLintLspAdapter {
 998    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
 999        Some(vec![
1000            CodeActionKind::QUICKFIX,
1001            CodeActionKind::new("source.fixAll.eslint"),
1002        ])
1003    }
1004
1005    async fn workspace_configuration(
1006        self: Arc<Self>,
1007        delegate: &Arc<dyn LspAdapterDelegate>,
1008        _: Option<Toolchain>,
1009        cx: &mut AsyncApp,
1010    ) -> Result<Value> {
1011        let workspace_root = delegate.worktree_root_path();
1012        let use_flat_config = Self::FLAT_CONFIG_FILE_NAMES
1013            .iter()
1014            .any(|file| workspace_root.join(file).is_file());
1015
1016        let mut default_workspace_configuration = json!({
1017            "validate": "on",
1018            "rulesCustomizations": [],
1019            "run": "onType",
1020            "nodePath": null,
1021            "workingDirectory": {
1022                "mode": "auto"
1023            },
1024            "workspaceFolder": {
1025                "uri": workspace_root,
1026                "name": workspace_root.file_name()
1027                    .unwrap_or(workspace_root.as_os_str())
1028                    .to_string_lossy(),
1029            },
1030            "problems": {},
1031            "codeActionOnSave": {
1032                // We enable this, but without also configuring code_actions_on_format
1033                // in the Zed configuration, it doesn't have an effect.
1034                "enable": true,
1035            },
1036            "codeAction": {
1037                "disableRuleComment": {
1038                    "enable": true,
1039                    "location": "separateLine",
1040                },
1041                "showDocumentation": {
1042                    "enable": true
1043                }
1044            },
1045            "experimental": {
1046                "useFlatConfig": use_flat_config,
1047            }
1048        });
1049
1050        let override_options = cx.update(|cx| {
1051            language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx)
1052                .and_then(|s| s.settings.clone())
1053        })?;
1054
1055        if let Some(override_options) = override_options {
1056            merge_json_value_into(override_options, &mut default_workspace_configuration);
1057        }
1058
1059        Ok(json!({
1060            "": default_workspace_configuration
1061        }))
1062    }
1063
1064    fn name(&self) -> LanguageServerName {
1065        Self::SERVER_NAME
1066    }
1067}
1068
1069#[cfg(target_os = "windows")]
1070async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
1071    anyhow::ensure!(
1072        fs::metadata(&src_dir).await.is_ok(),
1073        "Directory {src_dir:?} is not present"
1074    );
1075    if fs::metadata(&dest_dir).await.is_ok() {
1076        fs::remove_file(&dest_dir).await?;
1077    }
1078    fs::create_dir_all(&dest_dir).await?;
1079    let mut entries = fs::read_dir(&src_dir).await?;
1080    while let Some(entry) = entries.try_next().await? {
1081        let entry_path = entry.path();
1082        let entry_name = entry.file_name();
1083        let dest_path = dest_dir.join(&entry_name);
1084        fs::copy(&entry_path, &dest_path).await?;
1085    }
1086    Ok(())
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use std::path::Path;
1092
1093    use gpui::{AppContext as _, BackgroundExecutor, TestAppContext};
1094    use language::language_settings;
1095    use project::{FakeFs, Project};
1096    use serde_json::json;
1097    use task::TaskTemplates;
1098    use unindent::Unindent;
1099    use util::{path, rel_path::rel_path};
1100
1101    use crate::typescript::{
1102        PackageJsonData, TypeScriptContextProvider, replace_test_name_parameters,
1103    };
1104
1105    #[gpui::test]
1106    async fn test_outline(cx: &mut TestAppContext) {
1107        for language in [
1108            crate::language(
1109                "typescript",
1110                tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1111            ),
1112            crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1113        ] {
1114            let text = r#"
1115            function a() {
1116              // local variables are included
1117              let a1 = 1;
1118              // all functions are included
1119              async function a2() {}
1120            }
1121            // top-level variables are included
1122            let b: C
1123            function getB() {}
1124            // exported variables are included
1125            export const d = e;
1126        "#
1127            .unindent();
1128
1129            let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1130            let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1131            assert_eq!(
1132                outline
1133                    .items
1134                    .iter()
1135                    .map(|item| (item.text.as_str(), item.depth))
1136                    .collect::<Vec<_>>(),
1137                &[
1138                    ("function a()", 0),
1139                    ("let a1", 1),
1140                    ("async function a2()", 1),
1141                    ("let b", 0),
1142                    ("function getB()", 0),
1143                    ("const d", 0),
1144                ]
1145            );
1146        }
1147    }
1148
1149    #[gpui::test]
1150    async fn test_outline_with_destructuring(cx: &mut TestAppContext) {
1151        for language in [
1152            crate::language(
1153                "typescript",
1154                tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1155            ),
1156            crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1157        ] {
1158            let text = r#"
1159            // Top-level destructuring
1160            const { a1, a2 } = a;
1161            const [b1, b2] = b;
1162
1163            // Defaults and rest
1164            const [c1 = 1, , c2, ...rest1] = c;
1165            const { d1, d2: e1, f1 = 2, g1: h1 = 3, ...rest2 } = d;
1166
1167            function processData() {
1168              // Nested object destructuring
1169              const { c1, c2 } = c;
1170              // Nested array destructuring
1171              const [d1, d2, d3] = d;
1172              // Destructuring with renaming
1173              const { f1: g1 } = f;
1174              // With defaults
1175              const [x = 10, y] = xy;
1176            }
1177
1178            class DataHandler {
1179              method() {
1180                // Destructuring in class method
1181                const { a1, a2 } = a;
1182                const [b1, ...b2] = b;
1183              }
1184            }
1185        "#
1186            .unindent();
1187
1188            let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1189            let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1190            assert_eq!(
1191                outline
1192                    .items
1193                    .iter()
1194                    .map(|item| (item.text.as_str(), item.depth))
1195                    .collect::<Vec<_>>(),
1196                &[
1197                    ("const a1", 0),
1198                    ("const a2", 0),
1199                    ("const b1", 0),
1200                    ("const b2", 0),
1201                    ("const c1", 0),
1202                    ("const c2", 0),
1203                    ("const rest1", 0),
1204                    ("const d1", 0),
1205                    ("const e1", 0),
1206                    ("const h1", 0),
1207                    ("const rest2", 0),
1208                    ("function processData()", 0),
1209                    ("const c1", 1),
1210                    ("const c2", 1),
1211                    ("const d1", 1),
1212                    ("const d2", 1),
1213                    ("const d3", 1),
1214                    ("const g1", 1),
1215                    ("const x", 1),
1216                    ("const y", 1),
1217                    ("class DataHandler", 0),
1218                    ("method()", 1),
1219                    ("const a1", 2),
1220                    ("const a2", 2),
1221                    ("const b1", 2),
1222                    ("const b2", 2),
1223                ]
1224            );
1225        }
1226    }
1227
1228    #[gpui::test]
1229    async fn test_outline_with_object_properties(cx: &mut TestAppContext) {
1230        for language in [
1231            crate::language(
1232                "typescript",
1233                tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1234            ),
1235            crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1236        ] {
1237            let text = r#"
1238            // Object with function properties
1239            const o = { m() {}, async n() {}, g: function* () {}, h: () => {}, k: function () {} };
1240
1241            // Object with primitive properties
1242            const p = { p1: 1, p2: "hello", p3: true };
1243
1244            // Nested objects
1245            const q = {
1246                r: {
1247                    // won't be included due to one-level depth limit
1248                    s: 1
1249                },
1250                t: 2
1251            };
1252
1253            function getData() {
1254                const local = { x: 1, y: 2 };
1255                return local;
1256            }
1257        "#
1258            .unindent();
1259
1260            let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1261            let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1262            assert_eq!(
1263                outline
1264                    .items
1265                    .iter()
1266                    .map(|item| (item.text.as_str(), item.depth))
1267                    .collect::<Vec<_>>(),
1268                &[
1269                    ("const o", 0),
1270                    ("m()", 1),
1271                    ("async n()", 1),
1272                    ("g", 1),
1273                    ("h", 1),
1274                    ("k", 1),
1275                    ("const p", 0),
1276                    ("p1", 1),
1277                    ("p2", 1),
1278                    ("p3", 1),
1279                    ("const q", 0),
1280                    ("r", 1),
1281                    ("s", 2),
1282                    ("t", 1),
1283                    ("function getData()", 0),
1284                    ("const local", 1),
1285                    ("x", 2),
1286                    ("y", 2),
1287                ]
1288            );
1289        }
1290    }
1291
1292    #[gpui::test]
1293    async fn test_outline_with_computed_property_names(cx: &mut TestAppContext) {
1294        for language in [
1295            crate::language(
1296                "typescript",
1297                tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
1298            ),
1299            crate::language("tsx", tree_sitter_typescript::LANGUAGE_TSX.into()),
1300        ] {
1301            let text = r#"
1302            // Symbols as object keys
1303            const sym = Symbol("test");
1304            const obj1 = {
1305                [sym]: 1,
1306                [Symbol("inline")]: 2,
1307                normalKey: 3
1308            };
1309
1310            // Enums as object keys
1311            enum Color { Red, Blue, Green }
1312
1313            const obj2 = {
1314                [Color.Red]: "red value",
1315                [Color.Blue]: "blue value",
1316                regularProp: "normal"
1317            };
1318
1319            // Mixed computed properties
1320            const key = "dynamic";
1321            const obj3 = {
1322                [key]: 1,
1323                ["string" + "concat"]: 2,
1324                [1 + 1]: 3,
1325                static: 4
1326            };
1327
1328            // Nested objects with computed properties
1329            const obj4 = {
1330                [sym]: {
1331                    nested: 1
1332                },
1333                regular: {
1334                    [key]: 2
1335                }
1336            };
1337        "#
1338            .unindent();
1339
1340            let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1341            let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1342            assert_eq!(
1343                outline
1344                    .items
1345                    .iter()
1346                    .map(|item| (item.text.as_str(), item.depth))
1347                    .collect::<Vec<_>>(),
1348                &[
1349                    ("const sym", 0),
1350                    ("const obj1", 0),
1351                    ("[sym]", 1),
1352                    ("[Symbol(\"inline\")]", 1),
1353                    ("normalKey", 1),
1354                    ("enum Color", 0),
1355                    ("const obj2", 0),
1356                    ("[Color.Red]", 1),
1357                    ("[Color.Blue]", 1),
1358                    ("regularProp", 1),
1359                    ("const key", 0),
1360                    ("const obj3", 0),
1361                    ("[key]", 1),
1362                    ("[\"string\" + \"concat\"]", 1),
1363                    ("[1 + 1]", 1),
1364                    ("static", 1),
1365                    ("const obj4", 0),
1366                    ("[sym]", 1),
1367                    ("nested", 2),
1368                    ("regular", 1),
1369                    ("[key]", 2),
1370                ]
1371            );
1372        }
1373    }
1374
1375    #[gpui::test]
1376    async fn test_generator_function_outline(cx: &mut TestAppContext) {
1377        let language = crate::language("javascript", tree_sitter_typescript::LANGUAGE_TSX.into());
1378
1379        let text = r#"
1380            function normalFunction() {
1381                console.log("normal");
1382            }
1383
1384            function* simpleGenerator() {
1385                yield 1;
1386                yield 2;
1387            }
1388
1389            async function* asyncGenerator() {
1390                yield await Promise.resolve(1);
1391            }
1392
1393            function* generatorWithParams(start, end) {
1394                for (let i = start; i <= end; i++) {
1395                    yield i;
1396                }
1397            }
1398
1399            class TestClass {
1400                *methodGenerator() {
1401                    yield "method";
1402                }
1403
1404                async *asyncMethodGenerator() {
1405                    yield "async method";
1406                }
1407            }
1408        "#
1409        .unindent();
1410
1411        let buffer = cx.new(|cx| language::Buffer::local(text, cx).with_language(language, cx));
1412        let outline = buffer.read_with(cx, |buffer, _| buffer.snapshot().outline(None));
1413        assert_eq!(
1414            outline
1415                .items
1416                .iter()
1417                .map(|item| (item.text.as_str(), item.depth))
1418                .collect::<Vec<_>>(),
1419            &[
1420                ("function normalFunction()", 0),
1421                ("function* simpleGenerator()", 0),
1422                ("async function* asyncGenerator()", 0),
1423                ("function* generatorWithParams( )", 0),
1424                ("class TestClass", 0),
1425                ("*methodGenerator()", 1),
1426                ("async *asyncMethodGenerator()", 1),
1427            ]
1428        );
1429    }
1430
1431    #[gpui::test]
1432    async fn test_package_json_discovery(executor: BackgroundExecutor, cx: &mut TestAppContext) {
1433        cx.update(|cx| {
1434            settings::init(cx);
1435            Project::init_settings(cx);
1436            language_settings::init(cx);
1437        });
1438
1439        let package_json_1 = json!({
1440            "dependencies": {
1441                "mocha": "1.0.0",
1442                "vitest": "1.0.0"
1443            },
1444            "scripts": {
1445                "test": ""
1446            }
1447        })
1448        .to_string();
1449
1450        let package_json_2 = json!({
1451            "devDependencies": {
1452                "vitest": "2.0.0"
1453            },
1454            "scripts": {
1455                "test": ""
1456            }
1457        })
1458        .to_string();
1459
1460        let fs = FakeFs::new(executor);
1461        fs.insert_tree(
1462            path!("/root"),
1463            json!({
1464                "package.json": package_json_1,
1465                "sub": {
1466                    "package.json": package_json_2,
1467                    "file.js": "",
1468                }
1469            }),
1470        )
1471        .await;
1472
1473        let provider = TypeScriptContextProvider::new(fs.clone());
1474        let package_json_data = cx
1475            .update(|cx| {
1476                provider.combined_package_json_data(
1477                    fs.clone(),
1478                    path!("/root").as_ref(),
1479                    rel_path("sub/file1.js"),
1480                    cx,
1481                )
1482            })
1483            .await
1484            .unwrap();
1485        pretty_assertions::assert_eq!(
1486            package_json_data,
1487            PackageJsonData {
1488                jest_package_path: None,
1489                mocha_package_path: Some(Path::new(path!("/root/package.json")).into()),
1490                vitest_package_path: Some(Path::new(path!("/root/sub/package.json")).into()),
1491                jasmine_package_path: None,
1492                bun_package_path: None,
1493                node_package_path: None,
1494                scripts: [
1495                    (
1496                        Path::new(path!("/root/package.json")).into(),
1497                        "test".to_owned()
1498                    ),
1499                    (
1500                        Path::new(path!("/root/sub/package.json")).into(),
1501                        "test".to_owned()
1502                    )
1503                ]
1504                .into_iter()
1505                .collect(),
1506                package_manager: None,
1507            }
1508        );
1509
1510        let mut task_templates = TaskTemplates::default();
1511        package_json_data.fill_task_templates(&mut task_templates);
1512        let task_templates = task_templates
1513            .0
1514            .into_iter()
1515            .map(|template| (template.label, template.cwd))
1516            .collect::<Vec<_>>();
1517        pretty_assertions::assert_eq!(
1518            task_templates,
1519            [
1520                (
1521                    "vitest file test".into(),
1522                    Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1523                ),
1524                (
1525                    "vitest test $ZED_SYMBOL".into(),
1526                    Some("$ZED_CUSTOM_TYPESCRIPT_VITEST_PACKAGE_PATH".into()),
1527                ),
1528                (
1529                    "mocha file test".into(),
1530                    Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1531                ),
1532                (
1533                    "mocha test $ZED_SYMBOL".into(),
1534                    Some("$ZED_CUSTOM_TYPESCRIPT_MOCHA_PACKAGE_PATH".into()),
1535                ),
1536                (
1537                    "root/package.json > test".into(),
1538                    Some(path!("/root").into())
1539                ),
1540                (
1541                    "sub/package.json > test".into(),
1542                    Some(path!("/root/sub").into())
1543                ),
1544            ]
1545        );
1546    }
1547
1548    #[test]
1549    fn test_escaping_name() {
1550        let cases = [
1551            ("plain test name", "plain test name"),
1552            ("test name with $param_name", "test name with (.+?)"),
1553            ("test name with $nested.param.name", "test name with (.+?)"),
1554            ("test name with $#", "test name with (.+?)"),
1555            ("test name with $##", "test name with (.+?)\\#"),
1556            ("test name with %p", "test name with (.+?)"),
1557            ("test name with %s", "test name with (.+?)"),
1558            ("test name with %d", "test name with (.+?)"),
1559            ("test name with %i", "test name with (.+?)"),
1560            ("test name with %f", "test name with (.+?)"),
1561            ("test name with %j", "test name with (.+?)"),
1562            ("test name with %o", "test name with (.+?)"),
1563            ("test name with %#", "test name with (.+?)"),
1564            ("test name with %$", "test name with (.+?)"),
1565            ("test name with %%", "test name with (.+?)"),
1566            ("test name with %q", "test name with %q"),
1567            (
1568                "test name with regex chars .*+?^${}()|[]\\",
1569                "test name with regex chars \\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\",
1570            ),
1571            (
1572                "test name with multiple $params and %pretty and %b and (.+?)",
1573                "test name with multiple (.+?) and (.+?)retty and %b and \\(\\.\\+\\?\\)",
1574            ),
1575        ];
1576
1577        for (input, expected) in cases {
1578            assert_eq!(replace_test_name_parameters(input), expected);
1579        }
1580    }
1581
1582    // The order of test runner tasks is based on inferred user preference:
1583    // 1. Dedicated test runners (e.g., Jest, Vitest, Mocha, Jasmine) are prioritized.
1584    // 2. Bun's built-in test runner (`bun test`) comes next.
1585    // 3. Node.js's built-in test runner (`node --test`) is last.
1586    // This hierarchy assumes that if a dedicated test framework is installed, it is the
1587    // preferred testing mechanism. Between runtime-specific options, `bun test` is
1588    // typically preferred over `node --test` when @types/bun is present.
1589    #[gpui::test]
1590    async fn test_task_ordering_with_multiple_test_runners(
1591        executor: BackgroundExecutor,
1592        cx: &mut TestAppContext,
1593    ) {
1594        cx.update(|cx| {
1595            settings::init(cx);
1596            Project::init_settings(cx);
1597            language_settings::init(cx);
1598        });
1599
1600        // Test case with all test runners present
1601        let package_json_all_runners = json!({
1602            "devDependencies": {
1603                "@types/bun": "1.0.0",
1604                "@types/node": "^20.0.0",
1605                "jest": "29.0.0",
1606                "mocha": "10.0.0",
1607                "vitest": "1.0.0",
1608                "jasmine": "5.0.0",
1609            },
1610            "scripts": {
1611                "test": "jest"
1612            }
1613        })
1614        .to_string();
1615
1616        let fs = FakeFs::new(executor);
1617        fs.insert_tree(
1618            path!("/root"),
1619            json!({
1620                "package.json": package_json_all_runners,
1621                "file.js": "",
1622            }),
1623        )
1624        .await;
1625
1626        let provider = TypeScriptContextProvider::new(fs.clone());
1627
1628        let package_json_data = cx
1629            .update(|cx| {
1630                provider.combined_package_json_data(
1631                    fs.clone(),
1632                    path!("/root").as_ref(),
1633                    rel_path("file.js"),
1634                    cx,
1635                )
1636            })
1637            .await
1638            .unwrap();
1639
1640        assert!(package_json_data.jest_package_path.is_some());
1641        assert!(package_json_data.mocha_package_path.is_some());
1642        assert!(package_json_data.vitest_package_path.is_some());
1643        assert!(package_json_data.jasmine_package_path.is_some());
1644        assert!(package_json_data.bun_package_path.is_some());
1645        assert!(package_json_data.node_package_path.is_some());
1646
1647        let mut task_templates = TaskTemplates::default();
1648        package_json_data.fill_task_templates(&mut task_templates);
1649
1650        let test_tasks: Vec<_> = task_templates
1651            .0
1652            .iter()
1653            .filter(|template| {
1654                template.tags.contains(&"ts-test".to_owned())
1655                    || template.tags.contains(&"js-test".to_owned())
1656            })
1657            .map(|template| &template.label)
1658            .collect();
1659
1660        let node_test_index = test_tasks
1661            .iter()
1662            .position(|label| label.contains("node test"));
1663        let jest_test_index = test_tasks.iter().position(|label| label.contains("jest"));
1664        let bun_test_index = test_tasks
1665            .iter()
1666            .position(|label| label.contains("bun test"));
1667
1668        assert!(
1669            node_test_index.is_some(),
1670            "Node test tasks should be present"
1671        );
1672        assert!(
1673            jest_test_index.is_some(),
1674            "Jest test tasks should be present"
1675        );
1676        assert!(bun_test_index.is_some(), "Bun test tasks should be present");
1677
1678        assert!(
1679            jest_test_index.unwrap() < bun_test_index.unwrap(),
1680            "Jest should come before Bun"
1681        );
1682        assert!(
1683            bun_test_index.unwrap() < node_test_index.unwrap(),
1684            "Bun should come before Node"
1685        );
1686    }
1687}