prettier.rs

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