1use anyhow::Context as _;
2use collections::{HashMap, HashSet};
3use fs::Fs;
4use gpui::{AsyncApp, Entity};
5use language::language_settings::PrettierSettings;
6use language::{Buffer, Diff, Language, language_settings::language_settings};
7use lsp::{LanguageServer, LanguageServerId};
8use node_runtime::NodeRuntime;
9use paths::default_prettier_dir;
10use serde::{Deserialize, Serialize};
11use std::{
12 ops::ControlFlow,
13 path::{Path, PathBuf},
14 sync::Arc,
15};
16use util::{
17 paths::{PathMatcher, PathStyle},
18 rel_path::RelPath,
19};
20
21#[derive(Debug, Clone)]
22pub enum Prettier {
23 Real(RealPrettier),
24 #[cfg(any(test, feature = "test-support"))]
25 Test(TestPrettier),
26}
27
28#[derive(Debug, Clone)]
29pub struct RealPrettier {
30 default: bool,
31 prettier_dir: PathBuf,
32 server: Arc<LanguageServer>,
33}
34
35#[cfg(any(test, feature = "test-support"))]
36#[derive(Debug, Clone)]
37pub struct TestPrettier {
38 prettier_dir: PathBuf,
39 default: bool,
40}
41
42pub const FAIL_THRESHOLD: usize = 4;
43pub const PRETTIER_SERVER_FILE: &str = "prettier_server.js";
44pub const PRETTIER_SERVER_JS: &str = include_str!("./prettier_server.js");
45const PRETTIER_PACKAGE_NAME: &str = "prettier";
46const TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME: &str = "prettier-plugin-tailwindcss";
47
48#[cfg(any(test, feature = "test-support"))]
49pub const FORMAT_SUFFIX: &str = "\nformatted by test prettier";
50
51impl Prettier {
52 pub const CONFIG_FILE_NAMES: &'static [&'static str] = &[
53 ".prettierrc",
54 ".prettierrc.json",
55 ".prettierrc.json5",
56 ".prettierrc.yaml",
57 ".prettierrc.yml",
58 ".prettierrc.toml",
59 ".prettierrc.js",
60 ".prettierrc.cjs",
61 ".prettierrc.mjs",
62 ".prettierrc.ts",
63 ".prettierrc.cts",
64 ".prettierrc.mts",
65 "package.json",
66 "prettier.config.js",
67 "prettier.config.cjs",
68 "prettier.config.mjs",
69 "prettier.config.ts",
70 "prettier.config.cts",
71 "prettier.config.mts",
72 ".editorconfig",
73 ".prettierignore",
74 ];
75
76 pub async fn locate_prettier_installation(
77 fs: &dyn Fs,
78 installed_prettiers: &HashSet<PathBuf>,
79 locate_from: &Path,
80 ) -> anyhow::Result<ControlFlow<(), Option<PathBuf>>> {
81 let mut path_to_check = locate_from
82 .components()
83 .take_while(|component| component.as_os_str().to_string_lossy() != "node_modules")
84 .collect::<PathBuf>();
85 if path_to_check != locate_from {
86 log::debug!(
87 "Skipping prettier location for path {path_to_check:?} that is inside node_modules"
88 );
89 return Ok(ControlFlow::Break(()));
90 }
91 let path_to_check_metadata = fs
92 .metadata(&path_to_check)
93 .await
94 .with_context(|| format!("failed to get metadata for initial path {path_to_check:?}"))?
95 .with_context(|| format!("empty metadata for initial path {path_to_check:?}"))?;
96 if !path_to_check_metadata.is_dir {
97 path_to_check.pop();
98 }
99
100 let mut closest_package_json_path = None;
101 loop {
102 if installed_prettiers.contains(&path_to_check) {
103 log::debug!("Found prettier path {path_to_check:?} in installed prettiers");
104 return Ok(ControlFlow::Continue(Some(path_to_check)));
105 } else if let Some(package_json_contents) =
106 read_package_json(fs, &path_to_check).await?
107 {
108 if has_prettier_in_node_modules(fs, &path_to_check).await? {
109 log::debug!("Found prettier path {path_to_check:?} in the node_modules");
110 return Ok(ControlFlow::Continue(Some(path_to_check)));
111 } else {
112 match &closest_package_json_path {
113 None => closest_package_json_path = Some(path_to_check.clone()),
114 Some(closest_package_json_path) => {
115 match package_json_contents.get("workspaces") {
116 Some(serde_json::Value::Array(workspaces)) => {
117 let subproject_path = closest_package_json_path.strip_prefix(&path_to_check).expect("traversing path parents, should be able to strip prefix");
118 if workspaces.iter().filter_map(|value| {
119 if let serde_json::Value::String(s) = value {
120 Some(s.clone())
121 } else {
122 log::warn!("Skipping non-string 'workspaces' value: {value:?}");
123 None
124 }
125 }).any(|workspace_definition| {
126 workspace_definition == subproject_path.to_string_lossy() || PathMatcher::new(&[workspace_definition], PathStyle::local()).ok().is_some_and(
127 |path_matcher| RelPath::new(subproject_path, PathStyle::local()).is_ok_and(|path| path_matcher.is_match(path)))
128 }) {
129 anyhow::ensure!(has_prettier_in_node_modules(fs, &path_to_check).await?,
130 "Path {path_to_check:?} is the workspace root for project in \
131 {closest_package_json_path:?}, but it has no prettier installed"
132 );
133 log::info!(
134 "Found prettier path {path_to_check:?} in the workspace \
135 root for project in {closest_package_json_path:?}"
136 );
137 return Ok(ControlFlow::Continue(Some(path_to_check)));
138 } else {
139 log::warn!(
140 "Skipping path {path_to_check:?} workspace root with \
141 workspaces {workspaces:?} that have no prettier installed"
142 );
143 }
144 }
145 Some(unknown) => log::error!(
146 "Failed to parse workspaces for {path_to_check:?} from package.json, \
147 got {unknown:?}. Skipping."
148 ),
149 None => log::warn!(
150 "Skipping path {path_to_check:?} that has no prettier \
151 dependency and no workspaces section in its package.json"
152 ),
153 }
154 }
155 }
156 }
157 }
158
159 if !path_to_check.pop() {
160 log::debug!("Found no prettier in ancestors of {locate_from:?}");
161 return Ok(ControlFlow::Continue(None));
162 }
163 }
164 }
165
166 pub async fn locate_prettier_ignore(
167 fs: &dyn Fs,
168 prettier_ignores: &HashSet<PathBuf>,
169 locate_from: &Path,
170 ) -> anyhow::Result<ControlFlow<(), Option<PathBuf>>> {
171 let mut path_to_check = locate_from
172 .components()
173 .take_while(|component| component.as_os_str().to_string_lossy() != "node_modules")
174 .collect::<PathBuf>();
175 if path_to_check != locate_from {
176 log::debug!(
177 "Skipping prettier ignore location for path {path_to_check:?} that is inside node_modules"
178 );
179 return Ok(ControlFlow::Break(()));
180 }
181
182 let path_to_check_metadata = fs
183 .metadata(&path_to_check)
184 .await
185 .with_context(|| format!("failed to get metadata for initial path {path_to_check:?}"))?
186 .with_context(|| format!("empty metadata for initial path {path_to_check:?}"))?;
187 if !path_to_check_metadata.is_dir {
188 path_to_check.pop();
189 }
190
191 let mut closest_package_json_path = None;
192 loop {
193 if prettier_ignores.contains(&path_to_check) {
194 log::debug!("Found prettier ignore at {path_to_check:?}");
195 return Ok(ControlFlow::Continue(Some(path_to_check)));
196 } else if let Some(package_json_contents) =
197 read_package_json(fs, &path_to_check).await?
198 {
199 let ignore_path = path_to_check.join(".prettierignore");
200 if let Some(metadata) = fs
201 .metadata(&ignore_path)
202 .await
203 .with_context(|| format!("fetching metadata for {ignore_path:?}"))?
204 && !metadata.is_dir
205 && !metadata.is_symlink
206 {
207 log::info!("Found prettier ignore at {ignore_path:?}");
208 return Ok(ControlFlow::Continue(Some(path_to_check)));
209 }
210 match &closest_package_json_path {
211 None => closest_package_json_path = Some(path_to_check.clone()),
212 Some(closest_package_json_path) => {
213 if let Some(serde_json::Value::Array(workspaces)) =
214 package_json_contents.get("workspaces")
215 {
216 let subproject_path = closest_package_json_path
217 .strip_prefix(&path_to_check)
218 .expect("traversing path parents, should be able to strip prefix");
219
220 if workspaces
221 .iter()
222 .filter_map(|value| {
223 if let serde_json::Value::String(s) = value {
224 Some(s.clone())
225 } else {
226 log::warn!(
227 "Skipping non-string 'workspaces' value: {value:?}"
228 );
229 None
230 }
231 })
232 .any(|workspace_definition| {
233 workspace_definition == subproject_path.to_string_lossy()
234 || PathMatcher::new(
235 &[workspace_definition],
236 PathStyle::local(),
237 )
238 .ok()
239 .is_some_and(
240 |path_matcher| {
241 RelPath::new(subproject_path, PathStyle::local())
242 .is_ok_and(|rel_path| {
243 path_matcher.is_match(rel_path)
244 })
245 },
246 )
247 })
248 {
249 let workspace_ignore = path_to_check.join(".prettierignore");
250 if let Some(metadata) = fs.metadata(&workspace_ignore).await?
251 && !metadata.is_dir
252 {
253 log::info!(
254 "Found prettier ignore at workspace root {workspace_ignore:?}"
255 );
256 return Ok(ControlFlow::Continue(Some(path_to_check)));
257 }
258 }
259 }
260 }
261 }
262 }
263
264 if !path_to_check.pop() {
265 log::debug!("Found no prettier ignore in ancestors of {locate_from:?}");
266 return Ok(ControlFlow::Continue(None));
267 }
268 }
269 }
270
271 #[cfg(any(test, feature = "test-support"))]
272 pub async fn start(
273 _: LanguageServerId,
274 prettier_dir: PathBuf,
275 _: NodeRuntime,
276 _: AsyncApp,
277 ) -> anyhow::Result<Self> {
278 Ok(Self::Test(TestPrettier {
279 default: prettier_dir == default_prettier_dir().as_path(),
280 prettier_dir,
281 }))
282 }
283
284 #[cfg(not(any(test, feature = "test-support")))]
285 pub async fn start(
286 server_id: LanguageServerId,
287 prettier_dir: PathBuf,
288 node: NodeRuntime,
289 mut cx: AsyncApp,
290 ) -> anyhow::Result<Self> {
291 use lsp::{LanguageServerBinary, LanguageServerName};
292
293 let executor = cx.background_executor().clone();
294 anyhow::ensure!(
295 prettier_dir.is_dir(),
296 "Prettier dir {prettier_dir:?} is not a directory"
297 );
298 let prettier_server = default_prettier_dir().join(PRETTIER_SERVER_FILE);
299 anyhow::ensure!(
300 prettier_server.is_file(),
301 "no prettier server package found at {prettier_server:?}"
302 );
303
304 let node_path = executor
305 .spawn(async move { node.binary_path().await })
306 .await?;
307 let server_name = LanguageServerName("prettier".into());
308 let server_binary = LanguageServerBinary {
309 path: node_path,
310 arguments: vec![prettier_server.into(), prettier_dir.as_path().into()],
311 env: None,
312 };
313 let server = LanguageServer::new(
314 Arc::new(parking_lot::Mutex::new(None)),
315 server_id,
316 server_name,
317 server_binary,
318 &prettier_dir,
319 None,
320 Default::default(),
321 &mut cx,
322 )
323 .context("prettier server creation")?;
324
325 let server = cx
326 .update(|cx| {
327 let params = server.default_initialize_params(false, cx);
328 let configuration = lsp::DidChangeConfigurationParams {
329 settings: Default::default(),
330 };
331 executor.spawn(server.initialize(params, configuration.into(), cx))
332 })?
333 .await
334 .context("prettier server initialization")?;
335 Ok(Self::Real(RealPrettier {
336 server,
337 default: prettier_dir == default_prettier_dir().as_path(),
338 prettier_dir,
339 }))
340 }
341
342 pub async fn format(
343 &self,
344 buffer: &Entity<Buffer>,
345 buffer_path: Option<PathBuf>,
346 ignore_dir: Option<PathBuf>,
347 cx: &mut AsyncApp,
348 ) -> anyhow::Result<Diff> {
349 match self {
350 Self::Real(local) => {
351 let params = buffer
352 .update(cx, |buffer, cx| {
353 let buffer_language = buffer.language().map(|language| language.as_ref());
354 let language_settings = language_settings(buffer_language.map(|l| l.name()), buffer.file(), cx);
355 let prettier_settings = &language_settings.prettier;
356 anyhow::ensure!(
357 prettier_settings.allowed,
358 "Cannot format: prettier is not allowed for language {buffer_language:?}"
359 );
360 let prettier_node_modules = self.prettier_dir().join("node_modules");
361 anyhow::ensure!(
362 prettier_node_modules.is_dir(),
363 "Prettier node_modules dir does not exist: {prettier_node_modules:?}"
364 );
365 let plugin_name_into_path = |plugin_name: &str| {
366 let prettier_plugin_dir = prettier_node_modules.join(plugin_name);
367 [
368 prettier_plugin_dir.join("dist").join("index.mjs"),
369 prettier_plugin_dir.join("dist").join("index.js"),
370 prettier_plugin_dir.join("dist").join("plugin.js"),
371 prettier_plugin_dir.join("src").join("plugin.js"),
372 prettier_plugin_dir.join("lib").join("index.js"),
373 prettier_plugin_dir.join("index.mjs"),
374 prettier_plugin_dir.join("index.js"),
375 prettier_plugin_dir.join("plugin.js"),
376 // this one is for @prettier/plugin-php
377 prettier_plugin_dir.join("standalone.js"),
378 // this one is for prettier-plugin-latex
379 prettier_plugin_dir.join("dist").join("prettier-plugin-latex.js"),
380 prettier_plugin_dir,
381 ]
382 .into_iter()
383 .find(|possible_plugin_path| possible_plugin_path.is_file())
384 };
385
386 // Tailwind plugin requires being added last
387 // https://github.com/tailwindlabs/prettier-plugin-tailwindcss#compatibility-with-other-prettier-plugins
388 let mut add_tailwind_back = false;
389
390 let mut located_plugins = prettier_settings.plugins.iter()
391 .filter(|plugin_name| {
392 if plugin_name.as_str() == TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME {
393 add_tailwind_back = true;
394 false
395 } else {
396 true
397 }
398 })
399 .map(|plugin_name| {
400 let plugin_path = plugin_name_into_path(plugin_name);
401 (plugin_name.clone(), plugin_path)
402 })
403 .collect::<Vec<_>>();
404 if add_tailwind_back {
405 located_plugins.push((
406 TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME.to_owned(),
407 plugin_name_into_path(TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME),
408 ));
409 }
410
411 let prettier_options = if self.is_default() {
412 let mut options = prettier_settings.options.clone();
413 if !options.contains_key("tabWidth") {
414 options.insert(
415 "tabWidth".to_string(),
416 serde_json::Value::Number(serde_json::Number::from(
417 language_settings.tab_size.get(),
418 )),
419 );
420 }
421 if !options.contains_key("printWidth") {
422 options.insert(
423 "printWidth".to_string(),
424 serde_json::Value::Number(serde_json::Number::from(
425 language_settings.preferred_line_length,
426 )),
427 );
428 }
429 if !options.contains_key("useTabs") {
430 options.insert(
431 "useTabs".to_string(),
432 serde_json::Value::Bool(language_settings.hard_tabs),
433 );
434 }
435 Some(options)
436 } else {
437 None
438 };
439
440 let plugins = located_plugins
441 .into_iter()
442 .filter_map(|(plugin_name, located_plugin_path)| {
443 match located_plugin_path {
444 Some(path) => Some(path),
445 None => {
446 log::error!("Have not found plugin path for {plugin_name:?} inside {prettier_node_modules:?}");
447 None
448 }
449 }
450 })
451 .collect();
452
453 let parser = prettier_parser_name(buffer_path.as_deref(), buffer_language, prettier_settings).context("getting prettier parser")?;
454
455 let ignore_path = ignore_dir.and_then(|dir| {
456 let ignore_file = dir.join(".prettierignore");
457 ignore_file.is_file().then_some(ignore_file)
458 });
459
460 log::debug!(
461 "Formatting file {:?} with prettier, plugins :{:?}, options: {:?}, ignore_path: {:?}",
462 buffer.file().map(|f| f.full_path(cx)),
463 plugins,
464 prettier_options,
465 ignore_path,
466 );
467
468 anyhow::Ok(FormatParams {
469 text: buffer.text(),
470 options: FormatOptions {
471 path: buffer_path,
472 parser,
473 plugins,
474 prettier_options,
475 ignore_path,
476 },
477 })
478 })?
479 .context("building prettier request")?;
480
481 let response = local
482 .server
483 .request::<Format>(params)
484 .await
485 .into_response()?;
486 let diff_task = buffer.update(cx, |buffer, cx| buffer.diff(response.text, cx))?;
487 Ok(diff_task.await)
488 }
489 #[cfg(any(test, feature = "test-support"))]
490 Self::Test(_) => Ok(buffer
491 .update(cx, |buffer, cx| {
492 match buffer
493 .language()
494 .map(|language| language.lsp_id())
495 .as_deref()
496 {
497 Some("rust") => anyhow::bail!("prettier does not support Rust"),
498 Some(_other) => {
499 let mut formatted_text = buffer.text() + FORMAT_SUFFIX;
500
501 let buffer_language =
502 buffer.language().map(|language| language.as_ref());
503 let language_settings = language_settings(
504 buffer_language.map(|l| l.name()),
505 buffer.file(),
506 cx,
507 );
508 let prettier_settings = &language_settings.prettier;
509 let parser = prettier_parser_name(
510 buffer_path.as_deref(),
511 buffer_language,
512 prettier_settings,
513 )?;
514
515 if let Some(parser) = parser {
516 formatted_text = format!("{formatted_text}\n{parser}");
517 }
518
519 Ok(buffer.diff(formatted_text, cx))
520 }
521 None => panic!("Should not format buffer without a language with prettier"),
522 }
523 })??
524 .await),
525 }
526 }
527
528 pub async fn clear_cache(&self) -> anyhow::Result<()> {
529 match self {
530 Self::Real(local) => local
531 .server
532 .request::<ClearCache>(())
533 .await
534 .into_response()
535 .context("prettier clear cache"),
536 #[cfg(any(test, feature = "test-support"))]
537 Self::Test(_) => Ok(()),
538 }
539 }
540
541 pub fn server(&self) -> Option<&Arc<LanguageServer>> {
542 match self {
543 Self::Real(local) => Some(&local.server),
544 #[cfg(any(test, feature = "test-support"))]
545 Self::Test(_) => None,
546 }
547 }
548
549 pub fn is_default(&self) -> bool {
550 match self {
551 Self::Real(local) => local.default,
552 #[cfg(any(test, feature = "test-support"))]
553 Self::Test(test_prettier) => test_prettier.default,
554 }
555 }
556
557 pub fn prettier_dir(&self) -> &Path {
558 match self {
559 Self::Real(local) => &local.prettier_dir,
560 #[cfg(any(test, feature = "test-support"))]
561 Self::Test(test_prettier) => &test_prettier.prettier_dir,
562 }
563 }
564}
565
566fn prettier_parser_name(
567 buffer_path: Option<&Path>,
568 buffer_language: Option<&Language>,
569 prettier_settings: &PrettierSettings,
570) -> anyhow::Result<Option<String>> {
571 let parser = if buffer_path.is_none() {
572 let parser = prettier_settings
573 .parser
574 .as_deref()
575 .or_else(|| buffer_language.and_then(|language| language.prettier_parser_name()));
576 if parser.is_none() {
577 log::error!(
578 "Formatting unsaved file with prettier failed. No prettier parser configured for language {buffer_language:?}"
579 );
580 anyhow::bail!("Cannot determine prettier parser for unsaved file");
581 }
582 parser
583 } else if let (Some(buffer_language), Some(buffer_path)) = (buffer_language, buffer_path)
584 && buffer_path.extension().is_some_and(|extension| {
585 !buffer_language
586 .config()
587 .matcher
588 .path_suffixes
589 .contains(&extension.to_string_lossy().into_owned())
590 })
591 {
592 buffer_language.prettier_parser_name()
593 } else {
594 prettier_settings.parser.as_deref()
595 };
596
597 Ok(parser.map(ToOwned::to_owned))
598}
599
600async fn has_prettier_in_node_modules(fs: &dyn Fs, path: &Path) -> anyhow::Result<bool> {
601 let possible_node_modules_location = path.join("node_modules").join(PRETTIER_PACKAGE_NAME);
602 if let Some(node_modules_location_metadata) = fs
603 .metadata(&possible_node_modules_location)
604 .await
605 .with_context(|| format!("fetching metadata for {possible_node_modules_location:?}"))?
606 {
607 return Ok(node_modules_location_metadata.is_dir);
608 }
609 Ok(false)
610}
611
612async fn read_package_json(
613 fs: &dyn Fs,
614 path: &Path,
615) -> anyhow::Result<Option<HashMap<String, serde_json::Value>>> {
616 let possible_package_json = path.join("package.json");
617 if let Some(package_json_metadata) = fs
618 .metadata(&possible_package_json)
619 .await
620 .with_context(|| format!("fetching metadata for package json {possible_package_json:?}"))?
621 && !package_json_metadata.is_dir
622 && !package_json_metadata.is_symlink
623 {
624 let package_json_contents = fs
625 .load(&possible_package_json)
626 .await
627 .with_context(|| format!("reading {possible_package_json:?} file contents"))?;
628 return serde_json::from_str::<HashMap<String, serde_json::Value>>(&package_json_contents)
629 .map(Some)
630 .with_context(|| format!("parsing {possible_package_json:?} file contents"));
631 }
632 Ok(None)
633}
634
635enum Format {}
636
637#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
638#[serde(rename_all = "camelCase")]
639struct FormatParams {
640 text: String,
641 options: FormatOptions,
642}
643
644#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
645#[serde(rename_all = "camelCase")]
646struct FormatOptions {
647 plugins: Vec<PathBuf>,
648 parser: Option<String>,
649 #[serde(rename = "filepath")]
650 path: Option<PathBuf>,
651 prettier_options: Option<HashMap<String, serde_json::Value>>,
652 ignore_path: Option<PathBuf>,
653}
654
655#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
656#[serde(rename_all = "camelCase")]
657struct FormatResult {
658 text: String,
659}
660
661impl lsp::request::Request for Format {
662 type Params = FormatParams;
663 type Result = FormatResult;
664 const METHOD: &'static str = "prettier/format";
665}
666
667enum ClearCache {}
668
669impl lsp::request::Request for ClearCache {
670 type Params = ();
671 type Result = ();
672 const METHOD: &'static str = "prettier/clear_cache";
673}
674
675#[cfg(test)]
676mod tests {
677 use fs::FakeFs;
678 use serde_json::json;
679
680 use super::*;
681
682 #[gpui::test]
683 async fn test_prettier_lookup_finds_nothing(cx: &mut gpui::TestAppContext) {
684 let fs = FakeFs::new(cx.executor());
685 fs.insert_tree(
686 "/root",
687 json!({
688 ".config": {
689 "zed": {
690 "settings.json": r#"{ "formatter": "auto" }"#,
691 },
692 },
693 "work": {
694 "project": {
695 "src": {
696 "index.js": "// index.js file contents",
697 },
698 "node_modules": {
699 "expect": {
700 "build": {
701 "print.js": "// print.js file contents",
702 },
703 "package.json": r#"{
704 "devDependencies": {
705 "prettier": "2.5.1"
706 }
707 }"#,
708 },
709 "prettier": {
710 "index.js": "// Dummy prettier package file",
711 },
712 },
713 "package.json": r#"{}"#
714 },
715 }
716 }),
717 )
718 .await;
719
720 assert_eq!(
721 Prettier::locate_prettier_installation(
722 fs.as_ref(),
723 &HashSet::default(),
724 Path::new("/root/.config/zed/settings.json"),
725 )
726 .await
727 .unwrap(),
728 ControlFlow::Continue(None),
729 "Should find no prettier for path hierarchy without it"
730 );
731 assert_eq!(
732 Prettier::locate_prettier_installation(
733 fs.as_ref(),
734 &HashSet::default(),
735 Path::new("/root/work/project/src/index.js")
736 )
737 .await
738 .unwrap(),
739 ControlFlow::Continue(Some(PathBuf::from("/root/work/project"))),
740 "Should successfully find a prettier for path hierarchy that has node_modules with prettier, but no package.json mentions of it"
741 );
742 assert_eq!(
743 Prettier::locate_prettier_installation(
744 fs.as_ref(),
745 &HashSet::default(),
746 Path::new("/root/work/project/node_modules/expect/build/print.js")
747 )
748 .await
749 .unwrap(),
750 ControlFlow::Break(()),
751 "Should not format files inside node_modules/"
752 );
753 }
754
755 #[gpui::test]
756 async fn test_prettier_lookup_in_simple_npm_projects(cx: &mut gpui::TestAppContext) {
757 let fs = FakeFs::new(cx.executor());
758 fs.insert_tree(
759 "/root",
760 json!({
761 "web_blog": {
762 "node_modules": {
763 "prettier": {
764 "index.js": "// Dummy prettier package file",
765 },
766 "expect": {
767 "build": {
768 "print.js": "// print.js file contents",
769 },
770 "package.json": r#"{
771 "devDependencies": {
772 "prettier": "2.5.1"
773 }
774 }"#,
775 },
776 },
777 "pages": {
778 "[slug].tsx": "// [slug].tsx file contents",
779 },
780 "package.json": r#"{
781 "devDependencies": {
782 "prettier": "2.3.0"
783 },
784 "prettier": {
785 "semi": false,
786 "printWidth": 80,
787 "htmlWhitespaceSensitivity": "strict",
788 "tabWidth": 4
789 }
790 }"#
791 }
792 }),
793 )
794 .await;
795
796 assert_eq!(
797 Prettier::locate_prettier_installation(
798 fs.as_ref(),
799 &HashSet::default(),
800 Path::new("/root/web_blog/pages/[slug].tsx")
801 )
802 .await
803 .unwrap(),
804 ControlFlow::Continue(Some(PathBuf::from("/root/web_blog"))),
805 "Should find a preinstalled prettier in the project root"
806 );
807 assert_eq!(
808 Prettier::locate_prettier_installation(
809 fs.as_ref(),
810 &HashSet::default(),
811 Path::new("/root/web_blog/node_modules/expect/build/print.js")
812 )
813 .await
814 .unwrap(),
815 ControlFlow::Break(()),
816 "Should not allow formatting node_modules/ contents"
817 );
818 }
819
820 #[gpui::test]
821 async fn test_prettier_lookup_for_not_installed(cx: &mut gpui::TestAppContext) {
822 let fs = FakeFs::new(cx.executor());
823 fs.insert_tree(
824 "/root",
825 json!({
826 "work": {
827 "web_blog": {
828 "node_modules": {
829 "expect": {
830 "build": {
831 "print.js": "// print.js file contents",
832 },
833 "package.json": r#"{
834 "devDependencies": {
835 "prettier": "2.5.1"
836 }
837 }"#,
838 },
839 },
840 "pages": {
841 "[slug].tsx": "// [slug].tsx file contents",
842 },
843 "package.json": r#"{
844 "devDependencies": {
845 "prettier": "2.3.0"
846 },
847 "prettier": {
848 "semi": false,
849 "printWidth": 80,
850 "htmlWhitespaceSensitivity": "strict",
851 "tabWidth": 4
852 }
853 }"#
854 }
855 }
856 }),
857 )
858 .await;
859
860 assert_eq!(
861 Prettier::locate_prettier_installation(
862 fs.as_ref(),
863 &HashSet::default(),
864 Path::new("/root/work/web_blog/pages/[slug].tsx")
865 )
866 .await
867 .unwrap(),
868 ControlFlow::Continue(None),
869 "Should find no prettier when node_modules don't have it"
870 );
871
872 assert_eq!(
873 Prettier::locate_prettier_installation(
874 fs.as_ref(),
875 &HashSet::from_iter(
876 [PathBuf::from("/root"), PathBuf::from("/root/work")].into_iter()
877 ),
878 Path::new("/root/work/web_blog/pages/[slug].tsx")
879 )
880 .await
881 .unwrap(),
882 ControlFlow::Continue(Some(PathBuf::from("/root/work"))),
883 "Should return closest cached value found without path checks"
884 );
885
886 assert_eq!(
887 Prettier::locate_prettier_installation(
888 fs.as_ref(),
889 &HashSet::default(),
890 Path::new("/root/work/web_blog/node_modules/expect/build/print.js")
891 )
892 .await
893 .unwrap(),
894 ControlFlow::Break(()),
895 "Should not allow formatting files inside node_modules/"
896 );
897 assert_eq!(
898 Prettier::locate_prettier_installation(
899 fs.as_ref(),
900 &HashSet::from_iter(
901 [PathBuf::from("/root"), PathBuf::from("/root/work")].into_iter()
902 ),
903 Path::new("/root/work/web_blog/node_modules/expect/build/print.js")
904 )
905 .await
906 .unwrap(),
907 ControlFlow::Break(()),
908 "Should ignore cache lookup for files inside node_modules/"
909 );
910 }
911
912 #[gpui::test]
913 async fn test_prettier_lookup_in_npm_workspaces(cx: &mut gpui::TestAppContext) {
914 let fs = FakeFs::new(cx.executor());
915 fs.insert_tree(
916 "/root",
917 json!({
918 "work": {
919 "full-stack-foundations": {
920 "exercises": {
921 "03.loading": {
922 "01.problem.loader": {
923 "app": {
924 "routes": {
925 "users+": {
926 "$username_+": {
927 "notes.tsx": "// notes.tsx file contents",
928 },
929 },
930 },
931 },
932 "node_modules": {
933 "test.js": "// test.js contents",
934 },
935 "package.json": r#"{
936 "devDependencies": {
937 "prettier": "^3.0.3"
938 }
939 }"#
940 },
941 },
942 },
943 "package.json": r#"{
944 "workspaces": ["exercises/*/*", "examples/*"]
945 }"#,
946 "node_modules": {
947 "prettier": {
948 "index.js": "// Dummy prettier package file",
949 },
950 },
951 },
952 }
953 }),
954 )
955 .await;
956
957 assert_eq!(
958 Prettier::locate_prettier_installation(
959 fs.as_ref(),
960 &HashSet::default(),
961 Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/app/routes/users+/$username_+/notes.tsx"),
962 ).await.unwrap(),
963 ControlFlow::Continue(Some(PathBuf::from("/root/work/full-stack-foundations"))),
964 "Should ascend to the multi-workspace root and find the prettier there",
965 );
966
967 assert_eq!(
968 Prettier::locate_prettier_installation(
969 fs.as_ref(),
970 &HashSet::default(),
971 Path::new("/root/work/full-stack-foundations/node_modules/prettier/index.js")
972 )
973 .await
974 .unwrap(),
975 ControlFlow::Break(()),
976 "Should not allow formatting files inside root node_modules/"
977 );
978 assert_eq!(
979 Prettier::locate_prettier_installation(
980 fs.as_ref(),
981 &HashSet::default(),
982 Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/node_modules/test.js")
983 )
984 .await
985 .unwrap(),
986 ControlFlow::Break(()),
987 "Should not allow formatting files inside submodule's node_modules/"
988 );
989 }
990
991 #[gpui::test]
992 async fn test_prettier_lookup_in_npm_workspaces_for_not_installed(
993 cx: &mut gpui::TestAppContext,
994 ) {
995 let fs = FakeFs::new(cx.executor());
996 fs.insert_tree(
997 "/root",
998 json!({
999 "work": {
1000 "full-stack-foundations": {
1001 "exercises": {
1002 "03.loading": {
1003 "01.problem.loader": {
1004 "app": {
1005 "routes": {
1006 "users+": {
1007 "$username_+": {
1008 "notes.tsx": "// notes.tsx file contents",
1009 },
1010 },
1011 },
1012 },
1013 "node_modules": {},
1014 "package.json": r#"{
1015 "devDependencies": {
1016 "prettier": "^3.0.3"
1017 }
1018 }"#
1019 },
1020 },
1021 },
1022 "package.json": r#"{
1023 "workspaces": ["exercises/*/*", "examples/*"]
1024 }"#,
1025 },
1026 }
1027 }),
1028 )
1029 .await;
1030
1031 match Prettier::locate_prettier_installation(
1032 fs.as_ref(),
1033 &HashSet::default(),
1034 Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/app/routes/users+/$username_+/notes.tsx")
1035 )
1036 .await {
1037 Ok(path) => panic!("Expected to fail for prettier in package.json but not in node_modules found, but got path {path:?}"),
1038 Err(e) => {
1039 let message = e.to_string().replace("\\\\", "/");
1040 assert!(message.contains("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader"), "Error message should mention which project had prettier defined");
1041 assert!(message.contains("/root/work/full-stack-foundations"), "Error message should mention potential candidates without prettier node_modules contents");
1042 },
1043 };
1044 }
1045
1046 #[gpui::test]
1047 async fn test_prettier_ignore_with_editor_prettier(cx: &mut gpui::TestAppContext) {
1048 let fs = FakeFs::new(cx.executor());
1049 fs.insert_tree(
1050 "/root",
1051 json!({
1052 "project": {
1053 "src": {
1054 "index.js": "// index.js file contents",
1055 "ignored.js": "// this file should be ignored",
1056 },
1057 ".prettierignore": "ignored.js",
1058 "package.json": r#"{
1059 "name": "test-project"
1060 }"#
1061 }
1062 }),
1063 )
1064 .await;
1065
1066 assert_eq!(
1067 Prettier::locate_prettier_ignore(
1068 fs.as_ref(),
1069 &HashSet::default(),
1070 Path::new("/root/project/src/index.js"),
1071 )
1072 .await
1073 .unwrap(),
1074 ControlFlow::Continue(Some(PathBuf::from("/root/project"))),
1075 "Should find prettierignore in project root"
1076 );
1077 }
1078
1079 #[gpui::test]
1080 async fn test_prettier_ignore_in_monorepo_with_only_child_ignore(
1081 cx: &mut gpui::TestAppContext,
1082 ) {
1083 let fs = FakeFs::new(cx.executor());
1084 fs.insert_tree(
1085 "/root",
1086 json!({
1087 "monorepo": {
1088 "node_modules": {
1089 "prettier": {
1090 "index.js": "// Dummy prettier package file",
1091 }
1092 },
1093 "packages": {
1094 "web": {
1095 "src": {
1096 "index.js": "// index.js contents",
1097 "ignored.js": "// this should be ignored",
1098 },
1099 ".prettierignore": "ignored.js",
1100 "package.json": r#"{
1101 "name": "web-package"
1102 }"#
1103 }
1104 },
1105 "package.json": r#"{
1106 "workspaces": ["packages/*"],
1107 "devDependencies": {
1108 "prettier": "^2.0.0"
1109 }
1110 }"#
1111 }
1112 }),
1113 )
1114 .await;
1115
1116 assert_eq!(
1117 Prettier::locate_prettier_ignore(
1118 fs.as_ref(),
1119 &HashSet::default(),
1120 Path::new("/root/monorepo/packages/web/src/index.js"),
1121 )
1122 .await
1123 .unwrap(),
1124 ControlFlow::Continue(Some(PathBuf::from("/root/monorepo/packages/web"))),
1125 "Should find prettierignore in child package"
1126 );
1127 }
1128
1129 #[gpui::test]
1130 async fn test_prettier_ignore_in_monorepo_with_root_and_child_ignores(
1131 cx: &mut gpui::TestAppContext,
1132 ) {
1133 let fs = FakeFs::new(cx.executor());
1134 fs.insert_tree(
1135 "/root",
1136 json!({
1137 "monorepo": {
1138 "node_modules": {
1139 "prettier": {
1140 "index.js": "// Dummy prettier package file",
1141 }
1142 },
1143 ".prettierignore": "main.js",
1144 "packages": {
1145 "web": {
1146 "src": {
1147 "main.js": "// this should not be ignored",
1148 "ignored.js": "// this should be ignored",
1149 },
1150 ".prettierignore": "ignored.js",
1151 "package.json": r#"{
1152 "name": "web-package"
1153 }"#
1154 }
1155 },
1156 "package.json": r#"{
1157 "workspaces": ["packages/*"],
1158 "devDependencies": {
1159 "prettier": "^2.0.0"
1160 }
1161 }"#
1162 }
1163 }),
1164 )
1165 .await;
1166
1167 assert_eq!(
1168 Prettier::locate_prettier_ignore(
1169 fs.as_ref(),
1170 &HashSet::default(),
1171 Path::new("/root/monorepo/packages/web/src/main.js"),
1172 )
1173 .await
1174 .unwrap(),
1175 ControlFlow::Continue(Some(PathBuf::from("/root/monorepo/packages/web"))),
1176 "Should find child package prettierignore first"
1177 );
1178
1179 assert_eq!(
1180 Prettier::locate_prettier_ignore(
1181 fs.as_ref(),
1182 &HashSet::default(),
1183 Path::new("/root/monorepo/packages/web/src/ignored.js"),
1184 )
1185 .await
1186 .unwrap(),
1187 ControlFlow::Continue(Some(PathBuf::from("/root/monorepo/packages/web"))),
1188 "Should find child package prettierignore first"
1189 );
1190 }
1191}