prettier.rs

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