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