1use anyhow::Context as _;
2use collections::{HashMap, HashSet};
3use fs::Fs;
4use gpui::{AsyncApp, Entity};
5use language::language_settings::{LanguageSettings, PrettierSettings};
6use language::{Buffer, Diff, Language};
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 = LanguageSettings::for_buffer(&buffer, 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 = LanguageSettings::for_buffer(buffer, cx);
504 let prettier_settings = &language_settings.prettier;
505 let parser = prettier_parser_name(
506 buffer_path.as_deref(),
507 buffer_language,
508 prettier_settings,
509 )?;
510
511 if let Some(parser) = parser {
512 formatted_text = format!("{formatted_text}\n{parser}");
513 }
514
515 Ok(buffer.diff(formatted_text, cx))
516 }
517 None => panic!("Should not format buffer without a language with prettier"),
518 }
519 })?
520 .await),
521 }
522 }
523
524 pub async fn clear_cache(&self) -> anyhow::Result<()> {
525 match self {
526 Self::Real(local) => local
527 .server
528 .request::<ClearCache>(())
529 .await
530 .into_response()
531 .context("prettier clear cache"),
532 #[cfg(any(test, feature = "test-support"))]
533 Self::Test(_) => Ok(()),
534 }
535 }
536
537 pub fn server(&self) -> Option<&Arc<LanguageServer>> {
538 match self {
539 Self::Real(local) => Some(&local.server),
540 #[cfg(any(test, feature = "test-support"))]
541 Self::Test(_) => None,
542 }
543 }
544
545 pub fn is_default(&self) -> bool {
546 match self {
547 Self::Real(local) => local.default,
548 #[cfg(any(test, feature = "test-support"))]
549 Self::Test(test_prettier) => test_prettier.default,
550 }
551 }
552
553 pub fn prettier_dir(&self) -> &Path {
554 match self {
555 Self::Real(local) => &local.prettier_dir,
556 #[cfg(any(test, feature = "test-support"))]
557 Self::Test(test_prettier) => &test_prettier.prettier_dir,
558 }
559 }
560}
561
562fn prettier_parser_name(
563 buffer_path: Option<&Path>,
564 buffer_language: Option<&Language>,
565 prettier_settings: &PrettierSettings,
566) -> anyhow::Result<Option<String>> {
567 let parser = if buffer_path.is_none() {
568 let parser = prettier_settings
569 .parser
570 .as_deref()
571 .or_else(|| buffer_language.and_then(|language| language.prettier_parser_name()));
572 if parser.is_none() {
573 log::error!(
574 "Formatting unsaved file with prettier failed. No prettier parser configured for language {buffer_language:?}"
575 );
576 anyhow::bail!("Cannot determine prettier parser for unsaved file");
577 }
578 parser
579 } else if let (Some(buffer_language), Some(buffer_path)) = (buffer_language, buffer_path)
580 && buffer_path.extension().is_some_and(|extension| {
581 !buffer_language
582 .config()
583 .matcher
584 .path_suffixes
585 .contains(&extension.to_string_lossy().into_owned())
586 })
587 {
588 buffer_language.prettier_parser_name()
589 } else {
590 prettier_settings.parser.as_deref()
591 };
592
593 Ok(parser.map(ToOwned::to_owned))
594}
595
596async fn has_prettier_in_node_modules(fs: &dyn Fs, path: &Path) -> anyhow::Result<bool> {
597 let possible_node_modules_location = path.join("node_modules").join(PRETTIER_PACKAGE_NAME);
598 if let Some(node_modules_location_metadata) = fs
599 .metadata(&possible_node_modules_location)
600 .await
601 .with_context(|| format!("fetching metadata for {possible_node_modules_location:?}"))?
602 {
603 return Ok(node_modules_location_metadata.is_dir);
604 }
605 Ok(false)
606}
607
608async fn read_package_json(
609 fs: &dyn Fs,
610 path: &Path,
611) -> anyhow::Result<Option<HashMap<String, serde_json::Value>>> {
612 let possible_package_json = path.join("package.json");
613 if let Some(package_json_metadata) = fs
614 .metadata(&possible_package_json)
615 .await
616 .with_context(|| format!("fetching metadata for package json {possible_package_json:?}"))?
617 && !package_json_metadata.is_dir
618 && !package_json_metadata.is_symlink
619 {
620 let package_json_contents = fs
621 .load(&possible_package_json)
622 .await
623 .with_context(|| format!("reading {possible_package_json:?} file contents"))?;
624 return serde_json::from_str::<HashMap<String, serde_json::Value>>(&package_json_contents)
625 .map(Some)
626 .with_context(|| format!("parsing {possible_package_json:?} file contents"));
627 }
628 Ok(None)
629}
630
631enum Format {}
632
633#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
634#[serde(rename_all = "camelCase")]
635struct FormatParams {
636 text: String,
637 options: FormatOptions,
638}
639
640#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
641#[serde(rename_all = "camelCase")]
642struct FormatOptions {
643 plugins: Vec<PathBuf>,
644 parser: Option<String>,
645 #[serde(rename = "filepath")]
646 path: Option<PathBuf>,
647 prettier_options: Option<HashMap<String, serde_json::Value>>,
648 ignore_path: Option<PathBuf>,
649}
650
651#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
652#[serde(rename_all = "camelCase")]
653struct FormatResult {
654 text: String,
655}
656
657impl lsp::request::Request for Format {
658 type Params = FormatParams;
659 type Result = FormatResult;
660 const METHOD: &'static str = "prettier/format";
661}
662
663enum ClearCache {}
664
665impl lsp::request::Request for ClearCache {
666 type Params = ();
667 type Result = ();
668 const METHOD: &'static str = "prettier/clear_cache";
669}
670
671#[cfg(test)]
672mod tests {
673 use fs::FakeFs;
674 use serde_json::json;
675
676 use super::*;
677
678 #[gpui::test]
679 async fn test_prettier_lookup_finds_nothing(cx: &mut gpui::TestAppContext) {
680 let fs = FakeFs::new(cx.executor());
681 fs.insert_tree(
682 "/root",
683 json!({
684 ".config": {
685 "zed": {
686 "settings.json": r#"{ "formatter": "auto" }"#,
687 },
688 },
689 "work": {
690 "project": {
691 "src": {
692 "index.js": "// index.js file contents",
693 },
694 "node_modules": {
695 "expect": {
696 "build": {
697 "print.js": "// print.js file contents",
698 },
699 "package.json": r#"{
700 "devDependencies": {
701 "prettier": "2.5.1"
702 }
703 }"#,
704 },
705 "prettier": {
706 "index.js": "// Dummy prettier package file",
707 },
708 },
709 "package.json": r#"{}"#
710 },
711 }
712 }),
713 )
714 .await;
715
716 assert_eq!(
717 Prettier::locate_prettier_installation(
718 fs.as_ref(),
719 &HashSet::default(),
720 Path::new("/root/.config/zed/settings.json"),
721 )
722 .await
723 .unwrap(),
724 ControlFlow::Continue(None),
725 "Should find no prettier for path hierarchy without it"
726 );
727 assert_eq!(
728 Prettier::locate_prettier_installation(
729 fs.as_ref(),
730 &HashSet::default(),
731 Path::new("/root/work/project/src/index.js")
732 )
733 .await
734 .unwrap(),
735 ControlFlow::Continue(Some(PathBuf::from("/root/work/project"))),
736 "Should successfully find a prettier for path hierarchy that has node_modules with prettier, but no package.json mentions of it"
737 );
738 assert_eq!(
739 Prettier::locate_prettier_installation(
740 fs.as_ref(),
741 &HashSet::default(),
742 Path::new("/root/work/project/node_modules/expect/build/print.js")
743 )
744 .await
745 .unwrap(),
746 ControlFlow::Break(()),
747 "Should not format files inside node_modules/"
748 );
749 }
750
751 #[gpui::test]
752 async fn test_prettier_lookup_in_simple_npm_projects(cx: &mut gpui::TestAppContext) {
753 let fs = FakeFs::new(cx.executor());
754 fs.insert_tree(
755 "/root",
756 json!({
757 "web_blog": {
758 "node_modules": {
759 "prettier": {
760 "index.js": "// Dummy prettier package file",
761 },
762 "expect": {
763 "build": {
764 "print.js": "// print.js file contents",
765 },
766 "package.json": r#"{
767 "devDependencies": {
768 "prettier": "2.5.1"
769 }
770 }"#,
771 },
772 },
773 "pages": {
774 "[slug].tsx": "// [slug].tsx file contents",
775 },
776 "package.json": r#"{
777 "devDependencies": {
778 "prettier": "2.3.0"
779 },
780 "prettier": {
781 "semi": false,
782 "printWidth": 80,
783 "htmlWhitespaceSensitivity": "strict",
784 "tabWidth": 4
785 }
786 }"#
787 }
788 }),
789 )
790 .await;
791
792 assert_eq!(
793 Prettier::locate_prettier_installation(
794 fs.as_ref(),
795 &HashSet::default(),
796 Path::new("/root/web_blog/pages/[slug].tsx")
797 )
798 .await
799 .unwrap(),
800 ControlFlow::Continue(Some(PathBuf::from("/root/web_blog"))),
801 "Should find a preinstalled prettier in the project root"
802 );
803 assert_eq!(
804 Prettier::locate_prettier_installation(
805 fs.as_ref(),
806 &HashSet::default(),
807 Path::new("/root/web_blog/node_modules/expect/build/print.js")
808 )
809 .await
810 .unwrap(),
811 ControlFlow::Break(()),
812 "Should not allow formatting node_modules/ contents"
813 );
814 }
815
816 #[gpui::test]
817 async fn test_prettier_lookup_for_not_installed(cx: &mut gpui::TestAppContext) {
818 let fs = FakeFs::new(cx.executor());
819 fs.insert_tree(
820 "/root",
821 json!({
822 "work": {
823 "web_blog": {
824 "node_modules": {
825 "expect": {
826 "build": {
827 "print.js": "// print.js file contents",
828 },
829 "package.json": r#"{
830 "devDependencies": {
831 "prettier": "2.5.1"
832 }
833 }"#,
834 },
835 },
836 "pages": {
837 "[slug].tsx": "// [slug].tsx file contents",
838 },
839 "package.json": r#"{
840 "devDependencies": {
841 "prettier": "2.3.0"
842 },
843 "prettier": {
844 "semi": false,
845 "printWidth": 80,
846 "htmlWhitespaceSensitivity": "strict",
847 "tabWidth": 4
848 }
849 }"#
850 }
851 }
852 }),
853 )
854 .await;
855
856 assert_eq!(
857 Prettier::locate_prettier_installation(
858 fs.as_ref(),
859 &HashSet::default(),
860 Path::new("/root/work/web_blog/pages/[slug].tsx")
861 )
862 .await
863 .unwrap(),
864 ControlFlow::Continue(None),
865 "Should find no prettier when node_modules don't have it"
866 );
867
868 assert_eq!(
869 Prettier::locate_prettier_installation(
870 fs.as_ref(),
871 &HashSet::from_iter(
872 [PathBuf::from("/root"), PathBuf::from("/root/work")].into_iter()
873 ),
874 Path::new("/root/work/web_blog/pages/[slug].tsx")
875 )
876 .await
877 .unwrap(),
878 ControlFlow::Continue(Some(PathBuf::from("/root/work"))),
879 "Should return closest cached value found without path checks"
880 );
881
882 assert_eq!(
883 Prettier::locate_prettier_installation(
884 fs.as_ref(),
885 &HashSet::default(),
886 Path::new("/root/work/web_blog/node_modules/expect/build/print.js")
887 )
888 .await
889 .unwrap(),
890 ControlFlow::Break(()),
891 "Should not allow formatting files inside node_modules/"
892 );
893 assert_eq!(
894 Prettier::locate_prettier_installation(
895 fs.as_ref(),
896 &HashSet::from_iter(
897 [PathBuf::from("/root"), PathBuf::from("/root/work")].into_iter()
898 ),
899 Path::new("/root/work/web_blog/node_modules/expect/build/print.js")
900 )
901 .await
902 .unwrap(),
903 ControlFlow::Break(()),
904 "Should ignore cache lookup for files inside node_modules/"
905 );
906 }
907
908 #[gpui::test]
909 async fn test_prettier_lookup_in_npm_workspaces(cx: &mut gpui::TestAppContext) {
910 let fs = FakeFs::new(cx.executor());
911 fs.insert_tree(
912 "/root",
913 json!({
914 "work": {
915 "full-stack-foundations": {
916 "exercises": {
917 "03.loading": {
918 "01.problem.loader": {
919 "app": {
920 "routes": {
921 "users+": {
922 "$username_+": {
923 "notes.tsx": "// notes.tsx file contents",
924 },
925 },
926 },
927 },
928 "node_modules": {
929 "test.js": "// test.js contents",
930 },
931 "package.json": r#"{
932 "devDependencies": {
933 "prettier": "^3.0.3"
934 }
935 }"#
936 },
937 },
938 },
939 "package.json": r#"{
940 "workspaces": ["exercises/*/*", "examples/*"]
941 }"#,
942 "node_modules": {
943 "prettier": {
944 "index.js": "// Dummy prettier package file",
945 },
946 },
947 },
948 }
949 }),
950 )
951 .await;
952
953 assert_eq!(
954 Prettier::locate_prettier_installation(
955 fs.as_ref(),
956 &HashSet::default(),
957 Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/app/routes/users+/$username_+/notes.tsx"),
958 ).await.unwrap(),
959 ControlFlow::Continue(Some(PathBuf::from("/root/work/full-stack-foundations"))),
960 "Should ascend to the multi-workspace root and find the prettier there",
961 );
962
963 assert_eq!(
964 Prettier::locate_prettier_installation(
965 fs.as_ref(),
966 &HashSet::default(),
967 Path::new("/root/work/full-stack-foundations/node_modules/prettier/index.js")
968 )
969 .await
970 .unwrap(),
971 ControlFlow::Break(()),
972 "Should not allow formatting files inside root node_modules/"
973 );
974 assert_eq!(
975 Prettier::locate_prettier_installation(
976 fs.as_ref(),
977 &HashSet::default(),
978 Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/node_modules/test.js")
979 )
980 .await
981 .unwrap(),
982 ControlFlow::Break(()),
983 "Should not allow formatting files inside submodule's node_modules/"
984 );
985 }
986
987 #[gpui::test]
988 async fn test_prettier_lookup_in_npm_workspaces_for_not_installed(
989 cx: &mut gpui::TestAppContext,
990 ) {
991 let fs = FakeFs::new(cx.executor());
992 fs.insert_tree(
993 "/root",
994 json!({
995 "work": {
996 "full-stack-foundations": {
997 "exercises": {
998 "03.loading": {
999 "01.problem.loader": {
1000 "app": {
1001 "routes": {
1002 "users+": {
1003 "$username_+": {
1004 "notes.tsx": "// notes.tsx file contents",
1005 },
1006 },
1007 },
1008 },
1009 "node_modules": {},
1010 "package.json": r#"{
1011 "devDependencies": {
1012 "prettier": "^3.0.3"
1013 }
1014 }"#
1015 },
1016 },
1017 },
1018 "package.json": r#"{
1019 "workspaces": ["exercises/*/*", "examples/*"]
1020 }"#,
1021 },
1022 }
1023 }),
1024 )
1025 .await;
1026
1027 match Prettier::locate_prettier_installation(
1028 fs.as_ref(),
1029 &HashSet::default(),
1030 Path::new("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader/app/routes/users+/$username_+/notes.tsx")
1031 )
1032 .await {
1033 Ok(path) => panic!("Expected to fail for prettier in package.json but not in node_modules found, but got path {path:?}"),
1034 Err(e) => {
1035 let message = e.to_string().replace("\\\\", "/");
1036 assert!(message.contains("/root/work/full-stack-foundations/exercises/03.loading/01.problem.loader"), "Error message should mention which project had prettier defined");
1037 assert!(message.contains("/root/work/full-stack-foundations"), "Error message should mention potential candidates without prettier node_modules contents");
1038 },
1039 };
1040 }
1041
1042 #[gpui::test]
1043 async fn test_prettier_ignore_with_editor_prettier(cx: &mut gpui::TestAppContext) {
1044 let fs = FakeFs::new(cx.executor());
1045 fs.insert_tree(
1046 "/root",
1047 json!({
1048 "project": {
1049 "src": {
1050 "index.js": "// index.js file contents",
1051 "ignored.js": "// this file should be ignored",
1052 },
1053 ".prettierignore": "ignored.js",
1054 "package.json": r#"{
1055 "name": "test-project"
1056 }"#
1057 }
1058 }),
1059 )
1060 .await;
1061
1062 assert_eq!(
1063 Prettier::locate_prettier_ignore(
1064 fs.as_ref(),
1065 &HashSet::default(),
1066 Path::new("/root/project/src/index.js"),
1067 )
1068 .await
1069 .unwrap(),
1070 ControlFlow::Continue(Some(PathBuf::from("/root/project"))),
1071 "Should find prettierignore in project root"
1072 );
1073 }
1074
1075 #[gpui::test]
1076 async fn test_prettier_ignore_in_monorepo_with_only_child_ignore(
1077 cx: &mut gpui::TestAppContext,
1078 ) {
1079 let fs = FakeFs::new(cx.executor());
1080 fs.insert_tree(
1081 "/root",
1082 json!({
1083 "monorepo": {
1084 "node_modules": {
1085 "prettier": {
1086 "index.js": "// Dummy prettier package file",
1087 }
1088 },
1089 "packages": {
1090 "web": {
1091 "src": {
1092 "index.js": "// index.js contents",
1093 "ignored.js": "// this should be ignored",
1094 },
1095 ".prettierignore": "ignored.js",
1096 "package.json": r#"{
1097 "name": "web-package"
1098 }"#
1099 }
1100 },
1101 "package.json": r#"{
1102 "workspaces": ["packages/*"],
1103 "devDependencies": {
1104 "prettier": "^2.0.0"
1105 }
1106 }"#
1107 }
1108 }),
1109 )
1110 .await;
1111
1112 assert_eq!(
1113 Prettier::locate_prettier_ignore(
1114 fs.as_ref(),
1115 &HashSet::default(),
1116 Path::new("/root/monorepo/packages/web/src/index.js"),
1117 )
1118 .await
1119 .unwrap(),
1120 ControlFlow::Continue(Some(PathBuf::from("/root/monorepo/packages/web"))),
1121 "Should find prettierignore in child package"
1122 );
1123 }
1124
1125 #[gpui::test]
1126 async fn test_prettier_ignore_in_monorepo_with_root_and_child_ignores(
1127 cx: &mut gpui::TestAppContext,
1128 ) {
1129 let fs = FakeFs::new(cx.executor());
1130 fs.insert_tree(
1131 "/root",
1132 json!({
1133 "monorepo": {
1134 "node_modules": {
1135 "prettier": {
1136 "index.js": "// Dummy prettier package file",
1137 }
1138 },
1139 ".prettierignore": "main.js",
1140 "packages": {
1141 "web": {
1142 "src": {
1143 "main.js": "// this should not be ignored",
1144 "ignored.js": "// this should be ignored",
1145 },
1146 ".prettierignore": "ignored.js",
1147 "package.json": r#"{
1148 "name": "web-package"
1149 }"#
1150 }
1151 },
1152 "package.json": r#"{
1153 "workspaces": ["packages/*"],
1154 "devDependencies": {
1155 "prettier": "^2.0.0"
1156 }
1157 }"#
1158 }
1159 }),
1160 )
1161 .await;
1162
1163 assert_eq!(
1164 Prettier::locate_prettier_ignore(
1165 fs.as_ref(),
1166 &HashSet::default(),
1167 Path::new("/root/monorepo/packages/web/src/main.js"),
1168 )
1169 .await
1170 .unwrap(),
1171 ControlFlow::Continue(Some(PathBuf::from("/root/monorepo/packages/web"))),
1172 "Should find child package prettierignore first"
1173 );
1174
1175 assert_eq!(
1176 Prettier::locate_prettier_ignore(
1177 fs.as_ref(),
1178 &HashSet::default(),
1179 Path::new("/root/monorepo/packages/web/src/ignored.js"),
1180 )
1181 .await
1182 .unwrap(),
1183 ControlFlow::Continue(Some(PathBuf::from("/root/monorepo/packages/web"))),
1184 "Should find child package prettierignore first"
1185 );
1186 }
1187}