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}