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