Gate Format Selections on whether the active formatter can actually format ranges (#53178)

## What Changed

- compute range-format support from formatter configuration and
language-server capabilities
- show `Format Selections` only when at least one selected buffer has a
range-capable formatter
- keep Prettier-supported range formatting available without depending
on LSP support
- hide the action for formatter modes that cannot honor selections, such
as external-command and code-action formatters
- keep the action handler safe by rechecking support and returning early
when range formatting is unavailable
- restrict capability checks to the servers actually registered for the
current buffer
- add regression coverage for supported, unsupported, and mixed-server
cases
- document when `Format Selections` is available in both action docs and
user docs


Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #25796

Release Notes:
- Improved `Format Selections` so it is only shown when the active
formatter supports range formatting.

Testing I did:

Here are the formatters I used:
<img width="829" height="434" alt="Screenshot_20260405_102047"
src="https://github.com/user-attachments/assets/cae4a238-277e-4188-873f-189e9f098699"
/>

Note: I've chosen three formatters:
1. Prettier, which supports range formatting
2. clangd (LSP) which also supports range formatting
3. gopls which do not support range formatting


[Screencast_20260405_102431.webm](https://github.com/user-attachments/assets/69ce3f95-0b73-46a0-853d-3b5be6329dde)

---------

Signed-off-by: Pratik Karki <pratik@prertik.com>
Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Pratik KarkiandKirill Bulatov authored and GitHub committed 2026-04-16 09:11:22 +03:00
1 parent a9a77f1c6e
commit b6b2b63370
9 files changed
+374 -64

No files matched your search

+6 -4
View File
@@ -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,
+22
View File
@@ -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,
+128 -3
View File
@@ -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]
+9 -7
View File
@@ -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);
+2 -1
View File
@@ -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(
+114 -46
View File
@@ -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,48 +5002,22 @@ 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,
{
let Some(language) = buffer.read(cx).language().cloned() else {
return false;
};
let registered_language_servers = self
.languages
.lsp_adapters(&language.name())
.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)
})
.filter_map(|server_id| self.lsp_server_capabilities.get(server_id))
.any(check)
}
) -> 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();
}
fn all_capable_for_proto_request<F>(
&self,
buffer: &Entity<Buffer>,
mut check: F,
cx: &App,
) -> Vec<(lsp::LanguageServerId, lsp::LanguageServerName)>
where
F: FnMut(&lsp::LanguageServerName, &lsp::ServerCapabilities) -> bool,
{
let Some(language) = buffer.read(cx).language().cloned() else {
return Vec::default();
};
@@ -5063,15 +5036,103 @@ 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, &server_status.name))
is_relevant.then_some(*server_id)
})
.filter_map(|(server_id, server_name)| {
self.lsp_server_capabilities
.get(server_id)
.map(|c| (server_id, server_name, c))
.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>(
&self,
buffer: &Entity<Buffer>,
mut check: F,
cx: &App,
) -> Vec<(lsp::LanguageServerId, lsp::LanguageServerName)>
where
F: FnMut(&lsp::LanguageServerName, &lsp::ServerCapabilities) -> bool,
{
self.relevant_server_ids_for_capability_check(buffer, cx)
.into_iter()
.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>,
+6
View File
@@ -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);
+7 -2
View File
@@ -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