[analysis_server] Add command/resolve to support using Interactive Forms in refactors

This adds support for the `command/resolve` request to support the new Interactive Forms functionality, and updates the refactor processes to use it instead of the original protocol when the client indicates support.

Currently only the "Move to File" refactor uses this functionality (matching the previous version) and it requires the `dart.experimentalInteractiveForms` setting be enabled for it to be used (in case we find bugs while developing the front-end that require incompatible changes).

Fixes https://github.com/dart-lang/sdk/issues/63371

Change-Id: I414a41fa2958ca9dcefe097f35ba28f3fa2fe367
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/508121
Reviewed-by: Samuel Rawlins <srawlins@google.com>
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
This commit is contained in:
Danny Tuppeny
2026-06-03 08:47:33 -07:00
committed by Samuel Rawlins
parent 189f25b873
commit f1ceed2ba7
21 changed files with 887 additions and 144 deletions
@@ -105,6 +105,7 @@ class LspClientCapabilities {
final bool completionDefaultTextMode;
final bool experimentalSnippetTextEdit;
final Set<String> codeActionCommandParameterSupportedKinds;
final Set<String> supportedInteractiveFormInputTypes;
final bool supportsShowMessageRequest;
/// A set of commands that exist on the client that the server may call.
@@ -225,6 +226,8 @@ class LspClientCapabilities {
experimentalSnippetTextEdit: experimental.snippetTextEdit,
codeActionCommandParameterSupportedKinds:
experimental.commandParameterKinds,
supportedInteractiveFormInputTypes:
experimental.interactiveFormInputTypes,
supportsShowMessageRequest: experimental.showMessageRequest,
supportedCommands: experimental.commands,
experimentalCapabilitiesErrors: experimental.errors,
@@ -265,6 +268,7 @@ class LspClientCapabilities {
required this.completionDefaultTextMode,
required this.experimentalSnippetTextEdit,
required this.codeActionCommandParameterSupportedKinds,
required this.supportedInteractiveFormInputTypes,
required this.supportsShowMessageRequest,
required this.supportedCommands,
required this.experimentalCapabilitiesErrors,
@@ -291,12 +295,14 @@ class _ExperimentalClientCapabilities {
final bool snippetTextEdit;
final Set<String> commandParameterKinds;
final Set<String> interactiveFormInputTypes;
final Set<String> commands;
final bool showMessageRequest;
new({
required this.snippetTextEdit,
required this.commandParameterKinds,
required this.interactiveFormInputTypes,
required this.commands,
required this.showMessageRequest,
required this.errors,
@@ -362,6 +368,16 @@ class _ExperimentalClientCapabilities {
commandParameters['supportedKinds'],
);
// Interactive Forms.
var interactiveForms = expectMap(
'.interactiveResolve',
experimental['interactiveResolve'],
);
var interactiveFormInputTypes = expectNullableStringSet(
'.interactiveResolve.inputTypes',
interactiveForms?['inputTypes'],
);
// Executable commands.
var commands = expectNullableStringSet(
'.commands',
@@ -384,6 +400,7 @@ class _ExperimentalClientCapabilities {
return _ExperimentalClientCapabilities(
snippetTextEdit: snippetTextEdit ?? false,
commandParameterKinds: commandParameterKinds ?? {},
interactiveFormInputTypes: interactiveFormInputTypes ?? {},
commands: commands ?? {},
showMessageRequest: showMessageRequest ?? false,
errors: errors,
@@ -299,6 +299,16 @@ class LspGlobalClientConfiguration extends LspResourceClientConfiguration {
bool get experimentalInlineValuesProperties =>
_settings['experimentalInlineValuesProperties'] as bool? ?? false;
/// Whether the newer experimental Interactive Forms (that can be used by
/// refactors instead of the original Dart-specified mechanism for collecting
/// user input) are enabled by the client.
///
/// This is a temporary flag during development that will be checked by both
/// server + client to allow controlling which versions of each will enable
/// the functionality (in case of breaking changes during dev).
bool get experimentalInteractiveForms =>
_settings['experimentalInteractiveForms'] as bool? ?? false;
/// A flag for enabling interactive refactors flagged as experimental.
///
/// This flag is likely to be used by both analysis server developers (working
@@ -196,6 +196,9 @@ abstract final class CustomMethods {
'dart/updateDiagnosticInformation',
);
/// Used for resolving commands to support interactive forms.
static const resolveCommand = Method('command/resolve');
/// An experimental 'echo' handler that can used by tests to verify
/// experimental handlers only show up when requested.
static const experimentalEcho = Method('experimental/echo');
@@ -20,7 +20,7 @@ import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dar
/// A command handler that executes commands used to implement refactorings
/// that can describe their inputs (either via the original Dart protocol or
/// the updated Go-specified protocol).
/// the updated Interactive Forms protocol).
class RefactorCommandExecutor extends SimpleEditCommandHandler<AnalysisServer>
with RefactorCommandHandlerMixin<void> {
@override
@@ -2,6 +2,10 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// @docImport 'package:analysis_server/src/lsp/handlers/commands/refactor_command_executor.dart';
/// @docImport 'package:analysis_server/src/lsp/handlers/commands/refactor_command_resolver.dart';
library;
import 'package:analysis_server/src/lsp/client_capabilities.dart';
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
@@ -12,7 +16,8 @@ import 'package:analyzer/dart/analysis/results.dart';
import 'package:language_server_protocol/protocol_custom_generated.dart';
import 'package:language_server_protocol/protocol_generated.dart';
/// A mixin with functionality common to handlers for refactor commands.
/// A mixin with functionality common to handlers for refactor commands,
/// such as [RefactorCommandResolver] and [RefactorCommandExecutor].
mixin RefactorCommandHandlerMixin<T> on HandlerHelperMixin, Handler<T> {
Future<ErrorOr<T>> execute(
ProgressReporter progress,
@@ -0,0 +1,68 @@
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/analysis_server.dart';
import 'package:analysis_server/src/lsp/client_capabilities.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/lsp/handlers/commands/refactor_command_handler_mixin.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/progress.dart';
import 'package:analysis_server/src/services/refactoring/framework/refactoring_context.dart';
import 'package:analysis_server/src/services/refactoring/framework/refactoring_processor.dart';
import 'package:analysis_server/src/services/refactoring/framework/refactoring_producer.dart';
import 'package:analyzer/dart/analysis/results.dart';
/// A sub-handler for `command/resolve` that handles resolving commands for
/// refactors by delegating them to the appropriate [RefactoringProducer].
///
/// Some of the implementation here comes from [RefactorCommandHandlerMixin]
/// which has shared logic used for both resolving and executing refactors
/// (such as building the [RefactoringContext] from the commands arguments).
class RefactorCommandResolver
with
HandlerHelperMixin,
Handler<InteractiveExecuteCommandParams>,
RefactorCommandHandlerMixin {
final RefactoringProducerGenerator generator;
@override
final AnalysisServer server;
/// The client-supplied command to be resolved.
final InteractiveExecuteCommandParams command;
new(this.server, this.generator, this.command);
@override
Future<ErrorOr<InteractiveExecuteCommandParams>> execute(
ProgressReporter progress,
ResolvedLibraryResult library,
ResolvedUnitResult unit,
LspClientCapabilities clientCapabilities,
RefactoringContext context,
List<Object?> arguments,
) async {
var producer = generator(context);
if (!producer.isAvailable()) {
// Generally this shouldn't happen (because we shouldn't have produced a
// command that isn't valid), but it could if the client allowed the file
// to be modified and didn't cancel.
return error(
ErrorCodes.InvalidParams,
'Refactor command is no longer valid at this location',
);
}
// Delegate to the refactoring producer so it can handle custom validation
// etc.
if (producer is ParameterizedRefactoringProducer) {
return await producer.resolve(command);
}
// Otherwise, pass the original command back as-is.
return success(command);
}
}
@@ -0,0 +1,78 @@
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// @docImport 'package:analysis_server/src/services/refactoring/framework/refactoring_producer.dart';
library;
import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/lsp/handlers/commands/refactor_command_resolver.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/progress.dart';
import 'package:analysis_server/src/services/refactoring/framework/refactoring_processor.dart';
/// A handler for the [CustomMethods.resolveCommand] custom request that allows
/// collecting user input via interactive form fields.
///
/// This handler is the main entry point for the LSP request and delegates to
/// sub-handlers like [RefactorCommandResolver] depending on the command that
/// needs resolving.
class CommandResolveHandler
extends
SharedMessageHandler<
InteractiveExecuteCommandParams,
InteractiveExecuteCommandParams
> {
new(super.server);
@override
Method get handlesMessage => CustomMethods.resolveCommand;
@override
LspJsonHandler<InteractiveExecuteCommandParams> get jsonHandler =>
InteractiveExecuteCommandParams.jsonHandler;
@override
// This command is used as part of interactive forms and not expected to be
// used by non-editor clients.
bool get requiresTrustedCaller => true;
@override
Future<ErrorOr<InteractiveExecuteCommandParams>> handle(
InteractiveExecuteCommandParams command,
MessageInfo message,
CancellationToken token,
) async {
if (RefactoringProcessor.generators[command.command] case var generator?) {
return await _handleRefactorCommand(command, generator, message, token);
}
return success(command);
}
/// Handles resolving a command that relates to a refactor by using
/// [RefactorCommandResolver].
Future<ErrorOr<InteractiveExecuteCommandParams>> _handleRefactorCommand(
InteractiveExecuteCommandParams command,
RefactoringProducerGenerator generator,
MessageInfo message,
CancellationToken token,
) async {
if (command.arguments case [Map<String, Object?> arguments]) {
var resolver = RefactorCommandResolver(server, generator, command);
return await resolver.handle(
message,
arguments,
ProgressReporter.noop,
token,
);
} else {
return error(
ErrorCodes.InvalidParams,
'Refactor commands should always have exactly one argument, which is a map',
);
}
}
}
@@ -12,6 +12,7 @@ import 'package:analysis_server/src/lsp/handlers/custom/editable_arguments/handl
import 'package:analysis_server/src/lsp/handlers/custom/editable_arguments/handler_editable_arguments.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_augmentation.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_augmented.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_command_resolve.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_connect_to_dtd.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_diagnostic_server.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_experimental_echo.dart';
@@ -125,6 +126,7 @@ class InitializedStateMessageHandler extends ServerStateMessageHandler {
AugmentedHandler.new,
CodeActionHandler.new,
CodeLensHandler.new,
CommandResolveHandler.new,
ConnectToDtdHandler.new,
DiagnosticServerHandler.new,
DocumentColorHandler.new,
@@ -223,6 +223,15 @@ class ServerCapabilitiesComputer {
// Indicate that we support the 'updateDiagnosticInformation'
// custom request.
'updateDiagnosticInformation': {},
// Interactive Forms support.
'interactiveResolveProvider': {
// The kinds of interactive resolutions that the server supports.
// For example, "command" indicates that the server supports resolving
// `ExecuteCommandParams` interactively through "command/resolve".
'kinds': ['command'],
},
'textDocument': {
// These properties can be used by the client to know that we support
// custom methods like `dart/textDocument/augmented`.
@@ -72,6 +72,22 @@ class RefactoringProcessor {
return;
}
var server = context.server;
var interactiveFormsEnabled =
// Temporary flag to ensure this is opt-in during development.
server.lspClientConfiguration.global.experimentalInteractiveForms &&
// Client has shown it has support by providing at least one
// input kind that it supports.
// It is up to the individual refactors to handle the specific
// kinds of input that are supported, this check is just to know if
// we will use Interactive Forms instead of the original
// Dart-specified self-described refactors.
(context
.clientCapabilities
?.supportedInteractiveFormInputTypes
.isNotEmpty ??
false);
var parameters = producer is ParameterizedRefactoringProducer
? producer.parameters
: <CommandParameter>[];
@@ -107,7 +123,13 @@ class RefactoringProcessor {
parameters.map((param) => param.defaultValue).toList(),
),
),
data: {'parameters': parameters},
// Only include the parameters in data if interactive forms are NOT
// enabled, because if they are this will be handled by
// `command/resolve` and we don't want to trigger the old version
// on the client.
data: parameters.isNotEmpty && !interactiveFormsEnabled
? {'parameters': parameters}
: null,
),
);
_performance?.producerTimings.add((
@@ -2,6 +2,7 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/services/refactoring/framework/refactoring_context.dart';
import 'package:analysis_server/src/services/refactoring/framework/refactoring_processor.dart';
import 'package:analysis_server/src/services/search/search_engine.dart';
@@ -40,6 +41,11 @@ abstract class ParameterizedRefactoringProducer extends RefactoringProducer {
List<Object?> buildCommandArguments(List<Object?> args) {
return RefactoringProcessor.buildCommandArguments(refactoringContext, args);
}
/// Resolves command arguments using the interactive forms functionality.
Future<ErrorOr<InteractiveExecuteCommandParams>> resolve(
InteractiveExecuteCommandParams command,
);
}
/// An object that can compute a refactoring in a Dart file.
@@ -3,8 +3,11 @@
// BSD-style license that can be found in the LICENSE file.
import 'package:analysis_server/src/lsp/constants.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/services/interactive_forms/interactive_forms.dart';
import 'package:analysis_server/src/services/refactoring/framework/refactoring_producer.dart';
import 'package:analysis_server/src/utilities/extensions/ast.dart';
import 'package:analysis_server/src/utilities/extensions/list.dart';
import 'package:analysis_server/src/utilities/extensions/string.dart';
import 'package:analysis_server/src/utilities/import_analyzer.dart';
import 'package:analyzer/dart/ast/ast.dart';
@@ -16,7 +19,7 @@ import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dar
import 'package:analyzer_plugin/utilities/change_builder/change_builder_dart.dart';
import 'package:analyzer_plugin/utilities/range_factory.dart';
import 'package:language_server_protocol/protocol_custom_generated.dart'
show CommandParameter, SaveUriCommandParameter;
hide Element;
import 'package:language_server_protocol/protocol_generated.dart';
/// A refactoring that will move one or more top-level declarations to a
@@ -71,8 +74,8 @@ class MoveTopLevelToFile extends ParameterizedRefactoringProducer {
_initializeFromMembers(members);
var pathContext = refactoringContext.server.resourceProvider.pathContext;
var sourcePath = members.containingFile;
// TODO(dantup): Add refactor-specific validation for incoming arguments.
// Argument is a String URI.
// Fields are validated as part of resolve(). We'll keep showing the
// form inputs until all the fields have valid answers.
var destinationUri = Uri.parse(commandArguments[0] as String);
var destinationFilePath = pathContext.fromUri(destinationUri);
@@ -207,6 +210,34 @@ class MoveTopLevelToFile extends ParameterizedRefactoringProducer {
return false;
}
/// Handles resolving the command to execute this refactor using Interactive
/// Forms.
@override
Future<ErrorOr<InteractiveExecuteCommandParams>> resolve(
InteractiveExecuteCommandParams command,
) async {
var commandArguments = command.arguments;
if (commandArguments == null) {
return error(
ErrorCodes.InvalidParams,
'Refactor commands must have arguments',
);
}
var form = _createInteractiveForm()
..processResponse(command.formAnswers ?? []);
return success(
InteractiveExecuteCommandParams(
command: command.command,
arguments: buildCommandArguments(form.answers),
data: command.data,
formFields: form.clientFields.nullIfEmpty,
formAnswers: form.clientAnswers.nullIfEmpty,
),
);
}
/// Use the [builder] to add the imports that need to be added to the library
/// to which the code is being moved based on the information in the import
/// [analyzer].
@@ -235,6 +266,30 @@ class MoveTopLevelToFile extends ParameterizedRefactoringProducer {
}
}
/// Builds the [InteractiveForm] to collect input for this refactor.
InteractiveForm _createInteractiveForm() {
var destinationUriField = FormField(
id: 'destinationUri',
description: 'Move to file',
required: true,
defaultValue: refactoringContext.server.pathContext
.toUri(defaultFilePath)
.toString(),
type: FormFieldTypeFile(filters: ['*.dart']),
);
var supportedInteractiveFormInputTypes =
refactoringContext
.clientCapabilities
?.supportedInteractiveFormInputTypes ??
{};
return InteractiveForm(
supportedInteractiveFormInputTypes: supportedInteractiveFormInputTypes,
fields: [destinationUriField],
);
}
/// Initialize the [title] and [defaultFilePath] based on the [members] being
/// moved.
void _initializeFromMembers(_MembersToMove members) {
@@ -0,0 +1,283 @@
// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'package:analysis_server/src/services/refactoring/move_top_level_to_file.dart';
import 'package:language_server_protocol/protocol_custom_generated.dart';
import 'package:language_server_protocol/protocol_generated.dart';
import 'package:matcher/matcher.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import '../../src/services/refactoring/refactoring_test_support.dart';
import '../../support/interactive_forms.dart';
import '../../utils/lsp_protocol_extensions.dart';
import '../server_abstract.dart';
void main() {
defineReflectiveSuite(() {
defineReflectiveTests(CommandResolveTest);
defineReflectiveTests(CommandResolveFileInputTest);
});
}
/// Tests file inputs in `command/resolve` using the MoveToFile refactor.
@reflectiveTest
class CommandResolveFileInputTest extends RefactoringTest {
/// Simple file content with a single class named 'A'.
final simpleClassContent = '''
class ^A {}
''';
/// The title of the refactor when using [simpleClassContent].
final simpleClassRefactorTitle = "Move 'A' to file";
@override
String get refactoringCommandId => MoveTopLevelToFile.commandName;
@override
void setUp() {
super.setUp();
// Most of the tests here assume we support file. Tests that do not will
// explicitly unset this.
setSupportedInteractiveFormInputKinds({'file'});
// Move to file also requires file create support.
setFileCreateSupport();
}
Future<void> test_acceptsValidAnswers() async {
addTestSource(simpleClassContent);
var newFilePath = join(projectFolderPath, 'lib', 'valid_destination.dart');
var newFileUri = Uri.file(newFilePath);
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
var command = action.asCommand;
// Resolve the command, which should add the outstanding form fields.
var resolvedCommand = await resolveCommand(
ExecuteCommandParams(
command: command.command,
arguments: command.arguments,
),
);
// Resolve again, with a valid answer.
expect(resolvedCommand.formFields, hasLength(1));
var field = resolvedCommand.formFields!.single;
resolvedCommand = await resolveCommand(
InteractiveExecuteCommandParams(
command: resolvedCommand.command,
arguments: resolvedCommand.arguments,
formFields: resolvedCommand.formFields,
formAnswers: [field.answer(newFileUri.toString())], // Valid answer.
),
);
// Check that the form field has gone, and that the valid answer was moved
// into the command arguments.
expect(resolvedCommand.formFields, isNull); // No more fields to complete.
var arguments = getRefactorCommandArguments(resolvedCommand.arguments);
expect(arguments, hasLength(1));
expect(arguments.single, newFileUri.toString());
}
Future<void> test_formFields_notSupported() async {
// If we don't support the 'file' kind, then we shouldn't get back any
// form fields, only the default.
setSupportedInteractiveFormInputKinds({'number'});
addTestSource(simpleClassContent);
var newFilePath = join(projectFolderPath, 'lib', 'a.dart');
var newFileUri = Uri.file(newFilePath);
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
var command = action.asCommand;
// Resolve the command, which would normally add the form fields, but the
// only one used here is not supported by us.
var resolvedCommand = await resolveCommand(
ExecuteCommandParams(
command: command.command,
arguments: command.arguments,
),
);
// Check basics.
expect(resolvedCommand.command, command.command);
expect(resolvedCommand.arguments, command.arguments);
expect(resolvedCommand.formAnswers, isNull);
expect(resolvedCommand.formFields, isNull);
// Check the arguments to the command contain the default value so the
// command will still work without the user prompt.
var refactorArguments = getRefactorCommandArguments(
resolvedCommand.arguments,
);
expect(refactorArguments, hasLength(1));
expect(refactorArguments.single, newFileUri.toString());
}
Future<void> test_formFields_supported() async {
addTestSource(simpleClassContent);
var newFilePath = join(projectFolderPath, 'lib', 'a.dart');
var newFileUri = Uri.file(newFilePath);
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
var command = action.asCommand;
// Resolve the command, which should add the outstanding form fields.
var resolvedCommand = await resolveCommand(
ExecuteCommandParams(
command: command.command,
arguments: command.arguments,
),
);
// Check basics.
expect(resolvedCommand.command, command.command);
expect(resolvedCommand.arguments, command.arguments);
expect(resolvedCommand.formAnswers, isNull);
expect(resolvedCommand.formFields, hasLength(1));
// Check the form field is what we'd expect.
var field = resolvedCommand.formFields!.single;
expect(field.type.kind, 'file');
expect(field.description, 'Move to file');
expect(field.defaultValue, newFileUri.toString());
expect(field.error, isNull);
}
Future<void> test_validates_incorrectType() async {
addTestSource(simpleClassContent);
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
var command = action.asCommand;
// Resolve the command, which should add the outstanding form fields.
var resolvedCommand = await resolveCommand(
ExecuteCommandParams(
command: command.command,
arguments: command.arguments,
),
);
// Resolve again, with an incorrect type for the URI.
expect(resolvedCommand.formFields, hasLength(1));
var field = resolvedCommand.formFields!.single;
resolvedCommand = await resolveCommand(
InteractiveExecuteCommandParams(
command: resolvedCommand.command,
arguments: resolvedCommand.arguments,
formFields: resolvedCommand.formFields,
formAnswers: [field.answer(true)], // Wrong type.
),
);
// Check the field has a validation error and the current value is
// preserved.
expect(resolvedCommand.formFields, hasLength(1));
field = resolvedCommand.formFields!.single;
expect(field.error, contains('valid file:// URI'));
expect(resolvedCommand.formAnswers!.single, field.answer(true));
}
Future<void> test_validates_invalidUri() async {
addTestSource(simpleClassContent);
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
var command = action.asCommand;
// Resolve the command, which should add the outstanding form fields.
var resolvedCommand = await resolveCommand(
ExecuteCommandParams(
command: command.command,
arguments: command.arguments,
),
);
// Resolve again, with an invalid value for URI.
expect(resolvedCommand.formFields, hasLength(1));
var field = resolvedCommand.formFields!.single;
resolvedCommand = await resolveCommand(
InteractiveExecuteCommandParams(
command: resolvedCommand.command,
arguments: resolvedCommand.arguments,
formFields: resolvedCommand.formFields,
formAnswers: [field.answer('not a valid file uri')],
),
);
// Check the field has a validation error and the current value is
// preserved.
expect(resolvedCommand.formFields, hasLength(1));
field = resolvedCommand.formFields!.single;
expect(field.error, contains('valid file:// URI'));
expect(
resolvedCommand.formAnswers!.single,
field.answer('not a valid file uri'),
);
}
Future<void> test_validates_notFileUri() async {
addTestSource(simpleClassContent);
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
var command = action.asCommand;
// Resolve the command, which should add the outstanding form fields.
var resolvedCommand = await resolveCommand(
ExecuteCommandParams(
command: command.command,
arguments: command.arguments,
),
);
// Resolve again, with the wrong scheme for the URI.
expect(resolvedCommand.formFields, hasLength(1));
var field = resolvedCommand.formFields!.single;
resolvedCommand = await resolveCommand(
InteractiveExecuteCommandParams(
command: resolvedCommand.command,
arguments: resolvedCommand.arguments,
formFields: resolvedCommand.formFields,
formAnswers: [field.answer('https://example.org')],
),
);
// Check the field has a validation error and the current value is
// preserved.
expect(resolvedCommand.formFields, hasLength(1));
field = resolvedCommand.formFields!.single;
expect(field.error, contains('valid file:// URI'));
expect(
resolvedCommand.formAnswers!.single,
field.answer('https://example.org'),
);
}
}
@reflectiveTest
class CommandResolveTest extends AbstractLspAnalysisServerTest {
Future<void> test_returnsInputForUnknownCommand() async {
// Basic command that only includes the normal fields from
// ExecuteCommandParams. This ensures calling resolve() for commands that
// don't need form input will return the same result (with no formFields).
var command = InteractiveExecuteCommandParams(
command: 'my_unknown_command',
arguments: [1, 'two'],
);
await initialize();
var resolvedCommand = await resolveCommand(command);
expect(resolvedCommand, command);
}
}
@@ -6,10 +6,12 @@ import 'package:test_reflective_loader/test_reflective_loader.dart';
import 'apply_code_action_test.dart' as apply_code_action;
import 'fix_all_in_workspace_test.dart' as fix_all_in_workspace;
import 'resolve_test.dart' as resolve;
void main() {
defineReflectiveSuite(() {
apply_code_action.main();
fix_all_in_workspace.main();
resolve.main();
});
}
@@ -988,6 +988,16 @@ mixin LspRequestHelpersMixin {
);
}
Future<InteractiveExecuteCommandParams> resolveCommand(
ExecuteCommandParams command,
) {
var request = makeRequest(CustomMethods.resolveCommand, command);
return expectSuccessfulResponseTo(
request,
InteractiveExecuteCommandParams.fromJson,
);
}
Future<CompletionItem> resolveCompletion(CompletionItem item) {
var request = makeRequest(Method.completionItem_resolve, item);
return expectSuccessfulResponseTo(request, CompletionItem.fromJson);
@@ -786,6 +786,18 @@ mixin ClientCapabilitiesHelperMixin {
};
}
void setSupportedInteractiveFormInputKinds(Set<String>? inputTypes) {
const parentKey = 'interactiveResolve';
const inputTypesKey = 'inputTypes';
if (inputTypes != null) {
experimentalCapabilities[parentKey] = {
inputTypesKey: inputTypes.toList(),
};
} else {
experimentalCapabilities.remove(parentKey);
}
}
void setSupportsWindowShowMessageRequest([bool supported = true]) {
if (supported) {
experimentalCapabilities['supportsWindowShowMessageRequest'] = true;
@@ -9,35 +9,108 @@ import 'package:analyzer/src/test_utilities/test_code_format.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import '../../../support/interactive_forms.dart';
import '../../../utils/lsp_protocol_extensions.dart';
import 'refactoring_test_support.dart';
void main() {
defineReflectiveSuite(() {
defineReflectiveTests(MoveTopLevelToFileTest);
defineReflectiveTests(OriginalInteractiveRefactorsMoveTopLevelToFileTest);
defineReflectiveTests(InteractiveFormsMoveTopLevelToFileTest);
});
}
/// Tests running using the new Interactive Forms refactors
/// implementation. Extends [MoveTopLevelToFileTest] for basic tests and adds
/// protocol-specific tests.
///
/// Tests in the base class do not excercise the input collecting mechanism,
/// they just verify the refactors using defaults behave the same. Tests in this
/// sub-class like [test_protocol_clientModifiedValues] test providing custom
/// (non-default) values.
///
/// There are more tests verifying resolve behaviour in
/// `test/lsp/commands/resolve_test.dart` and tests verifying the Interactive
/// Forms helper classes in
/// `test/src/services/interactive_forms/interactive_forms_test.dart`.
@reflectiveTest
class MoveTopLevelToFileTest extends RefactoringTest {
class InteractiveFormsMoveTopLevelToFileTest extends MoveTopLevelToFileTest
with InteractiveFormsTestMixin {
@override
Future<void> initializeServer({
bool experimentalOptInFlag = true,
// We default this to true for these tests, though it's false in the
// the super implementation.
bool experimentalInteractiveForms = true,
}) {
return super.initializeServer(
experimentalOptInFlag: experimentalOptInFlag,
experimentalInteractiveForms: experimentalInteractiveForms,
);
}
@override
void setUp() {
super.setUp();
setSupportedInteractiveFormInputKinds({'file'});
}
Future<void> test_protocol_clientModifiedValues() async {
addTestSource(simpleClassContent);
/// Filename to inject to replace default.
var newFilePath = join(projectFolderPath, 'lib', 'my_new_class.dart');
var newFileUri = Uri.file(newFilePath);
/// Expected new file content.
const expected = '''
>>>>>>>>>> lib/main.dart empty
>>>>>>>>>> lib/my_new_class.dart created
class A {}
''';
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
var originalCommand = action.command!;
var completedCommand = await completeInteractiveForm(originalCommand, {
'destinationUri': newFileUri.toString(),
});
await verifyCommandEdits(completedCommand, expected);
}
/// Since we have Interactive Forms enabled, we should not see the parameters
/// (which support the original Dart-specific interactive refactors) in the
/// returned action's data field.
Future<void> test_protocol_doesNotAddParametersToData() async {
// data is on CodeAction so we need to support that, otherwise we'll get the
// Command version (which would just execute with the default value in the
// original system).
setSupportedCodeActionKinds([CodeActionKind.Refactor]);
addTestSource(simpleClassContent);
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
var actionLiteral = action.asCodeActionLiteral;
expect(actionLiteral.data, isNull);
}
}
abstract class MoveTopLevelToFileTest extends RefactoringTest {
/// Simple file content with a single class named 'A'.
static const simpleClassContent = '''
final simpleClassContent = '''
class ^A {}
''';
/// The title of the refactor when using [simpleClassContent].
static const simpleClassRefactorTitle = "Move 'A' to file";
final simpleClassRefactorTitle = "Move 'A' to file";
@override
String get refactoringCommandId => MoveTopLevelToFile.commandName;
/// Replaces the "Save URI" argument in [action].
void replaceSaveUriArgument(CodeAction action, Uri newFileUri) {
var arguments = getRefactorCommandArguments(action.command?.arguments);
// The filename is the first item we prompt for so is first in the
// arguments.
arguments[0] = newFileUri.toString();
}
@override
void setUp() {
super.setUp();
@@ -1349,34 +1422,6 @@ class A {}<<<<<<<<<<
await expectCodeActionWithTitle(simpleClassRefactorTitle);
}
Future<void> test_protocol_clientModifiedValues() async {
addTestSource(simpleClassContent);
/// Filename to inject to replace default.
var newFilePath = join(projectFolderPath, 'lib', 'my_new_class.dart');
var newFileUri = Uri.file(newFilePath);
/// Expected new file content.
const expected = '''
>>>>>>>>>> lib/main.dart empty
>>>>>>>>>> lib/my_new_class.dart created
class A {}
''';
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
// Replace the file URI argument with our custom path.
replaceSaveUriArgument(action, newFileUri);
await verifyCommandEdits(action.command!, expected);
}
Future<void> test_protocol_unavailable_withoutFileCreateSupport() async {
addTestSource(simpleClassContent);
setFileCreateSupport(false);
await initializeServer();
await expectNoCodeActionWithTitle(simpleClassRefactorTitle);
}
Future<void> test_sealedClass_enumImplements() async {
var originalSource = '''
sealed class [!Either!] {}
@@ -2027,6 +2072,13 @@ int variableToMove = 3;
);
}
Future<void> test_unavailable_withoutFileCreateSupport() async {
addTestSource(simpleClassContent);
setFileCreateSupport(false);
await initializeServer();
await expectNoCodeActionWithTitle(simpleClassRefactorTitle);
}
Future<void> _multipleDeclarations({
required String originalSource,
required int count,
@@ -2099,3 +2151,53 @@ ${code.code}
);
}
}
/// Tests running using the original (Dart-specific) interactive refactors
/// implementation. Extends [MoveTopLevelToFileTest] for basic tests and add
/// protocol-specific tests.
@reflectiveTest
class OriginalInteractiveRefactorsMoveTopLevelToFileTest
extends MoveTopLevelToFileTest {
/// Replaces the "Save URI" argument in [action].
void replaceSaveUriArgument(CodeAction action, Uri newFileUri) {
var arguments = getRefactorCommandArguments(action.command!.arguments);
// The filename is the first item we prompt for so is first in the
// arguments.
arguments[0] = newFileUri.toString();
}
Future<void> test_protocol_addsParametersToData() async {
// data is on CodeAction so we need to support that, otherwise we'll get the
// Command version (which would just execute with the default value).
setSupportedCodeActionKinds([CodeActionKind.Refactor]);
addTestSource(simpleClassContent);
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
var actionLiteral = action.asCodeActionLiteral;
expect(actionLiteral.data, isNotNull);
expect(actionLiteral.data, contains('parameters'));
}
Future<void> test_protocol_clientModifiedValues() async {
addTestSource(simpleClassContent);
/// Filename to inject to replace default.
var newFilePath = join(projectFolderPath, 'lib', 'my_new_class.dart');
var newFileUri = Uri.file(newFilePath);
/// Expected new file content.
const expected = '''
>>>>>>>>>> lib/main.dart empty
>>>>>>>>>> lib/my_new_class.dart created
class A {}
''';
await initializeServer();
var action = await expectCodeActionWithTitle(simpleClassRefactorTitle);
// Replace the file URI argument with our custom path.
replaceSaveUriArgument(action, newFileUri);
await verifyCommandEdits(action.command!, expected);
}
}
@@ -3,6 +3,65 @@
// BSD-style license that can be found in the LICENSE file.
import 'package:language_server_protocol/protocol_custom_generated.dart';
import 'package:language_server_protocol/protocol_generated.dart';
import 'package:matcher/expect.dart';
import '../lsp/request_helpers_mixin.dart';
mixin InteractiveFormsTestMixin on LspRequestHelpersMixin {
/// A helper to complete an interactive form, taking the place of a user and
/// the client-side code.
///
/// [command] is the original command that will be resolved.
///
/// [answers] is a map of Field ID -> answer values that should be provided.
///
/// Returns an updated command after fields have been answered that can be
/// executed.
Future<Command> completeInteractiveForm(
Command command,
Map<String, Object?> answers,
) async {
var interactiveCommand = InteractiveExecuteCommandParams(
command: command.command,
arguments: command.arguments,
);
// Perform an initial resolve to get the form.
interactiveCommand = await resolveCommand(interactiveCommand);
// Expect at least some fields (we wouldn't have been called if none were
// expected).
expect(interactiveCommand.formFields, allOf(isNotNull, isNotEmpty));
// Ensure all answers we have are in the form.
expect(
interactiveCommand.formFields!.map((field) => field.id),
containsAll(answers.keys),
);
// Resolve again, using the answers we were given.
interactiveCommand = InteractiveExecuteCommandParams(
command: interactiveCommand.command,
arguments: interactiveCommand.arguments,
formFields: interactiveCommand.formFields,
formAnswers: answers.keys
.map((id) => FormAnswer(id: id, value: answers[id]))
.toList(),
);
interactiveCommand = await resolveCommand(interactiveCommand);
// Ensure the form is considered complete.
expect(interactiveCommand.formFields, isNull);
// Return the updated command.
return Command(
title: command.title,
command: interactiveCommand.command,
arguments: interactiveCommand.arguments,
);
}
}
extension FormFieldExtension on FormField {
/// Returns a [FormAnswer] for this field with the answer [value].
+96 -96
View File
@@ -61,103 +61,103 @@ Below is a list of LSP methods and their implementation status.
- Method: The LSP method name
- Server: The method is supported by the Dart server.
- Plugins: This functionality can be extended by third party analyzer plugins.
| Method | Server | Plugins | Notes |
| - | - | - | - |
| initialize | ✅ | N/A | trace and other options NYI |
| initialized | ✅ | N/A | |
| shutdown | ✅ | N/A | supported but does nothing |
| exit | ✅ | N/A | |
| $/cancelRequest | ✅ | | |
| $/logTrace | | | |
| $/progress | | | |
| $/setTrace | | | |
| client/registerCapability | ✅ | ✅ | |
| client/unregisterCapability | ✅ | ✅ | |
| notebookDocument/* | | | |
| telemetry/event | | | |
| textDocument/codeAction (assists) | ✅ | ✅ | Only if the client advertises `codeActionLiteralSupport` with `Refactor` |
| textDocument/codeAction (fixAll) | ✅ | | |
| textDocument/codeAction (fixes) | ✅ | ✅ | Only if the client advertises `codeActionLiteralSupport` with `QuickFix` |
| textDocument/codeAction (organiseImports) | ✅ | | |
| textDocument/codeAction (refactors) | ✅ | | |
| textDocument/codeAction (sortMembers) | ✅ | | |
| codeAction/resolve | | | |
| textDocument/codeLens | ✅ | | |
| codeLens/resolve | | | |
| textDocument/completion | ✅ | ✅ | |
| completionItem/resolve | ✅ | | |
| textDocument/declaration | | | |
| textDocument/definition | ✅ | ✅ | |
| textDocument/diagnostic | | | |
| textDocument/didChange | ✅ | ✅ | |
| textDocument/didClose | ✅ | ✅ | |
| textDocument/didOpen | ✅ | ✅ | |
| textDocument/didSave | | | |
| textDocument/documentColor | ✅ | | |
| textDocument/colorPresentation | ✅ | | |
| textDocument/documentHighlight | ✅ | | |
| textDocument/documentLink | ✅ | | |
| documentLink/resolve | | | |
| textDocument/documentSymbol | ✅ | | |
| textDocument/foldingRange | ✅ | ✅ | |
| textDocument/formatting | ✅ | | |
| textDocument/onTypeFormatting | ✅ | | |
| textDocument/rangeFormatting | ✅ | | |
| textDocument/hover | ✅ | | |
| textDocument/implementation | ✅ | | |
| textDocument/inlayHint | ✅ | | |
| inlayHint/resolve | | | |
| textDocument/inlineValue | ✅ | | |
| textDocument/linkedEditingRange | | | |
| textDocument/moniker | | | |
| textDocument/prepareCallHierarchy | ✅ | | |
| callHierarchy/incomingCalls | ✅ | | |
| callHierarchy/outgoingCalls | ✅ | | |
| textDocument/prepareRename | ✅ | | |
| textDocument/rename | ✅ | | |
| textDocument/prepareTypeHierarchy | ✅ | | |
| typeHierarchy/subtypes | ✅ | | |
| typeHierarchy/supertypes | ✅ | | |
| textDocument/publishDiagnostics | ✅ | ✅ | |
| textDocument/references | ✅ | | |
| textDocument/selectionRange | ✅ | | |
| textDocument/semanticTokens/full | ✅ | ✅ | |
| textDocument/semanticTokens/full/delta | | | |
| textDocument/semanticTokens/range | ✅ | ✅ | |
| workspace/semanticTokens/refresh | | | |
| textDocument/signatureHelp | ✅ | | |
| textDocument/typeDefinition | ✅ | | |
| textDocument/willSave | | | |
| textDocument/willSaveWaitUntil | | | |
| window/logMessage | ✅ | | |
| window/showDocument | | | |
| window/showMessage | ✅ | | |
| window/showMessageRequest | | | |
| window/workDoneProgress/cancel | | | |
| window/workDoneProgress/create | ✅ | | |
| workspace/applyEdit | ✅ | | |
| workspace/codeLens/refresh | | | |
| workspace/configuration | ✅ | | |
| workspace/diagnostic | | | |
| workspace/diagnostic/refresh | | | |
| workspace/didChangeConfiguration | ✅ | | |
| workspace/didChangeWatchedFiles | | | unused, server does own watching |
| workspace/didChangeWorkspaceFolders | ✅ | ✅ | |
| workspace/didCreateFiles | | | |
| workspace/didDeleteFiles | | | |
| workspace/didRenameFiles | | | |
| workspace/executeCommand | ✅ | | |
| workspace/inlayHint/refresh | | | |
| workspace/inlineValue/refresh | | | |
| workspace/symbol | ✅ | | |
| workspaceSymbol/resolve | | | |
| workspace/willCreateFiles | | | |
| workspace/willDeleteFiles | | | |
| workspace/willRenameFiles | | | |
| workspace/willRenameFiles | ✅ | | |
| workspace/workspaceFolders | | | |
| Method | Server | Notes |
| - | - | - |
| initialize | ✅ | trace and other options NYI |
| initialized | ✅ | |
| shutdown | ✅ | supported but does nothing |
| exit | ✅ | |
| $/cancelRequest | ✅ | |
| $/logTrace | | |
| $/progress | | |
| $/setTrace | | |
| client/registerCapability | ✅ | |
| client/unregisterCapability | ✅ | |
| command/resolve | ✅ | A custom command used to support the experimental "Interactive Forms" feature |
| notebookDocument/* | | |
| telemetry/event | | |
| textDocument/codeAction (assists) | ✅ | Only if the client advertises `codeActionLiteralSupport` with `Refactor` |
| textDocument/codeAction (fixAll) | ✅ | |
| textDocument/codeAction (fixes) | ✅ | Only if the client advertises `codeActionLiteralSupport` with `QuickFix` |
| textDocument/codeAction (organiseImports) | ✅ | |
| textDocument/codeAction (refactors) | ✅ | |
| textDocument/codeAction (sortMembers) | | |
| codeAction/resolve | | |
| textDocument/codeLens | | |
| codeLens/resolve | | |
| textDocument/completion | ✅ | |
| completionItem/resolve | | |
| textDocument/declaration | | |
| textDocument/definition | | |
| textDocument/diagnostic | | |
| textDocument/didChange | ✅ | |
| textDocument/didClose | ✅ | |
| textDocument/didOpen | | |
| textDocument/didSave | | |
| textDocument/documentColor | ✅ | |
| textDocument/colorPresentation | ✅ | |
| textDocument/documentHighlight | ✅ | |
| textDocument/documentLink | | |
| documentLink/resolve | | |
| textDocument/documentSymbol | ✅ | |
| textDocument/foldingRange | ✅ | |
| textDocument/formatting | ✅ | |
| textDocument/onTypeFormatting | ✅ | |
| textDocument/rangeFormatting | ✅ | |
| textDocument/hover | ✅ | |
| textDocument/implementation | ✅ | |
| textDocument/inlayHint | | |
| inlayHint/resolve | | |
| textDocument/inlineValue | | |
| textDocument/linkedEditingRange | | |
| textDocument/moniker | | |
| textDocument/prepareCallHierarchy | ✅ | |
| callHierarchy/incomingCalls | ✅ | |
| callHierarchy/outgoingCalls | ✅ | |
| textDocument/prepareRename | ✅ | |
| textDocument/rename | ✅ | |
| textDocument/prepareTypeHierarchy | ✅ | |
| typeHierarchy/subtypes | ✅ | |
| typeHierarchy/supertypes | ✅ | |
| textDocument/publishDiagnostics | ✅ | |
| textDocument/references | ✅ | |
| textDocument/selectionRange | ✅ | |
| textDocument/semanticTokens/full | | |
| textDocument/semanticTokens/full/delta | | |
| textDocument/semanticTokens/range | | |
| workspace/semanticTokens/refresh | | |
| textDocument/signatureHelp | ✅ | |
| textDocument/typeDefinition | | |
| textDocument/willSave | | |
| textDocument/willSaveWaitUntil | | |
| window/logMessage | | |
| window/showDocument | | |
| window/showMessage | | |
| window/showMessageRequest | | |
| window/workDoneProgress/cancel | | |
| window/workDoneProgress/create | ✅ | |
| workspace/applyEdit | | |
| workspace/codeLens/refresh | | |
| workspace/configuration | | |
| workspace/diagnostic | | |
| workspace/diagnostic/refresh | | |
| workspace/didChangeConfiguration | | |
| workspace/didChangeWatchedFiles | | unused, server does own watching |
| workspace/didChangeWorkspaceFolders | | |
| workspace/didCreateFiles | | |
| workspace/didDeleteFiles | | |
| workspace/didRenameFiles | | |
| workspace/executeCommand | | |
| workspace/inlayHint/refresh | | |
| workspace/inlineValue/refresh | | |
| workspace/symbol | | |
| workspaceSymbol/resolve | | |
| workspace/willCreateFiles | | |
| workspace/willDeleteFiles | | |
| workspace/willRenameFiles | | |
| workspace/willRenameFiles | | |
| workspace/workspaceFolders | | |
## Custom Fields, Methods and Notifications
@@ -5,7 +5,7 @@
import '../meta_model.dart';
import '../utils.dart';
/// Classes that support for the new (Go-specified) interactive-refactors.
/// Classes that support for Interactive Forms.
final interactiveFormClasses = <LspEntity>[
// TODO(dantup): Generate this from a JSON metadata file if one is made in the
// same format as the LSP metaModel file.
@@ -321,7 +321,7 @@ List<LspEntity> getCustomClasses() {
// Support for the original (Dart-specific) interactive-refactors.
...interactiveRefactorsClasses,
// Support for the new (Go-specified) interactive-refactors.
// Support for Interactive Forms.
...interactiveFormClasses,
];
return customTypes;