| // 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; |
| } |
| } |