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