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 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}