eslint.rs

   1use anyhow::{Context as _, Result};
   2use async_trait::async_trait;
   3use gpui::AsyncApp;
   4use http_client::{
   5    github::{AssetKind, GitHubLspBinaryVersion, build_asset_url},
   6    github_download::download_server_binary,
   7};
   8use language::{LspAdapter, LspAdapterDelegate, LspInstaller, Toolchain};
   9use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerName, Uri};
  10use node_runtime::{NodeRuntime, read_package_installed_version};
  11use project::Fs;
  12use project::lsp_store::language_server_settings_for;
  13use semver::Version;
  14use serde::{Deserialize, Serialize};
  15use serde_json::{Value, json};
  16use settings::SettingsLocation;
  17use smol::{fs, stream::StreamExt};
  18use std::{
  19    ffi::OsString,
  20    path::{Path, PathBuf},
  21    sync::Arc,
  22};
  23use util::merge_json_value_into;
  24use util::{fs::remove_matching, rel_path::RelPath};
  25
  26fn eslint_server_binary_arguments(server_path: &Path) -> Vec<OsString> {
  27    vec![
  28        "--max-old-space-size=8192".into(),
  29        server_path.into(),
  30        "--stdio".into(),
  31    ]
  32}
  33
  34pub struct EsLintLspAdapter {
  35    node: NodeRuntime,
  36    fs: Arc<dyn Fs>,
  37}
  38
  39impl EsLintLspAdapter {
  40    const CURRENT_VERSION: &'static str = "3.0.24";
  41    const CURRENT_VERSION_TAG_NAME: &'static str = "release/3.0.24";
  42
  43    #[cfg(not(windows))]
  44    const GITHUB_ASSET_KIND: AssetKind = AssetKind::TarGz;
  45    #[cfg(windows)]
  46    const GITHUB_ASSET_KIND: AssetKind = AssetKind::Zip;
  47
  48    const SERVER_PATH: &'static str = "vscode-eslint/server/out/eslintServer.js";
  49    const SERVER_NAME: LanguageServerName = LanguageServerName::new_static("eslint");
  50
  51    const FLAT_CONFIG_FILE_NAMES_V8_21: &'static [&'static str] = &["eslint.config.js"];
  52    const FLAT_CONFIG_FILE_NAMES_V8_57: &'static [&'static str] =
  53        &["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs"];
  54    const FLAT_CONFIG_FILE_NAMES_V10: &'static [&'static str] = &[
  55        "eslint.config.js",
  56        "eslint.config.mjs",
  57        "eslint.config.cjs",
  58        "eslint.config.ts",
  59        "eslint.config.cts",
  60        "eslint.config.mts",
  61    ];
  62    const LEGACY_CONFIG_FILE_NAMES: &'static [&'static str] = &[
  63        ".eslintrc",
  64        ".eslintrc.js",
  65        ".eslintrc.cjs",
  66        ".eslintrc.yaml",
  67        ".eslintrc.yml",
  68        ".eslintrc.json",
  69    ];
  70
  71    pub fn new(node: NodeRuntime, fs: Arc<dyn Fs>) -> Self {
  72        EsLintLspAdapter { node, fs }
  73    }
  74
  75    fn build_destination_path(container_dir: &Path) -> PathBuf {
  76        container_dir.join(format!("vscode-eslint-{}", Self::CURRENT_VERSION))
  77    }
  78}
  79
  80impl LspInstaller for EsLintLspAdapter {
  81    type BinaryVersion = GitHubLspBinaryVersion;
  82
  83    async fn fetch_latest_server_version(
  84        &self,
  85        _delegate: &dyn LspAdapterDelegate,
  86        _: bool,
  87        _: &mut AsyncApp,
  88    ) -> Result<GitHubLspBinaryVersion> {
  89        let url = build_asset_url(
  90            "microsoft/vscode-eslint",
  91            Self::CURRENT_VERSION_TAG_NAME,
  92            Self::GITHUB_ASSET_KIND,
  93        )?;
  94
  95        Ok(GitHubLspBinaryVersion {
  96            name: Self::CURRENT_VERSION.into(),
  97            digest: None,
  98            url,
  99        })
 100    }
 101
 102    async fn fetch_server_binary(
 103        &self,
 104        version: GitHubLspBinaryVersion,
 105        container_dir: PathBuf,
 106        delegate: &dyn LspAdapterDelegate,
 107    ) -> Result<LanguageServerBinary> {
 108        let destination_path = Self::build_destination_path(&container_dir);
 109        let server_path = destination_path.join(Self::SERVER_PATH);
 110
 111        if fs::metadata(&server_path).await.is_err() {
 112            remove_matching(&container_dir, |_| true).await;
 113
 114            download_server_binary(
 115                &*delegate.http_client(),
 116                &version.url,
 117                None,
 118                &destination_path,
 119                Self::GITHUB_ASSET_KIND,
 120            )
 121            .await?;
 122
 123            let mut dir = fs::read_dir(&destination_path).await?;
 124            let first = dir.next().await.context("missing first file")??;
 125            let repo_root = destination_path.join("vscode-eslint");
 126            fs::rename(first.path(), &repo_root).await?;
 127
 128            #[cfg(target_os = "windows")]
 129            {
 130                handle_symlink(
 131                    repo_root.join("$shared"),
 132                    repo_root.join("client").join("src").join("shared"),
 133                )
 134                .await?;
 135                handle_symlink(
 136                    repo_root.join("$shared"),
 137                    repo_root.join("server").join("src").join("shared"),
 138                )
 139                .await?;
 140            }
 141
 142            self.node
 143                .run_npm_subcommand(Some(&repo_root), "install", &[])
 144                .await?;
 145
 146            self.node
 147                .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"])
 148                .await?;
 149        }
 150
 151        Ok(LanguageServerBinary {
 152            path: self.node.binary_path().await?,
 153            env: None,
 154            arguments: eslint_server_binary_arguments(&server_path),
 155        })
 156    }
 157
 158    async fn cached_server_binary(
 159        &self,
 160        container_dir: PathBuf,
 161        _: &dyn LspAdapterDelegate,
 162    ) -> Option<LanguageServerBinary> {
 163        let server_path =
 164            Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
 165        fs::metadata(&server_path).await.ok()?;
 166        Some(LanguageServerBinary {
 167            path: self.node.binary_path().await.ok()?,
 168            env: None,
 169            arguments: eslint_server_binary_arguments(&server_path),
 170        })
 171    }
 172}
 173
 174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 175enum EslintConfigKind {
 176    Flat,
 177    Legacy,
 178}
 179
 180#[derive(Debug, Default, Clone, PartialEq, Eq)]
 181struct EslintSettingsOverrides {
 182    use_flat_config: Option<bool>,
 183    experimental_use_flat_config: Option<bool>,
 184}
 185
 186impl EslintSettingsOverrides {
 187    fn apply_to(self, workspace_configuration: &mut Value) {
 188        if let Some(use_flat_config) = self.use_flat_config
 189            && let Some(workspace_configuration) = workspace_configuration.as_object_mut()
 190        {
 191            workspace_configuration.insert("useFlatConfig".to_string(), json!(use_flat_config));
 192        }
 193
 194        if let Some(experimental_use_flat_config) = self.experimental_use_flat_config
 195            && let Some(workspace_configuration) = workspace_configuration.as_object_mut()
 196        {
 197            let experimental = workspace_configuration
 198                .entry("experimental")
 199                .or_insert_with(|| json!({}));
 200            if let Some(experimental) = experimental.as_object_mut() {
 201                experimental.insert(
 202                    "useFlatConfig".to_string(),
 203                    json!(experimental_use_flat_config),
 204                );
 205            }
 206        }
 207    }
 208}
 209
 210#[async_trait(?Send)]
 211impl LspAdapter for EsLintLspAdapter {
 212    fn code_action_kinds(&self) -> Option<Vec<CodeActionKind>> {
 213        Some(vec![
 214            CodeActionKind::QUICKFIX,
 215            CodeActionKind::new("source.fixAll.eslint"),
 216        ])
 217    }
 218
 219    async fn workspace_configuration(
 220        self: Arc<Self>,
 221        delegate: &Arc<dyn LspAdapterDelegate>,
 222        _: Option<Toolchain>,
 223        requested_uri: Option<Uri>,
 224        cx: &mut AsyncApp,
 225    ) -> Result<Value> {
 226        let worktree_root = delegate.worktree_root_path();
 227        let requested_file_path = requested_uri
 228            .as_ref()
 229            .filter(|uri| uri.scheme() == "file")
 230            .and_then(|uri| uri.to_file_path().ok())
 231            .filter(|path| path.starts_with(worktree_root));
 232        let eslint_version = find_eslint_version(
 233            delegate.as_ref(),
 234            worktree_root,
 235            requested_file_path.as_deref(),
 236        )
 237        .await?;
 238        let config_kind = find_eslint_config_kind(
 239            worktree_root,
 240            requested_file_path.as_deref(),
 241            eslint_version.as_ref(),
 242            self.fs.as_ref(),
 243        )
 244        .await;
 245        let eslint_settings_overrides =
 246            eslint_settings_overrides_for(eslint_version.as_ref(), config_kind);
 247
 248        let mut default_workspace_configuration = json!({
 249            "validate": "on",
 250            "rulesCustomizations": [],
 251            "run": "onType",
 252            "nodePath": null,
 253            "workingDirectory": {
 254                "mode": "auto"
 255            },
 256            "workspaceFolder": {
 257                "uri": worktree_root,
 258                "name": worktree_root.file_name()
 259                    .unwrap_or(worktree_root.as_os_str())
 260                    .to_string_lossy(),
 261            },
 262            "problems": {},
 263            "codeActionOnSave": {
 264                // We enable this, but without also configuring code_actions_on_format
 265                // in the Zed configuration, it doesn't have an effect.
 266                "enable": true,
 267            },
 268            "codeAction": {
 269                "disableRuleComment": {
 270                    "enable": true,
 271                    "location": "separateLine",
 272                },
 273                "showDocumentation": {
 274                    "enable": true
 275                }
 276            }
 277        });
 278        eslint_settings_overrides.apply_to(&mut default_workspace_configuration);
 279
 280        let file_path = requested_file_path
 281            .as_ref()
 282            .and_then(|abs_path| abs_path.strip_prefix(worktree_root).ok())
 283            .and_then(|p| RelPath::unix(&p).ok().map(ToOwned::to_owned))
 284            .unwrap_or_else(|| RelPath::empty().to_owned());
 285        let override_options = cx.update(|cx| {
 286            language_server_settings_for(
 287                SettingsLocation {
 288                    worktree_id: delegate.worktree_id(),
 289                    path: &file_path,
 290                },
 291                &Self::SERVER_NAME,
 292                cx,
 293            )
 294            .and_then(|s| s.settings.clone())
 295        });
 296
 297        if let Some(override_options) = override_options {
 298            let working_directories = override_options.get("workingDirectories").and_then(|wd| {
 299                serde_json::from_value::<WorkingDirectories>(wd.clone())
 300                    .ok()
 301                    .and_then(|wd| wd.0)
 302            });
 303
 304            merge_json_value_into(override_options, &mut default_workspace_configuration);
 305
 306            let working_directory = working_directories
 307                .zip(requested_uri)
 308                .and_then(|(wd, uri)| {
 309                    determine_working_directory(uri, wd, worktree_root.to_owned())
 310                });
 311
 312            if let Some(working_directory) = working_directory
 313                && let Some(wd) = default_workspace_configuration.get_mut("workingDirectory")
 314            {
 315                *wd = serde_json::to_value(working_directory)?;
 316            }
 317        }
 318
 319        Ok(json!({
 320            "": default_workspace_configuration
 321        }))
 322    }
 323
 324    fn name(&self) -> LanguageServerName {
 325        Self::SERVER_NAME
 326    }
 327}
 328
 329fn ancestor_directories<'a>(
 330    worktree_root: &'a Path,
 331    requested_file: Option<&'a Path>,
 332) -> impl Iterator<Item = &'a Path> + 'a {
 333    let start = requested_file
 334        .filter(|file| file.starts_with(worktree_root))
 335        .and_then(Path::parent)
 336        .unwrap_or(worktree_root);
 337
 338    start
 339        .ancestors()
 340        .take_while(move |dir| dir.starts_with(worktree_root))
 341}
 342
 343fn flat_config_file_names(version: Option<&Version>) -> &'static [&'static str] {
 344    match version {
 345        Some(version) if version.major >= 10 => EsLintLspAdapter::FLAT_CONFIG_FILE_NAMES_V10,
 346        Some(version) if version.major == 9 => EsLintLspAdapter::FLAT_CONFIG_FILE_NAMES_V8_57,
 347        Some(version) if version.major == 8 && version.minor >= 57 => {
 348            EsLintLspAdapter::FLAT_CONFIG_FILE_NAMES_V8_57
 349        }
 350        Some(version) if version.major == 8 && version.minor >= 21 => {
 351            EsLintLspAdapter::FLAT_CONFIG_FILE_NAMES_V8_21
 352        }
 353        _ => &[],
 354    }
 355}
 356
 357async fn find_eslint_config_kind(
 358    worktree_root: &Path,
 359    requested_file: Option<&Path>,
 360    version: Option<&Version>,
 361    fs: &dyn Fs,
 362) -> Option<EslintConfigKind> {
 363    let flat_config_file_names = flat_config_file_names(version);
 364
 365    for directory in ancestor_directories(worktree_root, requested_file) {
 366        for file_name in flat_config_file_names {
 367            if fs.is_file(&directory.join(file_name)).await {
 368                return Some(EslintConfigKind::Flat);
 369            }
 370        }
 371
 372        for file_name in EsLintLspAdapter::LEGACY_CONFIG_FILE_NAMES {
 373            if fs.is_file(&directory.join(file_name)).await {
 374                return Some(EslintConfigKind::Legacy);
 375            }
 376        }
 377    }
 378
 379    None
 380}
 381
 382fn eslint_settings_overrides_for(
 383    version: Option<&Version>,
 384    config_kind: Option<EslintConfigKind>,
 385) -> EslintSettingsOverrides {
 386    // vscode-eslint 3.x already discovers config files and chooses a working
 387    // directory from the active file on its own. Zed only overrides settings
 388    // for the two cases where leaving everything unset is known to be wrong:
 389    //
 390    // - ESLint 8.21-8.56 flat config still needs experimental.useFlatConfig.
 391    // - ESLint 9.x legacy config needs useFlatConfig = false.
 392    //
 393    // All other cases should defer to the server's own defaults and discovery.
 394    let Some(version) = version else {
 395        return EslintSettingsOverrides::default();
 396    };
 397
 398    match config_kind {
 399        Some(EslintConfigKind::Flat) if version.major == 8 && (21..57).contains(&version.minor) => {
 400            EslintSettingsOverrides {
 401                use_flat_config: None,
 402                experimental_use_flat_config: Some(true),
 403            }
 404        }
 405        Some(EslintConfigKind::Legacy) if version.major == 9 => EslintSettingsOverrides {
 406            use_flat_config: Some(false),
 407            experimental_use_flat_config: None,
 408        },
 409        _ => EslintSettingsOverrides::default(),
 410    }
 411}
 412
 413async fn find_eslint_version(
 414    delegate: &dyn LspAdapterDelegate,
 415    worktree_root: &Path,
 416    requested_file: Option<&Path>,
 417) -> Result<Option<Version>> {
 418    for directory in ancestor_directories(worktree_root, requested_file) {
 419        if let Some(version) =
 420            read_package_installed_version(directory.join("node_modules"), "eslint").await?
 421        {
 422            return Ok(Some(version));
 423        }
 424    }
 425
 426    Ok(delegate
 427        .npm_package_installed_version("eslint")
 428        .await?
 429        .map(|(_, version)| version))
 430}
 431
 432/// On Windows, converts Unix-style separators (/) to Windows-style (\).
 433/// On Unix, returns the path unchanged
 434fn normalize_path_separators(path: &str) -> String {
 435    #[cfg(windows)]
 436    {
 437        path.replace('/', "\\")
 438    }
 439    #[cfg(not(windows))]
 440    {
 441        path.to_string()
 442    }
 443}
 444
 445fn determine_working_directory(
 446    uri: Uri,
 447    working_directories: Vec<WorkingDirectory>,
 448    workspace_folder_path: PathBuf,
 449) -> Option<ResultWorkingDirectory> {
 450    let mut working_directory = None;
 451
 452    for item in working_directories {
 453        let mut directory: Option<String> = None;
 454        let mut pattern: Option<String> = None;
 455        let mut no_cwd = false;
 456        match item {
 457            WorkingDirectory::String(contents) => {
 458                directory = Some(normalize_path_separators(&contents));
 459            }
 460            WorkingDirectory::LegacyDirectoryItem(legacy_directory_item) => {
 461                directory = Some(normalize_path_separators(&legacy_directory_item.directory));
 462                no_cwd = !legacy_directory_item.change_process_cwd;
 463            }
 464            WorkingDirectory::DirectoryItem(directory_item) => {
 465                directory = Some(normalize_path_separators(&directory_item.directory));
 466                if let Some(not_cwd) = directory_item.not_cwd {
 467                    no_cwd = not_cwd;
 468                }
 469            }
 470            WorkingDirectory::PatternItem(pattern_item) => {
 471                pattern = Some(normalize_path_separators(&pattern_item.pattern));
 472                if let Some(not_cwd) = pattern_item.not_cwd {
 473                    no_cwd = not_cwd;
 474                }
 475            }
 476            WorkingDirectory::ModeItem(mode_item) => {
 477                working_directory = Some(ResultWorkingDirectory::ModeItem(mode_item));
 478                continue;
 479            }
 480        }
 481
 482        let mut item_value: Option<String> = None;
 483        if directory.is_some() || pattern.is_some() {
 484            let file_path: Option<PathBuf> = (uri.scheme() == "file")
 485                .then(|| uri.to_file_path().ok())
 486                .flatten();
 487            if let Some(file_path) = file_path {
 488                if let Some(mut directory) = directory {
 489                    if Path::new(&directory).is_relative() {
 490                        directory = workspace_folder_path
 491                            .join(directory)
 492                            .to_string_lossy()
 493                            .to_string();
 494                    }
 495                    if !directory.ends_with(std::path::MAIN_SEPARATOR) {
 496                        directory.push(std::path::MAIN_SEPARATOR);
 497                    }
 498                    if file_path.starts_with(&directory) {
 499                        item_value = Some(directory);
 500                    }
 501                } else if let Some(mut pattern) = pattern
 502                    && !pattern.is_empty()
 503                {
 504                    if Path::new(&pattern).is_relative() {
 505                        pattern = workspace_folder_path
 506                            .join(pattern)
 507                            .to_string_lossy()
 508                            .to_string();
 509                    }
 510                    if !pattern.ends_with(std::path::MAIN_SEPARATOR) {
 511                        pattern.push(std::path::MAIN_SEPARATOR);
 512                    }
 513                    if let Some(matched) = match_glob_pattern(&pattern, &file_path) {
 514                        item_value = Some(matched);
 515                    }
 516                }
 517            }
 518        }
 519        if let Some(item_value) = item_value {
 520            if working_directory
 521                .as_ref()
 522                .is_none_or(|wd| matches!(wd, ResultWorkingDirectory::ModeItem(_)))
 523            {
 524                working_directory = Some(ResultWorkingDirectory::DirectoryItem(DirectoryItem {
 525                    directory: item_value,
 526                    not_cwd: Some(no_cwd),
 527                }));
 528            } else if let Some(ResultWorkingDirectory::DirectoryItem(item)) = &mut working_directory
 529                && item.directory.len() < item_value.len()
 530            {
 531                item.directory = item_value;
 532                item.not_cwd = Some(no_cwd);
 533            }
 534        }
 535    }
 536
 537    working_directory
 538}
 539
 540fn match_glob_pattern(pattern: &str, file_path: &Path) -> Option<String> {
 541    use globset::GlobBuilder;
 542
 543    let glob = GlobBuilder::new(pattern)
 544        .literal_separator(true)
 545        .build()
 546        .ok()?
 547        .compile_matcher();
 548
 549    let mut current = file_path.to_path_buf();
 550    let mut matched: Option<String> = None;
 551
 552    while let Some(parent) = current.parent() {
 553        let mut prefix = parent.to_string_lossy().to_string();
 554        if !prefix.ends_with(std::path::MAIN_SEPARATOR) {
 555            prefix.push(std::path::MAIN_SEPARATOR);
 556        }
 557        if glob.is_match(&prefix) {
 558            matched = Some(prefix);
 559        }
 560        current = parent.to_path_buf();
 561    }
 562
 563    matched
 564}
 565
 566#[cfg(target_os = "windows")]
 567async fn handle_symlink(src_dir: PathBuf, dest_dir: PathBuf) -> Result<()> {
 568    anyhow::ensure!(
 569        fs::metadata(&src_dir).await.is_ok(),
 570        "Directory {src_dir:?} is not present"
 571    );
 572    if fs::metadata(&dest_dir).await.is_ok() {
 573        fs::remove_file(&dest_dir).await?;
 574    }
 575    fs::create_dir_all(&dest_dir).await?;
 576    let mut entries = fs::read_dir(&src_dir).await?;
 577    while let Some(entry) = entries.try_next().await? {
 578        let entry_path = entry.path();
 579        let entry_name = entry.file_name();
 580        let dest_path = dest_dir.join(&entry_name);
 581        fs::copy(&entry_path, &dest_path).await?;
 582    }
 583    Ok(())
 584}
 585
 586#[derive(Serialize, Deserialize, Debug)]
 587struct LegacyDirectoryItem {
 588    directory: String,
 589    #[serde(rename = "changeProcessCWD")]
 590    change_process_cwd: bool,
 591}
 592
 593#[derive(Serialize, Deserialize, Debug)]
 594struct DirectoryItem {
 595    directory: String,
 596    #[serde(rename = "!cwd")]
 597    not_cwd: Option<bool>,
 598}
 599
 600#[derive(Serialize, Deserialize, Debug)]
 601struct PatternItem {
 602    pattern: String,
 603    #[serde(rename = "!cwd")]
 604    not_cwd: Option<bool>,
 605}
 606
 607#[derive(Serialize, Deserialize, Debug)]
 608struct ModeItem {
 609    mode: ModeEnum,
 610}
 611
 612#[derive(Serialize, Deserialize, Debug)]
 613#[serde(rename_all = "lowercase")]
 614enum ModeEnum {
 615    Auto,
 616    Location,
 617}
 618
 619#[derive(Serialize, Deserialize, Debug)]
 620#[serde(untagged)]
 621enum WorkingDirectory {
 622    String(String),
 623    LegacyDirectoryItem(LegacyDirectoryItem),
 624    DirectoryItem(DirectoryItem),
 625    PatternItem(PatternItem),
 626    ModeItem(ModeItem),
 627}
 628#[derive(Serialize, Deserialize)]
 629struct WorkingDirectories(Option<Vec<WorkingDirectory>>);
 630
 631#[derive(Serialize, Deserialize, Debug)]
 632#[serde(untagged)]
 633enum ResultWorkingDirectory {
 634    ModeItem(ModeItem),
 635    DirectoryItem(DirectoryItem),
 636}
 637
 638#[cfg(test)]
 639mod tests {
 640    use super::*;
 641
 642    mod glob_patterns {
 643        use super::*;
 644
 645        #[test]
 646        fn test_match_glob_pattern() {
 647            let pattern = unix_path_to_platform("/test/*/");
 648            let file_path = PathBuf::from(unix_path_to_platform("/test/foo/bar/file.txt"));
 649            let matched = match_glob_pattern(&pattern, &file_path);
 650            assert_eq!(matched, Some(unix_path_to_platform("/test/foo/")));
 651        }
 652
 653        #[test]
 654        fn test_match_glob_pattern_globstar() {
 655            let pattern = unix_path_to_platform("/workspace/**/src/");
 656            let file_path = PathBuf::from(unix_path_to_platform(
 657                "/workspace/packages/core/src/index.ts",
 658            ));
 659            let matched = match_glob_pattern(&pattern, &file_path);
 660            assert_eq!(
 661                matched,
 662                Some(unix_path_to_platform("/workspace/packages/core/src/"))
 663            );
 664        }
 665
 666        #[test]
 667        fn test_match_glob_pattern_no_match() {
 668            let pattern = unix_path_to_platform("/other/*/");
 669            let file_path = PathBuf::from(unix_path_to_platform("/test/foo/bar/file.txt"));
 670            let matched = match_glob_pattern(&pattern, &file_path);
 671            assert_eq!(matched, None);
 672        }
 673    }
 674
 675    mod unix_style_paths {
 676        use super::*;
 677
 678        #[test]
 679        fn test_working_directory_string() {
 680            let uri = make_uri("/workspace/packages/foo/src/file.ts");
 681            let working_directories = vec![WorkingDirectory::String("packages/foo".to_string())];
 682            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
 683
 684            let result = determine_working_directory(uri, working_directories, workspace_folder);
 685            assert_directory_result(
 686                result,
 687                &unix_path_to_platform("/workspace/packages/foo/"),
 688                false,
 689            );
 690        }
 691
 692        #[test]
 693        fn test_working_directory_absolute_path() {
 694            let uri = make_uri("/workspace/packages/foo/src/file.ts");
 695            let working_directories = vec![WorkingDirectory::String(unix_path_to_platform(
 696                "/workspace/packages/foo",
 697            ))];
 698            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
 699
 700            let result = determine_working_directory(uri, working_directories, workspace_folder);
 701            assert_directory_result(
 702                result,
 703                &unix_path_to_platform("/workspace/packages/foo/"),
 704                false,
 705            );
 706        }
 707
 708        #[test]
 709        fn test_working_directory_directory_item() {
 710            let uri = make_uri("/workspace/packages/foo/src/file.ts");
 711            let working_directories = vec![WorkingDirectory::DirectoryItem(DirectoryItem {
 712                directory: "packages/foo".to_string(),
 713                not_cwd: Some(true),
 714            })];
 715            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
 716
 717            let result = determine_working_directory(uri, working_directories, workspace_folder);
 718            assert_directory_result(
 719                result,
 720                &unix_path_to_platform("/workspace/packages/foo/"),
 721                true,
 722            );
 723        }
 724
 725        #[test]
 726        fn test_working_directory_legacy_item() {
 727            let uri = make_uri("/workspace/packages/foo/src/file.ts");
 728            let working_directories =
 729                vec![WorkingDirectory::LegacyDirectoryItem(LegacyDirectoryItem {
 730                    directory: "packages/foo".to_string(),
 731                    change_process_cwd: false,
 732                })];
 733            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
 734
 735            let result = determine_working_directory(uri, working_directories, workspace_folder);
 736            assert_directory_result(
 737                result,
 738                &unix_path_to_platform("/workspace/packages/foo/"),
 739                true,
 740            );
 741        }
 742
 743        #[test]
 744        fn test_working_directory_pattern_item() {
 745            let uri = make_uri("/workspace/packages/foo/src/file.ts");
 746            let working_directories = vec![WorkingDirectory::PatternItem(PatternItem {
 747                pattern: "packages/*/".to_string(),
 748                not_cwd: Some(false),
 749            })];
 750            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
 751
 752            let result = determine_working_directory(uri, working_directories, workspace_folder);
 753            assert_directory_result(
 754                result,
 755                &unix_path_to_platform("/workspace/packages/foo/"),
 756                false,
 757            );
 758        }
 759
 760        #[test]
 761        fn test_working_directory_multiple_patterns() {
 762            let uri = make_uri("/workspace/apps/web/src/file.ts");
 763            let working_directories = vec![
 764                WorkingDirectory::PatternItem(PatternItem {
 765                    pattern: "packages/*/".to_string(),
 766                    not_cwd: None,
 767                }),
 768                WorkingDirectory::PatternItem(PatternItem {
 769                    pattern: "apps/*/".to_string(),
 770                    not_cwd: None,
 771                }),
 772            ];
 773            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
 774
 775            let result = determine_working_directory(uri, working_directories, workspace_folder);
 776            assert_directory_result(
 777                result,
 778                &unix_path_to_platform("/workspace/apps/web/"),
 779                false,
 780            );
 781        }
 782    }
 783
 784    mod eslint_settings {
 785        use super::*;
 786        use ::fs::FakeFs;
 787        use gpui::TestAppContext;
 788
 789        #[test]
 790        fn test_ancestor_directories_for_package_local_file() {
 791            let worktree_root = PathBuf::from(unix_path_to_platform("/workspace"));
 792            let requested_file = PathBuf::from(unix_path_to_platform(
 793                "/workspace/packages/web/src/index.js",
 794            ));
 795
 796            let directories: Vec<&Path> =
 797                ancestor_directories(&worktree_root, Some(&requested_file)).collect();
 798
 799            assert_eq!(
 800                directories,
 801                vec![
 802                    Path::new(&unix_path_to_platform("/workspace/packages/web/src")),
 803                    Path::new(&unix_path_to_platform("/workspace/packages/web")),
 804                    Path::new(&unix_path_to_platform("/workspace/packages")),
 805                    Path::new(&unix_path_to_platform("/workspace")),
 806                ]
 807            );
 808        }
 809
 810        #[test]
 811        fn test_eslint_8_flat_root_repo_uses_experimental_flag() {
 812            let version = Version::parse("8.56.0").expect("valid ESLint version");
 813            let settings =
 814                eslint_settings_overrides_for(Some(&version), Some(EslintConfigKind::Flat));
 815
 816            assert_eq!(
 817                settings,
 818                EslintSettingsOverrides {
 819                    use_flat_config: None,
 820                    experimental_use_flat_config: Some(true),
 821                }
 822            );
 823        }
 824
 825        #[test]
 826        fn test_eslint_8_57_flat_repo_uses_no_override() {
 827            let version = Version::parse("8.57.0").expect("valid ESLint version");
 828            let settings =
 829                eslint_settings_overrides_for(Some(&version), Some(EslintConfigKind::Flat));
 830
 831            assert_eq!(settings, EslintSettingsOverrides::default());
 832        }
 833
 834        #[test]
 835        fn test_eslint_9_legacy_repo_uses_use_flat_config_false() {
 836            let version = Version::parse("9.0.0").expect("valid ESLint version");
 837            let settings =
 838                eslint_settings_overrides_for(Some(&version), Some(EslintConfigKind::Legacy));
 839
 840            assert_eq!(
 841                settings,
 842                EslintSettingsOverrides {
 843                    use_flat_config: Some(false),
 844                    experimental_use_flat_config: None,
 845                }
 846            );
 847        }
 848
 849        #[test]
 850        fn test_eslint_10_repo_uses_no_override() {
 851            let version = Version::parse("10.0.0").expect("valid ESLint version");
 852            let settings =
 853                eslint_settings_overrides_for(Some(&version), Some(EslintConfigKind::Flat));
 854
 855            assert_eq!(settings, EslintSettingsOverrides::default());
 856        }
 857
 858        #[gpui::test]
 859        async fn test_eslint_8_56_does_not_treat_cjs_as_flat_config(cx: &mut TestAppContext) {
 860            let fs = FakeFs::new(cx.executor());
 861            fs.insert_tree(
 862                unix_path_to_platform("/workspace"),
 863                json!({ "eslint.config.cjs": "" }),
 864            )
 865            .await;
 866            let worktree_root = PathBuf::from(unix_path_to_platform("/workspace"));
 867            let requested_file = PathBuf::from(unix_path_to_platform("/workspace/src/index.js"));
 868            let version = Version::parse("8.56.0").expect("valid ESLint version");
 869
 870            let config_kind = find_eslint_config_kind(
 871                &worktree_root,
 872                Some(&requested_file),
 873                Some(&version),
 874                fs.as_ref(),
 875            )
 876            .await;
 877
 878            assert_eq!(config_kind, None);
 879        }
 880
 881        #[gpui::test]
 882        async fn test_eslint_8_57_treats_cjs_as_flat_config(cx: &mut TestAppContext) {
 883            let fs = FakeFs::new(cx.executor());
 884            fs.insert_tree(
 885                unix_path_to_platform("/workspace"),
 886                json!({ "eslint.config.cjs": "" }),
 887            )
 888            .await;
 889            let worktree_root = PathBuf::from(unix_path_to_platform("/workspace"));
 890            let requested_file = PathBuf::from(unix_path_to_platform("/workspace/src/index.js"));
 891            let version = Version::parse("8.57.0").expect("valid ESLint version");
 892
 893            let config_kind = find_eslint_config_kind(
 894                &worktree_root,
 895                Some(&requested_file),
 896                Some(&version),
 897                fs.as_ref(),
 898            )
 899            .await;
 900
 901            assert_eq!(config_kind, Some(EslintConfigKind::Flat));
 902        }
 903
 904        #[gpui::test]
 905        async fn test_eslint_10_treats_typescript_config_as_flat_config(cx: &mut TestAppContext) {
 906            let fs = FakeFs::new(cx.executor());
 907            fs.insert_tree(
 908                unix_path_to_platform("/workspace"),
 909                json!({ "eslint.config.ts": "" }),
 910            )
 911            .await;
 912            let worktree_root = PathBuf::from(unix_path_to_platform("/workspace"));
 913            let requested_file = PathBuf::from(unix_path_to_platform("/workspace/src/index.js"));
 914            let version = Version::parse("10.0.0").expect("valid ESLint version");
 915
 916            let config_kind = find_eslint_config_kind(
 917                &worktree_root,
 918                Some(&requested_file),
 919                Some(&version),
 920                fs.as_ref(),
 921            )
 922            .await;
 923
 924            assert_eq!(config_kind, Some(EslintConfigKind::Flat));
 925        }
 926
 927        #[gpui::test]
 928        async fn test_package_local_flat_config_is_preferred_for_monorepo_file(
 929            cx: &mut TestAppContext,
 930        ) {
 931            let fs = FakeFs::new(cx.executor());
 932            fs.insert_tree(
 933                unix_path_to_platform("/workspace"),
 934                json!({
 935                    "eslint.config.js": "",
 936                    "packages": {
 937                        "web": {
 938                            "eslint.config.js": ""
 939                        }
 940                    }
 941                }),
 942            )
 943            .await;
 944            let worktree_root = PathBuf::from(unix_path_to_platform("/workspace"));
 945            let requested_file = PathBuf::from(unix_path_to_platform(
 946                "/workspace/packages/web/src/index.js",
 947            ));
 948            let version = Version::parse("8.56.0").expect("valid ESLint version");
 949
 950            let config_kind = find_eslint_config_kind(
 951                &worktree_root,
 952                Some(&requested_file),
 953                Some(&version),
 954                fs.as_ref(),
 955            )
 956            .await;
 957
 958            assert_eq!(config_kind, Some(EslintConfigKind::Flat));
 959        }
 960
 961        #[gpui::test]
 962        async fn test_package_local_legacy_config_is_detected_for_eslint_9(
 963            cx: &mut TestAppContext,
 964        ) {
 965            let fs = FakeFs::new(cx.executor());
 966            fs.insert_tree(
 967                unix_path_to_platform("/workspace"),
 968                json!({
 969                    "packages": {
 970                        "web": {
 971                            ".eslintrc.cjs": ""
 972                        }
 973                    }
 974                }),
 975            )
 976            .await;
 977            let worktree_root = PathBuf::from(unix_path_to_platform("/workspace"));
 978            let requested_file = PathBuf::from(unix_path_to_platform(
 979                "/workspace/packages/web/src/index.js",
 980            ));
 981            let version = Version::parse("9.0.0").expect("valid ESLint version");
 982
 983            let config_kind = find_eslint_config_kind(
 984                &worktree_root,
 985                Some(&requested_file),
 986                Some(&version),
 987                fs.as_ref(),
 988            )
 989            .await;
 990
 991            assert_eq!(config_kind, Some(EslintConfigKind::Legacy));
 992        }
 993    }
 994
 995    #[cfg(windows)]
 996    mod windows_style_paths {
 997        use super::*;
 998
 999        #[test]
1000        fn test_working_directory_string() {
1001            let uri = make_uri("/workspace/packages/foo/src/file.ts");
1002            let working_directories = vec![WorkingDirectory::String("packages\\foo".to_string())];
1003            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
1004
1005            let result = determine_working_directory(uri, working_directories, workspace_folder);
1006            assert_directory_result(
1007                result,
1008                &unix_path_to_platform("/workspace/packages/foo/"),
1009                false,
1010            );
1011        }
1012
1013        #[test]
1014        fn test_working_directory_absolute_path() {
1015            let uri = make_uri("/workspace/packages/foo/src/file.ts");
1016            let working_directories = vec![WorkingDirectory::String(
1017                unix_path_to_platform("/workspace/packages/foo").replace('/', "\\"),
1018            )];
1019            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
1020
1021            let result = determine_working_directory(uri, working_directories, workspace_folder);
1022            assert_directory_result(
1023                result,
1024                &unix_path_to_platform("/workspace/packages/foo/"),
1025                false,
1026            );
1027        }
1028
1029        #[test]
1030        fn test_working_directory_directory_item() {
1031            let uri = make_uri("/workspace/packages/foo/src/file.ts");
1032            let working_directories = vec![WorkingDirectory::DirectoryItem(DirectoryItem {
1033                directory: "packages\\foo".to_string(),
1034                not_cwd: Some(true),
1035            })];
1036            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
1037
1038            let result = determine_working_directory(uri, working_directories, workspace_folder);
1039            assert_directory_result(
1040                result,
1041                &unix_path_to_platform("/workspace/packages/foo/"),
1042                true,
1043            );
1044        }
1045
1046        #[test]
1047        fn test_working_directory_legacy_item() {
1048            let uri = make_uri("/workspace/packages/foo/src/file.ts");
1049            let working_directories =
1050                vec![WorkingDirectory::LegacyDirectoryItem(LegacyDirectoryItem {
1051                    directory: "packages\\foo".to_string(),
1052                    change_process_cwd: false,
1053                })];
1054            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
1055
1056            let result = determine_working_directory(uri, working_directories, workspace_folder);
1057            assert_directory_result(
1058                result,
1059                &unix_path_to_platform("/workspace/packages/foo/"),
1060                true,
1061            );
1062        }
1063
1064        #[test]
1065        fn test_working_directory_pattern_item() {
1066            let uri = make_uri("/workspace/packages/foo/src/file.ts");
1067            let working_directories = vec![WorkingDirectory::PatternItem(PatternItem {
1068                pattern: "packages\\*\\".to_string(),
1069                not_cwd: Some(false),
1070            })];
1071            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
1072
1073            let result = determine_working_directory(uri, working_directories, workspace_folder);
1074            assert_directory_result(
1075                result,
1076                &unix_path_to_platform("/workspace/packages/foo/"),
1077                false,
1078            );
1079        }
1080
1081        #[test]
1082        fn test_working_directory_multiple_patterns() {
1083            let uri = make_uri("/workspace/apps/web/src/file.ts");
1084            let working_directories = vec![
1085                WorkingDirectory::PatternItem(PatternItem {
1086                    pattern: "packages\\*\\".to_string(),
1087                    not_cwd: None,
1088                }),
1089                WorkingDirectory::PatternItem(PatternItem {
1090                    pattern: "apps\\*\\".to_string(),
1091                    not_cwd: None,
1092                }),
1093            ];
1094            let workspace_folder = PathBuf::from(unix_path_to_platform("/workspace"));
1095
1096            let result = determine_working_directory(uri, working_directories, workspace_folder);
1097            assert_directory_result(
1098                result,
1099                &unix_path_to_platform("/workspace/apps/web/"),
1100                false,
1101            );
1102        }
1103    }
1104
1105    /// Converts a Unix-style path to a platform-specific path.
1106    /// On Windows, converts "/workspace/foo/bar" to "C:\workspace\foo\bar"
1107    /// On Unix, returns the path unchanged.
1108    fn unix_path_to_platform(path: &str) -> String {
1109        #[cfg(windows)]
1110        {
1111            if path.starts_with('/') {
1112                format!("C:{}", path.replace('/', "\\"))
1113            } else {
1114                path.replace('/', "\\")
1115            }
1116        }
1117        #[cfg(not(windows))]
1118        {
1119            path.to_string()
1120        }
1121    }
1122
1123    fn make_uri(path: &str) -> Uri {
1124        let platform_path = unix_path_to_platform(path);
1125        Uri::from_file_path(&platform_path).unwrap()
1126    }
1127
1128    fn assert_directory_result(
1129        result: Option<ResultWorkingDirectory>,
1130        expected_directory: &str,
1131        expected_not_cwd: bool,
1132    ) {
1133        match result {
1134            Some(ResultWorkingDirectory::DirectoryItem(item)) => {
1135                assert_eq!(item.directory, expected_directory);
1136                assert_eq!(item.not_cwd, Some(expected_not_cwd));
1137            }
1138            other => panic!("Expected DirectoryItem, got {:?}", other),
1139        }
1140    }
1141}