blob: c38d619ff323ffc40cc4a1d6f189c04ca9b10232 [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:io' as io;
import 'package:file/file.dart';
import 'common/core.dart';
import 'common/file_filters.dart';
import 'common/flutter_command_utils.dart';
import 'common/gradle.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/plugin_utils.dart';
import 'common/repository_package.dart';
import 'common/xcode.dart';
/// A command to run Dart analysis on packages.
class AnalyzeCommand extends PackageLoopingCommand {
/// Creates a analysis command instance.
AnalyzeCommand(
super.packagesDir, {
super.processRunner,
super.platform,
super.gitDir,
}) {
// Platform options.
// By default, only Dart analysis is run.
argParser.addFlag(_dartFlag, help: "Runs 'dart analyze'", defaultsTo: true);
argParser.addFlag(platformAndroid,
help: "Runs 'gradle lint' on Android code");
argParser.addFlag(platformIOS,
help: "Runs 'xcodebuild analyze' on iOS code");
argParser.addFlag(platformMacOS,
help: "Runs 'xcodebuild analyze' on macOS code");
// Dart options.
argParser.addMultiOption(_customAnalysisFlag,
help:
'Directories (comma separated) that are allowed to have their own '
'analysis options.\n\n'
'Alternately, a list of one or more YAML files that contain a list '
'of allowed directories.',
defaultsTo: <String>[]);
argParser.addOption(_analysisSdk,
valueHelp: 'dart-sdk',
help: 'An optional path to a Dart SDK; this is used to override the '
'SDK used to provide analysis.');
argParser.addFlag(_downgradeFlag,
help: 'Runs "flutter pub downgrade" before analysis to verify that '
'the minimum constraints are sufficiently new for APIs used.');
argParser.addFlag(_libOnlyFlag,
help: 'Only analyze the lib/ directory of the main package, not the '
'entire package.');
argParser.addFlag(_skipIfResolvingFailsFlag,
help: 'If resolution fails, skip the package. This is only '
'intended to be used with pathified analysis, where a resolver '
'failure indicates that no out-of-band failure can result anyway.',
hide: true);
// Xcode options.
argParser.addOption(_minIOSVersionArg,
help: 'Sets the minimum iOS deployment version to use when compiling, '
'overriding the default minimum version. This can be used to find '
'deprecation warnings that will affect the plugin in the future.');
argParser.addOption(_minMacOSVersionArg,
help:
'Sets the minimum macOS deployment version to use when compiling, '
'overriding the default minimum version. This can be used to find '
'deprecation warnings that will affect the plugin in the future.');
}
static const String _dartFlag = 'dart';
static const String _customAnalysisFlag = 'custom-analysis';
static const String _downgradeFlag = 'downgrade';
static const String _libOnlyFlag = 'lib-only';
static const String _analysisSdk = 'analysis-sdk';
static const String _skipIfResolvingFailsFlag = 'skip-if-resolving-fails';
static const String _minIOSVersionArg = 'ios-min-version';
static const String _minMacOSVersionArg = 'macos-min-version';
late String _dartBinaryPath;
Set<String> _allowedCustomAnalysisDirectories = const <String>{};
@override
final String name = 'analyze';
@override
final String description = 'Analyzes all packages using dart analyze.\n\n'
'This command requires "dart" and "flutter" to be in your path.';
/// Checks that there are no unexpected analysis_options.yaml files.
bool _hasUnexpectedAnalysisOptions(RepositoryPackage package) {
final List<FileSystemEntity> files =
package.directory.listSync(recursive: true, followLinks: false);
for (final FileSystemEntity file in files) {
if (file.basename != 'analysis_options.yaml' &&
file.basename != '.analysis_options') {
continue;
}
final bool allowed = _allowedCustomAnalysisDirectories.any(
(String directory) =>
directory.isNotEmpty &&
path.isWithin(
packagesDir.childDirectory(directory).path, file.path));
if (allowed) {
continue;
}
printError(
'Found an extra analysis_options.yaml at ${file.absolute.path}.');
printError(
'If this was deliberate, pass the package to the analyze command '
'with the --$_customAnalysisFlag flag and try again.');
return true;
}
return false;
}
@override
bool shouldIgnoreFile(String path) {
// Support files don't affect any analysis.
if (isRepoLevelNonCodeImpactingFile(path) || isPackageSupportFile(path)) {
return true;
}
// For native code, it depends on the flags.
if (path.endsWith('.dart')) {
return !getBoolArg(_dartFlag);
}
if (path.endsWith('.java') || path.endsWith('.kt')) {
return !getBoolArg(platformAndroid);
}
if (path.endsWith('.c') ||
path.endsWith('.cc') ||
path.endsWith('.cpp') ||
path.endsWith('.h')) {
// If C/C++ linting is added, Windows and Linux should be added here.
return !(getBoolArg(platformIOS) || getBoolArg(platformMacOS));
}
if (path.endsWith('.m') ||
path.endsWith('.mm') ||
path.endsWith('.swift')) {
return !(getBoolArg(platformIOS) || getBoolArg(platformMacOS));
}
return false;
}
@override
Future<void> initializeRun() async {
_allowedCustomAnalysisDirectories = getYamlListArg(_customAnalysisFlag);
// Use the Dart SDK override if one was passed in.
final String? dartSdk = argResults![_analysisSdk] as String?;
_dartBinaryPath =
dartSdk == null ? 'dart' : path.join(dartSdk, 'bin', 'dart');
}
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
final Map<String, PackageResult> subResults = <String, PackageResult>{};
if (getBoolArg(_dartFlag)) {
_printSectionHeading('Running dart analyze.');
subResults['Dart'] = await _runDartAnalysisForPackage(package);
}
if (getBoolArg(platformAndroid)) {
_printSectionHeading('Running gradle lint.');
subResults['Android'] = await _runGradleLintForPackage(package);
}
if (getBoolArg(platformIOS)) {
_printSectionHeading('Running iOS xcodebuild analyze.');
final String minIOSVersion = getStringArg(_minIOSVersionArg);
subResults['iOS'] = await _runXcodeAnalysisForPackage(
package,
FlutterPlatform.ios,
extraFlags: <String>[
'-destination',
'generic/platform=iOS Simulator',
if (minIOSVersion.isNotEmpty)
'IPHONEOS_DEPLOYMENT_TARGET=$minIOSVersion',
],
);
}
if (getBoolArg(platformMacOS)) {
_printSectionHeading('Running macOS xcodebuild analyze.');
final String minMacOSVersion = getStringArg(_minMacOSVersionArg);
subResults['macOS'] = await _runXcodeAnalysisForPackage(
package,
FlutterPlatform.macos,
extraFlags: <String>[
if (minMacOSVersion.isNotEmpty)
'MACOSX_DEPLOYMENT_TARGET=$minMacOSVersion',
],
);
}
// Make sure at least one analysis option was requested.
if (subResults.isEmpty) {
printError('At least one analysis option flag must be provided.');
throw ToolExit(exitInvalidArguments);
}
// If only one analysis was requested, just return its result.
if (subResults.length == 1) {
return subResults.values.first;
}
// Otherwise, aggregate the messages, with the least positive status.
final Map<String, PackageResult> failedResults =
Map<String, PackageResult>.of(subResults)
..removeWhere((String key, PackageResult value) =>
value.state != RunState.failed);
final Map<String, PackageResult> skippedResults =
Map<String, PackageResult>.of(subResults)
..removeWhere((String key, PackageResult value) =>
value.state != RunState.skipped);
// If anything failed, collect all the failure messages, prefixed by type.
if (failedResults.isNotEmpty) {
return PackageResult.fail(<String>[
for (final MapEntry<String, PackageResult> entry
in failedResults.entries)
'${entry.key}${entry.value.details.isEmpty ? '' : ': ${entry.value.details.join(', ')}'}'
]);
}
// If everything was skipped, mark as skipped with all of the explanations.
if (skippedResults.length == subResults.length) {
return PackageResult.skip(skippedResults.entries
.map((MapEntry<String, PackageResult> entry) =>
'${entry.key}: ${entry.value.details.first}')
.join(', '));
}
// For all succes, or a mix of success and skip, log any skips but mark as
// success.
for (final MapEntry<String, PackageResult> skip in skippedResults.entries) {
printSkip('Skipped ${skip.key}: ${skip.value.details.first}');
}
return PackageResult.success();
}
void _printSectionHeading(String heading) {
print('\n$heading');
print('--------------------');
}
/// Runs Dart analysis for the given package, and returns the result that
/// applies to that analysis.
Future<PackageResult> _runDartAnalysisForPackage(
RepositoryPackage package) async {
final bool libOnly = getBoolArg(_libOnlyFlag);
if (libOnly && !package.libDirectory.existsSync()) {
return PackageResult.skip('No lib/ directory.');
}
if (getBoolArg(_downgradeFlag)) {
if (!await _runPubCommand(package, 'downgrade')) {
return PackageResult.fail(<String>['Unable to downgrade dependencies']);
}
}
// Analysis runs over the package and all subpackages (unless only lib/ is
// being analyzed), so all of them need `flutter pub get` run before
// analyzing. `example` packages can be skipped since 'flutter packages get'
// automatically runs `pub get` in examples as part of handling the parent
// directory.
final List<RepositoryPackage> packagesToGet = <RepositoryPackage>[
package,
if (!libOnly) ...await getSubpackages(package).toList(),
];
for (final RepositoryPackage packageToGet in packagesToGet) {
if (packageToGet.directory.basename != 'example' ||
!RepositoryPackage(packageToGet.directory.parent)
.pubspecFile
.existsSync()) {
if (!await _runPubCommand(packageToGet, 'get')) {
if (getBoolArg(_skipIfResolvingFailsFlag)) {
// Re-run, capturing output, to see if the failure was a resolver
// failure. (This is slightly inefficient, but this should be a
// very rare case.)
const String resolverFailureMessage = 'version solving failed';
final io.ProcessResult result = await processRunner.run(
flutterCommand, <String>['pub', 'get'],
workingDir: packageToGet.directory);
if ((result.stderr as String).contains(resolverFailureMessage) ||
(result.stdout as String).contains(resolverFailureMessage)) {
logWarning('Skipping package due to pub resolution failure.');
return PackageResult.skip('Resolution failed.');
}
}
return PackageResult.fail(<String>['Unable to get dependencies']);
}
}
}
if (_hasUnexpectedAnalysisOptions(package)) {
return PackageResult.fail(<String>['Unexpected local analysis options']);
}
final int exitCode = await processRunner.runAndStream(_dartBinaryPath,
<String>['analyze', '--fatal-infos', if (libOnly) 'lib'],
workingDir: package.directory);
if (exitCode != 0) {
return PackageResult.fail();
}
return PackageResult.success();
}
Future<bool> _runPubCommand(RepositoryPackage package, String command) async {
final int exitCode = await processRunner.runAndStream(
flutterCommand, <String>['pub', command],
workingDir: package.directory);
return exitCode == 0;
}
/// Runs Gradle lint analysis for the given package, and returns the result
/// that applies to that analysis.
Future<PackageResult> _runGradleLintForPackage(
RepositoryPackage package) async {
if (!pluginSupportsPlatform(platformAndroid, package,
requiredMode: PlatformSupport.inline)) {
return PackageResult.skip(
'Package does not contain native Android plugin code');
}
for (final RepositoryPackage example in package.getExamples()) {
final GradleProject project = GradleProject(example,
processRunner: processRunner, platform: platform);
if (!project.isConfigured()) {
final bool buildSuccess = await runConfigOnlyBuild(
example, processRunner, platform, FlutterPlatform.android);
if (!buildSuccess) {
printError('Unable to configure Gradle project.');
return PackageResult.fail(<String>['Unable to configure Gradle.']);
}
}
final String packageName = package.directory.basename;
// Only lint one build mode to avoid extra work.
// Only lint the plugin project itself, to avoid failing due to errors in
// dependencies.
//
// TODO(stuartmorgan): Consider adding an XML parser to read and summarize
// all results. Currently, only the first three errors will be shown
// inline, and the rest have to be checked via the CI-uploaded artifact.
final int exitCode = await project.runCommand('$packageName:lintDebug');
if (exitCode != 0) {
return PackageResult.fail();
}
}
return PackageResult.success();
}
/// Analyzes [plugin] for [targetPlatform].
Future<PackageResult> _runXcodeAnalysisForPackage(
RepositoryPackage package,
FlutterPlatform targetPlatform, {
List<String> extraFlags = const <String>[],
}) async {
final String platformString =
targetPlatform == FlutterPlatform.ios ? 'iOS' : 'macOS';
if (!pluginSupportsPlatform(targetPlatform.name, package,
requiredMode: PlatformSupport.inline)) {
return PackageResult.skip(
'Package does not contain native $platformString plugin code');
}
final Xcode xcode = Xcode(processRunner: processRunner, log: true);
final List<String> errors = <String>[];
for (final RepositoryPackage example in package.getExamples()) {
// See https://github.com/flutter/flutter/issues/172427 for discussion of
// why this is currently necessary.
print('Disabling Swift Package Manager...');
setSwiftPackageManagerState(example, enabled: false);
// Unconditionally re-run build with --debug --config-only, to ensure that
// the project is in a debug state even if it was previously configured,
// and that SwiftPM is disabled.
print('Running flutter build --config-only...');
final bool buildSuccess = await runConfigOnlyBuild(
example,
processRunner,
platform,
targetPlatform,
buildDebug: true,
);
if (!buildSuccess) {
printError('Unable to prepare native project files.');
errors.add(
'Unable to build ${getRelativePosixPath(example.directory, from: package.directory)}.');
continue;
}
// Running tests and static analyzer.
final String examplePath = getRelativePosixPath(example.directory,
from: package.directory.parent);
print('Running $platformString tests and analyzer for $examplePath...');
final int exitCode = await xcode.runXcodeBuild(
example.directory,
platformString,
// Clean before analyzing to remove cached swiftmodules from previous
// runs, which can cause conflicts.
actions: <String>['clean', 'analyze'],
workspace: '${platformString.toLowerCase()}/Runner.xcworkspace',
scheme: 'Runner',
configuration: 'Debug',
hostPlatform: platform,
extraFlags: <String>[
...extraFlags,
'GCC_TREAT_WARNINGS_AS_ERRORS=YES',
],
);
if (exitCode == 0) {
printSuccess('$examplePath ($platformString) passed analysis.');
} else {
printError('$examplePath ($platformString) failed analysis.');
errors.add(
'${getRelativePosixPath(example.directory, from: package.directory)} failed analysis.');
}
print('Removing Swift Package Manager override...');
setSwiftPackageManagerState(example, enabled: null);
}
return errors.isEmpty
? PackageResult.success()
: PackageResult.fail(errors);
}
}