prettier.rs

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