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