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,
199 )
200 .context("prettier server creation")?;
201 let server = executor
202 .spawn(server.initialize(None))
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 prettier_plugin_dir,
249 ] {
250 if possible_plugin_path.is_file() {
251 return Some(possible_plugin_path);
252 }
253 }
254 None
255 };
256 let (parser, located_plugins) = match parser_with_plugins {
257 Some((parser, plugins)) => {
258 // Tailwind plugin requires being added last
259 // https://github.com/tailwindlabs/prettier-plugin-tailwindcss#compatibility-with-other-prettier-plugins
260 let mut add_tailwind_back = false;
261
262 let mut plugins = plugins
263 .into_iter()
264 .filter(|&&plugin_name| {
265 if plugin_name == TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME {
266 add_tailwind_back = true;
267 false
268 } else {
269 true
270 }
271 })
272 .map(|plugin_name| {
273 (plugin_name, plugin_name_into_path(plugin_name))
274 })
275 .collect::<Vec<_>>();
276 if add_tailwind_back {
277 plugins.push((
278 &TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME,
279 plugin_name_into_path(
280 TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME,
281 ),
282 ));
283 }
284 (Some(parser.to_string()), plugins)
285 }
286 None => (None, Vec::new()),
287 };
288
289 let prettier_options = if self.is_default() {
290 let language_settings =
291 language_settings(buffer_language, buffer.file(), cx);
292 let mut options = language_settings.prettier.clone();
293 if !options.contains_key("tabWidth") {
294 options.insert(
295 "tabWidth".to_string(),
296 serde_json::Value::Number(serde_json::Number::from(
297 language_settings.tab_size.get(),
298 )),
299 );
300 }
301 if !options.contains_key("printWidth") {
302 options.insert(
303 "printWidth".to_string(),
304 serde_json::Value::Number(serde_json::Number::from(
305 language_settings.preferred_line_length,
306 )),
307 );
308 }
309 Some(options)
310 } else {
311 None
312 };
313
314 let plugins = located_plugins
315 .into_iter()
316 .filter_map(|(plugin_name, located_plugin_path)| {
317 match located_plugin_path {
318 Some(path) => Some(path),
319 None => {
320 log::error!(
321 "Have not found plugin path for {:?} inside {:?}",
322 plugin_name,
323 prettier_node_modules
324 );
325 None
326 }
327 }
328 })
329 .collect();
330 log::debug!(
331 "Formatting file {:?} with prettier, plugins :{:?}, options: {:?}",
332 plugins,
333 prettier_options,
334 buffer.file().map(|f| f.full_path(cx))
335 );
336
337 anyhow::Ok(FormatParams {
338 text: buffer.text(),
339 options: FormatOptions {
340 parser,
341 plugins,
342 path: buffer_path,
343 prettier_options,
344 },
345 })
346 })?
347 .context("prettier params calculation")?;
348 let response = local
349 .server
350 .request::<Format>(params)
351 .await
352 .context("prettier format request")?;
353 let diff_task = buffer.update(cx, |buffer, cx| buffer.diff(response.text, cx))?;
354 Ok(diff_task.await)
355 }
356 #[cfg(any(test, feature = "test-support"))]
357 Self::Test(_) => Ok(buffer
358 .update(cx, |buffer, cx| {
359 let formatted_text = buffer.text() + FORMAT_SUFFIX;
360 buffer.diff(formatted_text, cx)
361 })?
362 .await),
363 }
364 }
365
366 pub async fn clear_cache(&self) -> anyhow::Result<()> {
367 match self {
368 Self::Real(local) => local
369 .server
370 .request::<ClearCache>(())
371 .await
372 .context("prettier clear cache"),
373 #[cfg(any(test, feature = "test-support"))]
374 Self::Test(_) => Ok(()),
375 }
376 }
377
378 pub fn server(&self) -> Option<&Arc<LanguageServer>> {
379 match self {
380 Self::Real(local) => Some(&local.server),
381 #[cfg(any(test, feature = "test-support"))]
382 Self::Test(_) => None,
383 }
384 }
385
386 pub fn is_default(&self) -> bool {
387 match self {
388 Self::Real(local) => local.default,
389 #[cfg(any(test, feature = "test-support"))]
390 Self::Test(test_prettier) => test_prettier.default,
391 }
392 }
393
394 pub fn prettier_dir(&self) -> &Path {
395 match self {
396 Self::Real(local) => &local.prettier_dir,
397 #[cfg(any(test, feature = "test-support"))]
398 Self::Test(test_prettier) => &test_prettier.prettier_dir,
399 }
400 }
401}
402
403async fn has_prettier_in_node_modules(fs: &dyn Fs, path: &Path) -> anyhow::Result<bool> {
404 let possible_node_modules_location = path.join("node_modules").join(PRETTIER_PACKAGE_NAME);
405 if let Some(node_modules_location_metadata) = fs
406 .metadata(&possible_node_modules_location)
407 .await
408 .with_context(|| format!("fetching metadata for {possible_node_modules_location:?}"))?
409 {
410 return Ok(node_modules_location_metadata.is_dir);
411 }
412 Ok(false)
413}
414
415async fn read_package_json(
416 fs: &dyn Fs,
417 path: &Path,
418) -> anyhow::Result<Option<HashMap<String, serde_json::Value>>> {
419 let possible_package_json = path.join("package.json");
420 if let Some(package_json_metadata) = fs
421 .metadata(&possible_package_json)
422 .await
423 .with_context(|| format!("fetching metadata for package json {possible_package_json:?}"))?
424 {
425 if !package_json_metadata.is_dir && !package_json_metadata.is_symlink {
426 let package_json_contents = fs
427 .load(&possible_package_json)
428 .await
429 .with_context(|| format!("reading {possible_package_json:?} file contents"))?;
430 return serde_json::from_str::<HashMap<String, serde_json::Value>>(
431 &package_json_contents,
432 )
433 .map(Some)
434 .with_context(|| format!("parsing {possible_package_json:?} file contents"));
435 }
436 }
437 Ok(None)
438}
439
440fn has_prettier_in_package_json(
441 package_json_contents: &HashMap<String, serde_json::Value>,
442) -> bool {
443 if let Some(serde_json::Value::Object(o)) = package_json_contents.get("dependencies") {
444 if o.contains_key(PRETTIER_PACKAGE_NAME) {
445 return true;
446 }
447 }
448 if let Some(serde_json::Value::Object(o)) = package_json_contents.get("devDependencies") {
449 if o.contains_key(PRETTIER_PACKAGE_NAME) {
450 return true;
451 }
452 }
453 false
454}
455
456enum Format {}
457
458#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
459#[serde(rename_all = "camelCase")]
460struct FormatParams {
461 text: String,
462 options: FormatOptions,
463}
464
465#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
466#[serde(rename_all = "camelCase")]
467struct FormatOptions {
468 plugins: Vec<PathBuf>,
469 parser: Option<String>,
470 #[serde(rename = "filepath")]
471 path: Option<PathBuf>,
472 prettier_options: Option<HashMap<String, serde_json::Value>>,
473}
474
475#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
476#[serde(rename_all = "camelCase")]
477struct FormatResult {
478 text: String,
479}
480
481impl lsp::request::Request for Format {
482 type Params = FormatParams;
483 type Result = FormatResult;
484 const METHOD: &'static str = "prettier/format";
485}
486
487enum ClearCache {}
488
489impl lsp::request::Request for ClearCache {
490 type Params = ();
491 type Result = ();
492 const METHOD: &'static str = "prettier/clear_cache";
493}
494
495#[cfg(test)]
496mod tests {
497 use fs::FakeFs;
498 use serde_json::json;
499
500 use super::*;
501
502 #[gpui::test]
503 async fn test_prettier_lookup_finds_nothing(cx: &mut gpui::TestAppContext) {
504 let fs = FakeFs::new(cx.executor());
505 fs.insert_tree(
506 "/root",
507 json!({
508 ".config": {
509 "zed": {
510 "settings.json": r#"{ "formatter": "auto" }"#,
511 },
512 },
513 "work": {
514 "project": {
515 "src": {
516 "index.js": "// index.js file contents",
517 },
518 "node_modules": {
519 "expect": {
520 "build": {
521 "print.js": "// print.js file contents",
522 },
523 "package.json": r#"{
524 "devDependencies": {
525 "prettier": "2.5.1"
526 }
527 }"#,
528 },
529 "prettier": {
530 "index.js": "// Dummy prettier package file",
531 },
532 },
533 "package.json": r#"{}"#
534 },
535 }
536 }),
537 )
538 .await;
539
540 assert!(
541 matches!(
542 Prettier::locate_prettier_installation(
543 fs.as_ref(),
544 &HashSet::default(),
545 Path::new("/root/.config/zed/settings.json"),
546 )
547 .await,
548 Ok(ControlFlow::Continue(None))
549 ),
550 "Should successfully find no prettier for path hierarchy without it"
551 );
552 assert!(
553 matches!(
554 Prettier::locate_prettier_installation(
555 fs.as_ref(),
556 &HashSet::default(),
557 Path::new("/root/work/project/src/index.js")
558 )
559 .await,
560 Ok(ControlFlow::Continue(None))
561 ),
562 "Should successfully find no prettier for path hierarchy that has node_modules with prettier, but no package.json mentions of it"
563 );
564 assert!(
565 matches!(
566 Prettier::locate_prettier_installation(
567 fs.as_ref(),
568 &HashSet::default(),
569 Path::new("/root/work/project/node_modules/expect/build/print.js")
570 )
571 .await,
572 Ok(ControlFlow::Break(()))
573 ),
574 "Should not format files inside node_modules/"
575 );
576 }
577
578 #[gpui::test]
579 async fn test_prettier_lookup_in_simple_npm_projects(cx: &mut gpui::TestAppContext) {
580 let fs = FakeFs::new(cx.executor());
581 fs.insert_tree(
582 "/root",
583 json!({
584 "web_blog": {
585 "node_modules": {
586 "prettier": {
587 "index.js": "// Dummy prettier package file",
588 },
589 "expect": {
590 "build": {
591 "print.js": "// print.js file contents",
592 },
593 "package.json": r#"{
594 "devDependencies": {
595 "prettier": "2.5.1"
596 }
597 }"#,
598 },
599 },
600 "pages": {
601 "[slug].tsx": "// [slug].tsx file contents",
602 },
603 "package.json": r#"{
604 "devDependencies": {
605 "prettier": "2.3.0"
606 },
607 "prettier": {
608 "semi": false,
609 "printWidth": 80,
610 "htmlWhitespaceSensitivity": "strict",
611 "tabWidth": 4
612 }
613 }"#
614 }
615 }),
616 )
617 .await;
618
619 assert_eq!(
620 Prettier::locate_prettier_installation(
621 fs.as_ref(),
622 &HashSet::default(),
623 Path::new("/root/web_blog/pages/[slug].tsx")
624 )
625 .await
626 .unwrap(),
627 ControlFlow::Continue(Some(PathBuf::from("/root/web_blog"))),
628 "Should find a preinstalled prettier in the project root"
629 );
630 assert_eq!(
631 Prettier::locate_prettier_installation(
632 fs.as_ref(),
633 &HashSet::default(),
634 Path::new("/root/web_blog/node_modules/expect/build/print.js")
635 )
636 .await
637 .unwrap(),
638 ControlFlow::Break(()),
639 "Should not allow formatting node_modules/ contents"
640 );
641 }
642
643 #[gpui::test]
644 async fn test_prettier_lookup_for_not_installed(cx: &mut gpui::TestAppContext) {
645 let fs = FakeFs::new(cx.executor());
646 fs.insert_tree(
647 "/root",
648 json!({
649 "work": {
650 "web_blog": {
651 "node_modules": {
652 "expect": {
653 "build": {
654 "print.js": "// print.js file contents",
655 },
656 "package.json": r#"{
657 "devDependencies": {
658 "prettier": "2.5.1"
659 }
660 }"#,
661 },
662 },
663 "pages": {
664 "[slug].tsx": "// [slug].tsx file contents",
665 },
666 "package.json": r#"{
667 "devDependencies": {
668 "prettier": "2.3.0"
669 },
670 "prettier": {
671 "semi": false,
672 "printWidth": 80,
673 "htmlWhitespaceSensitivity": "strict",
674 "tabWidth": 4
675 }
676 }"#
677 }
678 }
679 }),
680 )
681 .await;
682
683 match Prettier::locate_prettier_installation(
684 fs.as_ref(),
685 &HashSet::default(),
686 Path::new("/root/work/web_blog/pages/[slug].tsx")
687 )
688 .await {
689 Ok(path) => panic!("Expected to fail for prettier in package.json but not in node_modules found, but got path {path:?}"),
690 Err(e) => {
691 let message = e.to_string();
692 assert!(message.contains("/root/work/web_blog"), "Error message should mention which project had prettier defined");
693 },
694 };
695
696 assert_eq!(
697 Prettier::locate_prettier_installation(
698 fs.as_ref(),
699 &HashSet::from_iter(
700 [PathBuf::from("/root"), PathBuf::from("/root/work")].into_iter()
701 ),
702 Path::new("/root/work/web_blog/pages/[slug].tsx")
703 )
704 .await
705 .unwrap(),
706 ControlFlow::Continue(Some(PathBuf::from("/root/work"))),
707 "Should return closest cached value found without path checks"
708 );
709
710 assert_eq!(
711 Prettier::locate_prettier_installation(
712 fs.as_ref(),
713 &HashSet::default(),
714 Path::new("/root/work/web_blog/node_modules/expect/build/print.js")
715 )
716 .await
717 .unwrap(),
718 ControlFlow::Break(()),
719 "Should not allow formatting files inside node_modules/"
720 );
721 assert_eq!(
722 Prettier::locate_prettier_installation(
723 fs.as_ref(),
724 &HashSet::from_iter(
725 [PathBuf::from("/root"), PathBuf::from("/root/work")].into_iter()
726 ),
727 Path::new("/root/work/web_blog/node_modules/expect/build/print.js")
728 )
729 .await
730 .unwrap(),
731 ControlFlow::Break(()),
732 "Should ignore cache lookup for files inside node_modules/"
733 );
734 }
735
736 #[gpui::test]
737 async fn test_prettier_lookup_in_npm_workspaces(cx: &mut gpui::TestAppContext) {
738 let fs = FakeFs::new(cx.executor());
739 fs.insert_tree(
740 "/root",
741 json!({
742 "work": {
743 "full-stack-foundations": {
744 "exercises": {
745 "03.loading": {
746 "01.problem.loader": {
747 "app": {
748 "routes": {
749 "users+": {
750 "$username_+": {
751 "notes.tsx": "// notes.tsx file contents",
752 },
753 },
754 },
755 },
756 "node_modules": {
757 "test.js": "// test.js contents",
758 },
759 "package.json": r#"{
760 "devDependencies": {
761 "prettier": "^3.0.3"
762 }
763 }"#
764 },
765 },
766 },
767 "package.json": r#"{
768 "workspaces": ["exercises/*/*", "examples/*"]
769 }"#,
770 "node_modules": {
771 "prettier": {
772 "index.js": "// Dummy prettier package file",
773 },
774 },
775 },
776 }
777 }),
778 )
779 .await;
780
781 assert_eq!(
782 Prettier::locate_prettier_installation(
783 fs.as_ref(),
784 &HashSet::default(),
785 Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/app/routes/users+/$username_+/notes.tsx"),
786 ).await.unwrap(),
787 ControlFlow::Continue(Some(PathBuf::from("/root/work/full-stack-foundations"))),
788 "Should ascend to the multi-workspace root and find the prettier there",
789 );
790
791 assert_eq!(
792 Prettier::locate_prettier_installation(
793 fs.as_ref(),
794 &HashSet::default(),
795 Path::new("/root/work/full-stack-foundations/node_modules/prettier/index.js")
796 )
797 .await
798 .unwrap(),
799 ControlFlow::Break(()),
800 "Should not allow formatting files inside root node_modules/"
801 );
802 assert_eq!(
803 Prettier::locate_prettier_installation(
804 fs.as_ref(),
805 &HashSet::default(),
806 Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/node_modules/test.js")
807 )
808 .await
809 .unwrap(),
810 ControlFlow::Break(()),
811 "Should not allow formatting files inside submodule's node_modules/"
812 );
813 }
814
815 #[gpui::test]
816 async fn test_prettier_lookup_in_npm_workspaces_for_not_installed(
817 cx: &mut gpui::TestAppContext,
818 ) {
819 let fs = FakeFs::new(cx.executor());
820 fs.insert_tree(
821 "/root",
822 json!({
823 "work": {
824 "full-stack-foundations": {
825 "exercises": {
826 "03.loading": {
827 "01.problem.loader": {
828 "app": {
829 "routes": {
830 "users+": {
831 "$username_+": {
832 "notes.tsx": "// notes.tsx file contents",
833 },
834 },
835 },
836 },
837 "node_modules": {},
838 "package.json": r#"{
839 "devDependencies": {
840 "prettier": "^3.0.3"
841 }
842 }"#
843 },
844 },
845 },
846 "package.json": r#"{
847 "workspaces": ["exercises/*/*", "examples/*"]
848 }"#,
849 },
850 }
851 }),
852 )
853 .await;
854
855 match Prettier::locate_prettier_installation(
856 fs.as_ref(),
857 &HashSet::default(),
858 Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/app/routes/users+/$username_+/notes.tsx")
859 )
860 .await {
861 Ok(path) => panic!("Expected to fail for prettier in package.json but not in node_modules found, but got path {path:?}"),
862 Err(e) => {
863 let message = e.to_string();
864 assert!(message.contains("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader"), "Error message should mention which project had prettier defined");
865 assert!(message.contains("/root/work/full-stack-foundations"), "Error message should mention potential candidates without prettier node_modules contents");
866 },
867 };
868 }
869}