prettier2.rs

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