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