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