prettier.rs

   1use anyhow::Context as _;
   2use collections::{HashMap, HashSet};
   3use fs::Fs;
   4use gpui::{AsyncApp, Entity};
   5use language::{Buffer, Diff, language_settings::language_settings};
   6use lsp::{LanguageServer, LanguageServerId};
   7use node_runtime::NodeRuntime;
   8use paths::default_prettier_dir;
   9use serde::{Deserialize, Serialize};
  10use std::{
  11    ops::ControlFlow,
  12    path::{Path, PathBuf},
  13    sync::Arc,
  14};
  15use util::paths::PathMatcher;
  16
  17#[derive(Debug, Clone)]
  18pub enum Prettier {
  19    Real(RealPrettier),
  20    #[cfg(any(test, feature = "test-support"))]
  21    Test(TestPrettier),
  22}
  23
  24#[derive(Debug, Clone)]
  25pub struct RealPrettier {
  26    default: bool,
  27    prettier_dir: PathBuf,
  28    server: Arc<LanguageServer>,
  29}
  30
  31#[cfg(any(test, feature = "test-support"))]
  32#[derive(Debug, Clone)]
  33pub struct TestPrettier {
  34    prettier_dir: PathBuf,
  35    default: bool,
  36}
  37
  38pub const FAIL_THRESHOLD: usize = 4;
  39pub const PRETTIER_SERVER_FILE: &str = "prettier_server.js";
  40pub const PRETTIER_SERVER_JS: &str = include_str!("./prettier_server.js");
  41const PRETTIER_PACKAGE_NAME: &str = "prettier";
  42const TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME: &str = "prettier-plugin-tailwindcss";
  43
  44#[cfg(any(test, feature = "test-support"))]
  45pub const FORMAT_SUFFIX: &str = "\nformatted by test prettier";
  46
  47impl Prettier {
  48    pub const CONFIG_FILE_NAMES: &'static [&'static str] = &[
  49        ".prettierrc",
  50        ".prettierrc.json",
  51        ".prettierrc.json5",
  52        ".prettierrc.yaml",
  53        ".prettierrc.yml",
  54        ".prettierrc.toml",
  55        ".prettierrc.js",
  56        ".prettierrc.cjs",
  57        "package.json",
  58        "prettier.config.js",
  59        "prettier.config.cjs",
  60        ".editorconfig",
  61        ".prettierignore",
  62    ];
  63
  64    pub async fn locate_prettier_installation(
  65        fs: &dyn Fs,
  66        installed_prettiers: &HashSet<PathBuf>,
  67        locate_from: &Path,
  68    ) -> anyhow::Result<ControlFlow<(), Option<PathBuf>>> {
  69        let mut path_to_check = locate_from
  70            .components()
  71            .take_while(|component| component.as_os_str().to_string_lossy() != "node_modules")
  72            .collect::<PathBuf>();
  73        if path_to_check != locate_from {
  74            log::debug!(
  75                "Skipping prettier location for path {path_to_check:?} that is inside node_modules"
  76            );
  77            return Ok(ControlFlow::Break(()));
  78        }
  79        let path_to_check_metadata = fs
  80            .metadata(&path_to_check)
  81            .await
  82            .with_context(|| format!("failed to get metadata for initial path {path_to_check:?}"))?
  83            .with_context(|| format!("empty metadata for initial path {path_to_check:?}"))?;
  84        if !path_to_check_metadata.is_dir {
  85            path_to_check.pop();
  86        }
  87
  88        let mut closest_package_json_path = None;
  89        loop {
  90            if installed_prettiers.contains(&path_to_check) {
  91                log::debug!("Found prettier path {path_to_check:?} in installed prettiers");
  92                return Ok(ControlFlow::Continue(Some(path_to_check)));
  93            } else if let Some(package_json_contents) =
  94                read_package_json(fs, &path_to_check).await?
  95            {
  96                if has_prettier_in_node_modules(fs, &path_to_check).await? {
  97                    log::debug!("Found prettier path {path_to_check:?} in the node_modules");
  98                    return Ok(ControlFlow::Continue(Some(path_to_check)));
  99                } else {
 100                    match &closest_package_json_path {
 101                        None => closest_package_json_path = Some(path_to_check.clone()),
 102                        Some(closest_package_json_path) => {
 103                            match package_json_contents.get("workspaces") {
 104                                Some(serde_json::Value::Array(workspaces)) => {
 105                                    let subproject_path = closest_package_json_path.strip_prefix(&path_to_check).expect("traversing path parents, should be able to strip prefix");
 106                                    if workspaces.iter().filter_map(|value| {
 107                                        if let serde_json::Value::String(s) = value {
 108                                            Some(s.clone())
 109                                        } else {
 110                                            log::warn!("Skipping non-string 'workspaces' value: {value:?}");
 111                                            None
 112                                        }
 113                                    }).any(|workspace_definition| {
 114                                        workspace_definition == subproject_path.to_string_lossy() || PathMatcher::new(&[workspace_definition]).ok().map_or(false, |path_matcher| path_matcher.is_match(subproject_path))
 115                                    }) {
 116                                        anyhow::ensure!(has_prettier_in_node_modules(fs, &path_to_check).await?, "Path {path_to_check:?} is the workspace root for project in {closest_package_json_path:?}, but it has no prettier installed");
 117                                        log::info!("Found prettier path {path_to_check:?} in the workspace root for project in {closest_package_json_path:?}");
 118                                        return Ok(ControlFlow::Continue(Some(path_to_check)));
 119                                    } else {
 120                                        log::warn!("Skipping path {path_to_check:?} workspace root with workspaces {workspaces:?} that have no prettier installed");
 121                                    }
 122                                }
 123                                Some(unknown) => log::error!(
 124                                    "Failed to parse workspaces for {path_to_check:?} from package.json, got {unknown:?}. Skipping."
 125                                ),
 126                                None => log::warn!(
 127                                    "Skipping path {path_to_check:?} that has no prettier dependency and no workspaces section in its package.json"
 128                                ),
 129                            }
 130                        }
 131                    }
 132                }
 133            }
 134
 135            if !path_to_check.pop() {
 136                log::debug!("Found no prettier in ancestors of {locate_from:?}");
 137                return Ok(ControlFlow::Continue(None));
 138            }
 139        }
 140    }
 141
 142    pub async fn locate_prettier_ignore(
 143        fs: &dyn Fs,
 144        prettier_ignores: &HashSet<PathBuf>,
 145        locate_from: &Path,
 146    ) -> anyhow::Result<ControlFlow<(), Option<PathBuf>>> {
 147        let mut path_to_check = locate_from
 148            .components()
 149            .take_while(|component| component.as_os_str().to_string_lossy() != "node_modules")
 150            .collect::<PathBuf>();
 151        if path_to_check != locate_from {
 152            log::debug!(
 153                "Skipping prettier ignore location for path {path_to_check:?} that is inside node_modules"
 154            );
 155            return Ok(ControlFlow::Break(()));
 156        }
 157
 158        let path_to_check_metadata = fs
 159            .metadata(&path_to_check)
 160            .await
 161            .with_context(|| format!("failed to get metadata for initial path {path_to_check:?}"))?
 162            .with_context(|| format!("empty metadata for initial path {path_to_check:?}"))?;
 163        if !path_to_check_metadata.is_dir {
 164            path_to_check.pop();
 165        }
 166
 167        let mut closest_package_json_path = None;
 168        loop {
 169            if prettier_ignores.contains(&path_to_check) {
 170                log::debug!("Found prettier ignore at {path_to_check:?}");
 171                return Ok(ControlFlow::Continue(Some(path_to_check)));
 172            } else if let Some(package_json_contents) =
 173                read_package_json(fs, &path_to_check).await?
 174            {
 175                let ignore_path = path_to_check.join(".prettierignore");
 176                if let Some(metadata) = fs
 177                    .metadata(&ignore_path)
 178                    .await
 179                    .with_context(|| format!("fetching metadata for {ignore_path:?}"))?
 180                {
 181                    if !metadata.is_dir && !metadata.is_symlink {
 182                        log::info!("Found prettier ignore at {ignore_path:?}");
 183                        return Ok(ControlFlow::Continue(Some(path_to_check)));
 184                    }
 185                }
 186                match &closest_package_json_path {
 187                    None => closest_package_json_path = Some(path_to_check.clone()),
 188                    Some(closest_package_json_path) => {
 189                        if let Some(serde_json::Value::Array(workspaces)) =
 190                            package_json_contents.get("workspaces")
 191                        {
 192                            let subproject_path = closest_package_json_path
 193                                .strip_prefix(&path_to_check)
 194                                .expect("traversing path parents, should be able to strip prefix");
 195
 196                            if workspaces
 197                                .iter()
 198                                .filter_map(|value| {
 199                                    if let serde_json::Value::String(s) = value {
 200                                        Some(s.clone())
 201                                    } else {
 202                                        log::warn!(
 203                                            "Skipping non-string 'workspaces' value: {value:?}"
 204                                        );
 205                                        None
 206                                    }
 207                                })
 208                                .any(|workspace_definition| {
 209                                    workspace_definition == subproject_path.to_string_lossy()
 210                                        || PathMatcher::new(&[workspace_definition])
 211                                            .ok()
 212                                            .map_or(false, |path_matcher| {
 213                                                path_matcher.is_match(subproject_path)
 214                                            })
 215                                })
 216                            {
 217                                let workspace_ignore = path_to_check.join(".prettierignore");
 218                                if let Some(metadata) = fs.metadata(&workspace_ignore).await? {
 219                                    if !metadata.is_dir {
 220                                        log::info!(
 221                                            "Found prettier ignore at workspace root {workspace_ignore:?}"
 222                                        );
 223                                        return Ok(ControlFlow::Continue(Some(path_to_check)));
 224                                    }
 225                                }
 226                            }
 227                        }
 228                    }
 229                }
 230            }
 231
 232            if !path_to_check.pop() {
 233                log::debug!("Found no prettier ignore in ancestors of {locate_from:?}");
 234                return Ok(ControlFlow::Continue(None));
 235            }
 236        }
 237    }
 238
 239    #[cfg(any(test, feature = "test-support"))]
 240    pub async fn start(
 241        _: LanguageServerId,
 242        prettier_dir: PathBuf,
 243        _: NodeRuntime,
 244        _: AsyncApp,
 245    ) -> anyhow::Result<Self> {
 246        Ok(Self::Test(TestPrettier {
 247            default: prettier_dir == default_prettier_dir().as_path(),
 248            prettier_dir,
 249        }))
 250    }
 251
 252    #[cfg(not(any(test, feature = "test-support")))]
 253    pub async fn start(
 254        server_id: LanguageServerId,
 255        prettier_dir: PathBuf,
 256        node: NodeRuntime,
 257        mut cx: AsyncApp,
 258    ) -> anyhow::Result<Self> {
 259        use lsp::{LanguageServerBinary, LanguageServerName};
 260
 261        let executor = cx.background_executor().clone();
 262        anyhow::ensure!(
 263            prettier_dir.is_dir(),
 264            "Prettier dir {prettier_dir:?} is not a directory"
 265        );
 266        let prettier_server = default_prettier_dir().join(PRETTIER_SERVER_FILE);
 267        anyhow::ensure!(
 268            prettier_server.is_file(),
 269            "no prettier server package found at {prettier_server:?}"
 270        );
 271
 272        let node_path = executor
 273            .spawn(async move { node.binary_path().await })
 274            .await?;
 275        let server_name = LanguageServerName("prettier".into());
 276        let server_binary = LanguageServerBinary {
 277            path: node_path,
 278            arguments: vec![prettier_server.into(), prettier_dir.as_path().into()],
 279            env: None,
 280        };
 281        let server = LanguageServer::new(
 282            Arc::new(parking_lot::Mutex::new(None)),
 283            server_id,
 284            server_name,
 285            server_binary,
 286            &prettier_dir,
 287            None,
 288            Default::default(),
 289            &mut cx,
 290        )
 291        .context("prettier server creation")?;
 292
 293        let server = cx
 294            .update(|cx| {
 295                let params = server.default_initialize_params(false, cx);
 296                let configuration = lsp::DidChangeConfigurationParams {
 297                    settings: Default::default(),
 298                };
 299                executor.spawn(server.initialize(params, configuration.into(), cx))
 300            })?
 301            .await
 302            .context("prettier server initialization")?;
 303        Ok(Self::Real(RealPrettier {
 304            server,
 305            default: prettier_dir == default_prettier_dir().as_path(),
 306            prettier_dir,
 307        }))
 308    }
 309
 310    pub async fn format(
 311        &self,
 312        buffer: &Entity<Buffer>,
 313        buffer_path: Option<PathBuf>,
 314        ignore_dir: Option<PathBuf>,
 315        cx: &mut AsyncApp,
 316    ) -> anyhow::Result<Diff> {
 317        match self {
 318            Self::Real(local) => {
 319                let params = buffer
 320                    .update(cx, |buffer, cx| {
 321                        let buffer_language = buffer.language();
 322                        let language_settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx);
 323                        let prettier_settings = &language_settings.prettier;
 324                        anyhow::ensure!(
 325                            prettier_settings.allowed,
 326                            "Cannot format: prettier is not allowed for language {buffer_language:?}"
 327                        );
 328                        let prettier_node_modules = self.prettier_dir().join("node_modules");
 329                        anyhow::ensure!(
 330                            prettier_node_modules.is_dir(),
 331                            "Prettier node_modules dir does not exist: {prettier_node_modules:?}"
 332                        );
 333                        let plugin_name_into_path = |plugin_name: &str| {
 334                            let prettier_plugin_dir = prettier_node_modules.join(plugin_name);
 335                            [
 336                                prettier_plugin_dir.join("dist").join("index.mjs"),
 337                                prettier_plugin_dir.join("dist").join("index.js"),
 338                                prettier_plugin_dir.join("dist").join("plugin.js"),
 339                                prettier_plugin_dir.join("src").join("plugin.js"),
 340                                prettier_plugin_dir.join("lib").join("index.js"),
 341                                prettier_plugin_dir.join("index.mjs"),
 342                                prettier_plugin_dir.join("index.js"),
 343                                prettier_plugin_dir.join("plugin.js"),
 344                                // this one is for @prettier/plugin-php
 345                                prettier_plugin_dir.join("standalone.js"),
 346                                // this one is for prettier-plugin-latex
 347                                prettier_plugin_dir.join("dist").join("prettier-plugin-latex.js"),
 348                                prettier_plugin_dir,
 349                            ]
 350                            .into_iter()
 351                            .find(|possible_plugin_path| possible_plugin_path.is_file())
 352                        };
 353
 354                        // Tailwind plugin requires being added last
 355                        // https://github.com/tailwindlabs/prettier-plugin-tailwindcss#compatibility-with-other-prettier-plugins
 356                        let mut add_tailwind_back = false;
 357
 358                        let mut located_plugins = prettier_settings.plugins.iter()
 359                            .filter(|plugin_name| {
 360                                if plugin_name.as_str() == TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME {
 361                                    add_tailwind_back = true;
 362                                    false
 363                                } else {
 364                                    true
 365                                }
 366                            })
 367                            .map(|plugin_name| {
 368                                let plugin_path = plugin_name_into_path(plugin_name);
 369                                (plugin_name.clone(), plugin_path)
 370                            })
 371                            .collect::<Vec<_>>();
 372                        if add_tailwind_back {
 373                            located_plugins.push((
 374                                TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME.to_owned(),
 375                                plugin_name_into_path(TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME),
 376                            ));
 377                        }
 378
 379                        let prettier_options = if self.is_default() {
 380                            let mut options = prettier_settings.options.clone();
 381                            if !options.contains_key("tabWidth") {
 382                                options.insert(
 383                                    "tabWidth".to_string(),
 384                                    serde_json::Value::Number(serde_json::Number::from(
 385                                        language_settings.tab_size.get(),
 386                                    )),
 387                                );
 388                            }
 389                            if !options.contains_key("printWidth") {
 390                                options.insert(
 391                                    "printWidth".to_string(),
 392                                    serde_json::Value::Number(serde_json::Number::from(
 393                                        language_settings.preferred_line_length,
 394                                    )),
 395                                );
 396                            }
 397                            if !options.contains_key("useTabs") {
 398                                options.insert(
 399                                    "useTabs".to_string(),
 400                                    serde_json::Value::Bool(language_settings.hard_tabs),
 401                                );
 402                            }
 403                            Some(options)
 404                        } else {
 405                            None
 406                        };
 407
 408                        let plugins = located_plugins
 409                            .into_iter()
 410                            .filter_map(|(plugin_name, located_plugin_path)| {
 411                                match located_plugin_path {
 412                                    Some(path) => Some(path),
 413                                    None => {
 414                                        log::error!("Have not found plugin path for {plugin_name:?} inside {prettier_node_modules:?}");
 415                                        None
 416                                    }
 417                                }
 418                            })
 419                            .collect();
 420
 421                        let mut prettier_parser = prettier_settings.parser.as_deref();
 422                        if buffer_path.is_none() {
 423                            prettier_parser = prettier_parser.or_else(|| buffer_language.and_then(|language| language.prettier_parser_name()));
 424                            if prettier_parser.is_none() {
 425                                log::error!("Formatting unsaved file with prettier failed. No prettier parser configured for language {buffer_language:?}");
 426                                anyhow::bail!("Cannot determine prettier parser for unsaved file");
 427                            }
 428
 429                        }
 430
 431                        let ignore_path = ignore_dir.and_then(|dir| {
 432                            let ignore_file = dir.join(".prettierignore");
 433                            ignore_file.is_file().then_some(ignore_file)
 434                        });
 435
 436                        log::debug!(
 437                            "Formatting file {:?} with prettier, plugins :{:?}, options: {:?}, ignore_path: {:?}",
 438                            buffer.file().map(|f| f.full_path(cx)),
 439                            plugins,
 440                            prettier_options,
 441                            ignore_path,
 442                        );
 443
 444                        anyhow::Ok(FormatParams {
 445                            text: buffer.text(),
 446                            options: FormatOptions {
 447                                parser: prettier_parser.map(ToOwned::to_owned),
 448                                plugins,
 449                                path: buffer_path,
 450                                prettier_options,
 451                                ignore_path,
 452                            },
 453                        })
 454                    })?
 455                    .context("prettier params calculation")?;
 456
 457                let response = local
 458                    .server
 459                    .request::<Format>(params)
 460                    .await
 461                    .into_response()
 462                    .context("prettier format")?;
 463                let diff_task = buffer.update(cx, |buffer, cx| buffer.diff(response.text, cx))?;
 464                Ok(diff_task.await)
 465            }
 466            #[cfg(any(test, feature = "test-support"))]
 467            Self::Test(_) => Ok(buffer
 468                .update(cx, |buffer, cx| {
 469                    match buffer
 470                        .language()
 471                        .map(|language| language.lsp_id())
 472                        .as_deref()
 473                    {
 474                        Some("rust") => anyhow::bail!("prettier does not support Rust"),
 475                        Some(_other) => {
 476                            let formatted_text = buffer.text() + FORMAT_SUFFIX;
 477                            Ok(buffer.diff(formatted_text, cx))
 478                        }
 479                        None => panic!("Should not format buffer without a language with prettier"),
 480                    }
 481                })??
 482                .await),
 483        }
 484    }
 485
 486    pub async fn clear_cache(&self) -> anyhow::Result<()> {
 487        match self {
 488            Self::Real(local) => local
 489                .server
 490                .request::<ClearCache>(())
 491                .await
 492                .into_response()
 493                .context("prettier clear cache"),
 494            #[cfg(any(test, feature = "test-support"))]
 495            Self::Test(_) => Ok(()),
 496        }
 497    }
 498
 499    pub fn server(&self) -> Option<&Arc<LanguageServer>> {
 500        match self {
 501            Self::Real(local) => Some(&local.server),
 502            #[cfg(any(test, feature = "test-support"))]
 503            Self::Test(_) => None,
 504        }
 505    }
 506
 507    pub fn is_default(&self) -> bool {
 508        match self {
 509            Self::Real(local) => local.default,
 510            #[cfg(any(test, feature = "test-support"))]
 511            Self::Test(test_prettier) => test_prettier.default,
 512        }
 513    }
 514
 515    pub fn prettier_dir(&self) -> &Path {
 516        match self {
 517            Self::Real(local) => &local.prettier_dir,
 518            #[cfg(any(test, feature = "test-support"))]
 519            Self::Test(test_prettier) => &test_prettier.prettier_dir,
 520        }
 521    }
 522}
 523
 524async fn has_prettier_in_node_modules(fs: &dyn Fs, path: &Path) -> anyhow::Result<bool> {
 525    let possible_node_modules_location = path.join("node_modules").join(PRETTIER_PACKAGE_NAME);
 526    if let Some(node_modules_location_metadata) = fs
 527        .metadata(&possible_node_modules_location)
 528        .await
 529        .with_context(|| format!("fetching metadata for {possible_node_modules_location:?}"))?
 530    {
 531        return Ok(node_modules_location_metadata.is_dir);
 532    }
 533    Ok(false)
 534}
 535
 536async fn read_package_json(
 537    fs: &dyn Fs,
 538    path: &Path,
 539) -> anyhow::Result<Option<HashMap<String, serde_json::Value>>> {
 540    let possible_package_json = path.join("package.json");
 541    if let Some(package_json_metadata) = fs
 542        .metadata(&possible_package_json)
 543        .await
 544        .with_context(|| format!("fetching metadata for package json {possible_package_json:?}"))?
 545    {
 546        if !package_json_metadata.is_dir && !package_json_metadata.is_symlink {
 547            let package_json_contents = fs
 548                .load(&possible_package_json)
 549                .await
 550                .with_context(|| format!("reading {possible_package_json:?} file contents"))?;
 551            return serde_json::from_str::<HashMap<String, serde_json::Value>>(
 552                &package_json_contents,
 553            )
 554            .map(Some)
 555            .with_context(|| format!("parsing {possible_package_json:?} file contents"));
 556        }
 557    }
 558    Ok(None)
 559}
 560
 561enum Format {}
 562
 563#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
 564#[serde(rename_all = "camelCase")]
 565struct FormatParams {
 566    text: String,
 567    options: FormatOptions,
 568}
 569
 570#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
 571#[serde(rename_all = "camelCase")]
 572struct FormatOptions {
 573    plugins: Vec<PathBuf>,
 574    parser: Option<String>,
 575    #[serde(rename = "filepath")]
 576    path: Option<PathBuf>,
 577    prettier_options: Option<HashMap<String, serde_json::Value>>,
 578    ignore_path: Option<PathBuf>,
 579}
 580
 581#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
 582#[serde(rename_all = "camelCase")]
 583struct FormatResult {
 584    text: String,
 585}
 586
 587impl lsp::request::Request for Format {
 588    type Params = FormatParams;
 589    type Result = FormatResult;
 590    const METHOD: &'static str = "prettier/format";
 591}
 592
 593enum ClearCache {}
 594
 595impl lsp::request::Request for ClearCache {
 596    type Params = ();
 597    type Result = ();
 598    const METHOD: &'static str = "prettier/clear_cache";
 599}
 600
 601#[cfg(test)]
 602mod tests {
 603    use fs::FakeFs;
 604    use serde_json::json;
 605
 606    use super::*;
 607
 608    #[gpui::test]
 609    async fn test_prettier_lookup_finds_nothing(cx: &mut gpui::TestAppContext) {
 610        let fs = FakeFs::new(cx.executor());
 611        fs.insert_tree(
 612            "/root",
 613            json!({
 614                ".config": {
 615                    "zed": {
 616                        "settings.json": r#"{ "formatter": "auto" }"#,
 617                    },
 618                },
 619                "work": {
 620                    "project": {
 621                        "src": {
 622                            "index.js": "// index.js file contents",
 623                        },
 624                        "node_modules": {
 625                            "expect": {
 626                                "build": {
 627                                    "print.js": "// print.js file contents",
 628                                },
 629                                "package.json": r#"{
 630                                    "devDependencies": {
 631                                        "prettier": "2.5.1"
 632                                    }
 633                                }"#,
 634                            },
 635                            "prettier": {
 636                                "index.js": "// Dummy prettier package file",
 637                            },
 638                        },
 639                        "package.json": r#"{}"#
 640                    },
 641                }
 642            }),
 643        )
 644        .await;
 645
 646        assert_eq!(
 647            Prettier::locate_prettier_installation(
 648                fs.as_ref(),
 649                &HashSet::default(),
 650                Path::new("/root/.config/zed/settings.json"),
 651            )
 652            .await
 653            .unwrap(),
 654            ControlFlow::Continue(None),
 655            "Should find no prettier for path hierarchy without it"
 656        );
 657        assert_eq!(
 658            Prettier::locate_prettier_installation(
 659                fs.as_ref(),
 660                &HashSet::default(),
 661                Path::new("/root/work/project/src/index.js")
 662            )
 663            .await
 664            .unwrap(),
 665            ControlFlow::Continue(Some(PathBuf::from("/root/work/project"))),
 666            "Should successfully find a prettier for path hierarchy that has node_modules with prettier, but no package.json mentions of it"
 667        );
 668        assert_eq!(
 669            Prettier::locate_prettier_installation(
 670                fs.as_ref(),
 671                &HashSet::default(),
 672                Path::new("/root/work/project/node_modules/expect/build/print.js")
 673            )
 674            .await
 675            .unwrap(),
 676            ControlFlow::Break(()),
 677            "Should not format files inside node_modules/"
 678        );
 679    }
 680
 681    #[gpui::test]
 682    async fn test_prettier_lookup_in_simple_npm_projects(cx: &mut gpui::TestAppContext) {
 683        let fs = FakeFs::new(cx.executor());
 684        fs.insert_tree(
 685            "/root",
 686            json!({
 687                "web_blog": {
 688                    "node_modules": {
 689                        "prettier": {
 690                            "index.js": "// Dummy prettier package file",
 691                        },
 692                        "expect": {
 693                            "build": {
 694                                "print.js": "// print.js file contents",
 695                            },
 696                            "package.json": r#"{
 697                                "devDependencies": {
 698                                    "prettier": "2.5.1"
 699                                }
 700                            }"#,
 701                        },
 702                    },
 703                    "pages": {
 704                        "[slug].tsx": "// [slug].tsx file contents",
 705                    },
 706                    "package.json": r#"{
 707                        "devDependencies": {
 708                            "prettier": "2.3.0"
 709                        },
 710                        "prettier": {
 711                            "semi": false,
 712                            "printWidth": 80,
 713                            "htmlWhitespaceSensitivity": "strict",
 714                            "tabWidth": 4
 715                        }
 716                    }"#
 717                }
 718            }),
 719        )
 720        .await;
 721
 722        assert_eq!(
 723            Prettier::locate_prettier_installation(
 724                fs.as_ref(),
 725                &HashSet::default(),
 726                Path::new("/root/web_blog/pages/[slug].tsx")
 727            )
 728            .await
 729            .unwrap(),
 730            ControlFlow::Continue(Some(PathBuf::from("/root/web_blog"))),
 731            "Should find a preinstalled prettier in the project root"
 732        );
 733        assert_eq!(
 734            Prettier::locate_prettier_installation(
 735                fs.as_ref(),
 736                &HashSet::default(),
 737                Path::new("/root/web_blog/node_modules/expect/build/print.js")
 738            )
 739            .await
 740            .unwrap(),
 741            ControlFlow::Break(()),
 742            "Should not allow formatting node_modules/ contents"
 743        );
 744    }
 745
 746    #[gpui::test]
 747    async fn test_prettier_lookup_for_not_installed(cx: &mut gpui::TestAppContext) {
 748        let fs = FakeFs::new(cx.executor());
 749        fs.insert_tree(
 750            "/root",
 751            json!({
 752                "work": {
 753                    "web_blog": {
 754                        "node_modules": {
 755                            "expect": {
 756                                "build": {
 757                                    "print.js": "// print.js file contents",
 758                                },
 759                                "package.json": r#"{
 760                                    "devDependencies": {
 761                                        "prettier": "2.5.1"
 762                                    }
 763                                }"#,
 764                            },
 765                        },
 766                        "pages": {
 767                            "[slug].tsx": "// [slug].tsx file contents",
 768                        },
 769                        "package.json": r#"{
 770                            "devDependencies": {
 771                                "prettier": "2.3.0"
 772                            },
 773                            "prettier": {
 774                                "semi": false,
 775                                "printWidth": 80,
 776                                "htmlWhitespaceSensitivity": "strict",
 777                                "tabWidth": 4
 778                            }
 779                        }"#
 780                    }
 781                }
 782            }),
 783        )
 784        .await;
 785
 786        assert_eq!(
 787            Prettier::locate_prettier_installation(
 788                fs.as_ref(),
 789                &HashSet::default(),
 790                Path::new("/root/work/web_blog/pages/[slug].tsx")
 791            )
 792            .await
 793            .unwrap(),
 794            ControlFlow::Continue(None),
 795            "Should find no prettier when node_modules don't have it"
 796        );
 797
 798        assert_eq!(
 799            Prettier::locate_prettier_installation(
 800                fs.as_ref(),
 801                &HashSet::from_iter(
 802                    [PathBuf::from("/root"), PathBuf::from("/root/work")].into_iter()
 803                ),
 804                Path::new("/root/work/web_blog/pages/[slug].tsx")
 805            )
 806            .await
 807            .unwrap(),
 808            ControlFlow::Continue(Some(PathBuf::from("/root/work"))),
 809            "Should return closest cached value found without path checks"
 810        );
 811
 812        assert_eq!(
 813            Prettier::locate_prettier_installation(
 814                fs.as_ref(),
 815                &HashSet::default(),
 816                Path::new("/root/work/web_blog/node_modules/expect/build/print.js")
 817            )
 818            .await
 819            .unwrap(),
 820            ControlFlow::Break(()),
 821            "Should not allow formatting files inside node_modules/"
 822        );
 823        assert_eq!(
 824            Prettier::locate_prettier_installation(
 825                fs.as_ref(),
 826                &HashSet::from_iter(
 827                    [PathBuf::from("/root"), PathBuf::from("/root/work")].into_iter()
 828                ),
 829                Path::new("/root/work/web_blog/node_modules/expect/build/print.js")
 830            )
 831            .await
 832            .unwrap(),
 833            ControlFlow::Break(()),
 834            "Should ignore cache lookup for files inside node_modules/"
 835        );
 836    }
 837
 838    #[gpui::test]
 839    async fn test_prettier_lookup_in_npm_workspaces(cx: &mut gpui::TestAppContext) {
 840        let fs = FakeFs::new(cx.executor());
 841        fs.insert_tree(
 842            "/root",
 843            json!({
 844                "work": {
 845                    "full-stack-foundations": {
 846                        "exercises": {
 847                            "03.loading": {
 848                                "01.problem.loader": {
 849                                    "app": {
 850                                        "routes": {
 851                                            "users+": {
 852                                                "$username_+": {
 853                                                    "notes.tsx": "// notes.tsx file contents",
 854                                                },
 855                                            },
 856                                        },
 857                                    },
 858                                    "node_modules": {
 859                                        "test.js": "// test.js contents",
 860                                    },
 861                                    "package.json": r#"{
 862                                        "devDependencies": {
 863                                            "prettier": "^3.0.3"
 864                                        }
 865                                    }"#
 866                                },
 867                            },
 868                        },
 869                        "package.json": r#"{
 870                            "workspaces": ["exercises/*/*", "examples/*"]
 871                        }"#,
 872                        "node_modules": {
 873                            "prettier": {
 874                                "index.js": "// Dummy prettier package file",
 875                            },
 876                        },
 877                    },
 878                }
 879            }),
 880        )
 881        .await;
 882
 883        assert_eq!(
 884            Prettier::locate_prettier_installation(
 885                fs.as_ref(),
 886                &HashSet::default(),
 887                Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/app/routes/users+/$username_+/notes.tsx"),
 888            ).await.unwrap(),
 889            ControlFlow::Continue(Some(PathBuf::from("/root/work/full-stack-foundations"))),
 890            "Should ascend to the multi-workspace root and find the prettier there",
 891        );
 892
 893        assert_eq!(
 894            Prettier::locate_prettier_installation(
 895                fs.as_ref(),
 896                &HashSet::default(),
 897                Path::new("/root/work/full-stack-foundations/node_modules/prettier/index.js")
 898            )
 899            .await
 900            .unwrap(),
 901            ControlFlow::Break(()),
 902            "Should not allow formatting files inside root node_modules/"
 903        );
 904        assert_eq!(
 905            Prettier::locate_prettier_installation(
 906                fs.as_ref(),
 907                &HashSet::default(),
 908                Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/node_modules/test.js")
 909            )
 910            .await
 911            .unwrap(),
 912            ControlFlow::Break(()),
 913            "Should not allow formatting files inside submodule's node_modules/"
 914        );
 915    }
 916
 917    #[gpui::test]
 918    async fn test_prettier_lookup_in_npm_workspaces_for_not_installed(
 919        cx: &mut gpui::TestAppContext,
 920    ) {
 921        let fs = FakeFs::new(cx.executor());
 922        fs.insert_tree(
 923            "/root",
 924            json!({
 925                "work": {
 926                    "full-stack-foundations": {
 927                        "exercises": {
 928                            "03.loading": {
 929                                "01.problem.loader": {
 930                                    "app": {
 931                                        "routes": {
 932                                            "users+": {
 933                                                "$username_+": {
 934                                                    "notes.tsx": "// notes.tsx file contents",
 935                                                },
 936                                            },
 937                                        },
 938                                    },
 939                                    "node_modules": {},
 940                                    "package.json": r#"{
 941                                        "devDependencies": {
 942                                            "prettier": "^3.0.3"
 943                                        }
 944                                    }"#
 945                                },
 946                            },
 947                        },
 948                        "package.json": r#"{
 949                            "workspaces": ["exercises/*/*", "examples/*"]
 950                        }"#,
 951                    },
 952                }
 953            }),
 954        )
 955        .await;
 956
 957        match Prettier::locate_prettier_installation(
 958            fs.as_ref(),
 959            &HashSet::default(),
 960            Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/app/routes/users+/$username_+/notes.tsx")
 961        )
 962        .await {
 963            Ok(path) => panic!("Expected to fail for prettier in package.json but not in node_modules found, but got path {path:?}"),
 964            Err(e) => {
 965                let message = e.to_string().replace("\\\\", "/");
 966                assert!(message.contains("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader"), "Error message should mention which project had prettier defined");
 967                assert!(message.contains("/root/work/full-stack-foundations"), "Error message should mention potential candidates without prettier node_modules contents");
 968            },
 969        };
 970    }
 971
 972    #[gpui::test]
 973    async fn test_prettier_ignore_with_editor_prettier(cx: &mut gpui::TestAppContext) {
 974        let fs = FakeFs::new(cx.executor());
 975        fs.insert_tree(
 976            "/root",
 977            json!({
 978                "project": {
 979                    "src": {
 980                        "index.js": "// index.js file contents",
 981                        "ignored.js": "// this file should be ignored",
 982                    },
 983                    ".prettierignore": "ignored.js",
 984                    "package.json": r#"{
 985                        "name": "test-project"
 986                    }"#
 987                }
 988            }),
 989        )
 990        .await;
 991
 992        assert_eq!(
 993            Prettier::locate_prettier_ignore(
 994                fs.as_ref(),
 995                &HashSet::default(),
 996                Path::new("/root/project/src/index.js"),
 997            )
 998            .await
 999            .unwrap(),
1000            ControlFlow::Continue(Some(PathBuf::from("/root/project"))),
1001            "Should find prettierignore in project root"
1002        );
1003    }
1004
1005    #[gpui::test]
1006    async fn test_prettier_ignore_in_monorepo_with_only_child_ignore(
1007        cx: &mut gpui::TestAppContext,
1008    ) {
1009        let fs = FakeFs::new(cx.executor());
1010        fs.insert_tree(
1011            "/root",
1012            json!({
1013                "monorepo": {
1014                    "node_modules": {
1015                        "prettier": {
1016                            "index.js": "// Dummy prettier package file",
1017                        }
1018                    },
1019                    "packages": {
1020                        "web": {
1021                            "src": {
1022                                "index.js": "// index.js contents",
1023                                "ignored.js": "// this should be ignored",
1024                            },
1025                            ".prettierignore": "ignored.js",
1026                            "package.json": r#"{
1027                                "name": "web-package"
1028                            }"#
1029                        }
1030                    },
1031                    "package.json": r#"{
1032                        "workspaces": ["packages/*"],
1033                        "devDependencies": {
1034                            "prettier": "^2.0.0"
1035                        }
1036                    }"#
1037                }
1038            }),
1039        )
1040        .await;
1041
1042        assert_eq!(
1043            Prettier::locate_prettier_ignore(
1044                fs.as_ref(),
1045                &HashSet::default(),
1046                Path::new("/root/monorepo/packages/web/src/index.js"),
1047            )
1048            .await
1049            .unwrap(),
1050            ControlFlow::Continue(Some(PathBuf::from("/root/monorepo/packages/web"))),
1051            "Should find prettierignore in child package"
1052        );
1053    }
1054
1055    #[gpui::test]
1056    async fn test_prettier_ignore_in_monorepo_with_root_and_child_ignores(
1057        cx: &mut gpui::TestAppContext,
1058    ) {
1059        let fs = FakeFs::new(cx.executor());
1060        fs.insert_tree(
1061            "/root",
1062            json!({
1063                "monorepo": {
1064                    "node_modules": {
1065                        "prettier": {
1066                            "index.js": "// Dummy prettier package file",
1067                        }
1068                    },
1069                    ".prettierignore": "main.js",
1070                    "packages": {
1071                        "web": {
1072                            "src": {
1073                                "main.js": "// this should not be ignored",
1074                                "ignored.js": "// this should be ignored",
1075                            },
1076                            ".prettierignore": "ignored.js",
1077                            "package.json": r#"{
1078                                "name": "web-package"
1079                            }"#
1080                        }
1081                    },
1082                    "package.json": r#"{
1083                        "workspaces": ["packages/*"],
1084                        "devDependencies": {
1085                            "prettier": "^2.0.0"
1086                        }
1087                    }"#
1088                }
1089            }),
1090        )
1091        .await;
1092
1093        assert_eq!(
1094            Prettier::locate_prettier_ignore(
1095                fs.as_ref(),
1096                &HashSet::default(),
1097                Path::new("/root/monorepo/packages/web/src/main.js"),
1098            )
1099            .await
1100            .unwrap(),
1101            ControlFlow::Continue(Some(PathBuf::from("/root/monorepo/packages/web"))),
1102            "Should find child package prettierignore first"
1103        );
1104
1105        assert_eq!(
1106            Prettier::locate_prettier_ignore(
1107                fs.as_ref(),
1108                &HashSet::default(),
1109                Path::new("/root/monorepo/packages/web/src/ignored.js"),
1110            )
1111            .await
1112            .unwrap(),
1113            ControlFlow::Continue(Some(PathBuf::from("/root/monorepo/packages/web"))),
1114            "Should find child package prettierignore first"
1115        );
1116    }
1117}