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