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