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