Detailed changes
@@ -552,11 +552,13 @@ actions!(
Format,
/// Formats only the selected text.
///
+ /// This action is only available when the active formatter can format ranges.
/// When using a language server, this sends an LSP range formatting request for each
- /// selection. When using Prettier, Prettier's own range formatting is used to format the
- /// encompassing range of all selections, and resulting edits outside the selected ranges
- /// are discarded. External command formatters do not support range formatting and are
- /// skipped.
+ /// selection, and is hidden when the selected buffer's configured language server does
+ /// not advertise range-formatting support. When using Prettier, Prettier's own range
+ /// formatting is used to format the encompassing range of all selections, and resulting
+ /// edits outside the selected ranges are discarded. External command formatters do not
+ /// support range formatting and are skipped.
FormatSelections,
/// Goes to the declaration of the symbol at cursor.
GoToDeclaration,
@@ -19608,6 +19608,28 @@ impl Editor {
self.pending_rename.as_ref()
}
+ fn can_format_selections(&self, cx: &App) -> bool {
+ if !self.mode.is_full() {
+ return false;
+ }
+
+ let Some(project) = &self.project else {
+ return false;
+ };
+
+ let project = project.read(cx);
+ let multi_buffer = self.buffer.read(cx);
+ let snapshot = multi_buffer.snapshot(cx);
+
+ self.selections
+ .disjoint_anchor_ranges()
+ .filter(|range| range.start != range.end)
+ .flat_map(|range| [range.start, range.end])
+ .filter_map(|anchor| snapshot.anchor_to_buffer_anchor(anchor))
+ .filter_map(|(_, buffer_snapshot)| multi_buffer.buffer(buffer_snapshot.remote_id()))
+ .any(|buffer| project.supports_range_formatting(&buffer, cx))
+ }
+
fn format(
&mut self,
_: &Format,
@@ -14199,8 +14199,9 @@ async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) {
);
}
-async fn setup_range_format_test(
+async fn setup_range_format_test_with_capabilities(
cx: &mut TestAppContext,
+ capabilities: lsp::ServerCapabilities,
) -> (
Entity<Project>,
Entity<Editor>,
@@ -14217,6 +14218,120 @@ async fn setup_range_format_test(
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let mut fake_servers = language_registry.register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ let buffer = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer(path!("/file.rs"), cx)
+ })
+ .await
+ .unwrap();
+
+ let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
+ let (editor, cx) = cx.add_window_view(|window, cx| {
+ build_editor_with_project(project.clone(), buffer, window, cx)
+ });
+ editor.update_in(cx, |editor, window, cx| {
+ window.focus(&editor.focus_handle(cx), cx);
+ });
+
+ let fake_server = fake_servers.next().await.unwrap();
+
+ (project, editor, cx, fake_server)
+}
+
+async fn setup_range_format_test(
+ cx: &mut TestAppContext,
+) -> (
+ Entity<Project>,
+ Entity<Editor>,
+ &mut gpui::VisualTestContext,
+ lsp::FakeLanguageServer,
+) {
+ setup_range_format_test_with_capabilities(
+ cx,
+ lsp::ServerCapabilities {
+ document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ },
+ )
+ .await
+}
+
+fn refresh_editor_actions(cx: &mut VisualTestContext) {
+ cx.executor().run_until_parked();
+ cx.update(|window, cx| {
+ let _ = window.draw(cx);
+ });
+}
+
+#[gpui::test]
+async fn test_format_selections_action_available_when_range_formatting_is_supported(
+ cx: &mut TestAppContext,
+) {
+ let (_, editor, cx, _) = setup_range_format_test(cx).await;
+
+ editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("one\ntwo\nthree\n", window, cx);
+ editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+ s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]);
+ });
+ });
+
+ refresh_editor_actions(cx);
+
+ assert!(cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) }));
+}
+
+#[gpui::test]
+async fn test_format_selections_action_hidden_without_range_formatting_support(
+ cx: &mut TestAppContext,
+) {
+ let (_, editor, cx, _) = setup_range_format_test_with_capabilities(
+ cx,
+ lsp::ServerCapabilities {
+ document_formatting_provider: Some(lsp::OneOf::Left(true)),
+ document_range_formatting_provider: Some(lsp::OneOf::Left(false)),
+ ..lsp::ServerCapabilities::default()
+ },
+ )
+ .await;
+
+ editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("one\ntwo\nthree\n", window, cx);
+ editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+ s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]);
+ });
+ });
+
+ refresh_editor_actions(cx);
+
+ assert!(!cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) }));
+}
+
+#[gpui::test]
+async fn test_format_selections_action_hidden_without_range_capable_formatter(
+ cx: &mut TestAppContext,
+) {
+ init_test(cx, |settings| {
+ settings.defaults.formatter = Some(FormatterList::Single(Formatter::External {
+ command: "awk".into(),
+ arguments: Some(vec!["{ print }".to_string()]),
+ }));
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_file(path!("/file.rs"), Default::default()).await;
+
+ let project = Project::test(fs, [path!("/").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(rust_lang());
+ let _ = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
@@ -14238,10 +14353,20 @@ async fn setup_range_format_test(
let (editor, cx) = cx.add_window_view(|window, cx| {
build_editor_with_project(project.clone(), buffer, window, cx)
});
+ editor.update_in(cx, |editor, window, cx| {
+ window.focus(&editor.focus_handle(cx), cx);
+ });
- let fake_server = fake_servers.next().await.unwrap();
+ editor.update_in(cx, |editor, window, cx| {
+ editor.set_text("one\ntwo\nthree\n", window, cx);
+ editor.change_selections(SelectionEffects::default(), window, cx, |s| {
+ s.select_ranges([Point::new(0, 0)..Point::new(1, 0)]);
+ });
+ });
- (project, editor, cx, fake_server)
+ refresh_editor_actions(cx);
+
+ assert!(!cx.update(|window, cx| { window.is_action_available(&FormatSelections, cx) }));
}
#[gpui::test]
@@ -552,13 +552,15 @@ impl EditorElement {
cx.propagate();
}
});
- register_action(editor, window, |editor, action, window, cx| {
- if let Some(task) = editor.format_selections(action, window, cx) {
- editor.detach_and_notify_err(task, window, cx);
- } else {
- cx.propagate();
- }
- });
+ if editor.read(cx).can_format_selections(cx) {
+ register_action(editor, window, |editor, action, window, cx| {
+ if let Some(task) = editor.format_selections(action, window, cx) {
+ editor.detach_and_notify_err(task, window, cx);
+ } else {
+ cx.propagate();
+ }
+ });
+ }
register_action(editor, window, |editor, action, window, cx| {
if let Some(task) = editor.organize_imports(action, window, cx) {
editor.detach_and_notify_err(task, window, cx);
@@ -219,6 +219,7 @@ pub fn deploy_context_menu(
let evaluate_selection = window.is_action_available(&EvaluateSelectedText, cx);
let run_to_cursor = window.is_action_available(&RunToCursor, cx);
+ let format_selections = window.is_action_available(&FormatSelections, cx);
let disable_ai = DisableAiSettings::is_ai_disabled_for_buffer(
editor.buffer.read(cx).as_singleton().as_ref(),
cx,
@@ -266,7 +267,7 @@ pub fn deploy_context_menu(
.separator()
.action("Rename Symbol", Box::new(Rename))
.action("Format Buffer", Box::new(Format))
- .when(has_selections, |cx| {
+ .when(format_selections, |cx| {
cx.action("Format Selections", Box::new(FormatSelections))
})
.action(
@@ -2340,9 +2340,8 @@ impl LocalLspStore {
fn server_supports_formatting(server: &Arc<LanguageServer>) -> bool {
let capabilities = server.capabilities();
let formatting = capabilities.document_formatting_provider.as_ref();
- let range_formatting = capabilities.document_range_formatting_provider.as_ref();
matches!(formatting, Some(p) if *p != OneOf::Left(false))
- || matches!(range_formatting, Some(p) if *p != OneOf::Left(false))
+ || server_capabilities_support_range_formatting(&capabilities)
}
async fn format_via_lsp(
@@ -5003,17 +5002,24 @@ impl LspStore {
)
}
- fn check_if_capable_for_proto_request<F>(
+ fn relevant_server_ids_for_capability_check(
&self,
buffer: &Entity<Buffer>,
- check: F,
cx: &App,
- ) -> bool
- where
- F: FnMut(&lsp::ServerCapabilities) -> bool,
- {
+ ) -> Vec<LanguageServerId> {
+ let buffer_id = buffer.read(cx).remote_id();
+ if let Some(local) = self.as_local() {
+ return local
+ .buffers_opened_in_servers
+ .get(&buffer_id)
+ .into_iter()
+ .flatten()
+ .copied()
+ .collect();
+ }
+
let Some(language) = buffer.read(cx).language().cloned() else {
- return false;
+ return Vec::default();
};
let registered_language_servers = self
.languages
@@ -5030,10 +5036,81 @@ impl LspStore {
// but only loaded on the server side)
let is_relevant = registered_language_servers.contains(&server_status.name)
|| self.languages.is_lsp_adapter_available(&server_status.name);
- is_relevant.then_some(server_id)
+ is_relevant.then_some(*server_id)
})
- .filter_map(|server_id| self.lsp_server_capabilities.get(server_id))
- .any(check)
+ .collect()
+ }
+
+ fn check_if_any_relevant_server_matches<F>(
+ &self,
+ buffer: &Entity<Buffer>,
+ mut check: F,
+ cx: &App,
+ ) -> bool
+ where
+ F: FnMut(&LanguageServerStatus, &lsp::ServerCapabilities) -> bool,
+ {
+ self.relevant_server_ids_for_capability_check(buffer, cx)
+ .into_iter()
+ .filter_map(|server_id| {
+ Some((
+ self.language_server_statuses.get(&server_id)?,
+ self.lsp_server_capabilities.get(&server_id)?,
+ ))
+ })
+ .any(|(server_status, capabilities)| check(server_status, capabilities))
+ }
+
+ fn check_if_capable_for_proto_request<F>(
+ &self,
+ buffer: &Entity<Buffer>,
+ mut check: F,
+ cx: &App,
+ ) -> bool
+ where
+ F: FnMut(&lsp::ServerCapabilities) -> bool,
+ {
+ self.check_if_any_relevant_server_matches(buffer, |_, capabilities| check(capabilities), cx)
+ }
+
+ pub fn supports_range_formatting(&self, buffer: &Entity<Buffer>, cx: &App) -> bool {
+ let settings = LanguageSettings::for_buffer(buffer.read(cx), cx);
+ settings.formatter.as_ref().iter().any(|formatter| {
+ match formatter {
+ Formatter::None => false,
+ Formatter::Auto => {
+ settings.prettier.allowed
+ || self.check_if_capable_for_proto_request(
+ buffer,
+ server_capabilities_support_range_formatting,
+ cx,
+ )
+ }
+ Formatter::Prettier => true,
+ Formatter::External { .. } => false,
+ Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current) => {
+ self.check_if_capable_for_proto_request(
+ buffer,
+ server_capabilities_support_range_formatting,
+ cx,
+ )
+ }
+ Formatter::LanguageServer(
+ settings::LanguageServerFormatterSpecifier::Specific { name },
+ ) => self.check_if_any_relevant_server_matches(
+ buffer,
+ |server_status, capabilities| {
+ server_status.name.0.as_ref() == name
+ && server_capabilities_support_range_formatting(capabilities)
+ },
+ cx,
+ ),
+ // `FormatSelections` should only surface when a formatter can honor the
+ // selected ranges. Code actions can still run as part of formatting, but
+ // they operate on the whole buffer rather than the selected text.
+ Formatter::CodeAction(_) => false,
+ }
+ })
}
fn all_capable_for_proto_request<F>(
@@ -5045,33 +5122,17 @@ impl LspStore {
where
F: FnMut(&lsp::LanguageServerName, &lsp::ServerCapabilities) -> bool,
{
- let Some(language) = buffer.read(cx).language().cloned() else {
- return Vec::default();
- };
- let registered_language_servers = self
- .languages
- .lsp_adapters(&language.name())
+ self.relevant_server_ids_for_capability_check(buffer, cx)
.into_iter()
- .map(|lsp_adapter| lsp_adapter.name())
- .collect::<HashSet<_>>();
- self.language_server_statuses
- .iter()
- .filter_map(|(server_id, server_status)| {
- // Include servers that are either registered for this language OR
- // available to be loaded (for SSH remote mode where adapters like
- // ty/pylsp/pyright are registered via register_available_lsp_adapter
- // but only loaded on the server side)
- let is_relevant = registered_language_servers.contains(&server_status.name)
- || self.languages.is_lsp_adapter_available(&server_status.name);
- is_relevant.then_some((server_id, &server_status.name))
- })
- .filter_map(|(server_id, server_name)| {
- self.lsp_server_capabilities
- .get(server_id)
- .map(|c| (server_id, server_name, c))
+ .filter_map(|server_id| {
+ Some((
+ server_id,
+ &self.language_server_statuses.get(&server_id)?.name,
+ self.lsp_server_capabilities.get(&server_id)?,
+ ))
})
.filter(|(_, server_name, capabilities)| check(server_name, capabilities))
- .map(|(server_id, server_name, _)| (*server_id, server_name.clone()))
+ .map(|(server_id, server_name, _)| (server_id, server_name.clone()))
.collect()
}
@@ -13287,6 +13348,13 @@ fn parse_register_capabilities<T: serde::de::DeserializeOwned>(
})
}
+fn server_capabilities_support_range_formatting(capabilities: &lsp::ServerCapabilities) -> bool {
+ matches!(
+ capabilities.document_range_formatting_provider.as_ref(),
+ Some(provider) if *provider != OneOf::Left(false)
+ )
+}
+
fn subscribe_to_binary_statuses(
languages: &Arc<LanguageRegistry>,
cx: &mut Context<'_, LspStore>,
@@ -4122,6 +4122,12 @@ impl Project {
})
}
+ pub fn supports_range_formatting(&self, buffer: &Entity<Buffer>, cx: &App) -> bool {
+ self.lsp_store
+ .read(cx)
+ .supports_range_formatting(buffer, cx)
+ }
+
pub fn definitions<T: ToPointUtf16>(
&mut self,
buffer: &Entity<Buffer>,
@@ -45,7 +45,7 @@ use language::{
LanguageConfig, LanguageMatcher, LanguageName, LineEnding, ManifestName, ManifestProvider,
ManifestQuery, OffsetRangeExt, Point, ToPoint, Toolchain, ToolchainList, ToolchainLister,
ToolchainMetadata,
- language_settings::{LanguageSettings, LanguageSettingsContent},
+ language_settings::{Formatter, FormatterList, LanguageSettings, LanguageSettingsContent},
markdown_lang, rust_lang, tree_sitter_typescript,
};
use lsp::{
@@ -4901,6 +4901,85 @@ async fn test_completions_with_carriage_returns(cx: &mut gpui::TestAppContext) {
assert_eq!(completions[0].new_text, "fully\nQualified\nName");
}
+#[gpui::test]
+async fn test_supports_range_formatting_ignores_unrelated_language_servers(
+ cx: &mut gpui::TestAppContext,
+) {
+ init_test(cx);
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project.all_languages.defaults.formatter = Some(FormatterList::Single(
+ Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current),
+ ));
+ });
+ });
+ });
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ path!("/dir"),
+ json!({
+ "a.ts": "",
+ "b.rs": "",
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
+ let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+ language_registry.add(typescript_lang());
+ language_registry.add(rust_lang());
+
+ let mut typescript_language_servers = language_registry.register_fake_lsp(
+ "TypeScript",
+ FakeLspAdapter {
+ name: "typescript-fake-language-server",
+ capabilities: lsp::ServerCapabilities {
+ document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ },
+ ..FakeLspAdapter::default()
+ },
+ );
+ let mut rust_language_servers = language_registry.register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ name: "rust-fake-language-server",
+ capabilities: lsp::ServerCapabilities {
+ document_formatting_provider: Some(lsp::OneOf::Left(true)),
+ document_range_formatting_provider: Some(lsp::OneOf::Left(false)),
+ ..lsp::ServerCapabilities::default()
+ },
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ let (typescript_buffer, _typescript_handle) = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx)
+ })
+ .await
+ .unwrap();
+ let (rust_buffer, _rust_handle) = project
+ .update(cx, |project, cx| {
+ project.open_local_buffer_with_lsp(path!("/dir/b.rs"), cx)
+ })
+ .await
+ .unwrap();
+
+ let _typescript_language_server = typescript_language_servers.next().await.unwrap();
+ let _rust_language_server = rust_language_servers.next().await.unwrap();
+ cx.executor().run_until_parked();
+
+ assert!(project.read_with(cx, |project, cx| {
+ project.supports_range_formatting(&typescript_buffer, cx)
+ }));
+ assert!(!project.read_with(cx, |project, cx| {
+ project.supports_range_formatting(&rust_buffer, cx)
+ }));
+}
+
#[gpui::test(iterations = 10)]
async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) {
init_test(cx);
@@ -356,12 +356,17 @@ To run linter fixes automatically on save:
Zed supports formatting only the selected text via `editor: format selections` ({#kb editor::FormatSelections}). How
this works depends on the configured formatter:
-- **Language server**: Sends an LSP range formatting request for each selection. This provides the most precise
- selection-only formatting.
+- The action is only shown when the active formatter can actually format ranges for at least one
+ selected buffer.
+- **Language server**: Sends an LSP range formatting request for each selection. This provides the
+ most precise selection-only formatting, and is only available when the configured language server
+ advertises range-formatting support.
- **Prettier**: Uses Prettier's built-in range formatting to format the encompassing range of all selections. Any
resulting edits that fall outside the selected ranges are discarded, so only the selected code is modified.
- **External commands**: External command formatters do not support range formatting and are skipped when formatting
selections.
+- **Code action formatters**: Code actions operate on the whole buffer, so they do not enable
+ `format selections` on their own.
### Integrating Formatting and Linting