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