blob: 4a49f9b591f960611de003e98dc08e89ab8a1049 [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' show LineSplitter, jsonDecode;
import 'dart:io' as io show File, stderr, stdout;
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
import 'package:process_runner/process_runner.dart';
import 'src/command.dart';
import 'src/git_repo.dart';
import 'src/options.dart';
const String _linterOutputHeader = '''
┌──────────────────────────┐
│ Engine Clang Tidy Linter │
└──────────────────────────┘
The following errors have been reported by the Engine Clang Tidy Linter. For
more information on addressing these issues please see:
https://github.com/flutter/flutter/wiki/Engine-Clang-Tidy-Linter
''';
class _ComputeJobsResult {
_ComputeJobsResult(this.jobs, this.sawMalformed);
final List<WorkerJob> jobs;
final bool sawMalformed;
}
enum _SetStatus {
Intersection,
Difference,
}
class _SetStatusCommand {
_SetStatusCommand(this.setStatus, this.command);
final _SetStatus setStatus;
final Command command;
}
/// A class that runs clang-tidy on all or only the changed files in a git
/// repo.
class ClangTidy {
/// Given the path to the build commands for a repo and its root, builds
/// an instance of [ClangTidy].
///
/// `buildCommandsPath` is the path to the build_commands.json file.
/// `repoPath` is the path to the Engine repo.
/// `checksArg` are specific checks for clang-tidy to do.
/// `lintAll` when true indicates that all files should be linted.
/// `outSink` when provided is the destination for normal log messages, which
/// will otherwise go to stdout.
/// `errSink` when provided is the destination for error messages, which
/// will otherwise go to stderr.
ClangTidy({
required io.File buildCommandsPath,
String checksArg = '',
bool lintAll = false,
bool lintHead = false,
bool fix = false,
StringSink? outSink,
StringSink? errSink,
}) :
options = Options(
buildCommandsPath: buildCommandsPath,
checksArg: checksArg,
lintAll: lintAll,
lintHead: lintHead,
fix: fix,
errSink: errSink,
),
_outSink = outSink ?? io.stdout,
_errSink = errSink ?? io.stderr;
/// Builds an instance of [ClangTidy] from a command line.
ClangTidy.fromCommandLine(
List<String> args, {
StringSink? outSink,
StringSink? errSink,
}) :
options = Options.fromCommandLine(args, errSink: errSink),
_outSink = outSink ?? io.stdout,
_errSink = errSink ?? io.stderr;
/// The [Options] that specify how this [ClangTidy] operates.
final Options options;
final StringSink _outSink;
final StringSink _errSink;
/// Runs clang-tidy on the repo as specified by the [Options].
Future<int> run() async {
if (options.help) {
options.printUsage();
return 0;
}
if (options.errorMessage != null) {
options.printUsage(message: options.errorMessage);
return 1;
}
_outSink.writeln(_linterOutputHeader);
final List<io.File> filesOfInterest = await computeFilesOfInterest();
if (options.verbose) {
_outSink.writeln('Checking lint in repo at ${options.repoPath.path}.');
if (options.checksArg.isNotEmpty) {
_outSink.writeln('Checking for specific checks: ${options.checks}.');
}
final int changedFilesCount = filesOfInterest.length;
if (options.lintAll) {
_outSink.writeln('Checking all $changedFilesCount files the repo dir.');
} else {
_outSink.writeln(
'Dectected $changedFilesCount files that have changed',
);
}
}
final List<Object?> buildCommandsData = jsonDecode(
options.buildCommandsPath.readAsStringSync(),
) as List<Object?>;
final List<List<Object?>> shardBuildCommandsData = options
.shardCommandsPaths
.map((io.File file) =>
jsonDecode(file.readAsStringSync()) as List<Object?>)
.toList();
final List<Command> changedFileBuildCommands = await getLintCommandsForFiles(
buildCommandsData,
filesOfInterest,
shardBuildCommandsData,
options.shardId,
);
if (changedFileBuildCommands.isEmpty) {
_outSink.writeln(
'No changed files that have build commands associated with them were '
'found.',
);
return 0;
}
if (options.verbose) {
_outSink.writeln(
'Found ${changedFileBuildCommands.length} files that have build '
'commands associated with them and can be lint checked.',
);
}
final _ComputeJobsResult computeJobsResult = await _computeJobs(
changedFileBuildCommands,
options,
);
final int computeResult = computeJobsResult.sawMalformed ? 1 : 0;
final List<WorkerJob> jobs = computeJobsResult.jobs;
final int runResult = await _runJobs(jobs);
_outSink.writeln('\n');
if (computeResult + runResult == 0) {
_outSink.writeln('No lint problems found.');
} else {
_errSink.writeln('Lint problems found.');
}
return computeResult + runResult > 0 ? 1 : 0;
}
/// The files with local modifications or all the files if `lintAll` was
/// specified.
@visibleForTesting
Future<List<io.File>> computeFilesOfInterest() async {
if (options.lintAll) {
return options.repoPath
.listSync(recursive: true)
.whereType<io.File>()
.toList();
}
final GitRepo repo = GitRepo(
options.repoPath,
verbose: options.verbose,
);
if (options.lintHead) {
return repo.changedFilesAtHead;
}
return repo.changedFiles;
}
/// Returns f(n) = value(n * [shardCount] + [id]).
Iterable<T> _takeShard<T>(Iterable<T> values, int id, int shardCount) sync* {
int count = 0;
for (final T val in values) {
if (count % shardCount == id) {
yield val;
}
count++;
}
}
/// This returns a `_SetStatusCommand` for each [Command] in [items].
/// `Intersection` if the Command shows up in [items] and its filePath in all
/// [filePathSets], otherwise `Difference`.
Iterable<_SetStatusCommand> _calcIntersection(
Iterable<Command> items, Iterable<Set<String>> filePathSets) sync* {
bool allSetsContain(Command command) {
for (final Set<String> filePathSet in filePathSets) {
if (!filePathSet.contains(command.filePath)) {
return false;
}
}
return true;
}
for (final Command command in items) {
if (allSetsContain(command)) {
yield _SetStatusCommand(_SetStatus.Intersection, command);
} else {
yield _SetStatusCommand(_SetStatus.Difference, command);
}
}
}
/// Given a build commands json file's contents in [buildCommandsData], and
/// the [files] with local changes, compute the lint commands to run. If
/// build commands are supplied in [sharedBuildCommandsData] the intersection
/// of those build commands will be calculated and distributed across
/// instances via the [shardId].
@visibleForTesting
Future<List<Command>> getLintCommandsForFiles(
List<Object?> buildCommandsData,
List<io.File> files,
List<List<Object?>> sharedBuildCommandsData,
int? shardId,
) {
final List<Command> totalCommands = <Command>[];
if (sharedBuildCommandsData.isNotEmpty) {
final List<Command> buildCommands = <Command>[
for (Object? data in buildCommandsData)
Command.fromMap((data as Map<String, Object?>?)!)
];
final List<Set<String>> shardFilePaths = <Set<String>>[
for (List<Object?> list in sharedBuildCommandsData)
<String>{
for (Object? data in list)
Command.fromMap((data as Map<String, Object?>?)!).filePath
}
];
final Iterable<_SetStatusCommand> intersectionResults =
_calcIntersection(buildCommands, shardFilePaths);
for (final _SetStatusCommand result in intersectionResults) {
if (result.setStatus == _SetStatus.Difference) {
totalCommands.add(result.command);
}
}
final List<Command> intersection = <Command>[
for (_SetStatusCommand result in intersectionResults)
if (result.setStatus == _SetStatus.Intersection) result.command
];
// Make sure to sort results so the sharding scheme is guaranteed to work
// since we are not sure if there is a defined order in the json file.
intersection
.sort((Command x, Command y) => x.filePath.compareTo(y.filePath));
totalCommands.addAll(
_takeShard(intersection, shardId!, 1 + sharedBuildCommandsData.length));
} else {
totalCommands.addAll(<Command>[
for (Object? data in buildCommandsData)
Command.fromMap((data as Map<String, Object?>?)!)
]);
}
return () async {
final List<Command> result = <Command>[];
for (final Command command in totalCommands) {
final LintAction lintAction = await command.lintAction;
// Short-circuit the expensive containsAny call for the many third_party files.
if (lintAction != LintAction.skipThirdParty &&
command.containsAny(files)) {
result.add(command);
}
}
return result;
}();
}
Future<_ComputeJobsResult> _computeJobs(
List<Command> commands,
Options options,
) async {
bool sawMalformed = false;
final List<WorkerJob> jobs = <WorkerJob>[];
for (final Command command in commands) {
final String relativePath = path.relative(
command.filePath,
from: options.repoPath.parent.path,
);
final LintAction action = await command.lintAction;
switch (action) {
case LintAction.skipNoLint:
_outSink.writeln('🔷 ignoring $relativePath (FLUTTER_NOLINT)');
break;
case LintAction.failMalformedNoLint:
_errSink.writeln('❌ malformed opt-out $relativePath');
_errSink.writeln(
' Required format: // FLUTTER_NOLINT: $issueUrlPrefix/ISSUE_ID',
);
sawMalformed = true;
break;
case LintAction.lint:
_outSink.writeln('🔶 linting $relativePath');
jobs.add(command.createLintJob(options));
break;
case LintAction.skipThirdParty:
_outSink.writeln('🔷 ignoring $relativePath (third_party)');
break;
case LintAction.skipMissing:
_outSink.writeln('🔷 ignoring $relativePath (missing)');
break;
}
}
return _ComputeJobsResult(jobs, sawMalformed);
}
static Iterable<String> _trimGenerator(String output) sync* {
const LineSplitter splitter = LineSplitter();
final List<String> lines = splitter.convert(output);
bool isPrintingError = false;
for (final String line in lines) {
if (line.contains(': error:') || line.contains(': warning:')) {
isPrintingError = true;
yield line;
} else if (line == ':') {
isPrintingError = false;
} else if (isPrintingError) {
yield line;
}
}
}
/// Visible for testing.
/// Function for trimming raw clang-tidy output.
@visibleForTesting
static String trimOutput(String output) => _trimGenerator(output).join('\n');
Future<int> _runJobs(List<WorkerJob> jobs) async {
int result = 0;
final ProcessPool pool = ProcessPool();
await for (final WorkerJob job in pool.startWorkers(jobs)) {
if (job.result.exitCode == 0) {
continue;
}
_errSink.writeln('❌ Failures for ${job.name}:');
if (!job.printOutput) {
final Exception? exception = job.exception;
if (exception != null) {
_errSink.writeln(trimOutput(exception.toString()));
} else {
_errSink.writeln(trimOutput(job.result.stdout));
}
}
result = 1;
}
return result;
}
}