prettier.rs

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