blob: 13ca5602c198b1375af11815530b4f97727c1e2f [file]
// 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/constants.dart';
import 'package:analysis_server/src/lsp/error_or.dart';
import 'package:analysis_server/src/lsp/handlers/handlers.dart';
import 'package:analysis_server/src/lsp/mapping.dart';
import 'package:analysis_server/src/lsp/migration_registry.dart';
import 'package:analysis_server/src/services/correction/bulk_fix_processor.dart';
import 'package:analysis_server/src/utilities/pubspec.dart';
import 'package:analysis_server_plugin/src/correction/dart_change_workspace.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/source/source_range.dart';
import 'package:analyzer/src/dart/analysis/driver_based_analysis_context.dart';
import 'package:analyzer/src/util/file_paths.dart' as file_paths;
import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml/yaml.dart';
class MigrateHandler
extends SharedMessageHandler<DartMigrateParams, DartMigrateResult> {
new(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);
}
var summaryBuffer = StringBuffer();
var targets = validationResult.resultOrNull!;
var migrationRunner = _MigrationRunner(
server: server,
pubspecTargets: targets,
summaryBuffer: summaryBuffer,
);
var changeBuilder = await migrationRunner.run();
var workspaceEdit = createWorkspaceEdit(
server,
message.clientCapabilities!,
changeBuilder.sourceChange,
);
return success(
DartMigrateResult(
summary: summaryBuffer.toString().trim(),
edit: workspaceEdit,
),
);
}
/// 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<List<_PubspecTarget>> _validateMigrationTargets(
List<DocumentUri> uris,
) {
var targets = <_PubspecTarget>[];
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.getFile(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) {
if (pubspec['resolution'] == 'workspace') {
return error(
ErrorCodes.InvalidParams,
"The directory '$path' is part of a workspace and can't be"
' migrated independently.',
);
}
targets.add(_PubspecTarget(file: pubspecFile, pubspec: pubspec));
}
} catch (e) {
return error(
ErrorCodes.InvalidParams,
"Failed to parse 'pubspec.yaml' at '$path': $e",
);
}
}
return success(targets);
}
}
/// An orchestrator that performs package migrations across one or more target
/// packages.
///
/// This runner manages a multi-stage migration pipeline:
/// 1. Runs pre-migration code cleanup fixes.
/// 2. Bumps the SDK version constraints in `pubspec.yaml`.
class _MigrationRunner({
required final AnalysisServer server,
required final List<_PubspecTarget> pubspecTargets,
required final StringBuffer summaryBuffer,
}) {
/// Runs the migration runner with the scheduled analysis pausing enabled.
Future<ChangeBuilder> run() async {
return await server.pauseSchedulerWhile(_runImpl);
}
/// Applies the pubspec SDK constraint bump edit.
Future<void> _bumpPubspecConstraint(
File pubspecFile,
PubspecEdit versionBumpEdit,
ChangeBuilder builder,
) async {
await builder.addYamlFileEdit(pubspecFile.path, (builder) {
builder.addSimpleReplacement(
SourceRange(versionBumpEdit.offset, versionBumpEdit.length),
versionBumpEdit.replacement,
);
});
}
Future<ChangeBuilder> _runImpl() async {
var workspace = DartChangeWorkspace(await server.currentSessions);
var builder = ChangeBuilder(workspace: workspace);
var bumpedLines = <String>[];
for (var pubspec in pubspecTargets) {
var pubspecFile = pubspec.file;
var context = server.contextManager.getContextFor(pubspecFile.path);
if (context == null) {
summaryBuffer.writeln(
'- ${pubspec.displayName}: Skipped (not analyzed)',
);
continue;
}
// TODO(kallentu): If we can't compute the version bump, provide a reason
// why for the user running the tool.
var versionBumpEdit = computeVersionBumpEdit(pubspecFile);
if (versionBumpEdit == null) continue;
var targetVersion = versionBumpEdit.targetVersion;
// Run pre-migrations.
var premigrationSuccess = await _runPreMigrations(
context,
pubspecFile,
targetVersion,
builder,
);
if (!premigrationSuccess) continue;
// Bump version constraint.
await _bumpPubspecConstraint(pubspecFile, versionBumpEdit, builder);
// TODO(kallentu): Fix post-migration lints.
bumpedLines.add(
'- ${pubspec.displayName}: ${versionBumpEdit.originalConstraint} -> '
'${versionBumpEdit.replacement}',
);
}
if (bumpedLines.isEmpty) {
summaryBuffer.writeln('No SDK constraints were bumped.');
} else {
summaryBuffer.writeln(
'Bumped SDK constraints in ${bumpedLines.length} package(s):',
);
for (var line in bumpedLines) {
summaryBuffer.writeln(line);
}
}
return builder;
}
/// Runs bulk fixes for the given [lintCodes] in the specified migration
/// phase.
///
/// Returns whether the migration fixes ran successfully.
Future<bool> _runMigrations({
required DriverBasedAnalysisContext context,
required File pubspecFile,
required List<String> lintCodes,
required ChangeBuilder builder,
required String phaseName,
}) async {
if (lintCodes.isEmpty) return true;
try {
var workspace = DartChangeWorkspace([context.driver.currentSession]);
var processor = BulkFixProcessor(
server.instrumentationService,
workspace,
byteStore: server.byteStore,
builder: builder,
additionalEnabledCodes: lintCodes,
);
// TODO(kallentu): Check for and report unfixed pre-migration diagnostics.
await processor.fixErrors([context]);
// TODO(kallentu): Provide a better summary of how many pre-migration
// diagnostics have been fixed in each file.
return true;
} catch (e) {
summaryBuffer.writeln(
'- ${pubspecFile.parent.shortName}: Failed $phaseName fixes with '
'exception: $e',
);
return false;
}
}
/// Runs pre-migration fixes for the given [targetVersion].
Future<bool> _runPreMigrations(
DriverBasedAnalysisContext context,
File pubspecFile,
Version targetVersion,
ChangeBuilder builder,
) {
var preMigrationLintCodes = preMigrationLintsRegistry[targetVersion] ?? [];
return _runMigrations(
context: context,
pubspecFile: pubspecFile,
lintCodes: preMigrationLintCodes,
builder: builder,
phaseName: 'pre-migration',
);
}
}
/// A target package's `pubspec.yaml` file and its derived display name.
///
/// Used to avoid reading and parsing the `pubspec.yaml` file multiple times.
class _PubspecTarget {
/// The `pubspec.yaml` file for the package.
final File file;
/// The display name of the package, which defaults to the defined package
/// name in `pubspec.yaml`, or the parent directory name as a fallback.
final String displayName;
new({required this.file, required YamlMap pubspec})
: displayName = (pubspec['name'] as String?) ?? file.parent.shortName;
}