mirror of
https://github.com/flutter/flutter
synced 2024-10-14 04:02:56 +00:00
925 lines
35 KiB
Dart
925 lines
35 KiB
Dart
// Copyright 2017 The Chromium Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
// This script analyzes all the sample code in API docs in the Flutter source.
|
|
//
|
|
// It uses the following conventions:
|
|
//
|
|
// Code is denoted by markdown ```dart / ``` markers.
|
|
//
|
|
// Only code in "## Sample code" or "### Sample code" sections is examined.
|
|
// Subheadings can also be specified, as in "## Sample code: foo".
|
|
//
|
|
// Additionally, code inside of dartdoc snippet and sample blocks
|
|
// ({@tool snippet ...}{@end-tool}, and {@tool sample ...}{@end-tool})
|
|
// is recognized as sample code. Snippets are processed as separate programs,
|
|
// and samples are processed in the same way as "## Sample code" blocks are.
|
|
//
|
|
// There are several kinds of sample code you can specify:
|
|
//
|
|
// * Constructor calls, typically showing what might exist in a build method.
|
|
// These start with "new" or "const", and will be inserted into an assignment
|
|
// expression assigning to a variable of type "dynamic" and followed by a
|
|
// semicolon, for the purposes of analysis.
|
|
//
|
|
// * Class definitions. These start with "class", and are analyzed verbatim.
|
|
//
|
|
// * Other code. It gets included verbatim, though any line that says "// ..."
|
|
// is considered to separate the block into multiple blocks to be processed
|
|
// individually.
|
|
//
|
|
// In addition, you can declare code that should be included in the analysis but
|
|
// not shown in the API docs by adding a comment "// Examples can assume:" to
|
|
// the file (usually at the top of the file, after the imports), following by
|
|
// one or more commented-out lines of code. That code is included verbatim in
|
|
// the analysis.
|
|
//
|
|
// All the sample code of every file is analyzed together. This means you can't
|
|
// have two pieces of sample code that define the same example class.
|
|
//
|
|
// Also, the above means that it's tricky to include verbatim imperative code
|
|
// (e.g. a call to a method), since it won't be valid to have such code at the
|
|
// top level. Instead, wrap it in a function or even a whole class, or make it a
|
|
// valid variable declaration.
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:args/args.dart';
|
|
import 'package:path/path.dart' as path;
|
|
|
|
// To run this: bin/cache/dart-sdk/bin/dart dev/bots/analyze-sample-code.dart
|
|
|
|
final String _flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script))));
|
|
final String _defaultFlutterPackage = path.join(_flutterRoot, 'packages', 'flutter', 'lib');
|
|
final String _flutter = path.join(_flutterRoot, 'bin', Platform.isWindows ? 'flutter.bat' : 'flutter');
|
|
|
|
void main(List<String> arguments) {
|
|
final ArgParser argParser = ArgParser();
|
|
argParser.addOption(
|
|
'temp',
|
|
defaultsTo: null,
|
|
help: 'A location where temporary files may be written. Defaults to a '
|
|
'directory in the system temp folder. If specified, will not be '
|
|
'automatically removed at the end of execution.',
|
|
);
|
|
argParser.addFlag(
|
|
'help',
|
|
defaultsTo: false,
|
|
negatable: false,
|
|
help: 'Print help for this command.',
|
|
);
|
|
|
|
final ArgResults parsedArguments = argParser.parse(arguments);
|
|
|
|
if (parsedArguments['help']) {
|
|
print(argParser.usage);
|
|
exit(0);
|
|
}
|
|
|
|
Directory flutterPackage;
|
|
if (parsedArguments.rest.length == 1) {
|
|
// Used for testing.
|
|
flutterPackage = Directory(parsedArguments.rest.single);
|
|
} else {
|
|
flutterPackage = Directory(_defaultFlutterPackage);
|
|
}
|
|
|
|
Directory tempDirectory;
|
|
if (parsedArguments.wasParsed('temp')) {
|
|
tempDirectory = Directory(path.join(Directory.systemTemp.absolute.path, path.basename(parsedArguments['temp'])));
|
|
if (path.basename(parsedArguments['temp']) != parsedArguments['temp']) {
|
|
stderr.writeln('Supplied temporary directory name should be a name, not a path. Using ${tempDirectory.absolute.path} instead.');
|
|
}
|
|
print('Leaving temporary output in ${tempDirectory.absolute.path}.');
|
|
// Make sure that any directory left around from a previous run is cleared
|
|
// out.
|
|
if (tempDirectory.existsSync()) {
|
|
tempDirectory.deleteSync(recursive: true);
|
|
}
|
|
tempDirectory.createSync();
|
|
}
|
|
try {
|
|
exitCode = SampleChecker(flutterPackage, tempDirectory: tempDirectory).checkSamples();
|
|
} on SampleCheckerException catch (e) {
|
|
stderr.write(e);
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
class SampleCheckerException implements Exception {
|
|
SampleCheckerException(this.message, {this.file, this.line});
|
|
final String message;
|
|
final String file;
|
|
final int line;
|
|
|
|
@override
|
|
String toString() {
|
|
if (file != null || line != null) {
|
|
final String fileStr = file == null ? '' : '$file:';
|
|
final String lineStr = line == null ? '' : '$line:';
|
|
return '$fileStr$lineStr Error: $message';
|
|
} else {
|
|
return 'Error: $message';
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Checks samples and code snippets for analysis errors.
|
|
///
|
|
/// Extracts dartdoc content from flutter package source code, identifies code
|
|
/// sections, and writes them to a temporary directory, where 'flutter analyze'
|
|
/// is used to analyze the sources for problems. If problems are found, the
|
|
/// error output from the analyzer is parsed for details, and the problem
|
|
/// locations are translated back to the source location.
|
|
///
|
|
/// For snippets, the snippets are generated using the snippets tool, and they
|
|
/// are analyzed with the samples. If errors are found in snippets, then the
|
|
/// line number of the start of the snippet is given instead of the actual error
|
|
/// line, since snippets get reformatted when written, and the line numbers
|
|
/// don't necessarily match. It does, however, print the source of the
|
|
/// problematic line.
|
|
class SampleChecker {
|
|
SampleChecker(this._flutterPackage, {Directory tempDirectory})
|
|
: _tempDirectory = tempDirectory,
|
|
_keepTmp = tempDirectory != null {
|
|
_tempDirectory ??= Directory.systemTemp.createTempSync('flutter_analyze_sample_code.');
|
|
}
|
|
|
|
/// The prefix of each comment line
|
|
static const String _dartDocPrefix = '///';
|
|
|
|
/// The prefix of each comment line with a space appended.
|
|
static const String _dartDocPrefixWithSpace = '$_dartDocPrefix ';
|
|
|
|
/// A RegExp that matches the beginning of a dartdoc snippet or sample.
|
|
static final RegExp _dartDocSampleBeginRegex = RegExp(r'{@tool (sample|snippet)(?:| ([^}]*))}');
|
|
|
|
/// A RegExp that matches the end of a dartdoc snippet or sample.
|
|
static final RegExp _dartDocSampleEndRegex = RegExp(r'{@end-tool}');
|
|
|
|
/// A RegExp that matches the start of a code block within dartdoc.
|
|
static final RegExp _codeBlockStartRegex = RegExp(r'///\s+```dart.*$');
|
|
|
|
/// A RegExp that matches the end of a code block within dartdoc.
|
|
static final RegExp _codeBlockEndRegex = RegExp(r'///\s+```\s*$');
|
|
|
|
/// A RegExp that matches a Dart constructor.
|
|
static final RegExp _constructorRegExp = RegExp(r'(const\s+)?_*[A-Z][a-zA-Z0-9<>._]*\(');
|
|
|
|
/// Whether or not to keep the temp directory around after running.
|
|
///
|
|
/// Defaults to false.
|
|
final bool _keepTmp;
|
|
|
|
/// The temporary directory where all output is written. This will be deleted
|
|
/// automatically if there are no errors.
|
|
Directory _tempDirectory;
|
|
|
|
/// The package directory for the flutter package within the flutter root dir.
|
|
final Directory _flutterPackage;
|
|
|
|
/// A serial number so that we can create unique expression names when we
|
|
/// generate them.
|
|
int _expressionId = 0;
|
|
|
|
/// The exit code from the analysis process.
|
|
int _exitCode = 0;
|
|
|
|
// Once the snippets tool has been precompiled by Dart, this contains the AOT
|
|
// snapshot.
|
|
String _snippetsSnapshotPath;
|
|
|
|
/// Finds the location of the snippets script.
|
|
String get _snippetsExecutable {
|
|
final String platformScriptPath = path.dirname(path.fromUri(Platform.script));
|
|
return path.canonicalize(path.join(platformScriptPath, '..', 'snippets', 'lib', 'main.dart'));
|
|
}
|
|
|
|
/// Finds the location of the Dart executable.
|
|
String get _dartExecutable {
|
|
final File dartExecutable = File(Platform.resolvedExecutable);
|
|
return dartExecutable.absolute.path;
|
|
}
|
|
|
|
static List<File> _listDartFiles(Directory directory, {bool recursive = false}) {
|
|
return directory.listSync(recursive: recursive, followLinks: false).whereType<File>().where((File file) => path.extension(file.path) == '.dart').toList();
|
|
}
|
|
|
|
/// Computes the headers needed for each sample file.
|
|
List<Line> get headers {
|
|
if (_headers == null) {
|
|
final List<String> buffer = <String>[];
|
|
buffer.add('// generated code');
|
|
buffer.add('import \'dart:async\';');
|
|
buffer.add('import \'dart:convert\';');
|
|
buffer.add('import \'dart:math\' as math;');
|
|
buffer.add('import \'dart:typed_data\';');
|
|
buffer.add('import \'dart:ui\' as ui;');
|
|
buffer.add('import \'package:flutter_test/flutter_test.dart\';');
|
|
for (File file in _listDartFiles(Directory(_defaultFlutterPackage))) {
|
|
buffer.add('');
|
|
buffer.add('// ${file.path}');
|
|
buffer.add('import \'package:flutter/${path.basename(file.path)}\';');
|
|
}
|
|
_headers = buffer.map<Line>((String code) => Line(code)).toList();
|
|
}
|
|
return _headers;
|
|
}
|
|
|
|
List<Line> _headers;
|
|
|
|
/// Checks all the samples in the Dart files in [_flutterPackage] for errors.
|
|
int checkSamples() {
|
|
_exitCode = 0;
|
|
Map<String, List<AnalysisError>> errors = <String, List<AnalysisError>>{};
|
|
try {
|
|
final Map<String, Section> sections = <String, Section>{};
|
|
final Map<String, Snippet> snippets = <String, Snippet>{};
|
|
_extractSamples(sections, snippets);
|
|
errors = _analyze(_tempDirectory, sections, snippets);
|
|
} finally {
|
|
if (errors.isNotEmpty) {
|
|
for (String filePath in errors.keys) {
|
|
errors[filePath].forEach(stderr.writeln);
|
|
}
|
|
stderr.writeln('\nFound ${errors.length} sample code errors.');
|
|
}
|
|
if (_keepTmp) {
|
|
print('Leaving temporary directory ${_tempDirectory.path} around for your perusal.');
|
|
} else {
|
|
try {
|
|
_tempDirectory.deleteSync(recursive: true);
|
|
} on FileSystemException catch (e) {
|
|
stderr.writeln('Failed to delete ${_tempDirectory.path}: $e');
|
|
}
|
|
}
|
|
// If we made a snapshot, remove it (so as not to clutter up the tree).
|
|
if (_snippetsSnapshotPath != null) {
|
|
final File snapshot = File(_snippetsSnapshotPath);
|
|
if (snapshot.existsSync()) {
|
|
snapshot.deleteSync();
|
|
}
|
|
}
|
|
}
|
|
return _exitCode;
|
|
}
|
|
|
|
/// Creates a name for the snippets tool to use for the snippet ID from a
|
|
/// filename and starting line number.
|
|
String _createNameFromSource(String prefix, String filename, int start) {
|
|
String snippetId = path.split(filename).join('.');
|
|
snippetId = path.basenameWithoutExtension(snippetId);
|
|
snippetId = '$prefix.$snippetId.$start';
|
|
return snippetId;
|
|
}
|
|
|
|
// Precompiles the snippets tool if _snippetsSnapshotPath isn't set yet, and
|
|
// runs the precompiled version if it is set.
|
|
ProcessResult _runSnippetsScript(List<String> args) {
|
|
final String workingDirectory = path.join(_flutterRoot, 'dev', 'docs');
|
|
if (_snippetsSnapshotPath == null) {
|
|
_snippetsSnapshotPath = '$_snippetsExecutable.snapshot';
|
|
return Process.runSync(
|
|
_dartExecutable,
|
|
<String>[
|
|
'--snapshot=$_snippetsSnapshotPath',
|
|
'--snapshot-kind=app-jit',
|
|
path.canonicalize(_snippetsExecutable),
|
|
]..addAll(args),
|
|
workingDirectory: workingDirectory,
|
|
);
|
|
} else {
|
|
return Process.runSync(
|
|
_dartExecutable,
|
|
<String>[path.canonicalize(_snippetsSnapshotPath)]..addAll(args),
|
|
workingDirectory: workingDirectory,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Writes out the given [snippet] to an output file in the [_tempDirectory] and
|
|
/// returns the output file.
|
|
File _writeSnippet(Snippet snippet) {
|
|
// Generate the snippet.
|
|
final String snippetId = _createNameFromSource('snippet', snippet.start.filename, snippet.start.line);
|
|
final String inputName = '$snippetId.input';
|
|
// Now we have a filename like 'lib.src.material.foo_widget.123.dart' for each snippet.
|
|
final File inputFile = File(path.join(_tempDirectory.path, inputName))..createSync(recursive: true);
|
|
inputFile.writeAsStringSync(snippet.input.join('\n'));
|
|
final File outputFile = File(path.join(_tempDirectory.path, '$snippetId.dart'));
|
|
final List<String> args = <String>[
|
|
'--output=${outputFile.absolute.path}',
|
|
'--input=${inputFile.absolute.path}',
|
|
]..addAll(snippet.args);
|
|
print('Generating snippet for ${snippet.start?.filename}:${snippet.start?.line}');
|
|
final ProcessResult process = _runSnippetsScript(args);
|
|
if (process.exitCode != 0) {
|
|
throw SampleCheckerException(
|
|
'Unable to create snippet for ${snippet.start.filename}:${snippet.start.line} '
|
|
'(using input from ${inputFile.path}):\n${process.stdout}\n${process.stderr}',
|
|
file: snippet.start.filename,
|
|
line: snippet.start.line,
|
|
);
|
|
}
|
|
return outputFile;
|
|
}
|
|
|
|
/// Extracts the samples from the Dart files in [_flutterPackage], writes them
|
|
/// to disk, and adds them to the appropriate [sectionMap] or [snippetMap].
|
|
void _extractSamples(Map<String, Section> sectionMap, Map<String, Snippet> snippetMap) {
|
|
final List<Section> sections = <Section>[];
|
|
final List<Snippet> snippets = <Snippet>[];
|
|
|
|
for (File file in _listDartFiles(_flutterPackage, recursive: true)) {
|
|
final String relativeFilePath = path.relative(file.path, from: _flutterPackage.path);
|
|
final List<String> sampleLines = file.readAsLinesSync();
|
|
final List<Section> preambleSections = <Section>[];
|
|
// Whether or not we're in the file-wide preamble section ("Examples can assume").
|
|
bool inPreamble = false;
|
|
// Whether or not we're in a code sample
|
|
bool inSampleSection = false;
|
|
// Whether or not we're in a snippet code sample (with template) specifically.
|
|
bool inSnippet = false;
|
|
// Whether or not we're in a '```dart' segment.
|
|
bool inDart = false;
|
|
int lineNumber = 0;
|
|
final List<String> block = <String>[];
|
|
List<String> snippetArgs = <String>[];
|
|
Line startLine;
|
|
for (String line in sampleLines) {
|
|
lineNumber += 1;
|
|
final String trimmedLine = line.trim();
|
|
if (inSnippet) {
|
|
if (!trimmedLine.startsWith(_dartDocPrefix)) {
|
|
throw SampleCheckerException('Snippet section unterminated.', file: relativeFilePath, line: lineNumber);
|
|
}
|
|
if (_dartDocSampleEndRegex.hasMatch(trimmedLine)) {
|
|
snippets.add(
|
|
Snippet(
|
|
start: startLine,
|
|
input: block,
|
|
args: snippetArgs,
|
|
serial: snippets.length,
|
|
),
|
|
);
|
|
snippetArgs = <String>[];
|
|
block.clear();
|
|
inSnippet = false;
|
|
inSampleSection = false;
|
|
} else {
|
|
block.add(line.replaceFirst(RegExp(r'\s*/// ?'), ''));
|
|
}
|
|
} else if (inPreamble) {
|
|
if (line.isEmpty) {
|
|
inPreamble = false;
|
|
preambleSections.add(_processBlock(startLine, block));
|
|
block.clear();
|
|
} else if (!line.startsWith('// ')) {
|
|
throw SampleCheckerException('Unexpected content in sample code preamble.', file: relativeFilePath, line: lineNumber);
|
|
} else {
|
|
block.add(line.substring(3));
|
|
}
|
|
} else if (inSampleSection) {
|
|
if (_dartDocSampleEndRegex.hasMatch(trimmedLine)) {
|
|
if (inDart) {
|
|
throw SampleCheckerException("Dart section didn't terminate before end of sample", file: relativeFilePath, line: lineNumber);
|
|
}
|
|
inSampleSection = false;
|
|
}
|
|
if (inDart) {
|
|
if (_codeBlockEndRegex.hasMatch(trimmedLine)) {
|
|
inDart = false;
|
|
final Section processed = _processBlock(startLine, block);
|
|
if (preambleSections.isEmpty) {
|
|
sections.add(processed);
|
|
} else {
|
|
sections.add(Section.combine(preambleSections
|
|
..toList()
|
|
..add(processed)));
|
|
}
|
|
block.clear();
|
|
} else if (trimmedLine == _dartDocPrefix) {
|
|
block.add('');
|
|
} else {
|
|
final int index = line.indexOf(_dartDocPrefixWithSpace);
|
|
if (index < 0) {
|
|
throw SampleCheckerException(
|
|
'Dart section inexplicably did not contain "$_dartDocPrefixWithSpace" prefix.',
|
|
file: relativeFilePath,
|
|
line: lineNumber,
|
|
);
|
|
}
|
|
block.add(line.substring(index + 4));
|
|
}
|
|
} else if (_codeBlockStartRegex.hasMatch(trimmedLine)) {
|
|
assert(block.isEmpty);
|
|
startLine = Line(
|
|
'',
|
|
filename: relativeFilePath,
|
|
line: lineNumber + 1,
|
|
indent: line.indexOf(_dartDocPrefixWithSpace) + _dartDocPrefixWithSpace.length,
|
|
);
|
|
inDart = true;
|
|
}
|
|
}
|
|
if (!inSampleSection) {
|
|
final Match sampleMatch = _dartDocSampleBeginRegex.firstMatch(trimmedLine);
|
|
if (line == '// Examples can assume:') {
|
|
assert(block.isEmpty);
|
|
startLine = Line('', filename: relativeFilePath, line: lineNumber + 1, indent: 3);
|
|
inPreamble = true;
|
|
} else if (sampleMatch != null) {
|
|
inSnippet = sampleMatch != null ? sampleMatch[1] == 'snippet' : false;
|
|
if (inSnippet) {
|
|
startLine = Line(
|
|
'',
|
|
filename: relativeFilePath,
|
|
line: lineNumber + 1,
|
|
indent: line.indexOf(_dartDocPrefixWithSpace) + _dartDocPrefixWithSpace.length,
|
|
);
|
|
if (sampleMatch[2] != null) {
|
|
// There are arguments to the snippet tool to keep track of.
|
|
snippetArgs = _splitUpQuotedArgs(sampleMatch[2]).toList();
|
|
} else {
|
|
snippetArgs = <String>[];
|
|
}
|
|
}
|
|
inSampleSection = !inSnippet;
|
|
} else if (RegExp(r'///\s*#+\s+[Ss]ample\s+[Cc]ode:?$').hasMatch(trimmedLine)) {
|
|
throw SampleCheckerException(
|
|
"Found deprecated '## Sample code' section: use {@tool sample}...{@end-tool} instead.",
|
|
file: relativeFilePath,
|
|
line: lineNumber,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
print('Found ${sections.length} sample code sections.');
|
|
for (Section section in sections) {
|
|
sectionMap[_writeSection(section).path] = section;
|
|
}
|
|
for (Snippet snippet in snippets) {
|
|
final File snippetFile = _writeSnippet(snippet);
|
|
snippet.contents = snippetFile.readAsLinesSync();
|
|
snippetMap[snippetFile.absolute.path] = snippet;
|
|
}
|
|
}
|
|
|
|
/// Helper to process arguments given as a (possibly quoted) string.
|
|
///
|
|
/// First, this will split the given [argsAsString] into separate arguments,
|
|
/// taking any quoting (either ' or " are accepted) into account, including
|
|
/// handling backslash-escaped quotes.
|
|
///
|
|
/// Then, it will prepend "--" to any args that start with an identifier
|
|
/// followed by an equals sign, allowing the argument parser to treat any
|
|
/// "foo=bar" argument as "--foo=bar" (which is a dartdoc-ism).
|
|
Iterable<String> _splitUpQuotedArgs(String argsAsString) {
|
|
// Regexp to take care of splitting arguments, and handling the quotes
|
|
// around arguments, if any.
|
|
//
|
|
// Match group 1 is the "foo=" (or "--foo=") part of the option, if any.
|
|
// Match group 2 contains the quote character used (which is discarded).
|
|
// Match group 3 is a quoted arg, if any, without the quotes.
|
|
// Match group 4 is the unquoted arg, if any.
|
|
final RegExp argMatcher = RegExp(r'([a-zA-Z\-_0-9]+=)?' // option name
|
|
r'(?:' // Start a new non-capture group for the two possibilities.
|
|
r'''(["'])((?:\\{2})*|(?:.*?[^\\](?:\\{2})*))\2|''' // with quotes.
|
|
r'([^ ]+))'); // without quotes.
|
|
final Iterable<Match> matches = argMatcher.allMatches(argsAsString);
|
|
|
|
// Remove quotes around args, and if convertToArgs is true, then for any
|
|
// args that look like assignments (start with valid option names followed
|
|
// by an equals sign), add a "--" in front so that they parse as options.
|
|
return matches.map<String>((Match match) {
|
|
String option = '';
|
|
if (match[1] != null && !match[1].startsWith('-')) {
|
|
option = '--';
|
|
}
|
|
if (match[2] != null) {
|
|
// This arg has quotes, so strip them.
|
|
return '$option${match[1] ?? ''}${match[3] ?? ''}${match[4] ?? ''}';
|
|
}
|
|
return '$option${match[0]}';
|
|
});
|
|
}
|
|
|
|
/// Creates the configuration files necessary for the analyzer to consider
|
|
/// the temporary director a package, and sets which lint rules to enforce.
|
|
void _createConfigurationFiles(Directory directory) {
|
|
final File pubSpec = File(path.join(directory.path, 'pubspec.yaml'))..createSync(recursive: true);
|
|
final File analysisOptions = File(path.join(directory.path, 'analysis_options.yaml'))..createSync(recursive: true);
|
|
pubSpec.writeAsStringSync('''
|
|
name: analyze_sample_code
|
|
dependencies:
|
|
flutter:
|
|
sdk: flutter
|
|
flutter_test:
|
|
sdk: flutter
|
|
''');
|
|
analysisOptions.writeAsStringSync('''
|
|
linter:
|
|
rules:
|
|
- unnecessary_const
|
|
- unnecessary_new
|
|
''');
|
|
}
|
|
|
|
/// Writes out a sample section to the disk and returns the file.
|
|
File _writeSection(Section section) {
|
|
final String sectionId = _createNameFromSource('sample', section.start.filename, section.start.line);
|
|
final File outputFile = File(path.join(_tempDirectory.path, '$sectionId.dart'))..createSync(recursive: true);
|
|
final List<Line> mainContents = headers.toList();
|
|
mainContents.add(const Line(''));
|
|
mainContents.add(Line('// From: ${section.start.filename}:${section.start.line}'));
|
|
mainContents.addAll(section.code);
|
|
outputFile.writeAsStringSync(mainContents.map<String>((Line line) => line.code).join('\n'));
|
|
return outputFile;
|
|
}
|
|
|
|
/// Invokes the analyzer on the given [directory] and returns the stdout.
|
|
List<String> _runAnalyzer(Directory directory) {
|
|
print('Starting analysis of samples.');
|
|
_createConfigurationFiles(directory);
|
|
final ProcessResult result = Process.runSync(
|
|
_flutter,
|
|
<String>['--no-wrap', 'analyze', '--no-preamble', '--no-congratulate', '.'],
|
|
workingDirectory: directory.absolute.path,
|
|
);
|
|
final List<String> stderr = result.stderr.toString().trim().split('\n');
|
|
final List<String> stdout = result.stdout.toString().trim().split('\n');
|
|
// Check out the stderr to see if the analyzer had it's own issues.
|
|
if (stderr.isNotEmpty && (stderr.first.contains(' issues found. (ran in ') || stderr.first.contains(' issue found. (ran in '))) {
|
|
// The "23 issues found" message goes onto stderr, which is concatenated first.
|
|
stderr.removeAt(0);
|
|
// If there's an "issues found" message, we put a blank line on stdout before it.
|
|
if (stderr.isNotEmpty && stderr.last.isEmpty) {
|
|
stderr.removeLast();
|
|
}
|
|
}
|
|
if (stderr.isNotEmpty) {
|
|
throw 'Cannot analyze dartdocs; unexpected error output:\n$stderr';
|
|
}
|
|
if (stdout.isNotEmpty && stdout.first == 'Building flutter tool...') {
|
|
stdout.removeAt(0);
|
|
}
|
|
if (stdout.isNotEmpty && stdout.first.startsWith('Running "flutter pub get" in ')) {
|
|
stdout.removeAt(0);
|
|
}
|
|
_exitCode = result.exitCode;
|
|
return stdout;
|
|
}
|
|
|
|
/// Starts the analysis phase of checking the samples by invoking the analyzer
|
|
/// and parsing its output to create a map of filename to [AnalysisError]s.
|
|
Map<String, List<AnalysisError>> _analyze(
|
|
Directory directory,
|
|
Map<String, Section> sections,
|
|
Map<String, Snippet> snippets,
|
|
) {
|
|
final List<String> errors = _runAnalyzer(directory);
|
|
final Map<String, List<AnalysisError>> analysisErrors = <String, List<AnalysisError>>{};
|
|
void addAnalysisError(File file, AnalysisError error) {
|
|
if (analysisErrors.containsKey(file.path)) {
|
|
analysisErrors[file.path].add(error);
|
|
} else {
|
|
analysisErrors[file.path] = <AnalysisError>[error];
|
|
}
|
|
}
|
|
|
|
final String kBullet = Platform.isWindows ? ' - ' : ' • ';
|
|
// RegExp to match an error output line of the analyzer.
|
|
final RegExp errorPattern = RegExp(
|
|
'^ +([a-z]+)$kBullet(.+)$kBullet(.+):([0-9]+):([0-9]+)$kBullet([-a-z_]+)\$',
|
|
caseSensitive: false,
|
|
);
|
|
bool unknownAnalyzerErrors = false;
|
|
final int headerLength = headers.length + 2;
|
|
for (String error in errors) {
|
|
final Match parts = errorPattern.matchAsPrefix(error);
|
|
if (parts != null) {
|
|
final String message = parts[2];
|
|
final File file = File(path.join(_tempDirectory.path, parts[3]));
|
|
final List<String> fileContents = file.readAsLinesSync();
|
|
final bool isSnippet = path.basename(file.path).startsWith('snippet.');
|
|
final bool isSample = path.basename(file.path).startsWith('sample.');
|
|
final String line = parts[4];
|
|
final String column = parts[5];
|
|
final String errorCode = parts[6];
|
|
final int lineNumber = int.parse(line, radix: 10) - (isSample ? headerLength : 0);
|
|
final int columnNumber = int.parse(column, radix: 10);
|
|
if (lineNumber < 0 && errorCode == 'unused_import') {
|
|
// We don't care about unused imports.
|
|
continue;
|
|
}
|
|
|
|
// For when errors occur outside of the things we're trying to analyze.
|
|
if (!isSnippet && !isSample) {
|
|
addAnalysisError(
|
|
file,
|
|
AnalysisError(
|
|
lineNumber,
|
|
columnNumber,
|
|
message,
|
|
errorCode,
|
|
Line(
|
|
'',
|
|
filename: file.path,
|
|
line: lineNumber,
|
|
),
|
|
),
|
|
);
|
|
throw SampleCheckerException(
|
|
'Cannot analyze dartdocs; analysis errors exist: $error',
|
|
file: file.path,
|
|
line: lineNumber,
|
|
);
|
|
}
|
|
|
|
if (errorCode == 'unused_element' || errorCode == 'unused_local_variable') {
|
|
// We don't really care if sample code isn't used!
|
|
continue;
|
|
}
|
|
if (isSnippet) {
|
|
addAnalysisError(
|
|
file,
|
|
AnalysisError(
|
|
lineNumber,
|
|
columnNumber,
|
|
message,
|
|
errorCode,
|
|
null,
|
|
snippet: snippets[file.path],
|
|
),
|
|
);
|
|
} else {
|
|
if (lineNumber < 1 || lineNumber > fileContents.length) {
|
|
addAnalysisError(
|
|
file,
|
|
AnalysisError(
|
|
lineNumber,
|
|
columnNumber,
|
|
message,
|
|
errorCode,
|
|
Line('', filename: file.path, line: lineNumber),
|
|
),
|
|
);
|
|
throw SampleCheckerException('Failed to parse error message: $error', file: file.path, line: lineNumber);
|
|
}
|
|
|
|
final Section actualSection = sections[file.path];
|
|
if (actualSection == null) {
|
|
throw SampleCheckerException(
|
|
"Unknown section for ${file.path}. Maybe the temporary directory wasn't empty?",
|
|
file: file.path,
|
|
line: lineNumber,
|
|
);
|
|
}
|
|
final Line actualLine = actualSection.code[lineNumber - 1];
|
|
|
|
if (actualLine?.filename == null) {
|
|
if (errorCode == 'missing_identifier' && lineNumber > 1) {
|
|
if (fileContents[lineNumber - 2].endsWith(',')) {
|
|
final Line actualLine = sections[file.path].code[lineNumber - 2];
|
|
addAnalysisError(
|
|
file,
|
|
AnalysisError(
|
|
actualLine.line,
|
|
actualLine.indent + fileContents[lineNumber - 2].length - 1,
|
|
'Unexpected comma at end of sample code.',
|
|
errorCode,
|
|
actualLine,
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
addAnalysisError(
|
|
file,
|
|
AnalysisError(
|
|
lineNumber - 1,
|
|
columnNumber,
|
|
message,
|
|
errorCode,
|
|
actualLine,
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
addAnalysisError(
|
|
file,
|
|
AnalysisError(
|
|
actualLine.line,
|
|
actualLine.indent + columnNumber,
|
|
message,
|
|
errorCode,
|
|
actualLine,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
stderr.writeln('Analyzer output: $error');
|
|
unknownAnalyzerErrors = true;
|
|
}
|
|
}
|
|
if (_exitCode == 1 && analysisErrors.isEmpty && !unknownAnalyzerErrors) {
|
|
_exitCode = 0;
|
|
}
|
|
if (_exitCode == 0) {
|
|
print('No analysis errors in samples!');
|
|
assert(analysisErrors.isEmpty);
|
|
}
|
|
return analysisErrors;
|
|
}
|
|
|
|
/// Process one block of sample code (the part inside of "```" markers).
|
|
/// Splits any sections denoted by "// ..." into separate blocks to be
|
|
/// processed separately. Uses a primitive heuristic to make sample blocks
|
|
/// into valid Dart code.
|
|
Section _processBlock(Line line, List<String> block) {
|
|
if (block.isEmpty) {
|
|
throw SampleCheckerException('$line: Empty ```dart block in sample code.');
|
|
}
|
|
if (block.first.startsWith('new ') || block.first.startsWith(_constructorRegExp)) {
|
|
_expressionId += 1;
|
|
return Section.surround(line, 'dynamic expression$_expressionId = ', block.toList(), ';');
|
|
} else if (block.first.startsWith('await ')) {
|
|
_expressionId += 1;
|
|
return Section.surround(line, 'Future<void> expression$_expressionId() async { ', block.toList(), ' }');
|
|
} else if (block.first.startsWith('class ') || block.first.startsWith('enum ')) {
|
|
return Section.fromStrings(line, block.toList());
|
|
} else if ((block.first.startsWith('_') || block.first.startsWith('final ')) && block.first.contains(' = ')) {
|
|
_expressionId += 1;
|
|
return Section.surround(line, 'void expression$_expressionId() { ', block.toList(), ' }');
|
|
} else {
|
|
final List<String> buffer = <String>[];
|
|
int subblocks = 0;
|
|
Line subline;
|
|
final List<Section> subsections = <Section>[];
|
|
for (int index = 0; index < block.length; index += 1) {
|
|
// Each section of the dart code that is either split by a blank line, or with '// ...' is
|
|
// treated as a separate code block.
|
|
if (block[index] == '' || block[index] == '// ...') {
|
|
if (subline == null)
|
|
throw SampleCheckerException('${Line('', filename: line.filename, line: line.line + index, indent: line.indent)}: '
|
|
'Unexpected blank line or "// ..." line near start of subblock in sample code.');
|
|
subblocks += 1;
|
|
subsections.add(_processBlock(subline, buffer));
|
|
buffer.clear();
|
|
assert(buffer.isEmpty);
|
|
subline = null;
|
|
} else if (block[index].startsWith('// ')) {
|
|
if (buffer.length > 1) // don't include leading comments
|
|
buffer.add('/${block[index]}'); // so that it doesn't start with "// " and get caught in this again
|
|
} else {
|
|
subline ??= Line(
|
|
block[index],
|
|
filename: line.filename,
|
|
line: line.line + index,
|
|
indent: line.indent,
|
|
);
|
|
buffer.add(block[index]);
|
|
}
|
|
}
|
|
if (subblocks > 0) {
|
|
if (subline != null) {
|
|
subsections.add(_processBlock(subline, buffer));
|
|
}
|
|
// Combine all of the subsections into one section, now that they've been processed.
|
|
return Section.combine(subsections);
|
|
} else {
|
|
return Section.fromStrings(line, block.toList());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A class to represent a line of input code.
|
|
class Line {
|
|
const Line(this.code, {this.filename, this.line, this.indent});
|
|
final String filename;
|
|
final int line;
|
|
final int indent;
|
|
final String code;
|
|
|
|
String toStringWithColumn(int column) {
|
|
if (column != null && indent != null) {
|
|
return '$filename:$line:${column + indent}: $code';
|
|
}
|
|
return toString();
|
|
}
|
|
|
|
@override
|
|
String toString() => '$filename:$line: $code';
|
|
}
|
|
|
|
/// A class to represent a section of sample code, either marked by
|
|
/// "/// ## Sample code" in the comment, or by "{@tool sample}...{@end-tool}".
|
|
class Section {
|
|
const Section(this.code);
|
|
factory Section.combine(List<Section> sections) {
|
|
final List<Line> code = <Line>[];
|
|
for (Section section in sections) {
|
|
code.addAll(section.code);
|
|
}
|
|
return Section(code);
|
|
}
|
|
factory Section.fromStrings(Line firstLine, List<String> code) {
|
|
final List<Line> codeLines = <Line>[];
|
|
for (int i = 0; i < code.length; ++i) {
|
|
codeLines.add(
|
|
Line(
|
|
code[i],
|
|
filename: firstLine.filename,
|
|
line: firstLine.line + i,
|
|
indent: firstLine.indent,
|
|
),
|
|
);
|
|
}
|
|
return Section(codeLines);
|
|
}
|
|
factory Section.surround(Line firstLine, String prefix, List<String> code, String postfix) {
|
|
assert(prefix != null);
|
|
assert(postfix != null);
|
|
final List<Line> codeLines = <Line>[];
|
|
for (int i = 0; i < code.length; ++i) {
|
|
codeLines.add(
|
|
Line(
|
|
code[i],
|
|
filename: firstLine.filename,
|
|
line: firstLine.line + i,
|
|
indent: firstLine.indent,
|
|
),
|
|
);
|
|
}
|
|
return Section(<Line>[Line(prefix)]
|
|
..addAll(codeLines)
|
|
..add(Line(postfix)));
|
|
}
|
|
Line get start => code.firstWhere((Line line) => line.filename != null);
|
|
final List<Line> code;
|
|
}
|
|
|
|
/// A class to represent a snippet in the dartdoc comments, marked by
|
|
/// "{@tool snippet ...}...{@end-tool}". Snippets are processed separately from
|
|
/// regular samples, because they must be injected into templates in order to be
|
|
/// analyzed.
|
|
class Snippet {
|
|
Snippet({this.start, List<String> input, List<String> args, this.serial}) {
|
|
this.input = <String>[]..addAll(input);
|
|
this.args = <String>[]..addAll(args);
|
|
}
|
|
final Line start;
|
|
final int serial;
|
|
List<String> input;
|
|
List<String> args;
|
|
List<String> contents;
|
|
|
|
@override
|
|
String toString() {
|
|
final StringBuffer buf = StringBuffer('snippet ${args.join(' ')}\n');
|
|
int count = start.line;
|
|
for (String line in input) {
|
|
buf.writeln(' ${count.toString().padLeft(4, ' ')}: $line');
|
|
count++;
|
|
}
|
|
return buf.toString();
|
|
}
|
|
}
|
|
|
|
/// A class representing an analysis error along with the context of the error.
|
|
///
|
|
/// Changes how it converts to a string based on the source of the error.
|
|
class AnalysisError {
|
|
const AnalysisError(
|
|
this.line,
|
|
this.column,
|
|
this.message,
|
|
this.errorCode,
|
|
this.source, {
|
|
this.snippet,
|
|
});
|
|
|
|
final int line;
|
|
final int column;
|
|
final String message;
|
|
final String errorCode;
|
|
final Line source;
|
|
final Snippet snippet;
|
|
|
|
@override
|
|
String toString() {
|
|
if (source != null) {
|
|
return '${source.toStringWithColumn(column)}\n>>> $message ($errorCode)';
|
|
} else if (snippet != null) {
|
|
return 'In snippet starting at '
|
|
'${snippet.start.filename}:${snippet.start.line}:${snippet.contents[line - 1]}\n'
|
|
'>>> $message ($errorCode)';
|
|
} else {
|
|
return '<source unknown>:$line:$column\n>>> $message ($errorCode)';
|
|
}
|
|
}
|
|
}
|