0a5604c223
Bug: https://github.com/dart-lang/sdk/issues/63318 Change-Id: Ie02aef92d9ab0e04ce864a85ed6071d5e4b5e14f Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/500820 Reviewed-by: Bob Nystrom <rnystrom@google.com> Commit-Queue: Konstantin Shcheglov <scheglov@google.com>
661 lines
20 KiB
Dart
661 lines
20 KiB
Dart
// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
|
|
// for details. All rights reserved. Use of this source code is governed by a
|
|
// BSD-style license that can be found in the LICENSE file.
|
|
|
|
/// Regenerates the static error test markers inside static error tests based
|
|
/// on the actual errors reported by analyzer and CFE.
|
|
library;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:analyzer/dart/analysis/analysis_context.dart';
|
|
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
|
|
import 'package:analyzer/dart/analysis/results.dart';
|
|
import 'package:analyzer/diagnostic/diagnostic.dart';
|
|
import 'package:analyzer/file_system/file_system.dart' show ResourceProvider;
|
|
import 'package:analyzer/file_system/overlay_file_system.dart';
|
|
import 'package:analyzer/file_system/physical_file_system.dart';
|
|
import 'package:args/args.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:glob/glob.dart';
|
|
import 'package:glob/list_local_fs.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:test_runner/src/command_output.dart';
|
|
import 'package:test_runner/src/path.dart';
|
|
import 'package:test_runner/src/static_error.dart';
|
|
import 'package:test_runner/src/test_file.dart';
|
|
import 'package:test_runner/src/update_errors.dart';
|
|
|
|
const _usage =
|
|
"Usage: dart update_static_error_tests.dart [flags...] <path glob>";
|
|
|
|
final dartPath = _findBinary("dart", "exe");
|
|
|
|
Future<void> main(List<String> args) async {
|
|
var sources = ErrorSource.all.map((e) => e.marker).toList();
|
|
|
|
var parser = ArgParser();
|
|
|
|
parser.addFlag("help", abbr: "h");
|
|
|
|
parser.addFlag(
|
|
"dry-run",
|
|
abbr: "n",
|
|
help: "Print result but do not overwrite any files.",
|
|
negatable: false,
|
|
);
|
|
parser.addFlag(
|
|
"context",
|
|
abbr: "c",
|
|
help: "Include context messages in output.",
|
|
);
|
|
parser.addFlag(
|
|
"only-static-error-tests",
|
|
abbr: "o",
|
|
help: "Skips files that don't already have static error expectations.",
|
|
negatable: false,
|
|
);
|
|
|
|
parser.addSeparator("What operations to perform:");
|
|
parser.addFlag(
|
|
"remove-all",
|
|
abbr: "r",
|
|
help: "Remove all existing error expectations.",
|
|
negatable: false,
|
|
);
|
|
parser.addMultiOption(
|
|
"remove",
|
|
help: "Remove error expectations for given front ends.",
|
|
allowed: sources,
|
|
);
|
|
parser.addFlag(
|
|
"insert-all",
|
|
abbr: "i",
|
|
help: "Insert error expectations for all front ends.",
|
|
negatable: false,
|
|
);
|
|
parser.addMultiOption(
|
|
"insert",
|
|
help: "Insert error expectations from given front ends.",
|
|
allowed: sources,
|
|
);
|
|
parser.addFlag(
|
|
"update-all",
|
|
abbr: "u",
|
|
help: "Replace error expectations for all front ends.",
|
|
negatable: false,
|
|
);
|
|
parser.addMultiOption(
|
|
"update",
|
|
help: "Update error expectations for given front ends.",
|
|
allowed: sources,
|
|
);
|
|
|
|
var results = parser.parse(args);
|
|
|
|
if (results["help"] as bool) {
|
|
print("Regenerates the test markers inside static error tests.");
|
|
print("");
|
|
print(_usage);
|
|
print("");
|
|
print(parser.usage);
|
|
exit(0);
|
|
}
|
|
|
|
var dryRun = results["dry-run"] as bool;
|
|
|
|
var includeContext = results["context"] as bool;
|
|
var onlyStaticErrorTests = results["only-static-error-tests"] as bool;
|
|
|
|
var removeSources = <ErrorSource>{};
|
|
var insertSources = <ErrorSource>{};
|
|
|
|
for (var source in results["remove"] as List<String>) {
|
|
removeSources.add(ErrorSource.find(source)!);
|
|
}
|
|
|
|
for (var source in results["insert"] as List<String>) {
|
|
insertSources.add(ErrorSource.find(source)!);
|
|
}
|
|
|
|
for (var source in results["update"] as List<String>) {
|
|
removeSources.add(ErrorSource.find(source)!);
|
|
insertSources.add(ErrorSource.find(source)!);
|
|
}
|
|
|
|
if (results["remove-all"] as bool) {
|
|
removeSources.addAll(ErrorSource.all);
|
|
}
|
|
|
|
if (results["insert-all"] as bool) {
|
|
insertSources.addAll(ErrorSource.all);
|
|
}
|
|
|
|
if (results["update-all"] as bool) {
|
|
removeSources.addAll(ErrorSource.all);
|
|
insertSources.addAll(ErrorSource.all);
|
|
}
|
|
|
|
if (removeSources.isEmpty && insertSources.isEmpty) {
|
|
_usageError(
|
|
parser,
|
|
"Must provide at least one flag for an operation to perform.",
|
|
);
|
|
}
|
|
|
|
var testFiles = _listFiles(
|
|
results.rest,
|
|
onlyStaticErrorTests: onlyStaticErrorTests,
|
|
);
|
|
|
|
var analyzerErrors = const <TestFile, List<StaticError>>{};
|
|
var cfeErrors = const <TestFile, List<StaticError>>{};
|
|
var webErrors = const <TestFile, List<StaticError>>{};
|
|
|
|
if (insertSources.contains(ErrorSource.analyzer)) {
|
|
analyzerErrors = await _runAnalyzer(testFiles);
|
|
}
|
|
|
|
if (insertSources.contains(ErrorSource.cfe)) {
|
|
cfeErrors = await _runCfe(testFiles);
|
|
}
|
|
|
|
if (insertSources.contains(ErrorSource.web)) {
|
|
webErrors = await _runDart2js(testFiles, cfeErrors);
|
|
}
|
|
|
|
print('Updating test files...');
|
|
var pendingUpdates = <Path, _PendingUpdate>{};
|
|
for (var testFile in testFiles) {
|
|
_collectUpdate(pendingUpdates, testFile, [
|
|
if (insertSources.contains(ErrorSource.analyzer))
|
|
...?analyzerErrors[testFile],
|
|
if (insertSources.contains(ErrorSource.cfe)) ...?cfeErrors[testFile],
|
|
if (insertSources.contains(ErrorSource.web)) ...?webErrors[testFile],
|
|
], includeContext: includeContext);
|
|
}
|
|
|
|
for (var update in pendingUpdates.values) {
|
|
_writeUpdate(
|
|
update,
|
|
remove: removeSources,
|
|
includeContext: includeContext,
|
|
dryRun: dryRun,
|
|
);
|
|
}
|
|
}
|
|
|
|
List<String> _testFileOptions(TestFile testFile, {bool cfe = false}) {
|
|
return [
|
|
...testFile.sharedOptions,
|
|
if (testFile.experiments.isNotEmpty)
|
|
"--enable-experiment=${testFile.experiments.join(',')}",
|
|
];
|
|
}
|
|
|
|
/// Find all of the files that match [pathGlobs], load corresponding [TestFile]s
|
|
/// and return all tests that should be updated.
|
|
///
|
|
/// Omits multitests since those don't support being used as static error tests.
|
|
/// Omits any [TestFile] that doesn't already contain a static error test
|
|
/// expectation if [onlyStaticErrorTests] is `true`.
|
|
List<TestFile> _listFiles(
|
|
List<String> pathGlobs, {
|
|
required bool onlyStaticErrorTests,
|
|
}) {
|
|
print('Listing files...');
|
|
var testFiles = <TestFile>[];
|
|
for (var pathGlob in pathGlobs) {
|
|
// Allow tests to be specified without the extension for compatibility with
|
|
// the regular test runner syntax.
|
|
if (!pathGlob.endsWith(".dart")) pathGlob += ".dart";
|
|
|
|
// Allow tests to be specified either relative to the "tests" directory
|
|
// or relative to the current directory.
|
|
var root = pathGlob.startsWith("tests") ? "." : "tests";
|
|
|
|
pathGlob = Uri.file(pathGlob).toFilePath(windows: false);
|
|
if (Platform.isWindows && p.isAbsolute(pathGlob)) {
|
|
// Trim absolute path's leading forward slash on Windows to accommodate
|
|
// Glob, which doesn't handle proper backslash-separated Windows paths
|
|
// and proper absolute file uri on Windows look like '/c:/abc/temp.txt'.
|
|
pathGlob = pathGlob.substring(1);
|
|
}
|
|
|
|
for (var file in Glob(pathGlob, recursive: true).listSync(root: root)) {
|
|
if (file is! File) continue;
|
|
|
|
if (!file.path.endsWith(".dart")) continue;
|
|
|
|
var f = StaticError.normalizePath(file.path);
|
|
var testFile = TestFile.read(Path("."), f);
|
|
|
|
if (testFile.isMultitest) {
|
|
print("Skip ${testFile.path} since this tool can't update multitests.");
|
|
continue;
|
|
}
|
|
|
|
if (onlyStaticErrorTests && testFile.expectedErrors.isEmpty) {
|
|
print("Skip ${testFile.path} since it isn't a static error test.");
|
|
continue;
|
|
}
|
|
|
|
testFiles.add(testFile);
|
|
}
|
|
}
|
|
|
|
return testFiles;
|
|
}
|
|
|
|
void _usageError(ArgParser parser, String message) {
|
|
stderr.writeln(message);
|
|
stderr.writeln();
|
|
stderr.writeln(_usage);
|
|
stderr.writeln(parser.usage);
|
|
exit(64);
|
|
}
|
|
|
|
/// Run analyzer on [allTestFiles] and return the errors for each file.
|
|
Future<Map<TestFile, List<StaticError>>> _runAnalyzer(
|
|
List<TestFile> allTestFiles,
|
|
) async {
|
|
// For performance, we want to analyze multiple tests using the same analysis
|
|
// context collection. But a context collection only works with a single set
|
|
// of experiment flags, so group the tests by their experiments.
|
|
var testsByExperiments = EqualityMap<List<String>, List<TestFile>>(
|
|
const ListEquality(),
|
|
);
|
|
for (var testFile in allTestFiles) {
|
|
var experiments = testFile.experiments.toList()..sort();
|
|
testsByExperiments.putIfAbsent(experiments, () => []).add(testFile);
|
|
}
|
|
|
|
var errors = <TestFile, List<StaticError>>{};
|
|
for (var experiments in testsByExperiments.keys) {
|
|
var testFiles = testsByExperiments[experiments]!;
|
|
|
|
ResourceProvider resourceProvider;
|
|
if (experiments.isEmpty) {
|
|
print('Running analyzer on ${_plural(testFiles, 'file')}...');
|
|
resourceProvider = PhysicalResourceProvider.INSTANCE;
|
|
} else {
|
|
print(
|
|
'Running analyzer on ${_plural(testFiles, 'file')} '
|
|
'with experiments "${experiments.join(', ')}"...',
|
|
);
|
|
|
|
// If there are experiments, then synthesize an analysis options file in the
|
|
// root directory that enables the experiments for all of the tests.
|
|
var options = StringBuffer();
|
|
options.writeln('analyzer:');
|
|
options.writeln(' enable-experiment:');
|
|
for (var experiment in experiments) {
|
|
options.writeln(' - $experiment');
|
|
}
|
|
|
|
resourceProvider =
|
|
OverlayResourceProvider(PhysicalResourceProvider.INSTANCE)
|
|
..setOverlay(
|
|
Path('tests/analysis_options.yaml').absolute.toNativePath(),
|
|
content: options.toString(),
|
|
modificationStamp: 0,
|
|
);
|
|
}
|
|
|
|
var paths = [
|
|
for (var testFile in testFiles)
|
|
p.normalize(testFile.path.absolute.toNativePath()),
|
|
];
|
|
|
|
var contextCollection = AnalysisContextCollection(
|
|
includedPaths: paths,
|
|
resourceProvider: resourceProvider,
|
|
);
|
|
|
|
for (var testFile in testFiles) {
|
|
errors[testFile] = await _runAnalyzerOnFile(contextCollection, testFile);
|
|
}
|
|
|
|
await contextCollection.dispose();
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/// Analyze [testFile] using [contextCollection] and return the list of reported
|
|
/// errors.
|
|
Future<List<StaticError>> _runAnalyzerOnFile(
|
|
AnalysisContextCollection contextCollection,
|
|
TestFile testFile,
|
|
) async {
|
|
var absolutePath = testFile.path.absolute.toNativePath();
|
|
|
|
var context = contextCollection.contextFor(absolutePath);
|
|
var errorsResult = await context.currentSession.getErrors(absolutePath);
|
|
|
|
// Convert the analyzer errors to test_runner [StaticErrors].
|
|
var errors = <StaticError>[];
|
|
if (errorsResult is ErrorsResult) {
|
|
for (var diagnostic in errorsResult.diagnostics) {
|
|
switch (diagnostic.severity) {
|
|
case Severity.error:
|
|
case Severity.warning
|
|
when AnalyzerError.isValidatedWarning(
|
|
diagnostic.diagnosticCode.lowerCaseName,
|
|
):
|
|
errors.add(
|
|
_convertAnalysisError(context, errorsResult.path, diagnostic),
|
|
);
|
|
default:
|
|
// Ignore todos and other harmless warnings like unused variables
|
|
// which the tests are riddled with but we don't want to bother
|
|
// validating.
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/// Convert an [Diagnostic] from the analyzer package to the test runner's
|
|
/// [StaticError] type.
|
|
StaticError _convertAnalysisError(
|
|
AnalysisContext analysisContext,
|
|
String containingFile,
|
|
Diagnostic error,
|
|
) {
|
|
var fileResult =
|
|
analysisContext.currentSession.getFile(containingFile) as FileResult;
|
|
var errorLocation = fileResult.lineInfo.getLocation(error.offset);
|
|
|
|
var staticError = StaticError(
|
|
ErrorSource.analyzer,
|
|
// Analyzer reports error codes as lower snake case, but the test runner
|
|
// still expects upper snake case, so convert the error code to upper
|
|
// case.
|
|
'${error.diagnosticCode.type.name}.'
|
|
'${error.diagnosticCode.lowerCaseName.toUpperCase()}',
|
|
path: containingFile,
|
|
line: errorLocation.lineNumber,
|
|
column: errorLocation.columnNumber,
|
|
length: error.length,
|
|
);
|
|
|
|
for (var context in error.contextMessages) {
|
|
var contextFileResult =
|
|
analysisContext.currentSession.getFile(context.filePath) as FileResult;
|
|
var contextLocation = contextFileResult.lineInfo.getLocation(
|
|
context.offset,
|
|
);
|
|
|
|
staticError.contextMessages.add(
|
|
StaticError(
|
|
ErrorSource.context,
|
|
context.messageText(includeUrl: true),
|
|
path: context.filePath,
|
|
line: contextLocation.lineNumber,
|
|
column: contextLocation.columnNumber,
|
|
length: context.length,
|
|
),
|
|
);
|
|
}
|
|
|
|
return staticError;
|
|
}
|
|
|
|
/// Invoke CFE on all [allTestFiles] and gather the static errors it reports.
|
|
Future<Map<TestFile, List<StaticError>>> _runCfe(
|
|
List<TestFile> allTestFiles,
|
|
) async {
|
|
// For performance, we want to run CFE on batches of files, but we can't use
|
|
// the same invocation for files with different options, so group by options
|
|
// first.
|
|
var testsByOptions = EqualityMap<List<String>, List<TestFile>>(
|
|
const ListEquality(),
|
|
);
|
|
for (var testFile in allTestFiles) {
|
|
var options = _testFileOptions(testFile, cfe: true)..sort();
|
|
testsByOptions.putIfAbsent(options, () => []).add(testFile);
|
|
}
|
|
|
|
var errors = <TestFile, List<StaticError>>{};
|
|
|
|
for (var options in testsByOptions.keys) {
|
|
var testFiles = testsByOptions[options]!;
|
|
|
|
if (options.isEmpty) {
|
|
print('Running CFE on ${_plural(testFiles, 'file')}...');
|
|
} else {
|
|
print(
|
|
'Running CFE on ${_plural(testFiles, 'file')} '
|
|
'with options "${options.join(', ')}"...',
|
|
);
|
|
}
|
|
|
|
var absolutePaths = [
|
|
for (var testFile in testFiles)
|
|
File(testFile.path.toNativePath()).absolute.path,
|
|
];
|
|
|
|
var result = await Process.run(dartPath, [
|
|
'pkg/front_end/tool/compile.dart',
|
|
...options,
|
|
'--packages=.dart_tool/package_config.json',
|
|
'--verify',
|
|
'-o',
|
|
'dev:null', // Output is only created for file URIs.
|
|
...absolutePaths,
|
|
]);
|
|
|
|
// Running the above command may generate dill files next to the tests,
|
|
// which we don't want, so delete them if present.
|
|
for (var absolutePath in absolutePaths) {
|
|
var dill = File("$absolutePath.dill");
|
|
if (await dill.exists()) {
|
|
await dill.delete();
|
|
}
|
|
}
|
|
|
|
if (result.exitCode != 0) {
|
|
print("CFE run failed: ${result.stdout}\n${result.stderr}");
|
|
exit(1);
|
|
}
|
|
|
|
var parsedErrors = <StaticError>[];
|
|
FastaCommandOutput.parseErrors(
|
|
result.stdout as String,
|
|
parsedErrors,
|
|
parsedErrors,
|
|
);
|
|
for (var error in parsedErrors) {
|
|
// If an error occurs in a file that is included in the current one, then
|
|
// `error.path` may not be found, and `testFile` will be null.
|
|
var testFile = testFiles.firstWhereOrNull(
|
|
(test) => test.path == Path(error.path),
|
|
);
|
|
if (testFile != null) {
|
|
errors.putIfAbsent(testFile, () => []).add(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
Future<Map<TestFile, List<StaticError>>> _runDart2js(
|
|
List<TestFile> testFiles,
|
|
Map<TestFile, List<StaticError>> cfeErrors,
|
|
) async {
|
|
var errors = <TestFile, List<StaticError>>{};
|
|
for (var testFile in testFiles) {
|
|
print('Running dart2js on ${testFile.path}...');
|
|
errors[testFile] = await _runDart2jsOnFile(
|
|
testFile,
|
|
cfeErrors[testFile] ?? const <StaticError>[],
|
|
);
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
/// Invoke dart2js on [testFile] and gather all static errors it reports.
|
|
Future<List<StaticError>> _runDart2jsOnFile(
|
|
TestFile testFile,
|
|
List<StaticError> cfeErrors,
|
|
) async {
|
|
var result = await Process.run(dartPath, [
|
|
'compile',
|
|
'js',
|
|
..._testFileOptions(testFile),
|
|
"-o",
|
|
"dev:null", // Output is only created for file URIs.
|
|
testFile.path.absolute.toNativePath(),
|
|
]);
|
|
|
|
var errors = <StaticError>[];
|
|
Dart2jsCompilerCommandOutput.parseErrors(
|
|
result.stdout as String,
|
|
errors,
|
|
errors,
|
|
);
|
|
|
|
// We only want the web-specific errors from dart2js, so filter out any errors
|
|
// that are also reported by the CFE.
|
|
errors.removeWhere((dart2jsError) {
|
|
return cfeErrors.any((cfeError) {
|
|
return dart2jsError.line == cfeError.line &&
|
|
dart2jsError.column == cfeError.column &&
|
|
dart2jsError.length == cfeError.length &&
|
|
dart2jsError.message == cfeError.message;
|
|
});
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
/// Collect the files that need to be updated for [testFile].
|
|
///
|
|
/// Each selected root test owns a slice of any shared helper files it touches.
|
|
/// The updater analyzes all roots before rewriting, so each touched file must
|
|
/// be written once using all selected owners; otherwise newly inserted comments
|
|
/// shift the line numbers for diagnostics collected from later roots.
|
|
void _collectUpdate(
|
|
Map<Path, _PendingUpdate> pendingUpdates,
|
|
TestFile testFile,
|
|
List<StaticError> errors, {
|
|
required bool includeContext,
|
|
}) {
|
|
// Error expectations can be in imported libraries or part files. Iterate
|
|
// over the set of paths that is the main file path plus all paths mentioned
|
|
// in expectations, updating them.
|
|
var paths = {
|
|
testFile.path,
|
|
for (var error in errors) Path(error.path),
|
|
if (includeContext)
|
|
for (var error in errors)
|
|
for (var context in error.contextMessages) Path(context.path),
|
|
};
|
|
|
|
for (var path in paths) {
|
|
var update = pendingUpdates.putIfAbsent(path, () => _PendingUpdate(path));
|
|
update.contextOwnerPaths.add(testFile.path.toNativePath());
|
|
update.addErrors(errors);
|
|
}
|
|
}
|
|
|
|
/// Update the static error expectations in [update].
|
|
///
|
|
/// Adds the collected errors to the file and removes any existing errors that
|
|
/// are in from the sources in [remove].
|
|
///
|
|
/// If [includeContext] is `true`, then includes context messages in the
|
|
/// resulting test. If [dryRun] is `false`, then writes the result to disk.
|
|
/// Otherwise, prints the resulting test file.
|
|
void _writeUpdate(
|
|
_PendingUpdate update, {
|
|
required Set<ErrorSource> remove,
|
|
required bool includeContext,
|
|
required bool dryRun,
|
|
}) {
|
|
var path = update.path;
|
|
var nativePath = path.toNativePath();
|
|
var file = File(nativePath);
|
|
var pathErrors = update.errors
|
|
.where(
|
|
(error) =>
|
|
Path(error.path) == path ||
|
|
error.contextMessages.any((context) => Path(context.path) == path),
|
|
)
|
|
.toList();
|
|
var result = updateErrorExpectations(
|
|
nativePath,
|
|
file.readAsStringSync(),
|
|
update.errors,
|
|
remove: remove,
|
|
includeContext: includeContext,
|
|
contextOwnerPaths: update.contextOwnerPaths,
|
|
);
|
|
|
|
if (dryRun) {
|
|
print(result);
|
|
} else {
|
|
file.writeAsStringSync(result);
|
|
print('- $nativePath (${_plural(pathErrors, 'error')})');
|
|
}
|
|
}
|
|
|
|
class _PendingUpdate {
|
|
final Path path;
|
|
final List<StaticError> errors = [];
|
|
final Set<String> contextOwnerPaths = {};
|
|
|
|
_PendingUpdate(this.path);
|
|
|
|
void addErrors(List<StaticError> errors) {
|
|
for (var error in errors) {
|
|
if (!this.errors.contains(error)) this.errors.add(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find the most recently-built binary [name] in any of the build directories.
|
|
String _findBinary(String name, String windowsExtension) {
|
|
var binary = Platform.isWindows ? "$name.$windowsExtension" : name;
|
|
|
|
String? newestPath;
|
|
DateTime? newestTime;
|
|
|
|
var buildDirectory = Directory(Platform.isMacOS ? "xcodebuild" : "out");
|
|
if (buildDirectory.existsSync()) {
|
|
for (var config in buildDirectory.listSync()) {
|
|
var path = p.join(config.path, "dart-sdk", "bin", binary);
|
|
var file = File(path);
|
|
if (!file.existsSync()) continue;
|
|
var modified = file.lastModifiedSync();
|
|
|
|
if (newestTime == null || modified.isAfter(newestTime)) {
|
|
newestPath = path;
|
|
newestTime = modified;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (newestPath == null) {
|
|
// Clear the current line since we're in the middle of a progress line.
|
|
print("");
|
|
print("Could not find a built SDK with a $binary to run.");
|
|
print("Make sure to build the Dart SDK before running this tool.");
|
|
exit(1);
|
|
}
|
|
|
|
return newestPath;
|
|
}
|
|
|
|
/// Returns a string with the number of [things] followed by [noun], pluralized
|
|
/// as needed.
|
|
String _plural(List<Object?> things, String noun) {
|
|
if (things.length == 1) return '1 $noun';
|
|
return '${things.length} ${noun}s';
|
|
}
|