prettier2.rs

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