blob: 5fbcd93265bde32d0d166a8a18a701f6c3d819c3 [file] [log] [blame]
// Copyright 2013 The Flutter Authors. 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:convert';
import 'dart:io' as io;
import 'package:file/file.dart';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import 'common/core.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/pub_utils.dart';
import 'common/repository_package.dart';
/// In theory this should be 8191, but in practice that was still resulting in
/// "The input line is too long" errors. This was chosen as a value that worked
/// in practice via trial and error, but may need to be adjusted based on
/// further experience.
@visibleForTesting
const int windowsCommandLineMax = 8000;
/// This value is picked somewhat arbitrarily based on checking `ARG_MAX` on a
/// macOS and Linux machine. If anyone encounters a lower limit in pratice, it
/// can be lowered accordingly.
@visibleForTesting
const int nonWindowsCommandLineMax = 1000000;
const int _exitClangFormatFailed = 3;
const int _exitFlutterFormatFailed = 4;
const int _exitJavaFormatFailed = 5;
const int _exitGitFailed = 6;
const int _exitDependencyMissing = 7;
const int _exitSwiftFormatFailed = 8;
const int _exitKotlinFormatFailed = 9;
const int _exitSwiftLintFoundIssues = 10;
final Uri _javaFormatterUrl = Uri.https('github.com',
'/google/google-java-format/releases/download/google-java-format-1.3/google-java-format-1.3-all-deps.jar');
final Uri _kotlinFormatterUrl = Uri.https('maven.org',
'/maven2/com/facebook/ktfmt/0.46/ktfmt-0.46-jar-with-dependencies.jar');
/// A command to format all package code.
class FormatCommand extends PackageLoopingCommand {
/// Creates an instance of the format command.
FormatCommand(
super.packagesDir, {
super.processRunner,
super.platform,
}) {
argParser.addFlag(_failonChangeArg, hide: true);
argParser.addFlag(_dartArg, help: 'Format Dart files', defaultsTo: true);
argParser.addFlag(_clangFormatArg,
help: 'Format with "clang-format"', defaultsTo: true);
argParser.addFlag(_kotlinArg,
help: 'Format Kotlin files', defaultsTo: true);
argParser.addFlag(_javaArg, help: 'Format Java files', defaultsTo: true);
argParser.addFlag(_swiftArg,
help: 'Format and lint Swift files', defaultsTo: true);
argParser.addOption(_clangFormatPathArg,
defaultsTo: 'clang-format', help: 'Path to "clang-format" executable.');
argParser.addOption(_javaPathArg,
defaultsTo: 'java', help: 'Path to "java" executable.');
argParser.addOption(_swiftFormatPathArg,
defaultsTo: 'swift-format', help: 'Path to "swift-format" executable.');
}
static const String _dartArg = 'dart';
static const String _clangFormatArg = 'clang-format';
static const String _failonChangeArg = 'fail-on-change';
static const String _kotlinArg = 'kotlin';
static const String _javaArg = 'java';
static const String _swiftArg = 'swift';
static const String _clangFormatPathArg = 'clang-format-path';
static const String _javaPathArg = 'java-path';
static const String _swiftFormatPathArg = 'swift-format-path';
@override
final String name = 'format';
@override
final String description =
'Formats the code of all packages (C++, Dart, Java, Kotlin, Objective-C, '
'and optionally Swift).\n\n'
'This command requires "git", "flutter", "java", and "clang-format" v5 '
'to be in your path.';
@override
Future<void> initializeRun() async {
final String javaFormatterPath = await _getJavaFormatterPath();
final String kotlinFormatterPath = await _getKotlinFormatterPath();
// All but Dart is formatted here rather than in runForPackage because
// running the formatters separately for each package is an order of
// magnitude slower, due to the startup overhead of the formatters.
//
// Dart has to be run per-package because the formatter can have different
// behavior based on the package's SDK, which can't be determined if the
// formatter isn't running in the context of the package.
final Iterable<String> files =
await _getFilteredFilePaths(getFiles(), relativeTo: packagesDir);
if (getBoolArg(_javaArg)) {
await _formatJava(files, javaFormatterPath);
}
if (getBoolArg(_kotlinArg)) {
await _formatKotlin(files, kotlinFormatterPath);
}
if (getBoolArg(_clangFormatArg)) {
await _formatCppAndObjectiveC(files);
}
if (getBoolArg(_swiftArg)) {
await _formatAndLintSwift(files);
}
}
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
final Iterable<String> files = await _getFilteredFilePaths(
getFilesForPackage(package),
relativeTo: package.directory,
);
if (getBoolArg(_dartArg)) {
// Ensure that .dart_tool exists, since without it `dart` doesn't know
// the lanugage version, so the formatter may give different output.
if (!package.directory.childDirectory('.dart_tool').existsSync()) {
if (!await runPubGet(package, processRunner, super.platform)) {
printError('Unable to fetch dependencies.');
return PackageResult.fail(<String>['unable to fetch dependencies']);
}
}
await _formatDart(files, workingDir: package.directory);
}
// Success or failure is determined overall in completeRun, since most code
// isn't being validated per-package, so just always return success at the
// package level.
// TODO(stuartmorgan): Consider doing _didModifyAnything checks per-package
// instead, since the other languages are already formatted by the time
// this code is being run.
return PackageResult.success();
}
@override
Future<void> completeRun() async {
if (getBoolArg(_failonChangeArg)) {
final bool modified = await _didModifyAnything();
if (modified) {
throw ToolExit(exitCommandFoundErrors);
}
}
}
Future<bool> _didModifyAnything() async {
final io.ProcessResult modifiedFiles = await processRunner.run(
'git',
<String>[
'ls-files',
'--modified',
packagesDir.path,
thirdPartyPackagesDir.path
],
workingDir: packagesDir.parent,
logOnError: true,
);
if (modifiedFiles.exitCode != 0) {
printError('Unable to determine changed files.');
throw ToolExit(_exitGitFailed);
}
print('\n\n');
final String stdout = modifiedFiles.stdout as String;
if (stdout.isEmpty) {
print('All files formatted correctly.');
return false;
}
print('These files are not formatted correctly (see diff below):');
LineSplitter.split(stdout).map((String line) => ' $line').forEach(print);
print('\nTo fix run the repository tooling `format` command: '
'https://github.com/flutter/packages/blob/main/script/tool/README.md#format-code\n'
'or copy-paste this command into your terminal:');
final io.ProcessResult diff = await processRunner.run(
'git',
<String>['diff', packagesDir.path, thirdPartyPackagesDir.path],
workingDir: packagesDir.parent,
logOnError: true,
);
if (diff.exitCode != 0) {
printError('Unable to determine diff.');
throw ToolExit(_exitGitFailed);
}
print('patch -p1 <<DONE');
print(diff.stdout);
print('DONE');
return true;
}
Future<void> _formatCppAndObjectiveC(Iterable<String> files) async {
final Iterable<String> clangFiles = _getPathsWithExtensions(
files, <String>{'.h', '.m', '.mm', '.cc', '.cpp'});
if (clangFiles.isNotEmpty) {
final String clangFormat = await _findValidClangFormat();
print('Formatting .cc, .cpp, .h, .m, and .mm files...');
final int exitCode = await _runBatched(
clangFormat, <String>['-i', '--style=file'],
files: clangFiles);
if (exitCode != 0) {
printError(
'Failed to format C, C++, and Objective-C files: exit code $exitCode.');
throw ToolExit(_exitClangFormatFailed);
}
}
}
Future<void> _formatAndLintSwift(Iterable<String> files) async {
final Iterable<String> swiftFiles =
_getPathsWithExtensions(files, <String>{'.swift'});
if (swiftFiles.isNotEmpty) {
final String swiftFormat = await _findValidSwiftFormat();
print('Formatting .swift files...');
final int formatExitCode =
await _runBatched(swiftFormat, <String>['-i'], files: swiftFiles);
if (formatExitCode != 0) {
printError('Failed to format Swift files: exit code $formatExitCode.');
throw ToolExit(_exitSwiftFormatFailed);
}
print('Linting .swift files...');
final int lintExitCode = await _runBatched(
swiftFormat,
<String>[
'lint',
'--parallel',
'--strict',
],
files: swiftFiles);
if (lintExitCode == 1) {
printError('Swift linter found issues. See above for linter output.');
throw ToolExit(_exitSwiftLintFoundIssues);
} else if (lintExitCode != 0) {
printError('Failed to lint Swift files: exit code $lintExitCode.');
throw ToolExit(_exitSwiftFormatFailed);
}
}
}
Future<String> _findValidClangFormat() async {
final String clangFormat = getStringArg(_clangFormatPathArg);
if (await _hasDependency(clangFormat)) {
return clangFormat;
}
// There is a known issue where "chromium/depot_tools/clang-format"
// fails with "Problem while looking for clang-format in Chromium source tree".
// Loop through all "clang-format"s in PATH until a working one is found,
// for example "/usr/local/bin/clang-format" or a "brew" installed version.
for (final String clangFormatPath in await _whichAll('clang-format')) {
if (await _hasDependency(clangFormatPath)) {
return clangFormatPath;
}
}
printError('Unable to run "clang-format". Make sure that it is in your '
'path, or provide a full path with --$_clangFormatPathArg.');
throw ToolExit(_exitDependencyMissing);
}
Future<String> _findValidSwiftFormat() async {
final String swiftFormat = getStringArg(_swiftFormatPathArg);
if (await _hasDependency(swiftFormat)) {
return swiftFormat;
}
printError('Unable to run "swift-format". Make sure that it is in your '
'path, or provide a full path with --$_swiftFormatPathArg.');
throw ToolExit(_exitDependencyMissing);
}
Future<void> _formatJava(Iterable<String> files, String formatterPath) async {
final Iterable<String> javaFiles =
_getPathsWithExtensions(files, <String>{'.java'});
if (javaFiles.isNotEmpty) {
final String java = getStringArg(_javaPathArg);
if (!await _hasDependency(java)) {
printError(
'Unable to run "java". Make sure that it is in your path, or '
'provide a full path with --$_javaPathArg.');
throw ToolExit(_exitDependencyMissing);
}
print('Formatting .java files...');
final int exitCode = await _runBatched(
java, <String>['-jar', formatterPath, '--replace'],
files: javaFiles);
if (exitCode != 0) {
printError('Failed to format Java files: exit code $exitCode.');
throw ToolExit(_exitJavaFormatFailed);
}
}
}
Future<void> _formatKotlin(
Iterable<String> files, String formatterPath) async {
final Iterable<String> kotlinFiles =
_getPathsWithExtensions(files, <String>{'.kt'});
if (kotlinFiles.isNotEmpty) {
final String java = getStringArg(_javaPathArg);
if (!await _hasDependency(java)) {
printError(
'Unable to run "java". Make sure that it is in your path, or '
'provide a full path with --$_javaPathArg.');
throw ToolExit(_exitDependencyMissing);
}
print('Formatting .kt files...');
final int exitCode = await _runBatched(
java, <String>['-jar', formatterPath],
files: kotlinFiles);
if (exitCode != 0) {
printError('Failed to format Kotlin files: exit code $exitCode.');
throw ToolExit(_exitKotlinFormatFailed);
}
}
}
Future<void> _formatDart(
Iterable<String> files, {
Directory? workingDir,
}) async {
final Iterable<String> dartFiles =
_getPathsWithExtensions(files, <String>{'.dart'});
if (dartFiles.isNotEmpty) {
print('Formatting .dart files...');
final int exitCode = await _runBatched('dart', <String>['format'],
files: dartFiles, workingDir: workingDir);
if (exitCode != 0) {
printError('Failed to format Dart files: exit code $exitCode.');
throw ToolExit(_exitFlutterFormatFailed);
}
}
}
/// Given a stream of [files], returns the paths of any that are not in known
/// locations to ignore, relative to [relativeTo].
Future<Iterable<String>> _getFilteredFilePaths(
Stream<File> files, {
required Directory relativeTo,
}) async {
// Returns a pattern to check for [directories] as a subset of a file path.
RegExp pathFragmentForDirectories(List<String> directories) {
String s = path.separator;
// Escape the separator for use in the regex.
if (s == r'\') {
s = r'\\';
}
return RegExp('(?:^|$s)${path.joinAll(directories)}$s');
}
final String fromPath = relativeTo.path;
// Dart files are allowed to have a pragma to disable auto-formatting. This
// was added because Hixie hurts when dealing with what dartfmt does to
// artisanally-formatted Dart, while Stuart gets really frustrated when
// dealing with PRs from newer contributors who don't know how to make Dart
// readable. After much discussion, it was decided that files in the plugins
// and packages repos that really benefit from hand-formatting (e.g. files
// with large blobs of hex literals) could be opted-out of the requirement
// that they be autoformatted, so long as the code's owner was willing to
// bear the cost of this during code reviews.
// In the event that code ownership moves to someone who does not hold the
// same views as the original owner, the pragma can be removed and the file
// auto-formatted.
const String handFormattedExtension = '.dart';
const String handFormattedPragma = '// This file is hand-formatted.';
return files
.where((File file) {
// See comment above near [handFormattedPragma].
return path.extension(file.path) != handFormattedExtension ||
!file.readAsLinesSync().contains(handFormattedPragma);
})
.map((File file) => path.relative(file.path, from: fromPath))
.where((String path) =>
// Ignore files in build/ directories (e.g., headers of frameworks)
// to avoid useless extra work in local repositories.
!path.contains(
pathFragmentForDirectories(<String>['example', 'build'])) &&
// Ignore files in Pods, which are not part of the repository.
!path.contains(pathFragmentForDirectories(<String>['Pods'])) &&
// See https://github.com/flutter/flutter/issues/144039
!path.endsWith('GeneratedPluginRegistrant.swift') &&
// Ignore .dart_tool/, which can have various intermediate files.
!path.contains(pathFragmentForDirectories(<String>['.dart_tool'])))
.toList();
}
Iterable<String> _getPathsWithExtensions(
Iterable<String> files, Set<String> extensions) {
return files.where(
(String filePath) => extensions.contains(path.extension(filePath)));
}
Future<String> _getJavaFormatterPath() async {
final String javaFormatterPath = path.join(
path.dirname(path.fromUri(platform.script)),
'google-java-format-1.3-all-deps.jar');
final File javaFormatterFile =
packagesDir.fileSystem.file(javaFormatterPath);
if (!javaFormatterFile.existsSync()) {
print('Downloading Google Java Format...');
final http.Response response = await http.get(_javaFormatterUrl);
javaFormatterFile.writeAsBytesSync(response.bodyBytes);
}
return javaFormatterPath;
}
Future<String> _getKotlinFormatterPath() async {
final String kotlinFormatterPath = path.join(
path.dirname(path.fromUri(platform.script)),
'ktfmt-0.46-jar-with-dependencies.jar');
final File kotlinFormatterFile =
packagesDir.fileSystem.file(kotlinFormatterPath);
if (!kotlinFormatterFile.existsSync()) {
print('Downloading ktfmt...');
final http.Response response = await http.get(_kotlinFormatterUrl);
kotlinFormatterFile.writeAsBytesSync(response.bodyBytes);
}
return kotlinFormatterPath;
}
/// Returns true if [command] can be run successfully.
Future<bool> _hasDependency(String command) async {
// Some versions of Java accept both -version and --version, but some only
// accept -version.
final String versionFlag = command == 'java' ? '-version' : '--version';
try {
final io.ProcessResult result =
await processRunner.run(command, <String>[versionFlag]);
if (result.exitCode != 0) {
return false;
}
} on io.ProcessException {
// Thrown when the binary is missing entirely.
return false;
}
return true;
}
/// Returns all instances of [command] executable found on user path.
Future<List<String>> _whichAll(String command) async {
try {
final io.ProcessResult result =
await processRunner.run('which', <String>['-a', command]);
if (result.exitCode != 0) {
return <String>[];
}
final String stdout = (result.stdout as String).trim();
if (stdout.isEmpty) {
return <String>[];
}
return LineSplitter.split(stdout).toList();
} on io.ProcessException {
return <String>[];
}
}
/// Runs [command] on [arguments] on all of the files in [files], batched as
/// necessary to avoid OS command-line length limits.
///
/// Returns the exit code of the first failure, which stops the run, or 0
/// on success.
Future<int> _runBatched(String command, List<String> arguments,
{required Iterable<String> files, Directory? workingDir}) async {
final int commandLineMax =
platform.isWindows ? windowsCommandLineMax : nonWindowsCommandLineMax;
// Compute the max length of the file argument portion of a batch.
// Add one to each argument's length for the space before it.
final int argumentTotalLength =
arguments.fold(0, (int sum, String arg) => sum + arg.length + 1);
final int batchMaxTotalLength =
commandLineMax - command.length - argumentTotalLength;
// Run the command in batches.
final List<List<String>> batches =
_partitionFileList(files, maxStringLength: batchMaxTotalLength);
for (final List<String> batch in batches) {
batch.sort(); // For ease of testing.
final int exitCode = await processRunner.runAndStream(
command, <String>[...arguments, ...batch],
workingDir: workingDir ?? packagesDir);
if (exitCode != 0) {
return exitCode;
}
}
return 0;
}
/// Partitions [files] into batches whose max string length as parameters to
/// a command (including the spaces between them, and between the list and
/// the command itself) is no longer than [maxStringLength].
List<List<String>> _partitionFileList(Iterable<String> files,
{required int maxStringLength}) {
final List<List<String>> batches = <List<String>>[<String>[]];
int currentBatchTotalLength = 0;
for (final String file in files) {
final int length = file.length + 1 /* for the space */;
if (currentBatchTotalLength + length > maxStringLength) {
// Start a new batch.
batches.add(<String>[]);
currentBatchTotalLength = 0;
}
batches.last.add(file);
currentBatchTotalLength += length;
}
return batches;
}
}