Skip to content

Commit 280bbfc

Browse files
authored
This makes the lint script use multiprocessing to speed it up. (#19987)
I got tired of waiting for it to run, so I added some of the "worker" queue code that I wrote for the assets-for-api-docs generator. I also tried out putting all the files in one call to clang-tidy with the -p argument, but that was still a lot slower because it runs them all on one core. This runs separate jobs for each file, simultaneously, and then reports the results at the end (associated with each file, of course).
1 parent a6cd3eb commit 280bbfc

File tree

4 files changed

+307
-172
lines changed

4 files changed

+307
-172
lines changed

ci/bin/lint.dart

+256
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/// Runs clang-tidy on files with changes.
2+
///
3+
/// usage:
4+
/// dart lint.dart <path to compile_commands.json> <path to git repository> [clang-tidy checks]
5+
///
6+
/// User environment variable FLUTTER_LINT_ALL to run on all files.
7+
8+
import 'dart:async' show Completer;
9+
import 'dart:convert' show jsonDecode, utf8, LineSplitter;
10+
import 'dart:io' show File, exit, Directory, FileSystemEntity, Platform, stderr;
11+
12+
import 'package:args/args.dart';
13+
import 'package:path/path.dart' as path;
14+
import 'package:process_runner/process_runner.dart';
15+
16+
String _linterOutputHeader = '''
17+
┌──────────────────────────┐
18+
│ Engine Clang Tidy Linter │
19+
└──────────────────────────┘
20+
The following errors have been reported by the Engine Clang Tidy Linter. For
21+
more information on addressing these issues please see:
22+
https://github.com/flutter/flutter/wiki/Engine-Clang-Tidy-Linter
23+
''';
24+
25+
class Command {
26+
Directory directory = Directory('');
27+
String command = '';
28+
File file = File('');
29+
}
30+
31+
Command parseCommand(Map<String, dynamic> map) {
32+
final Directory dir = Directory(map['directory'] as String).absolute;
33+
return Command()
34+
..directory = dir
35+
..command = map['command'] as String
36+
..file = File(path.normalize(path.join(dir.path, map['file'] as String)));
37+
}
38+
39+
String calcTidyArgs(Command command) {
40+
String result = command.command;
41+
result = result.replaceAll(RegExp(r'\S*clang/bin/clang'), '');
42+
result = result.replaceAll(RegExp(r'-MF \S*'), '');
43+
return result;
44+
}
45+
46+
String calcTidyPath(Command command) {
47+
final RegExp regex = RegExp(r'\S*clang/bin/clang');
48+
return regex
49+
.stringMatch(command.command)
50+
?.replaceAll('clang/bin/clang', 'clang/bin/clang-tidy') ??
51+
'';
52+
}
53+
54+
bool isNonEmptyString(String str) => str.isNotEmpty;
55+
56+
bool containsAny(File file, Iterable<File> queries) {
57+
return queries.where((File query) => path.equals(query.path, file.path)).isNotEmpty;
58+
}
59+
60+
/// Returns a list of all non-deleted files which differ from the nearest
61+
/// merge-base with `master`. If it can't find a fork point, uses the default
62+
/// merge-base.
63+
Future<List<File>> getListOfChangedFiles(Directory repoPath) async {
64+
final ProcessRunner processRunner = ProcessRunner(defaultWorkingDirectory: repoPath);
65+
final ProcessRunnerResult fetchResult = await processRunner.runProcess(
66+
<String>['git', 'fetch', 'upstream', 'master'],
67+
failOk: true,
68+
);
69+
if (fetchResult.exitCode != 0) {
70+
await processRunner.runProcess(<String>['git', 'fetch', 'origin', 'master']);
71+
}
72+
final Set<String> result = <String>{};
73+
ProcessRunnerResult mergeBaseResult = await processRunner.runProcess(
74+
<String>['git', 'merge-base', '--fork-point', 'FETCH_HEAD', 'HEAD'],
75+
failOk: true);
76+
if (mergeBaseResult.exitCode != 0) {
77+
if (verbose) {
78+
stderr.writeln("Didn't find a fork point, falling back to default merge base.");
79+
}
80+
mergeBaseResult = await processRunner
81+
.runProcess(<String>['git', 'merge-base', 'FETCH_HEAD', 'HEAD'], failOk: false);
82+
}
83+
final String mergeBase = mergeBaseResult.stdout.trim();
84+
final ProcessRunnerResult masterResult = await processRunner
85+
.runProcess(<String>['git', 'diff', '--name-only', '--diff-filter=ACMRT', mergeBase]);
86+
result.addAll(masterResult.stdout.split('\n').where(isNonEmptyString));
87+
return result.map<File>((String filePath) => File(path.join(repoPath.path, filePath))).toList();
88+
}
89+
90+
Future<List<File>> dirContents(Directory dir) {
91+
final List<File> files = <File>[];
92+
final Completer<List<File>> completer = Completer<List<File>>();
93+
final Stream<FileSystemEntity> lister = dir.list(recursive: true);
94+
lister.listen((FileSystemEntity file) => file is File ? files.add(file) : null,
95+
onError: (Object e) => completer.completeError(e), onDone: () => completer.complete(files));
96+
return completer.future;
97+
}
98+
99+
File buildFileAsRepoFile(String buildFile, Directory repoPath) {
100+
// Removes the "../../flutter" from the build files to make it relative to the flutter
101+
// dir.
102+
final String relativeBuildFile = path.joinAll(path.split(buildFile).sublist(3));
103+
final File result = File(path.join(repoPath.absolute.path, relativeBuildFile));
104+
print('Build file: $buildFile => ${result.path}');
105+
return result;
106+
}
107+
108+
Future<String> shouldIgnoreFile(File file) async {
109+
if (path.split(file.path).contains('third_party')) {
110+
return 'third_party';
111+
} else {
112+
final RegExp exp = RegExp(r'//\s*FLUTTER_NOLINT');
113+
await for (String line
114+
in file.openRead().transform(utf8.decoder).transform(const LineSplitter())) {
115+
if (exp.hasMatch(line)) {
116+
return 'FLUTTER_NOLINT';
117+
} else if (line.isNotEmpty && line[0] != '\n' && line[0] != '/') {
118+
// Quick out once we find a line that isn't empty or a comment. The
119+
// FLUTTER_NOLINT must show up before the first real code.
120+
return '';
121+
}
122+
}
123+
return '';
124+
}
125+
}
126+
127+
void _usage(ArgParser parser, {int exitCode = 1}) {
128+
stderr.writeln('lint.dart [--help] [--lint-all] [--verbose] [--diff-branch]');
129+
stderr.writeln(parser.usage);
130+
exit(exitCode);
131+
}
132+
133+
bool verbose = false;
134+
135+
void main(List<String> arguments) async {
136+
final ArgParser parser = ArgParser();
137+
parser.addFlag('help', help: 'Print help.');
138+
parser.addFlag('lint-all',
139+
help: 'lint all of the sources, regardless of FLUTTER_NOLINT.', defaultsTo: false);
140+
parser.addFlag('verbose', help: 'Print verbose output.', defaultsTo: verbose);
141+
parser.addOption('repo', help: 'Use the given path as the repo path');
142+
parser.addOption('compile-commands',
143+
help: 'Use the given path as the source of compile_commands.json. This '
144+
'file is created by running tools/gn');
145+
parser.addOption('checks',
146+
help: 'Perform the given checks on the code. Defaults to the empty '
147+
'string, indicating all checks should be performed.',
148+
defaultsTo: '');
149+
final ArgResults options = parser.parse(arguments);
150+
151+
verbose = options['verbose'] as bool;
152+
153+
if (options['help'] as bool) {
154+
_usage(parser, exitCode: 0);
155+
}
156+
157+
if (!options.wasParsed('compile-commands')) {
158+
stderr.writeln('ERROR: The --compile-commands argument is requried.');
159+
_usage(parser);
160+
}
161+
162+
if (!options.wasParsed('repo')) {
163+
stderr.writeln('ERROR: The --repo argument is requried.');
164+
_usage(parser);
165+
}
166+
167+
final File buildCommandsPath = File(options['compile-commands'] as String);
168+
if (!buildCommandsPath.existsSync()) {
169+
stderr.writeln("ERROR: Build commands path ${buildCommandsPath.absolute.path} doesn't exist.");
170+
_usage(parser);
171+
}
172+
173+
final Directory repoPath = Directory(options['repo'] as String);
174+
if (!repoPath.existsSync()) {
175+
stderr.writeln("ERROR: Repo path ${repoPath.absolute.path} doesn't exist.");
176+
_usage(parser);
177+
}
178+
179+
print(_linterOutputHeader);
180+
181+
final String checksArg = options.wasParsed('checks') ? options['checks'] as String : '';
182+
final String checks = checksArg.isNotEmpty ? '--checks=$checksArg' : '--config=';
183+
final bool lintAll =
184+
Platform.environment['FLUTTER_LINT_ALL'] != null || options['lint-all'] as bool;
185+
final List<File> changedFiles =
186+
lintAll ? await dirContents(repoPath) : await getListOfChangedFiles(repoPath);
187+
188+
if (verbose) {
189+
print('Checking lint in repo at $repoPath.');
190+
if (checksArg.isNotEmpty) {
191+
print('Checking for specific checks: $checks.');
192+
}
193+
if (lintAll) {
194+
print('Checking all ${changedFiles.length} files the repo dir.');
195+
} else {
196+
print('Dectected ${changedFiles.length} files that have changed');
197+
}
198+
}
199+
200+
final List<dynamic> buildCommandMaps =
201+
jsonDecode(await buildCommandsPath.readAsString()) as List<dynamic>;
202+
final List<Command> buildCommands = buildCommandMaps
203+
.map<Command>((dynamic x) => parseCommand(x as Map<String, dynamic>))
204+
.toList();
205+
final Command firstCommand = buildCommands[0];
206+
final String tidyPath = calcTidyPath(firstCommand);
207+
assert(tidyPath.isNotEmpty);
208+
final List<Command> changedFileBuildCommands =
209+
buildCommands.where((Command x) => containsAny(x.file, changedFiles)).toList();
210+
211+
if (changedFileBuildCommands.isEmpty) {
212+
print('No changed files that have build commands associated with them '
213+
'were found.');
214+
exit(0);
215+
}
216+
217+
if (verbose) {
218+
print('Found ${changedFileBuildCommands.length} files that have build '
219+
'commands associated with them and can be lint checked.');
220+
}
221+
222+
int exitCode = 0;
223+
final List<WorkerJob> jobs = <WorkerJob>[];
224+
for (Command command in changedFileBuildCommands) {
225+
final String relativePath = path.relative(command.file.path, from: repoPath.parent.path);
226+
final String ignoreReason = await shouldIgnoreFile(command.file);
227+
if (ignoreReason.isEmpty) {
228+
final String tidyArgs = calcTidyArgs(command);
229+
final List<String> args = <String>[command.file.path, checks, '--'];
230+
args.addAll(tidyArgs?.split(' ') ?? <String>[]);
231+
print('🔶 linting $relativePath');
232+
jobs.add(WorkerJob(
233+
<String>[tidyPath, ...args],
234+
workingDirectory: command.directory,
235+
name: 'clang-tidy on ${command.file.path}',
236+
));
237+
} else {
238+
print('🔷 ignoring $relativePath ($ignoreReason)');
239+
}
240+
}
241+
final ProcessPool pool = ProcessPool();
242+
243+
await for (final WorkerJob job in pool.startWorkers(jobs)) {
244+
if (job.result.stdout.isEmpty) {
245+
continue;
246+
}
247+
print('❌ Failures for ${job.name}:');
248+
print(job.result.stdout);
249+
exitCode = 1;
250+
}
251+
print('\n');
252+
if (exitCode == 0) {
253+
print('No lint problems found.');
254+
}
255+
exit(exitCode);
256+
}

0 commit comments

Comments
 (0)