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