Clean up formatting code and add testing for formatting with multiple formatters (including code actions!) (#28457)

Release Notes:

- N/A

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
Ben Kunkle 2025-04-10 11:32:43 -04:00 committed by GitHub
parent b55b310ad0
commit 53cde329da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 942 additions and 858 deletions

View file

@ -7756,77 +7756,81 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) {
cx.executor().start_waiting(); cx.executor().start_waiting();
let fake_server = fake_servers.next().await.unwrap(); let fake_server = fake_servers.next().await.unwrap();
let save = editor {
.update_in(cx, |editor, window, cx| { fake_server.set_request_handler::<lsp::request::Formatting, _, _>(
editor.save(true, project.clone(), window, cx) move |params, _| async move {
}) assert_eq!(
.unwrap(); params.text_document.uri,
fake_server lsp::Url::from_file_path(path!("/file.rs")).unwrap()
.set_request_handler::<lsp::request::Formatting, _, _>(move |params, _| async move { );
assert_eq!( assert_eq!(params.options.tab_size, 4);
params.text_document.uri, Ok(Some(vec![lsp::TextEdit::new(
lsp::Url::from_file_path(path!("/file.rs")).unwrap() lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)),
); ", ".to_string(),
assert_eq!(params.options.tab_size, 4); )]))
Ok(Some(vec![lsp::TextEdit::new( },
lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)), );
", ".to_string(), let save = editor
)])) .update_in(cx, |editor, window, cx| {
}) editor.save(true, project.clone(), window, cx)
.next() })
.await; .unwrap();
cx.executor().start_waiting(); cx.executor().start_waiting();
save.await; save.await;
assert_eq!( assert_eq!(
editor.update(cx, |editor, cx| editor.text(cx)), editor.update(cx, |editor, cx| editor.text(cx)),
"one, two\nthree\n" "one, two\nthree\n"
); );
assert!(!cx.read(|cx| editor.is_dirty(cx))); assert!(!cx.read(|cx| editor.is_dirty(cx)));
}
editor.update_in(cx, |editor, window, cx| { {
editor.set_text("one\ntwo\nthree\n", window, cx) editor.update_in(cx, |editor, window, cx| {
}); editor.set_text("one\ntwo\nthree\n", window, cx)
assert!(cx.read(|cx| editor.is_dirty(cx))); });
assert!(cx.read(|cx| editor.is_dirty(cx)));
// Ensure we can still save even if formatting hangs. // Ensure we can still save even if formatting hangs.
fake_server.set_request_handler::<lsp::request::Formatting, _, _>( fake_server.set_request_handler::<lsp::request::Formatting, _, _>(
move |params, _| async move { move |params, _| async move {
assert_eq!( assert_eq!(
params.text_document.uri, params.text_document.uri,
lsp::Url::from_file_path(path!("/file.rs")).unwrap() lsp::Url::from_file_path(path!("/file.rs")).unwrap()
); );
futures::future::pending::<()>().await; futures::future::pending::<()>().await;
unreachable!() unreachable!()
}, },
); );
let save = editor let save = editor
.update_in(cx, |editor, window, cx| { .update_in(cx, |editor, window, cx| {
editor.save(true, project.clone(), window, cx) editor.save(true, project.clone(), window, cx)
}) })
.unwrap(); .unwrap();
cx.executor().advance_clock(super::FORMAT_TIMEOUT); cx.executor().advance_clock(super::FORMAT_TIMEOUT);
cx.executor().start_waiting(); cx.executor().start_waiting();
save.await; save.await;
assert_eq!( assert_eq!(
editor.update(cx, |editor, cx| editor.text(cx)), editor.update(cx, |editor, cx| editor.text(cx)),
"one\ntwo\nthree\n" "one\ntwo\nthree\n"
); );
assert!(!cx.read(|cx| editor.is_dirty(cx))); }
// For non-dirty buffer, no formatting request should be sent // For non-dirty buffer, no formatting request should be sent
let save = editor {
.update_in(cx, |editor, window, cx| { assert!(!cx.read(|cx| editor.is_dirty(cx)));
editor.save(true, project.clone(), window, cx)
}) fake_server.set_request_handler::<lsp::request::Formatting, _, _>(move |_, _| async move {
.unwrap();
let _pending_format_request = fake_server
.set_request_handler::<lsp::request::RangeFormatting, _, _>(move |_, _| async move {
panic!("Should not be invoked on non-dirty buffer"); panic!("Should not be invoked on non-dirty buffer");
}) });
.next(); let save = editor
cx.executor().start_waiting(); .update_in(cx, |editor, window, cx| {
save.await; editor.save(true, project.clone(), window, cx)
})
.unwrap();
cx.executor().start_waiting();
save.await;
}
// Set rust language override and assert overridden tabsize is sent to language server // Set rust language override and assert overridden tabsize is sent to language server
update_test_language_settings(cx, |settings| { update_test_language_settings(cx, |settings| {
@ -7839,28 +7843,28 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) {
); );
}); });
editor.update_in(cx, |editor, window, cx| { {
editor.set_text("somehting_new\n", window, cx) editor.update_in(cx, |editor, window, cx| {
}); editor.set_text("somehting_new\n", window, cx)
assert!(cx.read(|cx| editor.is_dirty(cx))); });
let save = editor assert!(cx.read(|cx| editor.is_dirty(cx)));
.update_in(cx, |editor, window, cx| { let _formatting_request_signal = fake_server
editor.save(true, project.clone(), window, cx) .set_request_handler::<lsp::request::Formatting, _, _>(move |params, _| async move {
}) assert_eq!(
.unwrap(); params.text_document.uri,
fake_server lsp::Url::from_file_path(path!("/file.rs")).unwrap()
.set_request_handler::<lsp::request::Formatting, _, _>(move |params, _| async move { );
assert_eq!( assert_eq!(params.options.tab_size, 8);
params.text_document.uri, Ok(Some(vec![]))
lsp::Url::from_file_path(path!("/file.rs")).unwrap() });
); let save = editor
assert_eq!(params.options.tab_size, 8); .update_in(cx, |editor, window, cx| {
Ok(Some(vec![])) editor.save(true, project.clone(), window, cx)
}) })
.next() .unwrap();
.await; cx.executor().start_waiting();
cx.executor().start_waiting(); save.await;
save.await; }
} }
#[gpui::test] #[gpui::test]
@ -8342,6 +8346,272 @@ async fn test_document_format_manual_trigger(cx: &mut TestAppContext) {
); );
} }
#[gpui::test]
async fn test_multiple_formatters(cx: &mut TestAppContext) {
init_test(cx, |settings| {
settings.defaults.remove_trailing_whitespace_on_save = Some(true);
settings.defaults.formatter =
Some(language_settings::SelectedFormatter::List(FormatterList(
vec![
Formatter::LanguageServer { name: None },
Formatter::CodeActions(
[
("code-action-1".into(), true),
("code-action-2".into(), true),
]
.into_iter()
.collect(),
),
]
.into(),
)))
});
let fs = FakeFs::new(cx.executor());
fs.insert_file(path!("/file.rs"), "one \ntwo \nthree".into())
.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 mut fake_servers = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
document_formatting_provider: Some(lsp::OneOf::Left(true)),
execute_command_provider: Some(lsp::ExecuteCommandOptions {
commands: vec!["the-command-for-code-action-1".into()],
..Default::default()
}),
code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)),
..Default::default()
},
..Default::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)
});
cx.executor().start_waiting();
let fake_server = fake_servers.next().await.unwrap();
fake_server.set_request_handler::<lsp::request::Formatting, _, _>(
move |_params, _| async move {
Ok(Some(vec![lsp::TextEdit::new(
lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
"applied-formatting\n".to_string(),
)]))
},
);
fake_server.set_request_handler::<lsp::request::CodeActionRequest, _, _>(
move |params, _| async move {
assert_eq!(
params.context.only,
Some(vec!["code-action-1".into(), "code-action-2".into()])
);
let uri = lsp::Url::from_file_path(path!("/file.rs")).unwrap();
Ok(Some(vec![
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
kind: Some("code-action-1".into()),
edit: Some(lsp::WorkspaceEdit::new(
[(
uri.clone(),
vec![lsp::TextEdit::new(
lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
"applied-code-action-1-edit\n".to_string(),
)],
)]
.into_iter()
.collect(),
)),
command: Some(lsp::Command {
command: "the-command-for-code-action-1".into(),
..Default::default()
}),
..Default::default()
}),
lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction {
kind: Some("code-action-2".into()),
edit: Some(lsp::WorkspaceEdit::new(
[(
uri.clone(),
vec![lsp::TextEdit::new(
lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
"applied-code-action-2-edit\n".to_string(),
)],
)]
.into_iter()
.collect(),
)),
..Default::default()
}),
]))
},
);
fake_server.set_request_handler::<lsp::request::CodeActionResolveRequest, _, _>({
move |params, _| async move { Ok(params) }
});
let command_lock = Arc::new(futures::lock::Mutex::new(()));
fake_server.set_request_handler::<lsp::request::ExecuteCommand, _, _>({
let fake = fake_server.clone();
let lock = command_lock.clone();
move |params, _| {
assert_eq!(params.command, "the-command-for-code-action-1");
let fake = fake.clone();
let lock = lock.clone();
async move {
lock.lock().await;
fake.server
.request::<lsp::request::ApplyWorkspaceEdit>(lsp::ApplyWorkspaceEditParams {
label: None,
edit: lsp::WorkspaceEdit {
changes: Some(
[(
lsp::Url::from_file_path(path!("/file.rs")).unwrap(),
vec![lsp::TextEdit {
range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(0, 0),
),
new_text: "applied-code-action-1-command\n".into(),
}],
)]
.into_iter()
.collect(),
),
..Default::default()
},
})
.await
.unwrap();
Ok(Some(json!(null)))
}
}
});
cx.executor().start_waiting();
editor
.update_in(cx, |editor, window, cx| {
editor.perform_format(
project.clone(),
FormatTrigger::Manual,
FormatTarget::Buffers,
window,
cx,
)
})
.unwrap()
.await;
editor.update(cx, |editor, cx| {
assert_eq!(
editor.text(cx),
r#"
applied-code-action-2-edit
applied-code-action-1-command
applied-code-action-1-edit
applied-formatting
one
two
three
"#
.unindent()
);
});
editor.update_in(cx, |editor, window, cx| {
editor.undo(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "one \ntwo \nthree");
});
// Perform a manual edit while waiting for an LSP command
// that's being run as part of a formatting code action.
let lock_guard = command_lock.lock().await;
let format = editor
.update_in(cx, |editor, window, cx| {
editor.perform_format(
project.clone(),
FormatTrigger::Manual,
FormatTarget::Buffers,
window,
cx,
)
})
.unwrap();
cx.run_until_parked();
editor.update(cx, |editor, cx| {
assert_eq!(
editor.text(cx),
r#"
applied-code-action-1-edit
applied-formatting
one
two
three
"#
.unindent()
);
editor.buffer.update(cx, |buffer, cx| {
let ix = buffer.len(cx);
buffer.edit([(ix..ix, "edited\n")], None, cx);
});
});
// Allow the LSP command to proceed. Because the buffer was edited,
// the second code action will not be run.
drop(lock_guard);
format.await;
editor.update_in(cx, |editor, window, cx| {
assert_eq!(
editor.text(cx),
r#"
applied-code-action-1-command
applied-code-action-1-edit
applied-formatting
one
two
three
edited
"#
.unindent()
);
// The manual edit is undone first, because it is the last thing the user did
// (even though the command completed afterwards).
editor.undo(&Default::default(), window, cx);
assert_eq!(
editor.text(cx),
r#"
applied-code-action-1-command
applied-code-action-1-edit
applied-formatting
one
two
three
"#
.unindent()
);
// All the formatting (including the command, which completed after the manual edit)
// is undone together.
editor.undo(&Default::default(), window, cx);
assert_eq!(editor.text(cx), "one \ntwo \nthree");
});
}
#[gpui::test] #[gpui::test]
async fn test_organize_imports_manual_trigger(cx: &mut TestAppContext) { async fn test_organize_imports_manual_trigger(cx: &mut TestAppContext) {
init_test(cx, |settings| { init_test(cx, |settings| {

File diff suppressed because it is too large Load diff