Add prettier support (#3122)

Kirill Bulatov created

Change summary

Cargo.lock                                   |  24 +
Cargo.toml                                   |   1 
assets/settings/default.json                 |  17 
crates/collab/Cargo.toml                     |   1 
crates/collab/src/tests/integration_tests.rs | 138 +++++
crates/collab/src/tests/test_server.rs       |   3 
crates/editor/src/editor_tests.rs            |  92 +++
crates/fs/src/fs.rs                          |   2 
crates/language/src/language.rs              |  37 +
crates/language/src/language_settings.rs     |   9 
crates/node_runtime/src/node_runtime.rs      | 116 ++++
crates/prettier/Cargo.toml                   |  34 +
crates/prettier/src/prettier.rs              | 513 ++++++++++++++++++++++
crates/prettier/src/prettier_server.js       | 217 +++++++++
crates/project/Cargo.toml                    |   4 
crates/project/src/project.rs                | 468 +++++++++++++++++++
crates/semantic_index/examples/eval.rs       |   1 
crates/util/src/paths.rs                     |   1 
crates/workspace/Cargo.toml                  |   1 
crates/workspace/src/workspace.rs            |   8 
crates/zed/src/languages/css.rs              |   6 
crates/zed/src/languages/html.rs             |   6 
crates/zed/src/languages/json.rs             |   8 
crates/zed/src/languages/svelte.rs           |   9 
crates/zed/src/languages/tailwind.rs         |   9 
crates/zed/src/languages/typescript.rs       |  10 
crates/zed/src/languages/yaml.rs             |   7 
crates/zed/src/main.rs                       |   8 
28 files changed, 1,701 insertions(+), 49 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1501,6 +1501,7 @@ dependencies = [
  "log",
  "lsp",
  "nanoid",
+ "node_runtime",
  "parking_lot 0.11.2",
  "pretty_assertions",
  "project",
@@ -5517,6 +5518,26 @@ version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
 
+[[package]]
+name = "prettier"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "client",
+ "collections",
+ "fs",
+ "futures 0.3.28",
+ "gpui",
+ "language",
+ "log",
+ "lsp",
+ "node_runtime",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "util",
+]
+
 [[package]]
 name = "pretty_assertions"
 version = "1.4.0"
@@ -5629,8 +5650,10 @@ dependencies = [
  "lazy_static",
  "log",
  "lsp",
+ "node_runtime",
  "parking_lot 0.11.2",
  "postage",
+ "prettier",
  "pretty_assertions",
  "rand 0.8.5",
  "regex",
@@ -9986,6 +10009,7 @@ dependencies = [
  "lazy_static",
  "log",
  "menu",
+ "node_runtime",
  "parking_lot 0.11.2",
  "postage",
  "project",

Cargo.toml 🔗

@@ -52,6 +52,7 @@ members = [
     "crates/plugin",
     "crates/plugin_macros",
     "crates/plugin_runtime",
+    "crates/prettier",
     "crates/project",
     "crates/project_panel",
     "crates/project_symbols",

assets/settings/default.json 🔗

@@ -199,7 +199,12 @@
   //         "arguments": ["--stdin-filepath", "{buffer_path}"]
   //       }
   //     }
-  "formatter": "language_server",
+  // 3. Format code using Zed's Prettier integration:
+  //     "formatter": "prettier"
+  // 4. Default. Format files using Zed's Prettier integration (if applicable),
+  //    or falling back to formatting via language server:
+  //     "formatter": "auto"
+  "formatter": "auto",
   // How to soft-wrap long lines of text. This setting can take
   // three values:
   //
@@ -429,6 +434,16 @@
       "tab_size": 2
     }
   },
+  // Zed's Prettier integration settings.
+  // If Prettier is enabled, Zed will use this its Prettier instance for any applicable file, if
+  // project has no other Prettier installed.
+  "prettier": {
+    // Use regular Prettier json configuration:
+    // "trailingComma": "es5",
+    // "tabWidth": 4,
+    // "semi": false,
+    // "singleQuote": true
+  },
   // LSP Specific settings.
   "lsp": {
     // Specify the LSP name as a key here.

crates/collab/Cargo.toml 🔗

@@ -72,6 +72,7 @@ fs = { path = "../fs", features = ["test-support"] }
 git = { path = "../git", features = ["test-support"] }
 live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
 lsp = { path = "../lsp", features = ["test-support"] }
+node_runtime = { path = "../node_runtime" }
 project = { path = "../project", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }

crates/collab/src/tests/integration_tests.rs 🔗

@@ -15,12 +15,14 @@ use gpui::{executor::Deterministic, test::EmptyView, AppContext, ModelHandle, Te
 use indoc::indoc;
 use language::{
     language_settings::{AllLanguageSettings, Formatter, InlayHintSettings},
-    tree_sitter_rust, Anchor, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language,
-    LanguageConfig, LineEnding, OffsetRangeExt, Point, Rope,
+    tree_sitter_rust, Anchor, BundledFormatter, Diagnostic, DiagnosticEntry, FakeLspAdapter,
+    Language, LanguageConfig, LineEnding, OffsetRangeExt, Point, Rope,
 };
 use live_kit_client::MacOSDisplay;
 use lsp::LanguageServerId;
-use project::{search::SearchQuery, DiagnosticSummary, HoverBlockKind, Project, ProjectPath};
+use project::{
+    search::SearchQuery, DiagnosticSummary, FormatTrigger, HoverBlockKind, Project, ProjectPath,
+};
 use rand::prelude::*;
 use serde_json::json;
 use settings::SettingsStore;
@@ -4407,8 +4409,6 @@ async fn test_formatting_buffer(
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
 ) {
-    use project::FormatTrigger;
-
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
@@ -4511,6 +4511,134 @@ async fn test_formatting_buffer(
     );
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_prettier_formatting_buffer(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+
+    // Set up a fake language server.
+    let mut language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            path_suffixes: vec!["rs".to_string()],
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    );
+    let test_plugin = "test_plugin";
+    let mut fake_language_servers = language
+        .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+            enabled_formatters: vec![BundledFormatter::Prettier {
+                parser_name: Some("test_parser"),
+                plugin_names: vec![test_plugin],
+            }],
+            ..Default::default()
+        }))
+        .await;
+    let language = Arc::new(language);
+    client_a.language_registry().add(Arc::clone(&language));
+
+    // Here we insert a fake tree with a directory that exists on disk. This is needed
+    // because later we'll invoke a command, which requires passing a working directory
+    // that points to a valid location on disk.
+    let directory = env::current_dir().unwrap();
+    let buffer_text = "let one = \"two\"";
+    client_a
+        .fs()
+        .insert_tree(&directory, json!({ "a.rs": buffer_text }))
+        .await;
+    let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await;
+    let prettier_format_suffix = project_a.update(cx_a, |project, _| {
+        let suffix = project.enable_test_prettier(&[test_plugin]);
+        project.languages().add(language);
+        suffix
+    });
+    let buffer_a = cx_a
+        .background()
+        .spawn(project_a.update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx)))
+        .await
+        .unwrap();
+
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
+    let buffer_b = cx_b
+        .background()
+        .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx)))
+        .await
+        .unwrap();
+
+    cx_a.update(|cx| {
+        cx.update_global(|store: &mut SettingsStore, cx| {
+            store.update_user_settings::<AllLanguageSettings>(cx, |file| {
+                file.defaults.formatter = Some(Formatter::Auto);
+            });
+        });
+    });
+    cx_b.update(|cx| {
+        cx.update_global(|store: &mut SettingsStore, cx| {
+            store.update_user_settings::<AllLanguageSettings>(cx, |file| {
+                file.defaults.formatter = Some(Formatter::LanguageServer);
+            });
+        });
+    });
+    let fake_language_server = fake_language_servers.next().await.unwrap();
+    fake_language_server.handle_request::<lsp::request::Formatting, _, _>(|_, _| async move {
+        panic!(
+            "Unexpected: prettier should be preferred since it's enabled and language supports it"
+        )
+    });
+
+    project_b
+        .update(cx_b, |project, cx| {
+            project.format(
+                HashSet::from_iter([buffer_b.clone()]),
+                true,
+                FormatTrigger::Save,
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+    cx_a.foreground().run_until_parked();
+    cx_b.foreground().run_until_parked();
+    assert_eq!(
+        buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
+        buffer_text.to_string() + "\n" + prettier_format_suffix,
+        "Prettier formatting was not applied to client buffer after client's request"
+    );
+
+    project_a
+        .update(cx_a, |project, cx| {
+            project.format(
+                HashSet::from_iter([buffer_a.clone()]),
+                true,
+                FormatTrigger::Manual,
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+    cx_a.foreground().run_until_parked();
+    cx_b.foreground().run_until_parked();
+    assert_eq!(
+        buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
+        buffer_text.to_string() + "\n" + prettier_format_suffix + "\n" + prettier_format_suffix,
+        "Prettier formatting was not applied to client buffer after host's request"
+    );
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_definition(
     deterministic: Arc<Deterministic>,

crates/collab/src/tests/test_server.rs 🔗

@@ -15,6 +15,7 @@ use fs::FakeFs;
 use futures::{channel::oneshot, StreamExt as _};
 use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext, WindowHandle};
 use language::LanguageRegistry;
+use node_runtime::FakeNodeRuntime;
 use parking_lot::Mutex;
 use project::{Project, WorktreeId};
 use rpc::RECEIVE_TIMEOUT;
@@ -218,6 +219,7 @@ impl TestServer {
             build_window_options: |_, _, _| Default::default(),
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             background_actions: || &[],
+            node_runtime: FakeNodeRuntime::new(),
         });
 
         cx.update(|cx| {
@@ -567,6 +569,7 @@ impl TestClient {
         cx.update(|cx| {
             Project::local(
                 self.client().clone(),
+                self.app_state.node_runtime.clone(),
                 self.app_state.user_store.clone(),
                 self.app_state.languages.clone(),
                 self.app_state.fs.clone(),

crates/editor/src/editor_tests.rs 🔗

@@ -19,8 +19,8 @@ use gpui::{
 use indoc::indoc;
 use language::{
     language_settings::{AllLanguageSettings, AllLanguageSettingsContent, LanguageSettingsContent},
-    BracketPairConfig, FakeLspAdapter, LanguageConfig, LanguageConfigOverride, LanguageRegistry,
-    Override, Point,
+    BracketPairConfig, BundledFormatter, FakeLspAdapter, LanguageConfig, LanguageConfigOverride,
+    LanguageRegistry, Override, Point,
 };
 use parking_lot::Mutex;
 use project::project_settings::{LspSettings, ProjectSettings};
@@ -5076,7 +5076,9 @@ async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
-    init_test(cx, |_| {});
+    init_test(cx, |settings| {
+        settings.defaults.formatter = Some(language_settings::Formatter::LanguageServer)
+    });
 
     let mut language = Language::new(
         LanguageConfig {
@@ -5092,6 +5094,12 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
                 document_formatting_provider: Some(lsp::OneOf::Left(true)),
                 ..Default::default()
             },
+            // Enable Prettier formatting for the same buffer, and ensure
+            // LSP is called instead of Prettier.
+            enabled_formatters: vec![BundledFormatter::Prettier {
+                parser_name: Some("test_parser"),
+                plugin_names: Vec::new(),
+            }],
             ..Default::default()
         }))
         .await;
@@ -5100,7 +5108,10 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
     fs.insert_file("/file.rs", Default::default()).await;
 
     let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
-    project.update(cx, |project, _| project.languages().add(Arc::new(language)));
+    project.update(cx, |project, _| {
+        project.enable_test_prettier(&[]);
+        project.languages().add(Arc::new(language));
+    });
     let buffer = project
         .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
         .await
@@ -5218,7 +5229,9 @@ async fn test_concurrent_format_requests(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_strip_whitespace_and_format_via_lsp(cx: &mut gpui::TestAppContext) {
-    init_test(cx, |_| {});
+    init_test(cx, |settings| {
+        settings.defaults.formatter = Some(language_settings::Formatter::Auto)
+    });
 
     let mut cx = EditorLspTestContext::new_rust(
         lsp::ServerCapabilities {
@@ -7815,6 +7828,75 @@ async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui:
     });
 }
 
+#[gpui::test]
+async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.formatter = Some(language_settings::Formatter::Prettier)
+    });
+
+    let mut language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            path_suffixes: vec!["rs".to_string()],
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    );
+
+    let test_plugin = "test_plugin";
+    let _ = language
+        .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+            enabled_formatters: vec![BundledFormatter::Prettier {
+                parser_name: Some("test_parser"),
+                plugin_names: vec![test_plugin],
+            }],
+            ..Default::default()
+        }))
+        .await;
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_file("/file.rs", Default::default()).await;
+
+    let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
+    let prettier_format_suffix = project.update(cx, |project, _| {
+        let suffix = project.enable_test_prettier(&[test_plugin]);
+        project.languages().add(Arc::new(language));
+        suffix
+    });
+    let buffer = project
+        .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
+        .await
+        .unwrap();
+
+    let buffer_text = "one\ntwo\nthree\n";
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let editor = cx.add_window(|cx| build_editor(buffer, cx)).root(cx);
+    editor.update(cx, |editor, cx| editor.set_text(buffer_text, cx));
+
+    let format = editor.update(cx, |editor, cx| {
+        editor.perform_format(project.clone(), FormatTrigger::Manual, cx)
+    });
+    format.await.unwrap();
+    assert_eq!(
+        editor.read_with(cx, |editor, cx| editor.text(cx)),
+        buffer_text.to_string() + prettier_format_suffix,
+        "Test prettier formatting was not applied to the original buffer text",
+    );
+
+    update_test_language_settings(cx, |settings| {
+        settings.defaults.formatter = Some(language_settings::Formatter::Auto)
+    });
+    let format = editor.update(cx, |editor, cx| {
+        editor.perform_format(project.clone(), FormatTrigger::Manual, cx)
+    });
+    format.await.unwrap();
+    assert_eq!(
+        editor.read_with(cx, |editor, cx| editor.text(cx)),
+        buffer_text.to_string() + prettier_format_suffix + "\n" + prettier_format_suffix,
+        "Autoformatting (via test prettier) was not applied to the original buffer text",
+    );
+}
+
 fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
     let point = DisplayPoint::new(row as u32, column as u32);
     point..point

crates/fs/src/fs.rs 🔗

@@ -85,7 +85,7 @@ pub struct RemoveOptions {
     pub ignore_if_not_exists: bool,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Copy, Clone, Debug)]
 pub struct Metadata {
     pub inode: u64,
     pub mtime: SystemTime,

crates/language/src/language.rs 🔗

@@ -227,6 +227,10 @@ impl CachedLspAdapter {
     ) -> Option<CodeLabel> {
         self.adapter.label_for_symbol(name, kind, language).await
     }
+
+    pub fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        self.adapter.enabled_formatters()
+    }
 }
 
 pub trait LspAdapterDelegate: Send + Sync {
@@ -333,6 +337,33 @@ pub trait LspAdapter: 'static + Send + Sync {
     async fn language_ids(&self) -> HashMap<String, String> {
         Default::default()
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        Vec::new()
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum BundledFormatter {
+    Prettier {
+        // See https://prettier.io/docs/en/options.html#parser for a list of valid values.
+        // Usually, every language has a single parser (standard or plugin-provided), hence `Some("parser_name")` can be used.
+        // There can not be multiple parsers for a single language, in case of a conflict, we would attempt to select the one with most plugins.
+        //
+        // But exceptions like Tailwind CSS exist, which uses standard parsers for CSS/JS/HTML/etc. but require an extra plugin to be installed.
+        // For those cases, `None` will install the plugin but apply other, regular parser defined for the language, and this would not be a conflict.
+        parser_name: Option<&'static str>,
+        plugin_names: Vec<&'static str>,
+    },
+}
+
+impl BundledFormatter {
+    pub fn prettier(parser_name: &'static str) -> Self {
+        Self::Prettier {
+            parser_name: Some(parser_name),
+            plugin_names: Vec::new(),
+        }
+    }
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -467,6 +498,7 @@ pub struct FakeLspAdapter {
     pub initializer: Option<Box<dyn 'static + Send + Sync + Fn(&mut lsp::FakeLanguageServer)>>,
     pub disk_based_diagnostics_progress_token: Option<String>,
     pub disk_based_diagnostics_sources: Vec<String>,
+    pub enabled_formatters: Vec<BundledFormatter>,
 }
 
 #[derive(Clone, Debug, Default)]
@@ -1729,6 +1761,7 @@ impl Default for FakeLspAdapter {
             disk_based_diagnostics_progress_token: None,
             initialization_options: None,
             disk_based_diagnostics_sources: Vec::new(),
+            enabled_formatters: Vec::new(),
         }
     }
 }
@@ -1785,6 +1818,10 @@ impl LspAdapter for Arc<FakeLspAdapter> {
     async fn initialization_options(&self) -> Option<Value> {
         self.initialization_options.clone()
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        self.enabled_formatters.clone()
+    }
 }
 
 fn get_capture_indices(query: &Query, captures: &mut [(&str, &mut Option<u32>)]) {

crates/language/src/language_settings.rs 🔗

@@ -50,6 +50,7 @@ pub struct LanguageSettings {
     pub remove_trailing_whitespace_on_save: bool,
     pub ensure_final_newline_on_save: bool,
     pub formatter: Formatter,
+    pub prettier: HashMap<String, serde_json::Value>,
     pub enable_language_server: bool,
     pub show_copilot_suggestions: bool,
     pub show_whitespaces: ShowWhitespaceSetting,
@@ -98,6 +99,8 @@ pub struct LanguageSettingsContent {
     #[serde(default)]
     pub formatter: Option<Formatter>,
     #[serde(default)]
+    pub prettier: Option<HashMap<String, serde_json::Value>>,
+    #[serde(default)]
     pub enable_language_server: Option<bool>,
     #[serde(default)]
     pub show_copilot_suggestions: Option<bool>,
@@ -149,10 +152,13 @@ pub enum ShowWhitespaceSetting {
     All,
 }
 
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum Formatter {
+    #[default]
+    Auto,
     LanguageServer,
+    Prettier,
     External {
         command: Arc<str>,
         arguments: Arc<[String]>,
@@ -392,6 +398,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent
         src.preferred_line_length,
     );
     merge(&mut settings.formatter, src.formatter.clone());
+    merge(&mut settings.prettier, src.prettier.clone());
     merge(&mut settings.format_on_save, src.format_on_save.clone());
     merge(
         &mut settings.remove_trailing_whitespace_on_save,

crates/node_runtime/src/node_runtime.rs 🔗

@@ -220,29 +220,129 @@ impl NodeRuntime for RealNodeRuntime {
     }
 }
 
-pub struct FakeNodeRuntime;
+pub struct FakeNodeRuntime(Option<PrettierSupport>);
+
+struct PrettierSupport {
+    plugins: Vec<&'static str>,
+}
 
 impl FakeNodeRuntime {
     pub fn new() -> Arc<dyn NodeRuntime> {
-        Arc::new(FakeNodeRuntime)
+        Arc::new(FakeNodeRuntime(None))
+    }
+
+    pub fn with_prettier_support(plugins: &[&'static str]) -> Arc<dyn NodeRuntime> {
+        Arc::new(FakeNodeRuntime(Some(PrettierSupport::new(plugins))))
     }
 }
 
 #[async_trait::async_trait]
 impl NodeRuntime for FakeNodeRuntime {
-    async fn binary_path(&self) -> Result<PathBuf> {
-        unreachable!()
+    async fn binary_path(&self) -> anyhow::Result<PathBuf> {
+        if let Some(prettier_support) = &self.0 {
+            prettier_support.binary_path().await
+        } else {
+            unreachable!()
+        }
+    }
+
+    async fn run_npm_subcommand(
+        &self,
+        directory: Option<&Path>,
+        subcommand: &str,
+        args: &[&str],
+    ) -> anyhow::Result<Output> {
+        if let Some(prettier_support) = &self.0 {
+            prettier_support
+                .run_npm_subcommand(directory, subcommand, args)
+                .await
+        } else {
+            unreachable!()
+        }
+    }
+
+    async fn npm_package_latest_version(&self, name: &str) -> anyhow::Result<String> {
+        if let Some(prettier_support) = &self.0 {
+            prettier_support.npm_package_latest_version(name).await
+        } else {
+            unreachable!()
+        }
+    }
+
+    async fn npm_install_packages(
+        &self,
+        directory: &Path,
+        packages: &[(&str, &str)],
+    ) -> anyhow::Result<()> {
+        if let Some(prettier_support) = &self.0 {
+            prettier_support
+                .npm_install_packages(directory, packages)
+                .await
+        } else {
+            unreachable!()
+        }
+    }
+}
+
+impl PrettierSupport {
+    const PACKAGE_VERSION: &str = "0.0.1";
+
+    fn new(plugins: &[&'static str]) -> Self {
+        Self {
+            plugins: plugins.to_vec(),
+        }
+    }
+}
+
+#[async_trait::async_trait]
+impl NodeRuntime for PrettierSupport {
+    async fn binary_path(&self) -> anyhow::Result<PathBuf> {
+        Ok(PathBuf::from("prettier_fake_node"))
     }
 
     async fn run_npm_subcommand(&self, _: Option<&Path>, _: &str, _: &[&str]) -> Result<Output> {
         unreachable!()
     }
 
-    async fn npm_package_latest_version(&self, _: &str) -> Result<String> {
-        unreachable!()
+    async fn npm_package_latest_version(&self, name: &str) -> anyhow::Result<String> {
+        if name == "prettier" || self.plugins.contains(&name) {
+            Ok(Self::PACKAGE_VERSION.to_string())
+        } else {
+            panic!("Unexpected package name: {name}")
+        }
     }
 
-    async fn npm_install_packages(&self, _: &Path, _: &[(&str, &str)]) -> Result<()> {
-        unreachable!()
+    async fn npm_install_packages(
+        &self,
+        _: &Path,
+        packages: &[(&str, &str)],
+    ) -> anyhow::Result<()> {
+        assert_eq!(
+            packages.len(),
+            self.plugins.len() + 1,
+            "Unexpected packages length to install: {:?}, expected `prettier` + {:?}",
+            packages,
+            self.plugins
+        );
+        for (name, version) in packages {
+            assert!(
+                name == &"prettier" || self.plugins.contains(name),
+                "Unexpected package `{}` to install in packages {:?}, expected {} for `prettier` + {:?}",
+                name,
+                packages,
+                Self::PACKAGE_VERSION,
+                self.plugins
+            );
+            assert_eq!(
+                version,
+                &Self::PACKAGE_VERSION,
+                "Unexpected package version `{}` to install in packages {:?}, expected {} for `prettier` + {:?}",
+                version,
+                packages,
+                Self::PACKAGE_VERSION,
+                self.plugins
+            );
+        }
+        Ok(())
     }
 }

crates/prettier/Cargo.toml 🔗

@@ -0,0 +1,34 @@
+[package]
+name = "prettier"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/prettier.rs"
+doctest = false
+
+[features]
+test-support = []
+
+[dependencies]
+client = { path = "../client" }
+collections = { path = "../collections"}
+language = { path = "../language" }
+gpui = { path = "../gpui" }
+fs = { path = "../fs" }
+lsp = { path = "../lsp" }
+node_runtime = { path = "../node_runtime"}
+util = { path = "../util" }
+
+log.workspace = true
+serde.workspace = true
+serde_derive.workspace = true
+serde_json.workspace = true
+anyhow.workspace = true
+futures.workspace = true
+
+[dev-dependencies]
+language = { path = "../language", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
+fs = { path = "../fs",  features = ["test-support"] }

crates/prettier/src/prettier.rs 🔗

@@ -0,0 +1,513 @@
+use std::collections::VecDeque;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use anyhow::Context;
+use collections::{HashMap, HashSet};
+use fs::Fs;
+use gpui::{AsyncAppContext, ModelHandle};
+use language::language_settings::language_settings;
+use language::{Buffer, BundledFormatter, Diff};
+use lsp::{LanguageServer, LanguageServerId};
+use node_runtime::NodeRuntime;
+use serde::{Deserialize, Serialize};
+use util::paths::DEFAULT_PRETTIER_DIR;
+
+pub enum Prettier {
+    Real(RealPrettier),
+    #[cfg(any(test, feature = "test-support"))]
+    Test(TestPrettier),
+}
+
+pub struct RealPrettier {
+    worktree_id: Option<usize>,
+    default: bool,
+    prettier_dir: PathBuf,
+    server: Arc<LanguageServer>,
+}
+
+#[cfg(any(test, feature = "test-support"))]
+pub struct TestPrettier {
+    worktree_id: Option<usize>,
+    prettier_dir: PathBuf,
+    default: bool,
+}
+
+#[derive(Debug)]
+pub struct LocateStart {
+    pub worktree_root_path: Arc<Path>,
+    pub starting_path: Arc<Path>,
+}
+
+pub const PRETTIER_SERVER_FILE: &str = "prettier_server.js";
+pub const PRETTIER_SERVER_JS: &str = include_str!("./prettier_server.js");
+const PRETTIER_PACKAGE_NAME: &str = "prettier";
+const TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME: &str = "prettier-plugin-tailwindcss";
+
+impl Prettier {
+    pub const CONFIG_FILE_NAMES: &'static [&'static str] = &[
+        ".prettierrc",
+        ".prettierrc.json",
+        ".prettierrc.json5",
+        ".prettierrc.yaml",
+        ".prettierrc.yml",
+        ".prettierrc.toml",
+        ".prettierrc.js",
+        ".prettierrc.cjs",
+        "package.json",
+        "prettier.config.js",
+        "prettier.config.cjs",
+        ".editorconfig",
+    ];
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub const FORMAT_SUFFIX: &str = "\nformatted by test prettier";
+
+    pub async fn locate(
+        starting_path: Option<LocateStart>,
+        fs: Arc<dyn Fs>,
+    ) -> anyhow::Result<PathBuf> {
+        let paths_to_check = match starting_path.as_ref() {
+            Some(starting_path) => {
+                let worktree_root = starting_path
+                    .worktree_root_path
+                    .components()
+                    .into_iter()
+                    .take_while(|path_component| {
+                        path_component.as_os_str().to_string_lossy() != "node_modules"
+                    })
+                    .collect::<PathBuf>();
+
+                if worktree_root != starting_path.worktree_root_path.as_ref() {
+                    vec![worktree_root]
+                } else {
+                    let (worktree_root_metadata, start_path_metadata) = if starting_path
+                        .starting_path
+                        .as_ref()
+                        == Path::new("")
+                    {
+                        let worktree_root_data =
+                            fs.metadata(&worktree_root).await.with_context(|| {
+                                format!(
+                                    "FS metadata fetch for worktree root path {worktree_root:?}",
+                                )
+                            })?;
+                        (worktree_root_data.unwrap_or_else(|| {
+                            panic!("cannot query prettier for non existing worktree root at {worktree_root_data:?}")
+                        }), None)
+                    } else {
+                        let full_starting_path = worktree_root.join(&starting_path.starting_path);
+                        let (worktree_root_data, start_path_data) = futures::try_join!(
+                            fs.metadata(&worktree_root),
+                            fs.metadata(&full_starting_path),
+                        )
+                        .with_context(|| {
+                            format!("FS metadata fetch for starting path {full_starting_path:?}",)
+                        })?;
+                        (
+                            worktree_root_data.unwrap_or_else(|| {
+                                panic!("cannot query prettier for non existing worktree root at {worktree_root_data:?}")
+                            }),
+                            start_path_data,
+                        )
+                    };
+
+                    match start_path_metadata {
+                        Some(start_path_metadata) => {
+                            anyhow::ensure!(worktree_root_metadata.is_dir,
+                                "For non-empty start path, worktree root {starting_path:?} should be a directory");
+                            anyhow::ensure!(
+                                !start_path_metadata.is_dir,
+                                "For non-empty start path, it should not be a directory {starting_path:?}"
+                            );
+                            anyhow::ensure!(
+                                !start_path_metadata.is_symlink,
+                                "For non-empty start path, it should not be a symlink {starting_path:?}"
+                            );
+
+                            let file_to_format = starting_path.starting_path.as_ref();
+                            let mut paths_to_check = VecDeque::from(vec![worktree_root.clone()]);
+                            let mut current_path = worktree_root;
+                            for path_component in file_to_format.components().into_iter() {
+                                current_path = current_path.join(path_component);
+                                paths_to_check.push_front(current_path.clone());
+                                if path_component.as_os_str().to_string_lossy() == "node_modules" {
+                                    break;
+                                }
+                            }
+                            paths_to_check.pop_front(); // last one is the file itself or node_modules, skip it
+                            Vec::from(paths_to_check)
+                        }
+                        None => {
+                            anyhow::ensure!(
+                                !worktree_root_metadata.is_dir,
+                                "For empty start path, worktree root should not be a directory {starting_path:?}"
+                            );
+                            anyhow::ensure!(
+                                !worktree_root_metadata.is_symlink,
+                                "For empty start path, worktree root should not be a symlink {starting_path:?}"
+                            );
+                            worktree_root
+                                .parent()
+                                .map(|path| vec![path.to_path_buf()])
+                                .unwrap_or_default()
+                        }
+                    }
+                }
+            }
+            None => Vec::new(),
+        };
+
+        match find_closest_prettier_dir(paths_to_check, fs.as_ref())
+            .await
+            .with_context(|| format!("finding prettier starting with {starting_path:?}"))?
+        {
+            Some(prettier_dir) => Ok(prettier_dir),
+            None => Ok(DEFAULT_PRETTIER_DIR.to_path_buf()),
+        }
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub async fn start(
+        worktree_id: Option<usize>,
+        _: LanguageServerId,
+        prettier_dir: PathBuf,
+        _: Arc<dyn NodeRuntime>,
+        _: AsyncAppContext,
+    ) -> anyhow::Result<Self> {
+        Ok(
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Test(TestPrettier {
+                worktree_id,
+                default: prettier_dir == DEFAULT_PRETTIER_DIR.as_path(),
+                prettier_dir,
+            }),
+        )
+    }
+
+    #[cfg(not(any(test, feature = "test-support")))]
+    pub async fn start(
+        worktree_id: Option<usize>,
+        server_id: LanguageServerId,
+        prettier_dir: PathBuf,
+        node: Arc<dyn NodeRuntime>,
+        cx: AsyncAppContext,
+    ) -> anyhow::Result<Self> {
+        use lsp::LanguageServerBinary;
+
+        let backgroud = cx.background();
+        anyhow::ensure!(
+            prettier_dir.is_dir(),
+            "Prettier dir {prettier_dir:?} is not a directory"
+        );
+        let prettier_server = DEFAULT_PRETTIER_DIR.join(PRETTIER_SERVER_FILE);
+        anyhow::ensure!(
+            prettier_server.is_file(),
+            "no prettier server package found at {prettier_server:?}"
+        );
+
+        let node_path = backgroud
+            .spawn(async move { node.binary_path().await })
+            .await?;
+        let server = LanguageServer::new(
+            server_id,
+            LanguageServerBinary {
+                path: node_path,
+                arguments: vec![prettier_server.into(), prettier_dir.as_path().into()],
+            },
+            Path::new("/"),
+            None,
+            cx,
+        )
+        .context("prettier server creation")?;
+        let server = backgroud
+            .spawn(server.initialize(None))
+            .await
+            .context("prettier server initialization")?;
+        Ok(Self::Real(RealPrettier {
+            worktree_id,
+            server,
+            default: prettier_dir == DEFAULT_PRETTIER_DIR.as_path(),
+            prettier_dir,
+        }))
+    }
+
+    pub async fn format(
+        &self,
+        buffer: &ModelHandle<Buffer>,
+        buffer_path: Option<PathBuf>,
+        cx: &AsyncAppContext,
+    ) -> anyhow::Result<Diff> {
+        match self {
+            Self::Real(local) => {
+                let params = buffer.read_with(cx, |buffer, cx| {
+                    let buffer_language = buffer.language();
+                    let parsers_with_plugins = buffer_language
+                        .into_iter()
+                        .flat_map(|language| {
+                            language
+                                .lsp_adapters()
+                                .iter()
+                                .flat_map(|adapter| adapter.enabled_formatters())
+                                .filter_map(|formatter| match formatter {
+                                    BundledFormatter::Prettier {
+                                        parser_name,
+                                        plugin_names,
+                                    } => Some((parser_name, plugin_names)),
+                                })
+                        })
+                        .fold(
+                            HashMap::default(),
+                            |mut parsers_with_plugins, (parser_name, plugins)| {
+                                match parser_name {
+                                    Some(parser_name) => parsers_with_plugins
+                                        .entry(parser_name)
+                                        .or_insert_with(HashSet::default)
+                                        .extend(plugins),
+                                    None => parsers_with_plugins.values_mut().for_each(|existing_plugins| {
+                                        existing_plugins.extend(plugins.iter());
+                                    }),
+                                }
+                                parsers_with_plugins
+                            },
+                        );
+
+                    let selected_parser_with_plugins = parsers_with_plugins.iter().max_by_key(|(_, plugins)| plugins.len());
+                    if parsers_with_plugins.len() > 1 {
+                        log::warn!("Found multiple parsers with plugins {parsers_with_plugins:?}, will select only one: {selected_parser_with_plugins:?}");
+                    }
+
+                    let prettier_node_modules = self.prettier_dir().join("node_modules");
+                    anyhow::ensure!(prettier_node_modules.is_dir(), "Prettier node_modules dir does not exist: {prettier_node_modules:?}");
+                    let plugin_name_into_path = |plugin_name: &str| {
+                        let prettier_plugin_dir = prettier_node_modules.join(plugin_name);
+                        for possible_plugin_path in [
+                            prettier_plugin_dir.join("dist").join("index.mjs"),
+                            prettier_plugin_dir.join("dist").join("index.js"),
+                            prettier_plugin_dir.join("dist").join("plugin.js"),
+                            prettier_plugin_dir.join("index.mjs"),
+                            prettier_plugin_dir.join("index.js"),
+                            prettier_plugin_dir.join("plugin.js"),
+                            prettier_plugin_dir,
+                        ] {
+                            if possible_plugin_path.is_file() {
+                                return Some(possible_plugin_path);
+                            }
+                        }
+                        None
+                    };
+                    let (parser, located_plugins) = match selected_parser_with_plugins {
+                        Some((parser, plugins)) => {
+                            // Tailwind plugin requires being added last
+                            // https://github.com/tailwindlabs/prettier-plugin-tailwindcss#compatibility-with-other-prettier-plugins
+                            let mut add_tailwind_back = false;
+
+                            let mut plugins = plugins.into_iter().filter(|&&plugin_name| {
+                                if plugin_name == TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME {
+                                    add_tailwind_back = true;
+                                    false
+                                } else {
+                                    true
+                                }
+                            }).map(|plugin_name| (plugin_name, plugin_name_into_path(plugin_name))).collect::<Vec<_>>();
+                            if add_tailwind_back {
+                                plugins.push((&TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME, plugin_name_into_path(TAILWIND_PRETTIER_PLUGIN_PACKAGE_NAME)));
+                            }
+                            (Some(parser.to_string()), plugins)
+                        },
+                        None => (None, Vec::new()),
+                    };
+
+                    let prettier_options = if self.is_default() {
+                        let language_settings = language_settings(buffer_language, buffer.file(), cx);
+                        let mut options = language_settings.prettier.clone();
+                        if !options.contains_key("tabWidth") {
+                            options.insert(
+                                "tabWidth".to_string(),
+                                serde_json::Value::Number(serde_json::Number::from(
+                                    language_settings.tab_size.get(),
+                                )),
+                            );
+                        }
+                        if !options.contains_key("printWidth") {
+                            options.insert(
+                                "printWidth".to_string(),
+                                serde_json::Value::Number(serde_json::Number::from(
+                                    language_settings.preferred_line_length,
+                                )),
+                            );
+                        }
+                        Some(options)
+                    } else {
+                        None
+                    };
+
+                    let plugins = located_plugins.into_iter().filter_map(|(plugin_name, located_plugin_path)| {
+                        match located_plugin_path {
+                            Some(path) => Some(path),
+                            None => {
+                                log::error!("Have not found plugin path for {plugin_name:?} inside {prettier_node_modules:?}");
+                                None},
+                        }
+                    }).collect();
+                    log::debug!("Formatting file {:?} with prettier, plugins :{plugins:?}, options: {prettier_options:?}", buffer.file().map(|f| f.full_path(cx)));
+
+                    anyhow::Ok(FormatParams {
+                        text: buffer.text(),
+                        options: FormatOptions {
+                            parser,
+                            plugins,
+                            path: buffer_path,
+                            prettier_options,
+                        },
+                    })
+                }).context("prettier params calculation")?;
+                let response = local
+                    .server
+                    .request::<Format>(params)
+                    .await
+                    .context("prettier format request")?;
+                let diff_task = buffer.read_with(cx, |buffer, cx| buffer.diff(response.text, cx));
+                Ok(diff_task.await)
+            }
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Test(_) => Ok(buffer
+                .read_with(cx, |buffer, cx| {
+                    let formatted_text = buffer.text() + Self::FORMAT_SUFFIX;
+                    buffer.diff(formatted_text, cx)
+                })
+                .await),
+        }
+    }
+
+    pub async fn clear_cache(&self) -> anyhow::Result<()> {
+        match self {
+            Self::Real(local) => local
+                .server
+                .request::<ClearCache>(())
+                .await
+                .context("prettier clear cache"),
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Test(_) => Ok(()),
+        }
+    }
+
+    pub fn server(&self) -> Option<&Arc<LanguageServer>> {
+        match self {
+            Self::Real(local) => Some(&local.server),
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Test(_) => None,
+        }
+    }
+
+    pub fn is_default(&self) -> bool {
+        match self {
+            Self::Real(local) => local.default,
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Test(test_prettier) => test_prettier.default,
+        }
+    }
+
+    pub fn prettier_dir(&self) -> &Path {
+        match self {
+            Self::Real(local) => &local.prettier_dir,
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Test(test_prettier) => &test_prettier.prettier_dir,
+        }
+    }
+
+    pub fn worktree_id(&self) -> Option<usize> {
+        match self {
+            Self::Real(local) => local.worktree_id,
+            #[cfg(any(test, feature = "test-support"))]
+            Self::Test(test_prettier) => test_prettier.worktree_id,
+        }
+    }
+}
+
+async fn find_closest_prettier_dir(
+    paths_to_check: Vec<PathBuf>,
+    fs: &dyn Fs,
+) -> anyhow::Result<Option<PathBuf>> {
+    for path in paths_to_check {
+        let possible_package_json = path.join("package.json");
+        if let Some(package_json_metadata) = fs
+            .metadata(&possible_package_json)
+            .await
+            .with_context(|| format!("Fetching metadata for {possible_package_json:?}"))?
+        {
+            if !package_json_metadata.is_dir && !package_json_metadata.is_symlink {
+                let package_json_contents = fs
+                    .load(&possible_package_json)
+                    .await
+                    .with_context(|| format!("reading {possible_package_json:?} file contents"))?;
+                if let Ok(json_contents) = serde_json::from_str::<HashMap<String, serde_json::Value>>(
+                    &package_json_contents,
+                ) {
+                    if let Some(serde_json::Value::Object(o)) = json_contents.get("dependencies") {
+                        if o.contains_key(PRETTIER_PACKAGE_NAME) {
+                            return Ok(Some(path));
+                        }
+                    }
+                    if let Some(serde_json::Value::Object(o)) = json_contents.get("devDependencies")
+                    {
+                        if o.contains_key(PRETTIER_PACKAGE_NAME) {
+                            return Ok(Some(path));
+                        }
+                    }
+                }
+            }
+        }
+
+        let possible_node_modules_location = path.join("node_modules").join(PRETTIER_PACKAGE_NAME);
+        if let Some(node_modules_location_metadata) = fs
+            .metadata(&possible_node_modules_location)
+            .await
+            .with_context(|| format!("fetching metadata for {possible_node_modules_location:?}"))?
+        {
+            if node_modules_location_metadata.is_dir {
+                return Ok(Some(path));
+            }
+        }
+    }
+    Ok(None)
+}
+
+enum Format {}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct FormatParams {
+    text: String,
+    options: FormatOptions,
+}
+
+#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct FormatOptions {
+    plugins: Vec<PathBuf>,
+    parser: Option<String>,
+    #[serde(rename = "filepath")]
+    path: Option<PathBuf>,
+    prettier_options: Option<HashMap<String, serde_json::Value>>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct FormatResult {
+    text: String,
+}
+
+impl lsp::request::Request for Format {
+    type Params = FormatParams;
+    type Result = FormatResult;
+    const METHOD: &'static str = "prettier/format";
+}
+
+enum ClearCache {}
+
+impl lsp::request::Request for ClearCache {
+    type Params = ();
+    type Result = ();
+    const METHOD: &'static str = "prettier/clear_cache";
+}

crates/prettier/src/prettier_server.js 🔗

@@ -0,0 +1,217 @@
+const { Buffer } = require('buffer');
+const fs = require("fs");
+const path = require("path");
+const { once } = require('events');
+
+const prettierContainerPath = process.argv[2];
+if (prettierContainerPath == null || prettierContainerPath.length == 0) {
+    process.stderr.write(`Prettier path argument was not specified or empty.\nUsage: ${process.argv[0]} ${process.argv[1]} prettier/path\n`);
+    process.exit(1);
+}
+fs.stat(prettierContainerPath, (err, stats) => {
+    if (err) {
+        process.stderr.write(`Path '${prettierContainerPath}' does not exist\n`);
+        process.exit(1);
+    }
+
+    if (!stats.isDirectory()) {
+        process.stderr.write(`Path '${prettierContainerPath}' exists but is not a directory\n`);
+        process.exit(1);
+    }
+});
+const prettierPath = path.join(prettierContainerPath, 'node_modules/prettier');
+
+class Prettier {
+    constructor(path, prettier, config) {
+        this.path = path;
+        this.prettier = prettier;
+        this.config = config;
+    }
+}
+
+(async () => {
+    let prettier;
+    let config;
+    try {
+        prettier = await loadPrettier(prettierPath);
+        config = await prettier.resolveConfig(prettierPath) || {};
+    } catch (e) {
+        process.stderr.write(`Failed to load prettier: ${e}\n`);
+        process.exit(1);
+    }
+    process.stderr.write(`Prettier at path '${prettierPath}' loaded successfully, config: ${JSON.stringify(config)}\n`);
+    process.stdin.resume();
+    handleBuffer(new Prettier(prettierPath, prettier, config));
+})()
+
+async function handleBuffer(prettier) {
+    for await (const messageText of readStdin()) {
+        let message;
+        try {
+            message = JSON.parse(messageText);
+        } catch (e) {
+            sendResponse(makeError(`Failed to parse message '${messageText}': ${e}`));
+            continue;
+        }
+        // allow concurrent request handling by not `await`ing the message handling promise (async function)
+        handleMessage(message, prettier).catch(e => {
+            sendResponse({ id: message.id, ...makeError(`error during message handling: ${e}`) });
+        });
+    }
+}
+
+const headerSeparator = "\r\n";
+const contentLengthHeaderName = 'Content-Length';
+
+async function* readStdin() {
+    let buffer = Buffer.alloc(0);
+    let streamEnded = false;
+    process.stdin.on('end', () => {
+        streamEnded = true;
+    });
+    process.stdin.on('data', (data) => {
+        buffer = Buffer.concat([buffer, data]);
+    });
+
+    async function handleStreamEnded(errorMessage) {
+        sendResponse(makeError(errorMessage));
+        buffer = Buffer.alloc(0);
+        messageLength = null;
+        await once(process.stdin, 'readable');
+        streamEnded = false;
+    }
+
+    try {
+        let headersLength = null;
+        let messageLength = null;
+        main_loop: while (true) {
+            if (messageLength === null) {
+                while (buffer.indexOf(`${headerSeparator}${headerSeparator}`) === -1) {
+                    if (streamEnded) {
+                        await handleStreamEnded('Unexpected end of stream: headers not found');
+                        continue main_loop;
+                    } else if (buffer.length > contentLengthHeaderName.length * 10) {
+                        await handleStreamEnded(`Unexpected stream of bytes: no headers end found after ${buffer.length} bytes of input`);
+                        continue main_loop;
+                    }
+                    await once(process.stdin, 'readable');
+                }
+                const headers = buffer.subarray(0, buffer.indexOf(`${headerSeparator}${headerSeparator}`)).toString('ascii');
+                const contentLengthHeader = headers.split(headerSeparator)
+                    .map(header => header.split(':'))
+                    .filter(header => header[2] === undefined)
+                    .filter(header => (header[1] || '').length > 0)
+                    .find(header => (header[0] || '').trim() === contentLengthHeaderName);
+                const contentLength = (contentLengthHeader || [])[1];
+                if (contentLength === undefined) {
+                    await handleStreamEnded(`Missing or incorrect ${contentLengthHeaderName} header: ${headers}`);
+                    continue main_loop;
+                }
+                headersLength = headers.length + headerSeparator.length * 2;
+                messageLength = parseInt(contentLength, 10);
+            }
+
+            while (buffer.length < (headersLength + messageLength)) {
+                if (streamEnded) {
+                    await handleStreamEnded(
+                        `Unexpected end of stream: buffer length ${buffer.length} does not match expected header length ${headersLength} + body length ${messageLength}`);
+                    continue main_loop;
+                }
+                await once(process.stdin, 'readable');
+            }
+
+            const messageEnd = headersLength + messageLength;
+            const message = buffer.subarray(headersLength, messageEnd);
+            buffer = buffer.subarray(messageEnd);
+            headersLength = null;
+            messageLength = null;
+            yield message.toString('utf8');
+        }
+    } catch (e) {
+        sendResponse(makeError(`Error reading stdin: ${e}`));
+    } finally {
+        process.stdin.off('data', () => { });
+    }
+}
+
+async function handleMessage(message, prettier) {
+    const { method, id, params } = message;
+    if (method === undefined) {
+        throw new Error(`Message method is undefined: ${JSON.stringify(message)}`);
+    }
+    if (id === undefined) {
+        throw new Error(`Message id is undefined: ${JSON.stringify(message)}`);
+    }
+
+    if (method === 'prettier/format') {
+        if (params === undefined || params.text === undefined) {
+            throw new Error(`Message params.text is undefined: ${JSON.stringify(message)}`);
+        }
+        if (params.options === undefined) {
+            throw new Error(`Message params.options is undefined: ${JSON.stringify(message)}`);
+        }
+
+        let resolvedConfig = {};
+        if (params.options.filepath !== undefined) {
+            resolvedConfig = await prettier.prettier.resolveConfig(params.options.filepath) || {};
+        }
+
+        const options = {
+            ...(params.options.prettierOptions || prettier.config),
+            ...resolvedConfig,
+            parser: params.options.parser,
+            plugins: params.options.plugins,
+            path: params.options.filepath
+        };
+        process.stderr.write(`Resolved config: ${JSON.stringify(resolvedConfig)}, will format file '${params.options.filepath || ''}' with options: ${JSON.stringify(options)}\n`);
+        const formattedText = await prettier.prettier.format(params.text, options);
+        sendResponse({ id, result: { text: formattedText } });
+    } else if (method === 'prettier/clear_cache') {
+        prettier.prettier.clearConfigCache();
+        prettier.config = await prettier.prettier.resolveConfig(prettier.path) || {};
+        sendResponse({ id, result: null });
+    } else if (method === 'initialize') {
+        sendResponse({
+            id,
+            result: {
+                "capabilities": {}
+            }
+        });
+    } else {
+        throw new Error(`Unknown method: ${method}`);
+    }
+}
+
+function makeError(message) {
+    return {
+        error: {
+            "code": -32600, // invalid request code
+            message,
+        }
+    };
+}
+
+function sendResponse(response) {
+    const responsePayloadString = JSON.stringify({
+        jsonrpc: "2.0",
+        ...response
+    });
+    const headers = `${contentLengthHeaderName}: ${Buffer.byteLength(responsePayloadString)}${headerSeparator}${headerSeparator}`;
+    process.stdout.write(headers + responsePayloadString);
+}
+
+function loadPrettier(prettierPath) {
+    return new Promise((resolve, reject) => {
+        fs.access(prettierPath, fs.constants.F_OK, (err) => {
+            if (err) {
+                reject(`Path '${prettierPath}' does not exist.Error: ${err}`);
+            } else {
+                try {
+                    resolve(require(prettierPath));
+                } catch (err) {
+                    reject(`Error requiring prettier module from path '${prettierPath}'.Error: ${err}`);
+                }
+            }
+        });
+    });
+}

crates/project/Cargo.toml 🔗

@@ -15,6 +15,7 @@ test-support = [
     "language/test-support",
     "settings/test-support",
     "text/test-support",
+    "prettier/test-support",
 ]
 
 [dependencies]
@@ -31,6 +32,8 @@ git = { path = "../git" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 lsp = { path = "../lsp" }
+node_runtime = { path = "../node_runtime" }
+prettier = { path = "../prettier" }
 rpc = { path = "../rpc" }
 settings = { path = "../settings" }
 sum_tree = { path = "../sum_tree" }
@@ -73,6 +76,7 @@ gpui = { path = "../gpui", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }
 lsp = { path = "../lsp", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
+prettier = { path = "../prettier", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }
 git2.workspace = true

crates/project/src/project.rs 🔗

@@ -20,7 +20,7 @@ use futures::{
         mpsc::{self, UnboundedReceiver},
         oneshot,
     },
-    future::{try_join_all, Shared},
+    future::{self, try_join_all, Shared},
     stream::FuturesUnordered,
     AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt,
 };
@@ -31,17 +31,19 @@ use gpui::{
 };
 use itertools::Itertools;
 use language::{
-    language_settings::{language_settings, FormatOnSave, Formatter, InlayHintKind},
+    language_settings::{
+        language_settings, FormatOnSave, Formatter, InlayHintKind, LanguageSettings,
+    },
     point_to_lsp,
     proto::{
         deserialize_anchor, deserialize_fingerprint, deserialize_line_ending, deserialize_version,
         serialize_anchor, serialize_version, split_operations,
     },
-    range_from_lsp, range_to_lsp, Bias, Buffer, BufferSnapshot, CachedLspAdapter, CodeAction,
-    CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff, Event as BufferEvent,
-    File as _, Language, LanguageRegistry, LanguageServerName, LocalFile, LspAdapterDelegate,
-    OffsetRangeExt, Operation, Patch, PendingLanguageServer, PointUtf16, TextBufferSnapshot,
-    ToOffset, ToPointUtf16, Transaction, Unclipped,
+    range_from_lsp, range_to_lsp, Bias, Buffer, BufferSnapshot, BundledFormatter, CachedLspAdapter,
+    CodeAction, CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Diff,
+    Event as BufferEvent, File as _, Language, LanguageRegistry, LanguageServerName, LocalFile,
+    LspAdapterDelegate, OffsetRangeExt, Operation, Patch, PendingLanguageServer, PointUtf16,
+    TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction, Unclipped,
 };
 use log::error;
 use lsp::{
@@ -49,7 +51,9 @@ use lsp::{
     DocumentHighlightKind, LanguageServer, LanguageServerBinary, LanguageServerId, OneOf,
 };
 use lsp_command::*;
+use node_runtime::NodeRuntime;
 use postage::watch;
+use prettier::{LocateStart, Prettier, PRETTIER_SERVER_FILE, PRETTIER_SERVER_JS};
 use project_settings::{LspSettings, ProjectSettings};
 use rand::prelude::*;
 use search::SearchQuery;
@@ -75,10 +79,13 @@ use std::{
     time::{Duration, Instant},
 };
 use terminals::Terminals;
-use text::Anchor;
+use text::{Anchor, LineEnding, Rope};
 use util::{
-    debug_panic, defer, http::HttpClient, merge_json_value_into,
-    paths::LOCAL_SETTINGS_RELATIVE_PATH, post_inc, ResultExt, TryFutureExt as _,
+    debug_panic, defer,
+    http::HttpClient,
+    merge_json_value_into,
+    paths::{DEFAULT_PRETTIER_DIR, LOCAL_SETTINGS_RELATIVE_PATH},
+    post_inc, ResultExt, TryFutureExt as _,
 };
 
 pub use fs::*;
@@ -152,6 +159,11 @@ pub struct Project {
     copilot_lsp_subscription: Option<gpui::Subscription>,
     copilot_log_subscription: Option<lsp::Subscription>,
     current_lsp_settings: HashMap<Arc<str>, LspSettings>,
+    node: Option<Arc<dyn NodeRuntime>>,
+    prettier_instances: HashMap<
+        (Option<WorktreeId>, PathBuf),
+        Shared<Task<Result<Arc<Prettier>, Arc<anyhow::Error>>>>,
+    >,
 }
 
 struct DelayedDebounced {
@@ -605,6 +617,7 @@ impl Project {
 
     pub fn local(
         client: Arc<Client>,
+        node: Arc<dyn NodeRuntime>,
         user_store: ModelHandle<UserStore>,
         languages: Arc<LanguageRegistry>,
         fs: Arc<dyn Fs>,
@@ -660,6 +673,8 @@ impl Project {
                 copilot_lsp_subscription,
                 copilot_log_subscription: None,
                 current_lsp_settings: settings::get::<ProjectSettings>(cx).lsp.clone(),
+                node: Some(node),
+                prettier_instances: HashMap::default(),
             }
         })
     }
@@ -757,6 +772,8 @@ impl Project {
                 copilot_lsp_subscription,
                 copilot_log_subscription: None,
                 current_lsp_settings: settings::get::<ProjectSettings>(cx).lsp.clone(),
+                node: None,
+                prettier_instances: HashMap::default(),
             };
             for worktree in worktrees {
                 let _ = this.add_worktree(&worktree, cx);
@@ -795,8 +812,16 @@ impl Project {
         let http_client = util::http::FakeHttpClient::with_404_response();
         let client = cx.update(|cx| client::Client::new(http_client.clone(), cx));
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let project =
-            cx.update(|cx| Project::local(client, user_store, Arc::new(languages), fs, cx));
+        let project = cx.update(|cx| {
+            Project::local(
+                client,
+                node_runtime::FakeNodeRuntime::new(),
+                user_store,
+                Arc::new(languages),
+                fs,
+                cx,
+            )
+        });
         for path in root_paths {
             let (tree, _) = project
                 .update(cx, |project, cx| {
@@ -810,19 +835,37 @@ impl Project {
         project
     }
 
+    /// Enables a prettier mock that avoids interacting with node runtime, prettier LSP wrapper, or any real file changes.
+    /// Instead, if appends the suffix to every input, this suffix is returned by this method.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn enable_test_prettier(&mut self, plugins: &[&'static str]) -> &'static str {
+        self.node = Some(node_runtime::FakeNodeRuntime::with_prettier_support(
+            plugins,
+        ));
+        Prettier::FORMAT_SUFFIX
+    }
+
     fn on_settings_changed(&mut self, cx: &mut ModelContext<Self>) {
         let mut language_servers_to_start = Vec::new();
+        let mut language_formatters_to_check = Vec::new();
         for buffer in self.opened_buffers.values() {
             if let Some(buffer) = buffer.upgrade(cx) {
                 let buffer = buffer.read(cx);
-                if let Some((file, language)) = buffer.file().zip(buffer.language()) {
-                    let settings = language_settings(Some(language), Some(file), cx);
+                let buffer_file = File::from_dyn(buffer.file());
+                let buffer_language = buffer.language();
+                let settings = language_settings(buffer_language, buffer.file(), cx);
+                if let Some(language) = buffer_language {
                     if settings.enable_language_server {
-                        if let Some(file) = File::from_dyn(Some(file)) {
+                        if let Some(file) = buffer_file {
                             language_servers_to_start
-                                .push((file.worktree.clone(), language.clone()));
+                                .push((file.worktree.clone(), Arc::clone(language)));
                         }
                     }
+                    language_formatters_to_check.push((
+                        buffer_file.map(|f| f.worktree_id(cx)),
+                        Arc::clone(language),
+                        settings.clone(),
+                    ));
                 }
             }
         }
@@ -875,6 +918,11 @@ impl Project {
                 .detach();
         }
 
+        for (worktree, language, settings) in language_formatters_to_check {
+            self.install_default_formatters(worktree, &language, &settings, cx)
+                .detach_and_log_err(cx);
+        }
+
         // Start all the newly-enabled language servers.
         for (worktree, language) in language_servers_to_start {
             let worktree_path = worktree.read(cx).abs_path();
@@ -2623,7 +2671,26 @@ impl Project {
             }
         });
 
-        if let Some(file) = File::from_dyn(buffer.read(cx).file()) {
+        let buffer_file = buffer.read(cx).file().cloned();
+        let settings = language_settings(Some(&new_language), buffer_file.as_ref(), cx).clone();
+        let buffer_file = File::from_dyn(buffer_file.as_ref());
+        let worktree = buffer_file.as_ref().map(|f| f.worktree_id(cx));
+
+        let task_buffer = buffer.clone();
+        let prettier_installation_task =
+            self.install_default_formatters(worktree, &new_language, &settings, cx);
+        cx.spawn(|project, mut cx| async move {
+            prettier_installation_task.await?;
+            let _ = project
+                .update(&mut cx, |project, cx| {
+                    project.prettier_instance_for_buffer(&task_buffer, cx)
+                })
+                .await;
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+
+        if let Some(file) = buffer_file {
             let worktree = file.worktree.clone();
             if let Some(tree) = worktree.read(cx).as_local() {
                 self.start_language_servers(&worktree, tree.abs_path().clone(), new_language, cx);
@@ -3949,7 +4016,7 @@ impl Project {
         push_to_history: bool,
         trigger: FormatTrigger,
         cx: &mut ModelContext<Project>,
-    ) -> Task<Result<ProjectTransaction>> {
+    ) -> Task<anyhow::Result<ProjectTransaction>> {
         if self.is_local() {
             let mut buffers_with_paths_and_servers = buffers
                 .into_iter()
@@ -4027,6 +4094,7 @@ impl Project {
                     enum FormatOperation {
                         Lsp(Vec<(Range<Anchor>, String)>),
                         External(Diff),
+                        Prettier(Diff),
                     }
 
                     // Apply language-specific formatting using either a language server
@@ -4062,8 +4130,8 @@ impl Project {
                         | (_, FormatOnSave::External { command, arguments }) => {
                             if let Some(buffer_abs_path) = buffer_abs_path {
                                 format_operation = Self::format_via_external_command(
-                                    &buffer,
-                                    &buffer_abs_path,
+                                    buffer,
+                                    buffer_abs_path,
                                     &command,
                                     &arguments,
                                     &mut cx,
@@ -4076,6 +4144,69 @@ impl Project {
                                 .map(FormatOperation::External);
                             }
                         }
+                        (Formatter::Auto, FormatOnSave::On | FormatOnSave::Off) => {
+                            if let Some(prettier_task) = this
+                                .update(&mut cx, |project, cx| {
+                                    project.prettier_instance_for_buffer(buffer, cx)
+                                }).await {
+                                    match prettier_task.await
+                                    {
+                                        Ok(prettier) => {
+                                            let buffer_path = buffer.read_with(&cx, |buffer, cx| {
+                                                File::from_dyn(buffer.file()).map(|file| file.abs_path(cx))
+                                            });
+                                            format_operation = Some(FormatOperation::Prettier(
+                                                prettier
+                                                    .format(buffer, buffer_path, &cx)
+                                                    .await
+                                                    .context("formatting via prettier")?,
+                                            ));
+                                        }
+                                        Err(e) => anyhow::bail!(
+                                            "Failed to create prettier instance for buffer during autoformatting: {e:#}"
+                                        ),
+                                    }
+                            } else if let Some((language_server, buffer_abs_path)) =
+                                language_server.as_ref().zip(buffer_abs_path.as_ref())
+                            {
+                                format_operation = Some(FormatOperation::Lsp(
+                                    Self::format_via_lsp(
+                                        &this,
+                                        &buffer,
+                                        buffer_abs_path,
+                                        &language_server,
+                                        tab_size,
+                                        &mut cx,
+                                    )
+                                    .await
+                                    .context("failed to format via language server")?,
+                                ));
+                            }
+                        }
+                        (Formatter::Prettier { .. }, FormatOnSave::On | FormatOnSave::Off) => {
+                            if let Some(prettier_task) = this
+                                .update(&mut cx, |project, cx| {
+                                    project.prettier_instance_for_buffer(buffer, cx)
+                                }).await {
+                                    match prettier_task.await
+                                    {
+                                        Ok(prettier) => {
+                                            let buffer_path = buffer.read_with(&cx, |buffer, cx| {
+                                                File::from_dyn(buffer.file()).map(|file| file.abs_path(cx))
+                                            });
+                                            format_operation = Some(FormatOperation::Prettier(
+                                                prettier
+                                                    .format(buffer, buffer_path, &cx)
+                                                    .await
+                                                    .context("formatting via prettier")?,
+                                            ));
+                                        }
+                                        Err(e) => anyhow::bail!(
+                                            "Failed to create prettier instance for buffer during formatting: {e:#}"
+                                        ),
+                                    }
+                                }
+                        }
                     };
 
                     buffer.update(&mut cx, |b, cx| {
@@ -4100,6 +4231,9 @@ impl Project {
                                 FormatOperation::External(diff) => {
                                     b.apply_diff(diff, cx);
                                 }
+                                FormatOperation::Prettier(diff) => {
+                                    b.apply_diff(diff, cx);
+                                }
                             }
 
                             if let Some(transaction_id) = whitespace_transaction_id {
@@ -5873,6 +6007,7 @@ impl Project {
                     this.update_local_worktree_buffers(&worktree, changes, cx);
                     this.update_local_worktree_language_servers(&worktree, changes, cx);
                     this.update_local_worktree_settings(&worktree, changes, cx);
+                    this.update_prettier_settings(&worktree, changes, cx);
                     cx.emit(Event::WorktreeUpdatedEntries(
                         worktree.read(cx).id(),
                         changes.clone(),
@@ -6252,6 +6387,69 @@ impl Project {
         .detach();
     }
 
+    fn update_prettier_settings(
+        &self,
+        worktree: &ModelHandle<Worktree>,
+        changes: &[(Arc<Path>, ProjectEntryId, PathChange)],
+        cx: &mut ModelContext<'_, Project>,
+    ) {
+        let prettier_config_files = Prettier::CONFIG_FILE_NAMES
+            .iter()
+            .map(Path::new)
+            .collect::<HashSet<_>>();
+
+        let prettier_config_file_changed = changes
+            .iter()
+            .filter(|(_, _, change)| !matches!(change, PathChange::Loaded))
+            .filter(|(path, _, _)| {
+                !path
+                    .components()
+                    .any(|component| component.as_os_str().to_string_lossy() == "node_modules")
+            })
+            .find(|(path, _, _)| prettier_config_files.contains(path.as_ref()));
+        let current_worktree_id = worktree.read(cx).id();
+        if let Some((config_path, _, _)) = prettier_config_file_changed {
+            log::info!(
+                "Prettier config file {config_path:?} changed, reloading prettier instances for worktree {current_worktree_id}"
+            );
+            let prettiers_to_reload = self
+                .prettier_instances
+                .iter()
+                .filter_map(|((worktree_id, prettier_path), prettier_task)| {
+                    if worktree_id.is_none() || worktree_id == &Some(current_worktree_id) {
+                        Some((*worktree_id, prettier_path.clone(), prettier_task.clone()))
+                    } else {
+                        None
+                    }
+                })
+                .collect::<Vec<_>>();
+
+            cx.background()
+                .spawn(async move {
+                    for task_result in future::join_all(prettiers_to_reload.into_iter().map(|(worktree_id, prettier_path, prettier_task)| {
+                        async move {
+                            prettier_task.await?
+                                .clear_cache()
+                                .await
+                                .with_context(|| {
+                                    format!(
+                                        "clearing prettier {prettier_path:?} cache for worktree {worktree_id:?} on prettier settings update"
+                                    )
+                                })
+                                .map_err(Arc::new)
+                        }
+                    }))
+                    .await
+                    {
+                        if let Err(e) = task_result {
+                            log::error!("Failed to clear cache for prettier: {e:#}");
+                        }
+                    }
+                })
+                .detach();
+        }
+    }
+
     pub fn set_active_path(&mut self, entry: Option<ProjectPath>, cx: &mut ModelContext<Self>) {
         let new_active_entry = entry.and_then(|project_path| {
             let worktree = self.worktree_for_id(project_path.worktree_id, cx)?;
@@ -8109,6 +8307,236 @@ impl Project {
             Vec::new()
         }
     }
+
+    fn prettier_instance_for_buffer(
+        &mut self,
+        buffer: &ModelHandle<Buffer>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Option<Shared<Task<Result<Arc<Prettier>, Arc<anyhow::Error>>>>>> {
+        let buffer = buffer.read(cx);
+        let buffer_file = buffer.file();
+        let Some(buffer_language) = buffer.language() else {
+            return Task::ready(None);
+        };
+        if !buffer_language
+            .lsp_adapters()
+            .iter()
+            .flat_map(|adapter| adapter.enabled_formatters())
+            .any(|formatter| matches!(formatter, BundledFormatter::Prettier { .. }))
+        {
+            return Task::ready(None);
+        }
+
+        let buffer_file = File::from_dyn(buffer_file);
+        let buffer_path = buffer_file.map(|file| Arc::clone(file.path()));
+        let worktree_path = buffer_file
+            .as_ref()
+            .and_then(|file| Some(file.worktree.read(cx).abs_path()));
+        let worktree_id = buffer_file.map(|file| file.worktree_id(cx));
+        if self.is_local() || worktree_id.is_none() || worktree_path.is_none() {
+            let Some(node) = self.node.as_ref().map(Arc::clone) else {
+                return Task::ready(None);
+            };
+            cx.spawn(|this, mut cx| async move {
+                let fs = this.update(&mut cx, |project, _| Arc::clone(&project.fs));
+                let prettier_dir = match cx
+                    .background()
+                    .spawn(Prettier::locate(
+                        worktree_path.zip(buffer_path).map(
+                            |(worktree_root_path, starting_path)| LocateStart {
+                                worktree_root_path,
+                                starting_path,
+                            },
+                        ),
+                        fs,
+                    ))
+                    .await
+                {
+                    Ok(path) => path,
+                    Err(e) => {
+                        return Some(
+                            Task::ready(Err(Arc::new(e.context(
+                                "determining prettier path for worktree {worktree_path:?}",
+                            ))))
+                            .shared(),
+                        );
+                    }
+                };
+
+                if let Some(existing_prettier) = this.update(&mut cx, |project, _| {
+                    project
+                        .prettier_instances
+                        .get(&(worktree_id, prettier_dir.clone()))
+                        .cloned()
+                }) {
+                    return Some(existing_prettier);
+                }
+
+                log::info!("Found prettier in {prettier_dir:?}, starting.");
+                let task_prettier_dir = prettier_dir.clone();
+                let weak_project = this.downgrade();
+                let new_server_id =
+                    this.update(&mut cx, |this, _| this.languages.next_language_server_id());
+                let new_prettier_task = cx
+                    .spawn(|mut cx| async move {
+                        let prettier = Prettier::start(
+                            worktree_id.map(|id| id.to_usize()),
+                            new_server_id,
+                            task_prettier_dir,
+                            node,
+                            cx.clone(),
+                        )
+                        .await
+                        .context("prettier start")
+                        .map_err(Arc::new)?;
+                        log::info!("Started prettier in {:?}", prettier.prettier_dir());
+
+                        if let Some((project, prettier_server)) =
+                            weak_project.upgrade(&mut cx).zip(prettier.server())
+                        {
+                            project.update(&mut cx, |project, cx| {
+                                let name = if prettier.is_default() {
+                                    LanguageServerName(Arc::from("prettier (default)"))
+                                } else {
+                                    let prettier_dir = prettier.prettier_dir();
+                                    let worktree_path = prettier
+                                        .worktree_id()
+                                        .map(WorktreeId::from_usize)
+                                        .and_then(|id| project.worktree_for_id(id, cx))
+                                        .map(|worktree| worktree.read(cx).abs_path());
+                                    match worktree_path {
+                                        Some(worktree_path) => {
+                                            if worktree_path.as_ref() == prettier_dir {
+                                                LanguageServerName(Arc::from(format!(
+                                                    "prettier ({})",
+                                                    prettier_dir
+                                                        .file_name()
+                                                        .and_then(|name| name.to_str())
+                                                        .unwrap_or_default()
+                                                )))
+                                            } else {
+                                                let dir_to_display = match prettier_dir
+                                                    .strip_prefix(&worktree_path)
+                                                    .ok()
+                                                {
+                                                    Some(relative_path) => relative_path,
+                                                    None => prettier_dir,
+                                                };
+                                                LanguageServerName(Arc::from(format!(
+                                                    "prettier ({})",
+                                                    dir_to_display.display(),
+                                                )))
+                                            }
+                                        }
+                                        None => LanguageServerName(Arc::from(format!(
+                                            "prettier ({})",
+                                            prettier_dir.display(),
+                                        ))),
+                                    }
+                                };
+
+                                project
+                                    .supplementary_language_servers
+                                    .insert(new_server_id, (name, Arc::clone(prettier_server)));
+                                cx.emit(Event::LanguageServerAdded(new_server_id));
+                            });
+                        }
+                        Ok(Arc::new(prettier)).map_err(Arc::new)
+                    })
+                    .shared();
+                this.update(&mut cx, |project, _| {
+                    project
+                        .prettier_instances
+                        .insert((worktree_id, prettier_dir), new_prettier_task.clone());
+                });
+                Some(new_prettier_task)
+            })
+        } else if self.remote_id().is_some() {
+            return Task::ready(None);
+        } else {
+            Task::ready(Some(
+                Task::ready(Err(Arc::new(anyhow!("project does not have a remote id")))).shared(),
+            ))
+        }
+    }
+
+    fn install_default_formatters(
+        &self,
+        worktree: Option<WorktreeId>,
+        new_language: &Language,
+        language_settings: &LanguageSettings,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<anyhow::Result<()>> {
+        match &language_settings.formatter {
+            Formatter::Prettier { .. } | Formatter::Auto => {}
+            Formatter::LanguageServer | Formatter::External { .. } => return Task::ready(Ok(())),
+        };
+        let Some(node) = self.node.as_ref().cloned() else {
+            return Task::ready(Ok(()));
+        };
+
+        let mut prettier_plugins = None;
+        for formatter in new_language
+            .lsp_adapters()
+            .into_iter()
+            .flat_map(|adapter| adapter.enabled_formatters())
+        {
+            match formatter {
+                BundledFormatter::Prettier { plugin_names, .. } => prettier_plugins
+                    .get_or_insert_with(|| HashSet::default())
+                    .extend(plugin_names),
+            }
+        }
+        let Some(prettier_plugins) = prettier_plugins else {
+            return Task::ready(Ok(()));
+        };
+
+        let default_prettier_dir = DEFAULT_PRETTIER_DIR.as_path();
+        let already_running_prettier = self
+            .prettier_instances
+            .get(&(worktree, default_prettier_dir.to_path_buf()))
+            .cloned();
+
+        let fs = Arc::clone(&self.fs);
+        cx.background()
+            .spawn(async move {
+                let prettier_wrapper_path = default_prettier_dir.join(PRETTIER_SERVER_FILE);
+                // method creates parent directory if it doesn't exist
+                fs.save(&prettier_wrapper_path, &Rope::from(PRETTIER_SERVER_JS), LineEnding::Unix).await
+                .with_context(|| format!("writing {PRETTIER_SERVER_FILE} file at {prettier_wrapper_path:?}"))?;
+
+                let packages_to_versions = future::try_join_all(
+                    prettier_plugins
+                        .iter()
+                        .chain(Some(&"prettier"))
+                        .map(|package_name| async {
+                            let returned_package_name = package_name.to_string();
+                            let latest_version = node.npm_package_latest_version(package_name)
+                                .await
+                                .with_context(|| {
+                                    format!("fetching latest npm version for package {returned_package_name}")
+                                })?;
+                            anyhow::Ok((returned_package_name, latest_version))
+                        }),
+                )
+                .await
+                .context("fetching latest npm versions")?;
+
+                log::info!("Fetching default prettier and plugins: {packages_to_versions:?}");
+                let borrowed_packages = packages_to_versions.iter().map(|(package, version)| {
+                    (package.as_str(), version.as_str())
+                }).collect::<Vec<_>>();
+                node.npm_install_packages(default_prettier_dir, &borrowed_packages).await.context("fetching formatter packages")?;
+
+                if !prettier_plugins.is_empty() {
+                    if let Some(prettier) = already_running_prettier {
+                        prettier.await.map_err(|e| anyhow::anyhow!("Default prettier startup await failure: {e:#}"))?.clear_cache().await.context("clearing default prettier cache after plugins install")?;
+                    }
+                }
+
+                anyhow::Ok(())
+            })
+    }
 }
 
 fn subscribe_for_copilot_events(

crates/semantic_index/examples/eval.rs 🔗

@@ -494,6 +494,7 @@ fn main() {
                             let project = cx.update(|cx| {
                                 Project::local(
                                     client.clone(),
+                                    node_runtime::FakeNodeRuntime::new(),
                                     user_store.clone(),
                                     languages.clone(),
                                     fs.clone(),

crates/util/src/paths.rs 🔗

@@ -11,6 +11,7 @@ lazy_static::lazy_static! {
     pub static ref SUPPORT_DIR: PathBuf = HOME.join("Library/Application Support/Zed");
     pub static ref LANGUAGES_DIR: PathBuf = HOME.join("Library/Application Support/Zed/languages");
     pub static ref COPILOT_DIR: PathBuf = HOME.join("Library/Application Support/Zed/copilot");
+    pub static ref DEFAULT_PRETTIER_DIR: PathBuf = HOME.join("Library/Application Support/Zed/prettier");
     pub static ref DB_DIR: PathBuf = HOME.join("Library/Application Support/Zed/db");
     pub static ref SETTINGS: PathBuf = CONFIG_DIR.join("settings.json");
     pub static ref KEYMAP: PathBuf = CONFIG_DIR.join("keymap.json");

crates/workspace/Cargo.toml 🔗

@@ -30,6 +30,7 @@ gpui = { path = "../gpui" }
 install_cli = { path = "../install_cli" }
 language = { path = "../language" }
 menu = { path = "../menu" }
+node_runtime = { path = "../node_runtime" }
 project = { path = "../project" }
 settings = { path = "../settings" }
 terminal = { path = "../terminal" }

crates/workspace/src/workspace.rs 🔗

@@ -42,6 +42,7 @@ use gpui::{
 use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem};
 use itertools::Itertools;
 use language::{LanguageRegistry, Rope};
+use node_runtime::NodeRuntime;
 use std::{
     any::TypeId,
     borrow::Cow,
@@ -456,6 +457,7 @@ pub struct AppState {
     pub initialize_workspace:
         fn(WeakViewHandle<Workspace>, bool, Arc<AppState>, AsyncAppContext) -> Task<Result<()>>,
     pub background_actions: BackgroundActions,
+    pub node_runtime: Arc<dyn NodeRuntime>,
 }
 
 pub struct WorkspaceStore {
@@ -474,6 +476,7 @@ struct Follower {
 impl AppState {
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &mut AppContext) -> Arc<Self> {
+        use node_runtime::FakeNodeRuntime;
         use settings::SettingsStore;
 
         if !cx.has_global::<SettingsStore>() {
@@ -498,6 +501,7 @@ impl AppState {
             user_store,
             // channel_store,
             workspace_store,
+            node_runtime: FakeNodeRuntime::new(),
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             build_window_options: |_, _, _| Default::default(),
             background_actions: || &[],
@@ -816,6 +820,7 @@ impl Workspace {
     )> {
         let project_handle = Project::local(
             app_state.client.clone(),
+            app_state.node_runtime.clone(),
             app_state.user_store.clone(),
             app_state.languages.clone(),
             app_state.fs.clone(),
@@ -3517,6 +3522,8 @@ impl Workspace {
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn test_new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
+        use node_runtime::FakeNodeRuntime;
+
         let client = project.read(cx).client();
         let user_store = project.read(cx).user_store();
 
@@ -3530,6 +3537,7 @@ impl Workspace {
             build_window_options: |_, _, _| Default::default(),
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             background_actions: || &[],
+            node_runtime: FakeNodeRuntime::new(),
         });
         Self::new(0, project, app_state, cx)
     }

crates/zed/src/languages/css.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::{anyhow, Result};
 use async_trait::async_trait;
 use futures::StreamExt;
-use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
 use lsp::LanguageServerBinary;
 use node_runtime::NodeRuntime;
 use serde_json::json;
@@ -96,6 +96,10 @@ impl LspAdapter for CssLspAdapter {
             "provideFormatter": true
         }))
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        vec![BundledFormatter::prettier("css")]
+    }
 }
 
 async fn get_cached_server_binary(

crates/zed/src/languages/html.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::{anyhow, Result};
 use async_trait::async_trait;
 use futures::StreamExt;
-use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
 use lsp::LanguageServerBinary;
 use node_runtime::NodeRuntime;
 use serde_json::json;
@@ -96,6 +96,10 @@ impl LspAdapter for HtmlLspAdapter {
             "provideFormatter": true
         }))
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        vec![BundledFormatter::prettier("html")]
+    }
 }
 
 async fn get_cached_server_binary(

crates/zed/src/languages/json.rs 🔗

@@ -4,7 +4,9 @@ use collections::HashMap;
 use feature_flags::FeatureFlagAppExt;
 use futures::{future::BoxFuture, FutureExt, StreamExt};
 use gpui::AppContext;
-use language::{LanguageRegistry, LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{
+    BundledFormatter, LanguageRegistry, LanguageServerName, LspAdapter, LspAdapterDelegate,
+};
 use lsp::LanguageServerBinary;
 use node_runtime::NodeRuntime;
 use serde_json::json;
@@ -144,6 +146,10 @@ impl LspAdapter for JsonLspAdapter {
     async fn language_ids(&self) -> HashMap<String, String> {
         [("JSON".into(), "jsonc".into())].into_iter().collect()
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        vec![BundledFormatter::prettier("json")]
+    }
 }
 
 async fn get_cached_server_binary(

crates/zed/src/languages/svelte.rs 🔗

@@ -1,7 +1,7 @@
 use anyhow::{anyhow, Result};
 use async_trait::async_trait;
 use futures::StreamExt;
-use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
 use lsp::LanguageServerBinary;
 use node_runtime::NodeRuntime;
 use serde_json::json;
@@ -95,6 +95,13 @@ impl LspAdapter for SvelteLspAdapter {
             "provideFormatter": true
         }))
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        vec![BundledFormatter::Prettier {
+            parser_name: Some("svelte"),
+            plugin_names: vec!["prettier-plugin-svelte"],
+        }]
+    }
 }
 
 async fn get_cached_server_binary(

crates/zed/src/languages/tailwind.rs 🔗

@@ -6,7 +6,7 @@ use futures::{
     FutureExt, StreamExt,
 };
 use gpui::AppContext;
-use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
 use lsp::LanguageServerBinary;
 use node_runtime::NodeRuntime;
 use serde_json::{json, Value};
@@ -127,6 +127,13 @@ impl LspAdapter for TailwindLspAdapter {
             .into_iter(),
         )
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        vec![BundledFormatter::Prettier {
+            parser_name: None,
+            plugin_names: vec!["prettier-plugin-tailwindcss"],
+        }]
+    }
 }
 
 async fn get_cached_server_binary(

crates/zed/src/languages/typescript.rs 🔗

@@ -4,7 +4,7 @@ use async_tar::Archive;
 use async_trait::async_trait;
 use futures::{future::BoxFuture, FutureExt};
 use gpui::AppContext;
-use language::{LanguageServerName, LspAdapter, LspAdapterDelegate};
+use language::{BundledFormatter, LanguageServerName, LspAdapter, LspAdapterDelegate};
 use lsp::{CodeActionKind, LanguageServerBinary};
 use node_runtime::NodeRuntime;
 use serde_json::{json, Value};
@@ -161,6 +161,10 @@ impl LspAdapter for TypeScriptLspAdapter {
             "provideFormatter": true
         }))
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        vec![BundledFormatter::prettier("typescript")]
+    }
 }
 
 async fn get_cached_ts_server_binary(
@@ -309,6 +313,10 @@ impl LspAdapter for EsLintLspAdapter {
     async fn initialization_options(&self) -> Option<serde_json::Value> {
         None
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        vec![BundledFormatter::prettier("babel")]
+    }
 }
 
 async fn get_cached_eslint_server_binary(

crates/zed/src/languages/yaml.rs 🔗

@@ -3,7 +3,8 @@ use async_trait::async_trait;
 use futures::{future::BoxFuture, FutureExt, StreamExt};
 use gpui::AppContext;
 use language::{
-    language_settings::all_language_settings, LanguageServerName, LspAdapter, LspAdapterDelegate,
+    language_settings::all_language_settings, BundledFormatter, LanguageServerName, LspAdapter,
+    LspAdapterDelegate,
 };
 use lsp::LanguageServerBinary;
 use node_runtime::NodeRuntime;
@@ -108,6 +109,10 @@ impl LspAdapter for YamlLspAdapter {
         }))
         .boxed()
     }
+
+    fn enabled_formatters(&self) -> Vec<BundledFormatter> {
+        vec![BundledFormatter::prettier("yaml")]
+    }
 }
 
 async fn get_cached_server_binary(

crates/zed/src/main.rs 🔗

@@ -154,7 +154,12 @@ fn main() {
         semantic_index::init(fs.clone(), http.clone(), languages.clone(), cx);
         vim::init(cx);
         terminal_view::init(cx);
-        copilot::init(copilot_language_server_id, http.clone(), node_runtime, cx);
+        copilot::init(
+            copilot_language_server_id,
+            http.clone(),
+            node_runtime.clone(),
+            cx,
+        );
         assistant::init(cx);
         component_test::init(cx);
 
@@ -181,6 +186,7 @@ fn main() {
             initialize_workspace,
             background_actions,
             workspace_store,
+            node_runtime,
         });
         cx.set_global(Arc::downgrade(&app_state));