prettier.rs

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