diff --git a/pkg/analysis_server/lib/src/lsp/constants.dart b/pkg/analysis_server/lib/src/lsp/constants.dart index acb77d62913..11c0f44f5ab 100644 --- a/pkg/analysis_server/lib/src/lsp/constants.dart +++ b/pkg/analysis_server/lib/src/lsp/constants.dart @@ -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 diff --git a/pkg/analysis_server/lib/src/lsp/handlers/custom/handler_migrate.dart b/pkg/analysis_server/lib/src/lsp/handlers/custom/handler_migrate.dart new file mode 100644 index 00000000000..05b6f205241 --- /dev/null +++ b/pkg/analysis_server/lib/src/lsp/handlers/custom/handler_migrate.dart @@ -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 { + MigrateHandler(super.server); + + @override + Method get handlesMessage => CustomMethods.migrate; + + @override + LspJsonHandler get jsonHandler => + DartMigrateParams.jsonHandler; + + @override + bool get requiresTrustedCaller => true; + + @override + Future> 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 _validateMigrationTargets(List 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); + } +} diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart index 4d2e61dff74..dcc073a9dc9 100644 --- a/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart +++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart @@ -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, diff --git a/pkg/analysis_server/test/lsp/migrate_test.dart b/pkg/analysis_server/test/lsp/migrate_test.dart new file mode 100644 index 00000000000..e81b8c469d1 --- /dev/null +++ b/pkg/analysis_server/test/lsp/migrate_test.dart @@ -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 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 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 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 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 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 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); + } +} diff --git a/pkg/analysis_server/test/lsp/test_all.dart b/pkg/analysis_server/test/lsp/test_all.dart index 6a10b594815..dff46721b7d 100644 --- a/pkg/analysis_server/test/lsp/test_all.dart +++ b/pkg/analysis_server/test/lsp/test_all.dart @@ -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(); diff --git a/pkg/analysis_server/tool/lsp_spec/README.md b/pkg/analysis_server/tool/lsp_spec/README.md index feddac760f7..32fed9a6324 100644 --- a/pkg/analysis_server/tool/lsp_spec/README.md +++ b/pkg/analysis_server/tool/lsp_spec/README.md @@ -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 diff --git a/pkg/analysis_server/tool/lsp_spec/generate_all.dart b/pkg/analysis_server/tool/lsp_spec/generate_all.dart index b7dcb0533fb..c00870e4a63 100644 --- a/pkg/analysis_server/tool/lsp_spec/generate_all.dart +++ b/pkg/analysis_server/tool/lsp_spec/generate_all.dart @@ -722,6 +722,35 @@ List 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; } diff --git a/third_party/pkg/language_server_protocol/lib/protocol_custom_generated.dart b/third_party/pkg/language_server_protocol/lib/protocol_custom_generated.dart index 24e6adb668f..2942b145c07 100644 --- a/third_party/pkg/language_server_protocol/lib/protocol_custom_generated.dart +++ b/third_party/pkg/language_server_protocol/lib/protocol_custom_generated.dart @@ -736,6 +736,31 @@ bool _canParseUri( return true; } +bool _canParseWorkspaceEdit( + Map 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 _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 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 toJson() { + var result = {}; + 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) { + return _canParseListUri(obj, reporter, 'uris', + allowsUndefined: false, allowsNull: false); + } else { + reporter.reportError('must be of type DartMigrateParams'); + return false; + } + } + + static DartMigrateParams fromJson(Map json) { + final urisJson = json['uris']; + final uris = (urisJson as List) + .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 toJson() { + var result = {}; + 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) { + 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 json) { + final editJson = json['edit']; + final edit = editJson != null + ? WorkspaceEdit.fromJson(editJson as Map) + : 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,