| // 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 { | |
| return _headers ??= <String>[ | |
| '// generated code', | |
| "import 'dart:async';", | |
| "import 'dart:convert';", | |
| "import 'dart:math' as math;", | |
| "import 'dart:typed_data';", | |
| "import 'dart:ui' as ui;", | |
| "import 'package:flutter_test/flutter_test.dart';", | |
| for (File file in _listDartFiles(Directory(_defaultFlutterPackage))) ...<String>[ | |
| '', | |
| '// ${file.path}', | |
| "import 'package:flutter/${path.basename(file.path)}';", | |
| ], | |
| ].map<Line>((String code) => Line(code)).toList(); | |
| } | |
| 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), | |
| ...args, | |
| ], | |
| workingDirectory: workingDirectory, | |
| ); | |
| } else { | |
| return Process.runSync( | |
| _dartExecutable, | |
| <String>[path.canonicalize(_snippetsSnapshotPath), ...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}', | |
| ...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..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'; | |
| 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 = <Line>[ | |
| ...headers, | |
| const Line(''), | |
| Line('// From: ${section.start.filename}:${section.start.line}'), | |
| ...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 = sections | |
| .expand((Section section) => section.code) | |
| .toList(); | |
| 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), | |
| ...codeLines, | |
| 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 = input.toList(); | |
| this.args = args.toList(); | |
| } | |
| 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)'; | |
| } | |
| } | |
| } |