prettier.rs

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