| // 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) { | |
| 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'], | |
| allowedHelp: { | |
| 'prepare': | |
| 'Apply fixes that make code ready for the new SDK version.', | |
| 'bump': "Raise the SDK constraint in 'pubspec.yaml'.", | |
| 'cleanup': | |
| 'Apply optional fixes that adopt features of the new SDK ' | |
| 'version.', | |
| 'all': 'Run every step, including cleanup.', | |
| }, | |
| defaultsTo: ['prepare', 'bump'], | |
| 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); | |
| } | |
| } | |
| } on LspRequestError catch (e) { | |
| // Print only the server message without stack traces. These are errors | |
| // that the user can fix and are not bugs to report. | |
| progress?.cancel(); | |
| progress = null; | |
| log.stderr(e.error.message); | |
| return 1; | |
| } 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'; | |
| } | |
| // Repeat the steps only if the user chose them. Otherwise the suggested | |
| // command runs the same default steps. | |
| var stepArg = ''; | |
| if (argResults!.wasParsed('step')) { | |
| stepArg = ' --step=${steps.join(',')}'; | |
| } | |
| log.stdout(''); | |
| log.stdout('To apply the proposed changes, run:'); | |
| log.stdout(' dart migrate --apply$targetSdkArg$stepArg$targetArgs'); | |
| } | |
| } |