[migrate][analysis_server] LSP Protocol for migrate tool.

This some basic scaffolding for the migrate tool. We'll add a protocol to the analysis server with the following parameters and result:

Parameters
- `uris`: Workspaces/packages to be migrated

Result
- `summary`: Information about fixes that could not be applied automatically. (e.g. if there was a conflict) or information about what fixes were applied and what SDK version the pubspec has been changed to.
- `edit`: A list of edits to be applied.

There are no interesting tests yet, but I hope to have a suite of tests for the next change.

Fixes: https://github.com/dart-lang/sdk/issues/63247
Change-Id: I77508720acb17af5ec86675fd3f3045e2a610bf2
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/496801
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
Commit-Queue: Kallen Tu <kallentu@google.com>
This commit is contained in:
Kallen Tu
2026-05-01 12:13:41 -07:00
committed by dart-scoped@luci-project-accounts.iam.gserviceaccount.com
parent 72600b32af
commit 81a14586a9
8 changed files with 448 additions and 0 deletions
@@ -163,6 +163,7 @@ abstract final class CustomMethods {
);
static const summary = Method('dart/textDocument/summary');
static const super_ = Method('dart/textDocument/super');
static const migrate = Method('dart/workspace/migrate');
static const imports = Method('dart/textDocument/imports');
/// Method for requesting the set of editable arguments at a location in a
@@ -0,0 +1,106 @@
// 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/lsp/constants.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/src/util/file_paths.dart' as file_paths;
import 'package:yaml/yaml.dart';
class MigrateHandler
extends SharedMessageHandler<DartMigrateParams, DartMigrateResult> {
MigrateHandler(super.server);
@override
Method get handlesMessage => CustomMethods.migrate;
@override
LspJsonHandler<DartMigrateParams> get jsonHandler =>
DartMigrateParams.jsonHandler;
@override
bool get requiresTrustedCaller => true;
@override
Future<ErrorOr<DartMigrateResult>> handle(
DartMigrateParams params,
MessageInfo message,
CancellationToken token,
) async {
var validationResult = _validateMigrationTargets(params.uris);
if (validationResult.isError) {
return failure(validationResult);
}
// TODO(kallentu): Add a registry of pre-migration lints to enable based
// on pubspec version.
// TODO(kallentu): Support bumping the pubspec SDK constraint and
// reanalyzing with the new version constraint.
// TODO(kallentu): Add a registry of post-migration lints to enable based
// on pubspec version.
// TODO(kallentu): Fix post-migration lints.
return success(DartMigrateResult(summary: 'Not implemented yet.'));
}
/// Validates that all provided [uris] are directories and each directory
/// contains a `pubspec.yaml` file.
///
/// Returns an error if any URI points to a file, does not exist, or does
/// not contain a `pubspec.yaml` file.
ErrorOr<void> _validateMigrationTargets(List<DocumentUri> uris) {
for (var uri in uris) {
var pathResult = pathOfUri(uri);
if (pathResult.isError) {
return failure(pathResult);
}
var path = pathResult.resultOrNull!;
var resource = server.resourceProvider.getResource(path);
if (!resource.exists) {
return error(
ErrorCodes.InvalidParams,
"The path '$path' doesn't exist.",
);
}
if (resource is! Folder) {
return error(
ErrorCodes.InvalidParams,
"The path '$path' doesn't refer to a package or pub workspace"
' directory.',
);
}
var pubspecFile = resource.getChildAssumingFile(file_paths.pubspecYaml);
if (!pubspecFile.exists) {
return error(
ErrorCodes.InvalidParams,
"The directory '$path' doesn't contain a 'pubspec.yaml' file.",
);
}
try {
var pubspecContent = pubspecFile.readAsStringSync();
var pubspec = loadYamlNode(
pubspecContent,
sourceUrl: pubspecFile.toUri(),
);
if (pubspec is YamlMap && pubspec['resolution'] == 'workspace') {
return error(
ErrorCodes.InvalidParams,
"The directory '$path' is part of a workspace and can't be migrated"
' independently.',
);
}
} catch (e) {
return error(ErrorCodes.InvalidParams, "Failed to parse '$path': $e");
}
}
return success(null);
}
}
@@ -17,6 +17,7 @@ import 'package:analysis_server/src/lsp/handlers/custom/handler_diagnostic_serve
import 'package:analysis_server/src/lsp/handlers/custom/handler_experimental_echo.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_get_widget_previews.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_imports.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_migrate.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_reanalyze.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_summary.dart';
import 'package:analysis_server/src/lsp/handlers/custom/handler_super.dart';
@@ -143,6 +144,7 @@ class InitializedStateMessageHandler extends ServerStateMessageHandler {
ImplementationHandler.new,
IncomingCallHierarchyHandler.new,
InlineValueHandler.new,
MigrateHandler.new,
OutgoingCallHierarchyHandler.new,
PrepareCallHierarchyHandler.new,
PrepareTypeHierarchyHandler.new,
@@ -0,0 +1,143 @@
// 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/lsp/constants.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import '../tool/lsp_spec/matchers.dart';
import 'server_abstract.dart';
void main() {
defineReflectiveSuite(() {
defineReflectiveTests(MigrateTest);
});
}
@reflectiveTest
class MigrateTest extends AbstractLspAnalysisServerTest {
Future<void> test_error_directoryWithoutPubspec() async {
await initialize();
var request = makeRequest(
CustomMethods.migrate,
DartMigrateParams(uris: [projectFolderUri]),
);
var response = await sendRequestToServer(request);
expect(
response.error,
isResponseError(
ErrorCodes.InvalidParams,
message:
"The directory '/home/my_project' doesn't contain a 'pubspec.yaml' "
'file.',
),
);
}
Future<void> test_error_fileUri() async {
await initialize();
newFile(mainFilePath, '');
var request = makeRequest(
CustomMethods.migrate,
DartMigrateParams(uris: [mainFileUri]),
);
var response = await sendRequestToServer(request);
expect(
response.error,
isResponseError(
ErrorCodes.InvalidParams,
message:
"The path '/home/my_project/lib/main.dart' doesn't refer to a "
'package or pub workspace directory.',
),
);
}
Future<void> test_error_fileUri_multipleWithOneInvalid() async {
await initialize();
newFile(pubspecFilePath, 'name: test_project');
var validUri = projectFolderUri;
var invalidUri = Uri.parse('http://example.com');
var request = makeRequest(
CustomMethods.migrate,
DartMigrateParams(uris: [validUri, invalidUri]),
);
var response = await sendRequestToServer(request);
expect(
response.error,
isResponseError(
ServerErrorCodes.invalidFilePath,
message: contains("URI scheme 'http' is not supported"),
),
);
}
Future<void> test_error_nonExistentDirectory() async {
await initialize();
var dirUri = Uri.file(convertPath('/non/existent/dir'));
var request = makeRequest(
CustomMethods.migrate,
DartMigrateParams(uris: [dirUri]),
);
var response = await sendRequestToServer(request);
expect(
response.error,
isResponseError(
ErrorCodes.InvalidParams,
message: "The path '/non/existent/dir' doesn't exist.",
),
);
}
Future<void> test_error_workspacePackage() async {
await initialize();
newFile(pubspecFilePath, '''
name: test_project
resolution: workspace
''');
var request = makeRequest(
CustomMethods.migrate,
DartMigrateParams(uris: [projectFolderUri]),
);
var response = await sendRequestToServer(request);
expect(
response.error,
isResponseError(
ErrorCodes.InvalidParams,
message:
"The directory '/home/my_project' is part of a workspace and can't "
'be migrated independently.',
),
);
}
Future<void> test_validDirectory() async {
await initialize();
newFile(pubspecFilePath, 'name: test_project');
var request = makeRequest(
CustomMethods.migrate,
DartMigrateParams(uris: [projectFolderUri]),
);
var response = await sendRequestToServer(request);
expect(response.error, isNull);
}
}
@@ -45,6 +45,7 @@ import 'initialization_test.dart' as initialization;
import 'inlay_hint_test.dart' as inlay_hint;
import 'inline_value_test.dart' as inline_value;
import 'mapping_test.dart' as mapping;
import 'migrate_test.dart' as migrate;
import 'open_uri_test.dart' as open_uri;
import 'outline_test.dart' as outline;
import 'priority_files_test.dart' as priority_files;
@@ -113,6 +114,7 @@ void main() {
inline_value.main();
lsp_packet_transformer.main();
mapping.main();
migrate.main();
open_uri.main();
outline.main();
priority_files.main();
@@ -295,6 +295,20 @@ Returns: `FlutterWidgetPreviews | null`
Returns the set of detected Flutter Widget Previews in the analyzed project.
### dart/workspace/migrate Method
Direction: Client -> Server
Params: `DartMigrateParams`
Returns: `DartMigrateResult`
Migrates the provided pub workspace folders or non-pub workspace packages to the
latest Dart version. For packages that are part of a pub workspace, only the
workspace root should be passed. Migrating individual packages within a
workspace independently is not supported.
The response includes a summary of the results and a `WorkspaceEdit` containing
the changes to be applied.
### dart/openUri Notification
Direction: Server -> Client
@@ -722,6 +722,35 @@ List<LspEntity> getCustomClasses() {
interface('DocumentSummary', [
field('summary', type: 'String', canBeNull: true),
]),
// Types for `dart/workspace/migrate`.
interface('DartMigrateParams', [
field(
'uris',
type: 'DocumentUri',
array: true,
comment:
'The URIs of the directories (packages or workspaces) to migrate. '
'Individual file URIs are not supported.',
),
]),
interface('DartMigrateResult', [
field(
'summary',
type: 'String',
canBeNull: true,
comment:
'A summary of the migration results, detailing which fixes '
'succeeded, which fixes failed to be applied, and the new '
'SDK version constraint applied to the pubspec.yaml.',
),
field(
'edit',
type: 'WorkspaceEdit',
canBeNull: true,
comment: 'The edits to be applied to the workspace.',
),
]),
];
return customTypes;
}
@@ -736,6 +736,31 @@ bool _canParseUri(
return true;
}
bool _canParseWorkspaceEdit(
Map<String, Object?> map, LspJsonReporter reporter, String fieldName,
{required bool allowsUndefined, required bool allowsNull}) {
reporter.push(fieldName);
try {
if (!allowsUndefined && !map.containsKey(fieldName)) {
reporter.reportError('must not be undefined');
return false;
}
final value = map[fieldName];
final nullCheck = allowsNull || allowsUndefined;
if (!nullCheck && value == null) {
reporter.reportError('must not be null');
return false;
}
if ((!nullCheck || value != null) &&
!WorkspaceEdit.canParse(value, reporter)) {
return false;
}
} finally {
reporter.pop();
}
return true;
}
Either2<int, String> _eitherIntString(Object? value) {
return value is int
? Either2.t1(value)
@@ -1253,6 +1278,132 @@ class DartDiagnosticServer implements ToJsonable {
}
}
class DartMigrateParams implements ToJsonable {
static const jsonHandler = LspJsonHandler(
DartMigrateParams.canParse,
DartMigrateParams.fromJson,
);
/// The URIs of the directories (packages or workspaces) to migrate.
/// Individual file URIs are not supported.
final List<DocumentUri> uris;
DartMigrateParams({
required this.uris,
});
@override
int get hashCode => lspHashCode(uris);
@override
bool operator ==(Object other) {
return other is DartMigrateParams &&
other.runtimeType == DartMigrateParams &&
const DeepCollectionEquality().equals(uris, other.uris);
}
@override
Map<String, Object?> toJson() {
var result = <String, Object?>{};
result['uris'] = uris.map((uri) => uri.toString()).toList();
return result;
}
@override
String toString() => jsonEncoder.convert(toJson());
static bool canParse(Object? obj, LspJsonReporter reporter) {
if (obj is Map<String, Object?>) {
return _canParseListUri(obj, reporter, 'uris',
allowsUndefined: false, allowsNull: false);
} else {
reporter.reportError('must be of type DartMigrateParams');
return false;
}
}
static DartMigrateParams fromJson(Map<String, Object?> json) {
final urisJson = json['uris'];
final uris = (urisJson as List<Object?>)
.map((item) => Uri.parse(item as String))
.toList();
return DartMigrateParams(
uris: uris,
);
}
}
class DartMigrateResult implements ToJsonable {
static const jsonHandler = LspJsonHandler(
DartMigrateResult.canParse,
DartMigrateResult.fromJson,
);
/// The edits to be applied to the workspace.
final WorkspaceEdit? edit;
/// A summary of the migration results, detailing which fixes succeeded, which
/// fixes failed to be applied, and the new SDK version constraint applied to
/// the pubspec.yaml.
final String? summary;
DartMigrateResult({
this.edit,
this.summary,
});
@override
int get hashCode => Object.hash(
edit,
summary,
);
@override
bool operator ==(Object other) {
return other is DartMigrateResult &&
other.runtimeType == DartMigrateResult &&
edit == other.edit &&
summary == other.summary;
}
@override
Map<String, Object?> toJson() {
var result = <String, Object?>{};
result['edit'] = edit?.toJson();
result['summary'] = summary;
return result;
}
@override
String toString() => jsonEncoder.convert(toJson());
static bool canParse(Object? obj, LspJsonReporter reporter) {
if (obj is Map<String, Object?>) {
if (!_canParseWorkspaceEdit(obj, reporter, 'edit',
allowsUndefined: false, allowsNull: true)) {
return false;
}
return _canParseString(obj, reporter, 'summary',
allowsUndefined: false, allowsNull: true);
} else {
reporter.reportError('must be of type DartMigrateResult');
return false;
}
}
static DartMigrateResult fromJson(Map<String, Object?> json) {
final editJson = json['edit'];
final edit = editJson != null
? WorkspaceEdit.fromJson(editJson as Map<String, Object?>)
: null;
final summaryJson = json['summary'];
final summary = summaryJson as String?;
return DartMigrateResult(
edit: edit,
summary: summary,
);
}
}
class DartTextDocumentSummaryParams implements ToJsonable {
static const jsonHandler = LspJsonHandler(
DartTextDocumentSummaryParams.canParse,