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