prettier.rs

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