prettier.rs

  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}