blob: 0e483f5b1ef4c2e49dc33dea241debbfea519870 [file] [edit]
// 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 'dart:async';
import 'dart:io' as io;
import 'package:cli_util/cli_logging.dart' show Progress;
import 'package:dartdev/src/commands/utils/lsp_workspace_edits.dart';
import 'package:language_server_protocol/protocol_custom_generated.dart';
import 'package:language_server_protocol/protocol_generated.dart' as lsp;
import 'package:path/path.dart' as path;
import '../core.dart';
import '../lsp_analysis_server.dart';
import '../sdk.dart';
/// A command to run the package migration tool.
class MigrateCommand extends DartdevCommand {
static const String cmdName = 'migrate';
static const String cmdDescription =
'Migrate Dart packages to newer SDK versions.';
MigrateCommand({bool verbose = false})
: super(cmdName, cmdDescription, verbose, hidden: true) {
argParser
..addOption(
'target-sdk',
help: 'The target Dart SDK version to migrate to (e.g., "3.13.0").',
)
..addFlag(
'dry-run',
abbr: 'n',
defaultsTo: false,
negatable: false,
help: 'Preview the proposed changes but make no changes.',
)
..addFlag(
'apply',
defaultsTo: false,
negatable: false,
help: 'Apply the proposed changes.',
)
..addMultiOption(
'step',
allowed: ['prepare', 'bump', 'cleanup', 'all'],
defaultsTo: ['all'],
help: 'The migration steps to run.',
);
}
@override
CommandCategory get commandCategory => CommandCategory.sourceCode;
@override
Future<int> run() async {
final args = argResults!;
final globalArgs = globalResults!;
final suppressAnalytics =
!globalArgs.flag('analytics') || globalArgs.flag('suppress-analytics');
final dryRun = args.flag('dry-run');
final apply = args.flag('apply');
// Ensure the user specified either --apply or --dry-run, but not both.
if (apply && dryRun) {
usageException(
'Cannot specify both --apply and --dry-run. Please specify one.',
);
} else if (!apply && !dryRun) {
usageException('Must specify either --apply or --dry-run.');
}
final steps = args.multiOption('step');
final targetSdk = args.option('target-sdk');
final rest = args.rest;
final targets = _getTargets(rest);
String targetDescription;
if (targets.length == 1) {
final targetName = path.basename(targets.single.path);
targetDescription = 'package ${log.ansi.emphasized(targetName)}';
} else {
targetDescription = '${targets.length} packages';
}
final modeText = dryRun ? ' (dry run)' : '';
final initialMessage = 'Migrating $targetDescription$modeText';
Progress? progress = log.progress(initialMessage);
final workDoneToken = lsp.ProgressToken.t2(
'migrate-${DateTime.now().millisecondsSinceEpoch}',
);
final server = LspAnalysisServer(
null,
io.Directory(sdk.sdkPath),
targets,
commandName: 'migrate',
argResults: argResults,
usePlugins: false,
suppressAnalytics: suppressAnalytics,
);
await server.start();
final progressSubscription = server.onProgress.listen((params) {
if (params.token != workDoneToken || progress == null) return;
if (params.value case {
'kind': 'report' || 'begin',
'message': String message,
} when message.isNotEmpty) {
progress?.cancel();
progress = log.progress('$initialMessage: $message');
}
});
server.onExit.then((int exitCode) {
if (progress != null && exitCode != 0) {
progress?.cancel();
progress = null;
io.exitCode = exitCode;
}
});
server.onCrash.then((_) {
log.stderr('The analysis server shut down unexpectedly.');
log.stdout('Please report this at dartbug.com.');
io.exit(1);
});
try {
final result = await _executeMigration(
server,
targets,
apply: apply,
steps: steps,
targetSdk: targetSdk,
workDoneToken: workDoneToken,
);
if (result == null) return 1;
if (progress != null) {
progress!.finish(showTiming: true);
progress = null;
}
final summary = result.summary;
if (summary != null && summary.isNotEmpty) {
log.stdout(summary);
}
if (result.edit.hasEdits) {
if (apply) {
_applyWorkspaceEdit(result.edit!);
} else {
_printApplyTip(steps, rest, targetSdk);
}
}
} catch (e, st) {
progress?.cancel();
progress = null;
log.stderr('An error occurred during migration: $e');
log.stderr(st.toString());
log.stdout(
'Please report this at dartbug.com and include the stack trace above.',
);
return 1;
} finally {
progress?.cancel();
progress = null;
await progressSubscription.cancel();
}
return 0;
}
/// Applies the changes defined in a [lsp.WorkspaceEdit] to the local
/// filesystem.
void _applyWorkspaceEdit(lsp.WorkspaceEdit workspaceEdit) {
String? readFile(String filePath) {
final file = io.File(filePath);
if (!file.existsSync()) {
log.stderr(
"Warning: File doesn't exist for migration edit: ${file.path}",
);
return null;
}
return file.readAsStringSync();
}
void writeFile(String filePath, String content) {
final file = io.File(filePath);
file.writeAsStringSync(content);
}
applyWorkspaceEdit(workspaceEdit, readFile, writeFile);
}
/// Sends the migration request to the analysis server and returns the
/// [DartMigrateResult], or `null` if an error occurred.
Future<DartMigrateResult?> _executeMigration(
LspAnalysisServer server,
List<io.FileSystemEntity> targets, {
required bool apply,
required List<String> steps,
String? targetSdk,
lsp.ProgressToken? workDoneToken,
}) async {
final uris = [for (final target in targets) Uri.file(target.path)];
try {
// Ensure the server has finished discovering analysis roots and building
// contexts for the target workspace before sending the migration request.
await server.workspaceAnalysisComplete();
return await server.migrate(
uris,
apply: apply,
steps: steps.map(MigrationStep.new).toList(),
targetSdk: targetSdk,
workDoneToken: workDoneToken,
);
} finally {
await server.shutdown();
}
}
/// Returns a list of unique [io.FileSystemEntity] targets to migrate.
///
/// Defaults to the current directory if [rest] is empty. Validates that all
/// specified targets exist and deduplicates any paths that refer to the same
/// target.
List<io.FileSystemEntity> _getTargets(List<String> rest) {
// If there are no targets, the tool migrates the current directory.
if (rest.isEmpty) {
return [getTarget([])];
}
final targets = <io.FileSystemEntity>[];
final nonExistentPaths = <String>[];
for (final arg in rest) {
final currentTarget = getTarget([arg]);
if (!currentTarget.existsSync()) {
nonExistentPaths.add(currentTarget.path);
continue;
}
// Deduplicate target paths.
final currentTargetPath = currentTarget.resolveSymbolicLinksSync();
if (!targets.any(
(t) => io.FileSystemEntity.identicalSync(
t.resolveSymbolicLinksSync(),
currentTargetPath,
),
)) {
targets.add(currentTarget);
}
}
if (nonExistentPaths.isNotEmpty) {
usageException(
[
"Directory or file doesn't exist:",
for (final target in nonExistentPaths) ' $target',
].join('\n'),
);
}
return targets;
}
/// Prints a command tip instructing the user how to apply the proposed
/// changes.
void _printApplyTip(
List<String> steps,
List<String> targets,
String? targetSdk,
) {
var targetArgs = '';
if (targets.isNotEmpty) {
targetArgs = ' ${targets.join(' ')}';
}
var targetSdkArg = '';
if (argResults!.wasParsed('target-sdk') && targetSdk != null) {
targetSdkArg = ' --target-sdk=$targetSdk';
}
// Omit '--step=all' from the suggested command because running all steps is
// the default behavior.
var stepArg = '';
if (argResults!.wasParsed('step') &&
!(steps.length == 1 && steps.first == 'all')) {
stepArg = ' --step=${steps.join(',')}';
}
log.stdout('');
log.stdout('To apply the proposed changes, run:');
log.stdout(' dart migrate --apply$targetSdkArg$stepArg$targetArgs');
}
}