diff --git a/dev/bots/analyze.dart b/dev/bots/analyze.dart index 5663ac062eb..c0bfff7184c 100644 --- a/dev/bots/analyze.dart +++ b/dev/bots/analyze.dart @@ -2246,6 +2246,7 @@ const Set kExecutableAllowlist = { 'dev/tools/gen_keycodes/bin/gen_keycodes', 'dev/tools/repackage_gradle_wrapper.sh', 'dev/tools/bin/engine_hash.sh', + 'dev/tools/format.sh', 'packages/flutter_tools/bin/macos_assemble.sh', 'packages/flutter_tools/bin/tool_backend.sh', diff --git a/dev/tools/bin/format.dart b/dev/tools/bin/format.dart new file mode 100644 index 00000000000..dc5a978a27d --- /dev/null +++ b/dev/tools/bin/format.dart @@ -0,0 +1,256 @@ +// Copyright 2014 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:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart' as path; +import 'package:process_runner/process_runner.dart'; + +Future main(List arguments) async { + final ArgParser parser = ArgParser(); + parser.addFlag('help', help: 'Print help.', abbr: 'h'); + parser.addFlag('fix', + abbr: 'f', + help: 'Instead of just checking for formatting errors, fix them in place.'); + parser.addFlag('all-files', + abbr: 'a', + help: 'Instead of just checking for formatting errors in changed files, ' + 'check for them in all files.'); + + late final ArgResults options; + try { + options = parser.parse(arguments); + } on FormatException catch (e) { + stderr.writeln('ERROR: $e'); + _usage(parser, exitCode: 0); + } + + if (options['help'] as bool) { + _usage(parser, exitCode: 0); + } + + final File script = File.fromUri(Platform.script).absolute; + final Directory flutterRoot = script.parent.parent.parent.parent; + + final bool result = (await DartFormatChecker( + flutterRoot: flutterRoot, + allFiles: options['all-files'] as bool, + ).check(fix: options['fix'] as bool)) == 0; + + exit(result ? 0 : 1); +} + +void _usage(ArgParser parser, {int exitCode = 1}) { + stderr.writeln('format.dart [--help] [--fix] [--all-files]'); + stderr.writeln(parser.usage); + exit(exitCode); +} + +class DartFormatChecker { + DartFormatChecker({ + required this.flutterRoot, + required this.allFiles, + }) : processRunner = ProcessRunner( + defaultWorkingDirectory: flutterRoot, + ); + + final Directory flutterRoot; + final bool allFiles; + final ProcessRunner processRunner; + + Future check({required bool fix}) async { + final String baseGitRef = await _getDiffBaseRevision(); + final List filesToCheck = await _getFileList( + types: ['*.dart'], + allFiles: allFiles, + baseGitRef: baseGitRef, + ); + return _checkFormat( + filesToCheck: filesToCheck, + fix: fix, + ); + } + + Future _getDiffBaseRevision() async { + String upstream = 'upstream'; + final String upstreamUrl = await _runGit( + ['remote', 'get-url', upstream], + processRunner, + failOk: true, + ); + if (upstreamUrl.isEmpty) { + upstream = 'origin'; + } + await _runGit(['fetch', upstream, 'main'], processRunner); + String result = ''; + try { + // This is the preferred command to use, but developer checkouts often do + // not have a clear fork point, so we fall back to just the regular + // merge-base in that case. + result = await _runGit( + ['merge-base', '--fork-point', 'FETCH_HEAD', 'HEAD'], + processRunner, + ); + } on ProcessRunnerException { + result = await _runGit(['merge-base', 'FETCH_HEAD', 'HEAD'], processRunner); + } + return result.trim(); + } + + Future _runGit( + List args, + ProcessRunner processRunner, { + bool failOk = false, + }) async { + final ProcessRunnerResult result = await processRunner.runProcess( + ['git', ...args], + failOk: failOk, + ); + return result.stdout; + } + + Future> _getFileList({ + required List types, + required bool allFiles, + required String baseGitRef, + }) async { + String output; + if (allFiles) { + output = await _runGit([ + 'ls-files', + '--', + ...types, + ], processRunner); + } else { + output = await _runGit([ + 'diff', + '-U0', + '--no-color', + '--diff-filter=d', + '--name-only', + baseGitRef, + '--', + ...types, + ], processRunner); + } + return output.split('\n').where((String line) => line.isNotEmpty).toList(); + } + + Future _checkFormat({ + required List filesToCheck, + required bool fix, + }) async { + final List cmd = [ + path.join(flutterRoot.path, 'bin', 'dart'), + 'format', + '--set-exit-if-changed', + '--show=none', + if (!fix) '--output=show', + if (fix) '--output=write', + ]; + final List jobs = []; + for (final String file in filesToCheck) { + jobs.add(WorkerJob([...cmd, file])); + } + final ProcessPool dartFmt = ProcessPool( + processRunner: processRunner, + printReport: _namedReport('dart format'), + ); + + Iterable incorrect; + if (!fix) { + final Stream completedJobs = dartFmt.startWorkers(jobs); + final List diffJobs = []; + await for (final WorkerJob completedJob in completedJobs) { + if (completedJob.result.exitCode == 1) { + diffJobs.add( + WorkerJob( + [ + 'git', + 'diff', + '--no-index', + '--no-color', + '--ignore-cr-at-eol', + '--', + completedJob.command.last, + '-', + ], + stdinRaw: _codeUnitsAsStream(completedJob.result.stdoutRaw), + ), + ); + } + } + final ProcessPool diffPool = ProcessPool( + processRunner: processRunner, + printReport: _namedReport('diff'), + ); + final List completedDiffs = await diffPool.runToCompletion(diffJobs); + incorrect = completedDiffs.where((WorkerJob job) => job.result.exitCode != 0); + } else { + final List completedJobs = await dartFmt.runToCompletion(jobs); + incorrect = completedJobs.where((WorkerJob job) => job.result.exitCode == 1); + } + + _clearOutput(); + + if (incorrect.isNotEmpty) { + final bool plural = incorrect.length > 1; + if (fix) { + stdout.writeln('Fixing ${incorrect.length} dart file${plural ? 's' : ''}' + ' which ${plural ? 'were' : 'was'} formatted incorrectly.'); + } else { + stderr.writeln('Found ${incorrect.length} Dart file${plural ? 's' : ''}' + ' which ${plural ? 'were' : 'was'} formatted incorrectly.'); + final String fileList = incorrect.map( + (WorkerJob job) => job.command[job.command.length - 2] + ).join(' '); + stdout.writeln(); + stdout.writeln('To fix, run `dart format $fileList` or:'); + stdout.writeln(); + stdout.writeln('git apply <> _codeUnitsAsStream(List? input) async* { + if (input != null) { + yield input; + } +} diff --git a/dev/tools/format.bat b/dev/tools/format.bat new file mode 100644 index 00000000000..46ed331d7ab --- /dev/null +++ b/dev/tools/format.bat @@ -0,0 +1,22 @@ +@ECHO off +REM Copyright 2014 The Flutter Authors. All rights reserved. +REM Use of this source code is governed by a BSD-style license that can be +REM found in the LICENSE file. + +SETLOCAL ENABLEDELAYEDEXPANSION + +FOR %%i IN ("%~dp0..\..") DO SET FLUTTER_ROOT=%%~fi + +REM Test if Git is available on the Host +where /q git || ECHO Error: Unable to find git in your PATH. && EXIT /B 1 + +SET tools_dir=%FLUTTER_ROOT%\dev\tools + +SET dart=%FLUTTER_ROOT%\bin\dart.bat + +cd "%tools_dir%" + +REM Do not use the CALL command in the next line to execute Dart. CALL causes +REM Windows to re-read the line from disk after the CALL command has finished +REM regardless of the ampersand chain. +"%dart%" pub get > NUL && "%dart%" "%tools_dir%\bin\format.dart" %* & exit /B !ERRORLEVEL! diff --git a/dev/tools/format.sh b/dev/tools/format.sh new file mode 100755 index 00000000000..eb37f067d97 --- /dev/null +++ b/dev/tools/format.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Copyright 2014 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. + +set -e + +# Needed because if it is set, cd may print the path it changed to. +unset CDPATH + +# On Mac OS, readlink -f doesn't work, so follow_links traverses the path one +# link at a time, and then cds into the link destination and find out where it +# ends up. +# +# The function is enclosed in a subshell to avoid changing the working directory +# of the caller. +function follow_links() ( + cd -P "$(dirname -- "$1")" + file="$PWD/$(basename -- "$1")" + while [[ -h "$file" ]]; do + cd -P "$(dirname -- "$file")" + file="$(readlink -- "$file")" + cd -P "$(dirname -- "$file")" + file="$PWD/$(basename -- "$file")" + done + echo "$file" +) + +SCRIPT_DIR=$(follow_links "$(dirname -- "${BASH_SOURCE[0]}")") +FLUTTER_DIR="$(cd "$SCRIPT_DIR/../.."; pwd -P)" +DART="${FLUTTER_DIR}/bin/dart" + +cd "$SCRIPT_DIR" +"$DART" pub get > /dev/null +"$DART" \ + bin/format.dart \ + "$@" diff --git a/dev/tools/pubspec.yaml b/dev/tools/pubspec.yaml index 55be000332f..3afdde5a1de 100644 --- a/dev/tools/pubspec.yaml +++ b/dev/tools/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: meta: 1.15.0 path: 1.9.1 process: 5.0.3 + process_runner: 4.2.0 pub_semver: 2.1.4 yaml: 3.1.2 @@ -63,4 +64,4 @@ dev_dependencies: web_socket_channel: 3.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" webkit_inspection_protocol: 1.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" -# PUBSPEC CHECKSUM: f620 +# PUBSPEC CHECKSUM: 2d6b diff --git a/dev/tools/test/format_test.dart b/dev/tools/test/format_test.dart new file mode 100644 index 00000000000..0ca7f5232e4 --- /dev/null +++ b/dev/tools/test/format_test.dart @@ -0,0 +1,94 @@ +// Copyright 2014 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:io' as io; + +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +class FileContentPair { + FileContentPair({required this.name, required this.original, required this.formatted}); + + final String name; + final String original; + final String formatted; +} + +final FileContentPair dartContentPair = FileContentPair( + name: 'format_test.dart', + original: 'enum \n\nfoo {\n entry1,\n entry2,\n}', + formatted: 'enum foo { entry1, entry2 }\n', +); + +class TestFileFixture { + TestFileFixture(this.filePairs, this.baseDir) { + for (final FileContentPair filePair in filePairs) { + final io.File file = io.File(path.join(baseDir.path, filePair.name)); + file.writeAsStringSync(filePair.original); + files.add(file); + } + } + + final List files = []; + final io.Directory baseDir; + final List filePairs; + + void gitAdd() { + final List args = ['add']; + for (final io.File file in files) { + args.add(file.path); + } + + io.Process.runSync('git', args); + } + + void gitRemove() { + final List args = ['rm', '-f']; + for (final io.File file in files) { + args.add(file.path); + } + io.Process.runSync('git', args); + } + + Iterable getFileContents() { + final List results = []; + for (int i = 0; i < files.length; i++) { + final io.File file = files[i]; + final FileContentPair filePair = filePairs[i]; + final String content = file.readAsStringSync().replaceAll('\r\n', '\n'); + results.add( + FileContentPair(name: filePair.name, original: content, formatted: filePair.formatted), + ); + } + return results; + } +} + +void main() { + final io.File script = io.File(path.current).absolute; + final io.Directory flutterRoot = script.parent.parent; + final String formatterPath = path.join( + flutterRoot.path, + 'dev', + 'tools', + 'format.${io.Platform.isWindows ? 'bat' : 'sh'}', + ); + + test('Can fix Dart formatting errors', () { + final TestFileFixture fixture = TestFileFixture([ + dartContentPair, + ], flutterRoot); + try { + fixture.gitAdd(); + io.Process.runSync(formatterPath, ['--fix'], workingDirectory: flutterRoot.path); + + final Iterable files = fixture.getFileContents(); + for (final FileContentPair pair in files) { + expect(pair.original, equals(pair.formatted)); + } + } finally { + fixture.gitRemove(); + } + }); +}