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}