prettier.rs

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