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