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