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