prettier.rs

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