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