blob: 41f90f4c9d8ee2e41474f93e593123aecca3118f [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/protocol/protocol_generated.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/lsp/temporary_overlay_operation.dart';
import 'package:analysis_server/src/services/correction/bulk_fix_processor.dart';
import 'package:analysis_server/src/utilities/pubspec.dart';
import 'package:analysis_server/src/utilities/source_change_merger.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/protocol/protocol_common.dart';
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 apply = params.apply ?? false;
var migrationRunner = _MigrationRunner(
server: server,
pubspecTargets: targets,
summaryBuffer: summaryBuffer,
apply: apply,
);
var fileEdits = await migrationRunner.computeEdits();
WorkspaceEdit? workspaceEdit;
if (apply) {
// Merge all the accumulated sequential edits per file.
var mergedFileEdits = SourceChangeMerger().merge(fileEdits);
var sourceChange = SourceChange(
'Migrate package(s)',
edits: mergedFileEdits,
);
workspaceEdit = createWorkspaceEdit(
server,
message.clientCapabilities!,
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({
@override required final AnalysisServer server,
required final List<_PubspecTarget> pubspecTargets,
required final StringBuffer summaryBuffer,
/// Whether to apply the migration edits to the files.
///
/// If `false`, the migration is run as a dry run (previewing changes in the
/// summary without applying them to the workspace).
required final bool apply,
}) extends TemporaryOverlayOperation {
final List<SourceFileEdit> _fileEdits = [];
/// Accumulated pre-migration fixes per file.
///
/// Keyed by file path, mapping to diagnostic code names and their count.
final Map<String, Map<String, int>> _preMigrationFixDetailsMap = {};
/// Accumulated post-migration fixes per file.
///
/// Keyed by file path, mapping to diagnostic code names and their count.
final Map<String, Map<String, int>> _postMigrationFixDetailsMap = {};
this : super(server);
/// Runs the migration runner with the scheduled analysis pausing enabled.
Future<List<SourceFileEdit>> computeEdits() async {
return await pauseSchedulerWithTemporaryOverlays(_computeMigrationEdits);
}
/// Populate file fix occurrences from [details] into the [detailsMap],
/// converting absolute file paths to project-relative paths relative to
/// [pubspec].
void _accumulateFixDetails(
List<BulkFix> details,
Map<String, Map<String, int>> detailsMap,
_PubspecTarget pubspec,
) {
var pubspecFolder = pubspec.file.parent;
for (var detail in details) {
var relative = server.resourceProvider.pathContext
.relative(detail.path, from: pubspecFolder.path)
.replaceAll('\\', '/');
var key = '${pubspecFolder.shortName}/$relative';
var fileFixes = detailsMap[key] ??= {};
for (var fix in detail.fixes) {
var count = fileFixes[fix.code] ?? 0;
fileFixes[fix.code] = count + fix.occurrences;
}
}
}
void _applyAndRecordEdits(ChangeBuilder builder) {
for (var fileEdit in builder.sourceChange.edits) {
if (apply) {
// Record the edit to be returned to the client at the end of the entire
// migration.
_fileEdits.add(fileEdit);
}
// Apply the edit to the in-memory overlays so that subsequent analysis
// (like post-migration or other packages in the workspace) sees the
// updated code.
applyTemporaryOverlayEdits(fileEdit);
}
}
/// 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<List<SourceFileEdit>> _computeMigrationEdits() async {
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;
if (_shouldSkipDueToDependencies(context, pubspec, versionBumpEdit)) {
continue;
}
var targetVersion = versionBumpEdit.targetVersion;
var originalVersionChangeBuilder = await _createBuilder();
// Run pre-migrations fixes.
var preMigrationFixDetails = await _runPreMigrations(
context,
pubspec,
targetVersion,
originalVersionChangeBuilder,
);
if (preMigrationFixDetails == null) continue;
_accumulateFixDetails(
preMigrationFixDetails,
_preMigrationFixDetailsMap,
pubspec,
);
// Bump version constraint.
await _bumpPubspecConstraint(
pubspecFile,
versionBumpEdit,
originalVersionChangeBuilder,
);
_applyAndRecordEdits(originalVersionChangeBuilder);
bumpedLines.add(
'- ${pubspec.displayName}: ${versionBumpEdit.originalConstraint} -> '
'${versionBumpEdit.replacement}',
);
// Apply the pre-migration and pubspec constraint.
await applyOverlays();
await server.analysisDriverScheduler.waitForIdle();
// Get the updated context.
var updatedContext = server.contextManager.getContextFor(
pubspecFile.path,
);
if (updatedContext == null) {
summaryBuffer.writeln(
'- ${pubspec.displayName}: Skipped post-migrations '
'(context lost after pubspec update)',
);
continue;
}
// Run post-migration fixes.
var targetVersionChangeBuilder = await _createBuilder();
// TODO(kallentu): Allow the user to choose which ones.
var postMigrationFixDetails = await _runPostMigrations(
updatedContext,
pubspec,
targetVersion,
targetVersionChangeBuilder,
);
if (postMigrationFixDetails != null) {
_accumulateFixDetails(
postMigrationFixDetails,
_postMigrationFixDetailsMap,
pubspec,
);
_applyAndRecordEdits(targetVersionChangeBuilder);
}
}
if (bumpedLines.isEmpty) {
var verb = apply ? 'were' : 'would be';
summaryBuffer.writeln('No SDK constraints $verb bumped.');
} else {
var action = apply ? 'Bumped' : 'Would bump';
summaryBuffer.writeln(
'$action SDK constraints in ${bumpedLines.length} package(s):',
);
for (var line in bumpedLines) {
summaryBuffer.writeln(' $line');
}
}
_writeFixesSummary(
summaryBuffer,
'Pre-migration fixes:',
_preMigrationFixDetailsMap,
);
_writeFixesSummary(
summaryBuffer,
'Post-migration fixes:',
_postMigrationFixDetailsMap,
);
// Revert all temporary overlays back to their original state.
await revertOverlays();
return _fileEdits;
}
Future<ChangeBuilder> _createBuilder() async {
return ChangeBuilder(
workspace: DartChangeWorkspace(await server.currentSessions),
);
}
/// Runs bulk fixes for the given [lintCodes] in the specified migration
/// phase.
///
/// Returns the list of bulk fixes applied, or `null` if the phase failed.
Future<List<BulkFix>?> _runMigrations({
required DriverBasedAnalysisContext context,
required _PubspecTarget pubspec,
required List<String> lintCodes,
required ChangeBuilder builder,
required String phaseName,
}) async {
if (lintCodes.isEmpty) return const [];
try {
var workspace = DartChangeWorkspace([context.driver.currentSession]);
// TODO(kallentu): Use an IterativeBulkFixProcessor to loop until code
// stabilizes.
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]);
return processor.fixDetails;
} catch (e) {
summaryBuffer.writeln(
'- ${pubspec.displayName}: Failed $phaseName fixes with '
'exception: $e',
);
return null;
}
}
/// Runs post-migration fixes for the given [targetVersion].
///
/// Returns the list of bulk fixes applied, or `null` if the phase failed.
Future<List<BulkFix>?> _runPostMigrations(
DriverBasedAnalysisContext context,
_PubspecTarget pubspec,
Version targetVersion,
ChangeBuilder builder,
) {
var postMigrationLintCodes =
postMigrationLintsRegistry[targetVersion] ?? [];
return _runMigrations(
context: context,
pubspec: pubspec,
lintCodes: postMigrationLintCodes,
builder: builder,
phaseName: 'post-migration',
);
}
/// Runs pre-migration fixes for the given [targetVersion].
///
/// Returns the list of bulk fixes applied, or `null` if the phase failed.
Future<List<BulkFix>?> _runPreMigrations(
DriverBasedAnalysisContext context,
_PubspecTarget pubspec,
Version targetVersion,
ChangeBuilder builder,
) {
var preMigrationLintCodes = preMigrationLintsRegistry[targetVersion] ?? [];
return _runMigrations(
context: context,
pubspec: pubspec,
lintCodes: preMigrationLintCodes,
builder: builder,
phaseName: 'pre-migration',
);
}
/// Returns `true` if the migration should be skipped due to incompatible dependencies.
bool _shouldSkipDueToDependencies(
DriverBasedAnalysisContext context,
_PubspecTarget pubspec,
PubspecEdit versionBumpEdit,
) {
var packageDependencies = context.contextRoot.workspace.packages.packages
.where(
(package) => package.rootFolder.path != pubspec.file.parent.path,
);
var incompatibleDeps = checkDependencyCompatibility(
packages: packageDependencies,
targetVersion: versionBumpEdit.targetVersion,
);
if (incompatibleDeps.isNotEmpty) {
incompatibleDeps.sort();
summaryBuffer.writeln('- ${pubspec.displayName}: Skipped');
summaryBuffer.writeln(' Incompatible dependencies:');
for (var dep in incompatibleDeps) {
summaryBuffer.writeln(' - $dep');
}
return true;
}
return false;
}
/// Writes a summary of the fixes in [fixesMap] preceded by [phaseLabel] to
/// the [buffer] if any fixes were made.
void _writeFixesSummary(
StringBuffer buffer,
String phaseLabel,
Map<String, Map<String, int>> fixesMap,
) {
var totalFixes = 0;
var totalFiles = fixesMap.length;
for (var fileFixes in fixesMap.values) {
for (var count in fileFixes.values) {
totalFixes += count;
}
}
if (totalFixes > 0) {
buffer.writeln();
buffer.writeln(phaseLabel);
var fixPlural = totalFixes == 1 ? 'fix' : 'fixes';
var filePlural = totalFiles == 1 ? 'file' : 'files';
var verb = apply ? 'made' : 'would be made';
buffer.writeln(
' $totalFixes $fixPlural $verb in $totalFiles $filePlural.',
);
var sortedPaths = fixesMap.keys.toList()..sort();
for (var path in sortedPaths) {
buffer.writeln();
buffer.writeln(' $path');
var fileFixes = fixesMap[path]!;
var sortedCodes = fileFixes.keys.toList()..sort();
for (var code in sortedCodes) {
var count = fileFixes[code]!;
var fixPlural = count == 1 ? 'fix' : 'fixes';
buffer.writeln(' $code • $count $fixPlural');
}
}
}
}
}
/// 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;
}