prettier.rs

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