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