blob: 35c76921977b0eb6f33181712ce2cfda24d1f093 [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:analysis_server_client/protocol.dart' show SourceEdit;
import 'package:analyzer/source/line_info.dart';
import 'package:cli_util/cli_logging.dart' show Progress;
import 'package:language_server_protocol/protocol_custom_generated.dart';
import 'package:language_server_protocol/protocol_generated.dart' as lsp;
import 'package:language_server_protocol/protocol_special.dart';
import 'package:path/path.dart' as path;
import '../core.dart';
import '../lsp_analysis_server.dart';
import '../sdk.dart';
import '../utils.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
..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 rest = args.rest;
// TODO(kallentu): Support multiple targets.
final target = getTarget(rest);
if (!target.existsSync()) {
final entity = target is io.Directory ? 'Directory' : 'File';
usageException("$entity doesn't exist: ${target.path}");
}
final migratePath = target.path;
final targetName = path.basename(migratePath);
final modeText = dryRun ? ' (dry run)' : '';
Progress? progress = log.progress(
'Migrating package ${log.ansi.emphasized(targetName)}$modeText',
);
final server = LspAnalysisServer(
null,
io.Directory(sdk.sdkPath),
[target],
commandName: 'migrate',
argResults: argResults,
usePlugins: false,
suppressAnalytics: suppressAnalytics,
);
await server.start();
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,
migratePath,
apply: apply,
steps: steps,
);
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 (apply && result.edit != null) {
_applyWorkspaceEdit(result.edit!);
}
} catch (e, st) {
if (progress != null) {
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;
}
return 0;
}
/// Applies the changes defined in a [lsp.WorkspaceEdit] to the local
/// filesystem.
void _applyWorkspaceEdit(lsp.WorkspaceEdit workspaceEdit) {
void applyEdits(Uri uri, List<lsp.TextEdit> edits) {
final file = io.File.fromUri(uri);
if (!file.existsSync()) {
log.stderr(
"Warning: File doesn't exist for migration edit: ${file.path}",
);
return;
}
final content = file.readAsStringSync();
final lineInfo = LineInfo.fromContent(content);
final sourceEdits = <SourceEdit>[];
for (final edit in edits) {
final startOffset = lineInfo.offsetOfPosition(edit.range.start);
final endOffset = lineInfo.offsetOfPosition(edit.range.end);
if (startOffset < 0 || endOffset < startOffset) {
log.stderr('Warning: Invalid edit range in ${file.path}');
continue;
}
sourceEdits.add(
SourceEdit(startOffset, endOffset - startOffset, edit.newText),
);
}
// SourceEdit.applySequence applies edits from the back of the list to the
// front, so edits must be sorted in descending order by offset to avoid
// shifting character offsets for subsequent edits.
sourceEdits.sort((a, b) => b.offset.compareTo(a.offset));
final updatedContent = SourceEdit.applySequence(content, sourceEdits);
file.writeAsStringSync(updatedContent);
}
// LSP WorkspaceEdits can encode changes in two ways:
// 1. A simple map of URIs to lists of TextEdits (`changes`).
// 2. A list of resource operations and versioned document edits
// (`documentChanges`).
// We check and handle both representations.
if (workspaceEdit.changes case final changes?) {
changes.forEach(applyEdits);
}
if (workspaceEdit.documentChanges case final documentChanges?) {
for (final change in documentChanges) {
if (change.textDocumentEdit case final docEdit?) {
applyEdits(docEdit.textDocument.uri, docEdit.plainTextEdits);
}
}
}
}
/// Sends the migration request to the analysis server and returns the
/// [DartMigrateResult], or `null` if an error occurred.
Future<DartMigrateResult?> _executeMigration(
LspAnalysisServer server,
String migratePath, {
required bool apply,
required List<String> steps,
}) async {
final uri = Uri.file(
path.canonicalize(path.normalize(path.absolute(migratePath))),
);
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(
[uri],
apply: apply,
steps: steps.map(MigrationStep.new).toList(),
);
} finally {
await server.shutdown();
}
}
}
extension on lsp.TextDocumentEdit {
/// Converts all edits in this document edit (including snippet edits) into
/// a uniform list of plain [lsp.TextEdit]s.
List<lsp.TextEdit> get plainTextEdits {
return edits
.map(
(e) => e.map(
(a) => a,
(l) => l,
(s) => lsp.TextEdit(range: s.range, newText: s.snippet.value),
(t) => t,
),
)
.toList();
}
}
extension
on
Either4<
lsp.CreateFile,
lsp.DeleteFile,
lsp.RenameFile,
lsp.TextDocumentEdit
> {
/// Extracts the [lsp.TextDocumentEdit] from this union, or returns `null` if
/// this is a resource operation ([lsp.CreateFile], [lsp.DeleteFile], or
/// [lsp.RenameFile]).
lsp.TextDocumentEdit? get textDocumentEdit {
return map((_) => null, (_) => null, (_) => null, (docEdit) => docEdit);
}
}