// Copyright 2014 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. // To run this, from the root of the Flutter repository: // bin/cache/dart-sdk/bin/dart --enable-asserts dev/bots/analyze_snippet_code.dart // In general, please prefer using full inline examples in API docs. // // For documentation on creating sample code, see ../../examples/api/README.md // See also our style guide's discussion on documentation and sample code: // https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#documentation-dartdocs-javadocs-etc // // This tool is used to analyze smaller snippets of code in the API docs. // Such snippets are wrapped in ```dart ... ``` blocks, which may themselves // be wrapped in {@tool snippet} ... {@endtool} blocks to set them apart // in the rendered output. // // Such snippets: // // * If they start with `import` are treated as full application samples; avoid // doing this in general, it's better to use samples as described above. (One // exception might be in dart:ui where the sample code would end up in a // different repository which would be awkward.) // // * If they start with a comment that says `// continuing from previous example...`, // they automatically import the previous test's file. // // * If they start with a comment that says `// (e.g. in a stateful widget)`, // are analyzed after being inserted into a class that inherits from State. // // * If they start with what looks like a getter, function declaration, or // other top-level keyword (`class`, `typedef`, etc), or if they start with // the keyword `final`, they are analyzed directly. // // * If they end with a trailing semicolon or have a line starting with a // statement keyword like `while` or `try`, are analyzed after being inserted // into a function body. // // * If they start with the word `static`, are placed in a class body before // analysis. // // * Otherwise, are used as an initializer for a global variable for the // purposes of analysis; in this case, any leading label (`foo:`) // and any trailing comma are removed. // // In particular, these rules imply that starting an example with `const` means // it is an _expression_, not a top-level declaration. This is because mostly // `const` indicates a Widget. // // A line that contains just a comment with an ellipsis (`// ...`) adds an ignore // for the `non_abstract_class_inherits_abstract_member` error for the snippet. // This is useful when you're writing an example that extends an abstract class // with lots of members, but you only care to show one. // // At the top of a file you can say `// Examples can assume:` and then list some // commented-out declarations that will be included in the analysis for snippets // in that file. // // Snippets generally import all the main Flutter packages (including material // and flutter_test), as well as most core Dart packages with the usual prefixes. import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:args/args.dart'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:watcher/watcher.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 _defaultDartUiLocation = path.join(_flutterRoot, 'bin', 'cache', 'pkg', 'sky_engine', 'lib', 'ui'); final String _flutter = path.join(_flutterRoot, 'bin', Platform.isWindows ? 'flutter.bat' : 'flutter'); Future<void> main(List<String> arguments) async { bool asserts = false; assert(() { asserts = true; return true; }()); if (!asserts) { print('You must run this script with asserts enabled.'); exit(1); } int width; try { width = stdout.terminalColumns; } on StdoutException { width = 80; } final ArgParser argParser = ArgParser(usageLineLength: width); argParser.addOption( 'temp', valueHelp: 'path', 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( 'verbose', negatable: false, help: 'Print verbose output for the analysis process.', ); argParser.addOption( 'dart-ui-location', defaultsTo: _defaultDartUiLocation, valueHelp: 'path', help: 'A location where the dart:ui dart files are to be found. Defaults to ' 'the sky_engine directory installed in this flutter repo. This ' 'is typically the engine/src/flutter/lib/ui directory in an engine dev setup. ' 'Implies --include-dart-ui.', ); argParser.addFlag( 'include-dart-ui', defaultsTo: true, help: 'Includes the dart:ui code supplied by the engine in the analysis.', ); argParser.addFlag( 'help', negatable: false, help: 'Print help for this command.', ); argParser.addOption( 'interactive', abbr: 'i', valueHelp: 'file', help: 'Analyzes the snippet code in a specified file interactively.', ); final ArgResults parsedArguments; try { parsedArguments = argParser.parse(arguments); } on FormatException catch (e) { print(e.message); print('dart --enable-asserts analyze_snippet_code.dart [options]'); print(argParser.usage); exit(1); } if (parsedArguments['help'] as bool) { print('dart --enable-asserts analyze_snippet_code.dart [options]'); print(argParser.usage); exit(0); } Directory flutterPackage; if (parsedArguments.rest.length == 1) { // Used for testing. flutterPackage = Directory(parsedArguments.rest.single); } else { flutterPackage = Directory(_defaultFlutterPackage); } final bool includeDartUi = parsedArguments.wasParsed('dart-ui-location') || parsedArguments['include-dart-ui'] as bool; late Directory dartUiLocation; if (((parsedArguments['dart-ui-location'] ?? '') as String).isNotEmpty) { dartUiLocation = Directory( path.absolute(parsedArguments['dart-ui-location'] as String)); } else { dartUiLocation = Directory(_defaultDartUiLocation); } if (!dartUiLocation.existsSync()) { stderr.writeln('Unable to find dart:ui directory ${dartUiLocation.path}'); exit(1); } if (parsedArguments['interactive'] != null) { await _runInteractive( flutterPackage: flutterPackage, tempDirectory: parsedArguments['temp'] as String?, filePath: parsedArguments['interactive'] as String, dartUiLocation: includeDartUi ? dartUiLocation : null, ); } else { if (await _SnippetChecker( flutterPackage, tempDirectory: parsedArguments['temp'] as String?, verbose: parsedArguments['verbose'] as bool, dartUiLocation: includeDartUi ? dartUiLocation : null, ).checkSnippets()) { stderr.writeln('See the documentation at the top of dev/bots/analyze_snippet_code.dart for details.'); exit(1); } } } /// A class to represent a line of input code. @immutable class _Line { const _Line({this.code = '', this.line = -1, this.indent = 0}) : generated = false; const _Line.generated({this.code = ''}) : line = -1, indent = 0, generated = true; final int line; final int indent; final String code; final bool generated; String asLocation(String filename, int column) { return '$filename:$line:${column + indent}'; } @override String toString() => code; @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is _Line && other.line == line && other.indent == indent && other.code == code && other.generated == generated; } @override int get hashCode => Object.hash(line, indent, code, generated); } @immutable class _ErrorBase implements Comparable<Object> { const _ErrorBase({this.file, this.line, this.column}); final String? file; final int? line; final int? column; @override int compareTo(Object other) { if (other is _ErrorBase) { if (other.file != file) { if (other.file == null) { return -1; } if (file == null) { return 1; } return file!.compareTo(other.file!); } if (other.line != line) { if (other.line == null) { return -1; } if (line == null) { return 1; } return line!.compareTo(other.line!); } if (other.column != column) { if (other.column == null) { return -1; } if (column == null) { return 1; } return column!.compareTo(other.column!); } } return toString().compareTo(other.toString()); } } @immutable class _SnippetCheckerException extends _ErrorBase implements Exception { const _SnippetCheckerException(this.message, {super.file, super.line}); final String message; @override String toString() { if (file != null || line != null) { final String fileStr = file == null ? '' : '$file:'; final String lineStr = line == null ? '' : '$line:'; return '$fileStr$lineStr $message'; } else { return message; } } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is _SnippetCheckerException && other.message == message && other.file == file && other.line == line; } @override int get hashCode => Object.hash(message, file, line); } /// 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. @immutable class _AnalysisError extends _ErrorBase { const _AnalysisError( String file, int line, int column, this.message, this.errorCode, this.source, ) : super(file: file, line: line, column: column); final String message; final String errorCode; final _Line source; @override String toString() { return '${source.asLocation(file!, column!)}: $message ($errorCode)'; } @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) { return false; } return other is _AnalysisError && other.file == file && other.line == line && other.column == column && other.message == message && other.errorCode == errorCode && other.source == source; } @override int get hashCode => Object.hash(file, line, column, message, errorCode, source); } /// Checks 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. class _SnippetChecker { /// Creates a [_SnippetChecker]. /// /// The positional argument is the path to the package directory for the /// flutter package within the Flutter root dir. /// /// The optional `tempDirectory` argument supplies the location for the /// temporary files to be written and analyzed. If not supplied, it defaults /// to a system generated temp directory. /// /// The optional `verbose` argument indicates whether or not status output /// should be emitted while doing the check. /// /// The optional `dartUiLocation` argument indicates the location of the /// `dart:ui` code to be analyzed along with the framework code. If not /// supplied, the default location of the `dart:ui` code in the Flutter /// repository is used (i.e. "<flutter repo>/bin/cache/pkg/sky_engine/lib/ui"). _SnippetChecker( this._flutterPackage, { String? tempDirectory, this.verbose = false, Directory? dartUiLocation, }) : _tempDirectory = _createTempDirectory(tempDirectory), _keepTmp = tempDirectory != null, _dartUiLocation = dartUiLocation; /// 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. static final RegExp _dartDocSnippetBeginRegex = RegExp(r'{@tool ([^ }]+)(?:| ([^}]*))}'); /// A RegExp that matches the end of a dartdoc snippet. static final RegExp _dartDocSnippetEndRegex = RegExp(r'{@end-tool}'); /// A RegExp that matches the start of a code block within dartdoc. static final RegExp _codeBlockStartRegex = RegExp(r'^ */// *```dart$'); /// A RegExp that matches the start of a code block within a regular comment. /// Such blocks are not analyzed. They can be used to give sample code for /// internal (private) APIs where visibilty would make analyzing the sample /// code problematic. static final RegExp _uncheckedCodeBlockStartRegex = RegExp(r'^ *// *```dart$'); /// A RegExp that matches the end of a code block within dartdoc. static final RegExp _codeBlockEndRegex = RegExp(r'^ */// *``` *$'); /// A RegExp that matches a line starting with a comment or annotation static final RegExp _nonCodeRegExp = RegExp(r'^ *(//|@)'); /// A RegExp that matches things that look like a function declaration. static final RegExp _maybeFunctionDeclarationRegExp = RegExp(r'^([A-Z][A-Za-z0-9_<>, ?]*|int|double|num|bool|void)\?? (_?[a-z][A-Za-z0-9_<>]*)\(.*'); /// A RegExp that matches things that look like a getter. static final RegExp _maybeGetterDeclarationRegExp = RegExp(r'^([A-Z][A-Za-z0-9_<>?]*|int|double|num|bool)\?? get (_?[a-z][A-Za-z0-9_<>]*) (?:=>|{).*'); /// A RegExp that matches an identifier followed by a colon, potentially with two spaces of indent. static final RegExp _namedArgumentRegExp = RegExp(r'^(?: )?([a-zA-Z0-9_]+): '); /// A RegExp that matches things that look unambiguously like top-level declarations. static final RegExp _topLevelDeclarationRegExp = RegExp(r'^(abstract|class|mixin|enum|typedef|final|extension) '); /// A RegExp that matches things that look unambiguously like statements. static final RegExp _statementRegExp = RegExp(r'^(if|while|for|try) '); /// A RegExp that matches things that look unambiguously like declarations that must be in a class. static final RegExp _classDeclarationRegExp = RegExp(r'^(static) '); /// A RegExp that matches a line that ends with a comma (and maybe a comment) static final RegExp _trailingCommaRegExp = RegExp(r'^(.*),(| *//.*)$'); /// A RegExp that matches a line that ends with a semicolon (and maybe a comment) static final RegExp _trailingSemicolonRegExp = RegExp(r'^(.*);(| *//.*)$'); /// A RegExp that matches a line that ends with a closing blace (and maybe a comment) static final RegExp _trailingCloseBraceRegExp = RegExp(r'^(.*)}(| *//.*)$'); /// A RegExp that matches a line that only contains a commented-out ellipsis /// (and maybe whitespace). Has three groups: before, ellipsis, after. static final RegExp _ellipsisRegExp = RegExp(r'^( *)(// \.\.\.)( *)$'); /// Whether or not to print verbose output. final bool verbose; /// 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 unless _keepTmp is true. final Directory _tempDirectory; /// The package directory for the flutter package within the flutter root dir. final Directory _flutterPackage; /// The directory for the dart:ui code to be analyzed with the flutter code. /// /// If this is null, then no dart:ui code is included in the analysis. It /// defaults to the location inside of the flutter bin/cache directory that /// contains the dart:ui code supplied by the engine. final Directory? _dartUiLocation; 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(); } static const List<String> ignoresDirectives = <String>[ '// ignore_for_file: duplicate_ignore', '// ignore_for_file: directives_ordering', '// ignore_for_file: prefer_final_locals', '// ignore_for_file: unnecessary_import', '// ignore_for_file: unreachable_from_main', '// ignore_for_file: unused_element', '// ignore_for_file: unused_local_variable', ]; /// Computes the headers needed for each snippet file. List<_Line> get headersWithoutImports { return _headersWithoutImports ??= ignoresDirectives.map<_Line>((String code) => _Line.generated(code: code)).toList(); } List<_Line>? _headersWithoutImports; /// Computes the headers needed for each snippet file. List<_Line> get headersWithImports { return _headersWithImports ??= <String>[ ...ignoresDirectives, '// ignore_for_file: unused_import', "import 'dart:async';", "import 'dart:convert';", "import 'dart:io';", "import 'dart:math' as math;", "import 'dart:typed_data';", "import 'dart:ui' as ui;", "import 'package:flutter_test/flutter_test.dart';", for (final File file in _listDartFiles(Directory(_defaultFlutterPackage))) "import 'package:flutter/${path.basename(file.path)}';", ].map<_Line>((String code) => _Line.generated(code: code)).toList(); } List<_Line>? _headersWithImports; /// Checks all the snippets in the Dart files in [_flutterPackage] for errors. /// Returns true if any errors are found, false otherwise. Future<bool> checkSnippets() async { final Map<String, _SnippetFile> snippets = <String, _SnippetFile>{}; if (_dartUiLocation != null && !_dartUiLocation!.existsSync()) { stderr.writeln('Unable to analyze engine dart snippets at ${_dartUiLocation!.path}.'); } final List<File> filesToAnalyze = <File>[ ..._listDartFiles(_flutterPackage, recursive: true), if (_dartUiLocation != null && _dartUiLocation!.existsSync()) ..._listDartFiles(_dartUiLocation!, recursive: true), ]; final Set<Object> errors = <Object>{}; errors.addAll(await _extractSnippets(filesToAnalyze, snippetMap: snippets)); errors.addAll(_analyze(snippets)); (errors.toList()..sort()).map(_stringify).forEach(stderr.writeln); stderr.writeln('Found ${errors.length} snippet code errors.'); cleanupTempDirectory(); return errors.isNotEmpty; } static Directory _createTempDirectory(String? tempArg) { if (tempArg != null) { final Directory tempDirectory = Directory(path.join(Directory.systemTemp.absolute.path, path.basename(tempArg))); if (path.basename(tempArg) != tempArg) { 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(); return tempDirectory; } return Directory.systemTemp.createTempSync('flutter_analyze_snippet_code.'); } void recreateTempDirectory() { _tempDirectory.deleteSync(recursive: true); _tempDirectory.createSync(); } void cleanupTempDirectory() { 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'); } } } /// 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; } /// Extracts the snippets from the Dart files in [files], writes them /// to disk, and adds them to the [snippetMap]. Future<List<Object>> _extractSnippets( List<File> files, { required Map<String, _SnippetFile> snippetMap, }) async { final List<Object> errors = <Object>[]; _SnippetFile? lastExample; for (final File file in files) { try { final String relativeFilePath = path.relative(file.path, from: _flutterRoot); final List<String> fileLines = file.readAsLinesSync(); final List<_Line> ignorePreambleLinesOnly = <_Line>[]; final List<_Line> preambleLines = <_Line>[]; bool inExamplesCanAssumePreamble = false; // Whether or not we're in the file-wide preamble section ("Examples can assume"). bool inToolSection = false; // Whether or not we're in a code snippet bool inDartSection = false; // Whether or not we're in a '```dart' segment. bool inOtherBlock = false; // Whether we're in some other '```' segment. int lineNumber = 0; final List<String> block = <String>[]; late _Line startLine; for (final String line in fileLines) { lineNumber += 1; final String trimmedLine = line.trim(); if (inExamplesCanAssumePreamble) { if (line.isEmpty) { // end of preamble inExamplesCanAssumePreamble = false; } else if (!line.startsWith('// ')) { throw _SnippetCheckerException('Unexpected content in snippet code preamble.', file: relativeFilePath, line: lineNumber); } else { final _Line newLine = _Line(line: lineNumber, indent: 3, code: line.substring(3)); preambleLines.add(newLine); if (line.startsWith('// // ignore_for_file: ')) { ignorePreambleLinesOnly.add(newLine); } } } else if (trimmedLine.startsWith(_dartDocSnippetEndRegex)) { if (!inToolSection) { throw _SnippetCheckerException('{@tool-end} marker detected without matching {@tool}.', file: relativeFilePath, line: lineNumber); } if (inDartSection) { throw _SnippetCheckerException("Dart section didn't terminate before end of snippet", file: relativeFilePath, line: lineNumber); } inToolSection = false; } else if (inDartSection) { final RegExpMatch? snippetMatch = _dartDocSnippetBeginRegex.firstMatch(trimmedLine); if (snippetMatch != null) { throw _SnippetCheckerException('{@tool} found inside Dart section', file: relativeFilePath, line: lineNumber); } if (trimmedLine.startsWith(_codeBlockEndRegex)) { inDartSection = false; final _SnippetFile snippet = _processBlock(startLine, block, preambleLines, ignorePreambleLinesOnly, relativeFilePath, lastExample); final String path = _writeSnippetFile(snippet).path; assert(!snippetMap.containsKey(path)); snippetMap[path] = snippet; block.clear(); lastExample = snippet; } else if (trimmedLine == _dartDocPrefix) { block.add(''); } else { final int index = line.indexOf(_dartDocPrefixWithSpace); if (index < 0) { throw _SnippetCheckerException( 'Dart section inexplicably did not contain "$_dartDocPrefixWithSpace" prefix.', file: relativeFilePath, line: lineNumber, ); } block.add(line.substring(index + 4)); } } else if (trimmedLine.startsWith(_codeBlockStartRegex)) { if (inOtherBlock) { throw _SnippetCheckerException( 'Found "```dart" section in another "```" section.', file: relativeFilePath, line: lineNumber, ); } assert(block.isEmpty); startLine = _Line( line: lineNumber + 1, indent: line.indexOf(_dartDocPrefixWithSpace) + _dartDocPrefixWithSpace.length, ); inDartSection = true; } else if (line.contains('```')) { if (inOtherBlock) { inOtherBlock = false; } else if (line.contains('```yaml') || line.contains('```ascii') || line.contains('```java') || line.contains('```objectivec') || line.contains('```kotlin') || line.contains('```swift') || line.contains('```glsl') || line.contains('```csv')) { inOtherBlock = true; } else if (line.startsWith(_uncheckedCodeBlockStartRegex)) { // this is an intentionally-unchecked block that doesn't appear in the API docs. inOtherBlock = true; } else { throw _SnippetCheckerException( 'Found "```" in code but it did not match $_codeBlockStartRegex so something is wrong. Line was: "$line"', file: relativeFilePath, line: lineNumber, ); } } else if (!inToolSection) { final RegExpMatch? snippetMatch = _dartDocSnippetBeginRegex.firstMatch(trimmedLine); if (snippetMatch != null) { inToolSection = true; } else if (line == '// Examples can assume:') { if (inToolSection || inDartSection) { throw _SnippetCheckerException( '"// Examples can assume:" sections must come before all sample code.', file: relativeFilePath, line: lineNumber, ); } inExamplesCanAssumePreamble = true; } } } } on _SnippetCheckerException catch (e) { errors.add(e); } } return errors; } /// Process one block of snippet code (the part inside of "```" markers). Uses /// a primitive heuristic to make snippet blocks into valid Dart code. /// /// `block` argument will get mutated, but is copied before this function returns. _SnippetFile _processBlock(_Line startingLine, List<String> block, List<_Line> assumptions, List<_Line> ignoreAssumptionsOnly, String filename, _SnippetFile? lastExample) { if (block.isEmpty) { throw _SnippetCheckerException('${startingLine.asLocation(filename, 0)}: Empty ```dart block in snippet code.'); } bool hasEllipsis = false; for (int index = 0; index < block.length; index += 1) { final Match? match = _ellipsisRegExp.matchAsPrefix(block[index]); if (match != null) { hasEllipsis = true; // in case the "..." is implying some overridden members, add an ignore to silence relevant warnings break; } } bool hasStatefulWidgetComment = false; bool importPreviousExample = false; int index = startingLine.line; for (final String line in block) { if (line == '// (e.g. in a stateful widget)') { if (hasStatefulWidgetComment) { throw _SnippetCheckerException('Example says it is in a stateful widget twice.', file: filename, line: index); } hasStatefulWidgetComment = true; } else if (line == '// continuing from previous example...') { if (importPreviousExample) { throw _SnippetCheckerException('Example says it continues from the previous example twice.', file: filename, line: index); } if (lastExample == null) { throw _SnippetCheckerException('Example says it continues from the previous example but it is the first example in the file.', file: filename, line: index); } importPreviousExample = true; } else { break; } index += 1; } final List<_Line> preamble; if (importPreviousExample) { preamble = <_Line>[ ...lastExample!.code, // includes assumptions if (hasEllipsis || hasStatefulWidgetComment) const _Line.generated(code: '// ignore_for_file: non_abstract_class_inherits_abstract_member'), ]; } else { preamble = <_Line>[ if (hasEllipsis || hasStatefulWidgetComment) const _Line.generated(code: '// ignore_for_file: non_abstract_class_inherits_abstract_member'), ...assumptions, ]; } final String firstCodeLine = block.firstWhere((String line) => !line.startsWith(_nonCodeRegExp)).trim(); final String lastCodeLine = block.lastWhere((String line) => !line.startsWith(_nonCodeRegExp)).trim(); if (firstCodeLine.startsWith('import ')) { // probably an entire program if (importPreviousExample) { throw _SnippetCheckerException('An example cannot both be self-contained (with its own imports) and say it wants to import the previous example.', file: filename, line: startingLine.line); } if (hasStatefulWidgetComment) { throw _SnippetCheckerException('An example cannot both be self-contained (with its own imports) and say it is in a stateful widget.', file: filename, line: startingLine.line); } return _SnippetFile.fromStrings( startingLine, block.toList(), importPreviousExample ? <_Line>[] : headersWithoutImports, <_Line>[ ...ignoreAssumptionsOnly, if (hasEllipsis) const _Line.generated(code: '// ignore_for_file: non_abstract_class_inherits_abstract_member'), ], 'self-contained program', filename, ); } else if (hasStatefulWidgetComment) { return _SnippetFile.fromStrings( startingLine, prefix: 'class _State extends State<StatefulWidget> {', block.toList(), postfix: '}', importPreviousExample ? <_Line>[] : headersWithImports, preamble, 'stateful widget', filename, ); } else if (firstCodeLine.startsWith(_maybeGetterDeclarationRegExp) || (firstCodeLine.startsWith(_maybeFunctionDeclarationRegExp) && lastCodeLine.startsWith(_trailingCloseBraceRegExp)) || block.any((String line) => line.startsWith(_topLevelDeclarationRegExp))) { // probably a top-level declaration return _SnippetFile.fromStrings( startingLine, block.toList(), importPreviousExample ? <_Line>[] : headersWithImports, preamble, 'top-level declaration', filename, ); } else if (lastCodeLine.startsWith(_trailingSemicolonRegExp) || block.any((String line) => line.startsWith(_statementRegExp))) { // probably a statement return _SnippetFile.fromStrings( startingLine, prefix: 'Future<void> function() async {', block.toList(), postfix: '}', importPreviousExample ? <_Line>[] : headersWithImports, preamble, 'statement', filename, ); } else if (firstCodeLine.startsWith(_classDeclarationRegExp)) { // probably a static method return _SnippetFile.fromStrings( startingLine, prefix: 'class Class {', block.toList(), postfix: '}', importPreviousExample ? <_Line>[] : headersWithImports, <_Line>[ ...preamble, const _Line.generated(code: '// ignore_for_file: avoid_classes_with_only_static_members'), ], 'class declaration', filename, ); } else { // probably an expression if (firstCodeLine.startsWith(_namedArgumentRegExp)) { // This is for snippets like: // // ```dart // // bla bla // foo: 2, // ``` // // This section removes the label. for (int index = 0; index < block.length; index += 1) { final Match? prefix = _namedArgumentRegExp.matchAsPrefix(block[index]); if (prefix != null) { block[index] = block[index].substring(prefix.group(0)!.length); break; } } } // strip trailing comma, if any for (int index = block.length - 1; index >= 0; index -= 1) { if (!block[index].startsWith(_nonCodeRegExp)) { final Match? lastLine = _trailingCommaRegExp.matchAsPrefix(block[index]); if (lastLine != null) { block[index] = lastLine.group(1)! + lastLine.group(2)!; } break; } } return _SnippetFile.fromStrings( startingLine, prefix: 'dynamic expression = ', block.toList(), postfix: ';', importPreviousExample ? <_Line>[] : headersWithImports, preamble, 'expression', filename, ); } } /// Creates the configuration files necessary for the analyzer to consider /// the temporary directory a package, and sets which lint rules to enforce. void _createConfigurationFiles() { final File targetPubSpec = File(path.join(_tempDirectory.path, 'pubspec.yaml')); if (!targetPubSpec.existsSync()) { // Copying pubspec.yaml from examples/api into temp directory. final File sourcePubSpec = File(path.join(_flutterRoot, 'examples', 'api', 'pubspec.yaml')); if (!sourcePubSpec.existsSync()) { throw 'Cannot find pubspec.yaml at ${sourcePubSpec.path}, which is also used to analyze code snippets.'; } sourcePubSpec.copySync(targetPubSpec.path); } final File targetAnalysisOptions = File(path.join(_tempDirectory.path, 'analysis_options.yaml')); if (!targetAnalysisOptions.existsSync()) { // Use the same analysis_options.yaml configuration that's used for examples/api. final File sourceAnalysisOptions = File(path.join(_flutterRoot, 'examples', 'api', 'analysis_options.yaml')); if (!sourceAnalysisOptions.existsSync()) { throw 'Cannot find analysis_options.yaml at ${sourceAnalysisOptions.path}, which is also used to analyze code snippets.'; } targetAnalysisOptions ..createSync(recursive: true) ..writeAsStringSync('include: ${sourceAnalysisOptions.absolute.path}'); } } /// Writes out a snippet section to the disk and returns the file. File _writeSnippetFile(_SnippetFile snippetFile) { final String snippetFileId = _createNameFromSource('snippet', snippetFile.filename, snippetFile.indexLine); final File outputFile = File(path.join(_tempDirectory.path, '$snippetFileId.dart'))..createSync(recursive: true); final String contents = snippetFile.code.map<String>((_Line line) => line.code).join('\n').trimRight(); outputFile.writeAsStringSync('$contents\n'); return outputFile; } /// Starts the analysis phase of checking the snippets by invoking the analyzer /// and parsing its output. Returns the errors, if any. List<Object> _analyze(Map<String, _SnippetFile> snippets) { final List<String> analyzerOutput = _runAnalyzer(); final List<Object> errors = <Object>[]; final String kBullet = Platform.isWindows ? ' - ' : ' • '; // RegExp to match an error output line of the analyzer. final RegExp errorPattern = RegExp( '^ *(?<type>[a-z]+)' '$kBullet(?<description>.+)' '$kBullet(?<file>.+):(?<line>[0-9]+):(?<column>[0-9]+)' '$kBullet(?<code>[-a-z_]+)\$', caseSensitive: false, ); for (final String error in analyzerOutput) { final RegExpMatch? match = errorPattern.firstMatch(error); if (match == null) { errors.add(_SnippetCheckerException('Could not parse analyzer output: $error')); continue; } final String message = match.namedGroup('description')!; final File file = File(path.join(_tempDirectory.path, match.namedGroup('file'))); final List<String> fileContents = file.readAsLinesSync(); final String lineString = match.namedGroup('line')!; final String columnString = match.namedGroup('column')!; final String errorCode = match.namedGroup('code')!; final int lineNumber = int.parse(lineString, radix: 10); final int columnNumber = int.parse(columnString, radix: 10); if (lineNumber < 1 || lineNumber > fileContents.length + 1) { errors.add(_AnalysisError( file.path, lineNumber, columnNumber, message, errorCode, _Line(line: lineNumber), )); errors.add(_SnippetCheckerException('Error message points to non-existent line number: $error', file: file.path, line: lineNumber)); continue; } final _SnippetFile? snippet = snippets[file.path]; if (snippet == null) { errors.add(_SnippetCheckerException( "Unknown section for ${file.path}. Maybe the temporary directory wasn't empty?", file: file.path, line: lineNumber, )); continue; } if (fileContents.length != snippet.code.length) { errors.add(_SnippetCheckerException( 'Unexpected file contents for ${file.path}. File has ${fileContents.length} lines but we generated ${snippet.code.length} lines:\n${snippet.code.join("\n")}', file: file.path, line: lineNumber, )); continue; } late final _Line actualSource; late final int actualLine; late final int actualColumn; late final String actualMessage; int delta = 0; while (true) { // find the nearest non-generated line to the error if ((lineNumber - delta > 0) && (lineNumber - delta <= snippet.code.length) && !snippet.code[lineNumber - delta - 1].generated) { actualSource = snippet.code[lineNumber - delta - 1]; actualLine = actualSource.line; actualColumn = delta == 0 ? columnNumber : actualSource.code.length + 1; actualMessage = delta == 0 ? message : '$message -- in later generated code'; break; } if ((lineNumber + delta < snippet.code.length) && (lineNumber + delta >= 0) && !snippet.code[lineNumber + delta].generated) { actualSource = snippet.code[lineNumber + delta]; actualLine = actualSource.line; actualColumn = 1; actualMessage = '$message -- in earlier generated code'; break; } delta += 1; assert((lineNumber - delta > 0) || (lineNumber + delta < snippet.code.length)); } errors.add(_AnalysisError( snippet.filename, actualLine, actualColumn, '$actualMessage (${snippet.generatorComment})', errorCode, actualSource, )); } return errors; } /// Invokes the analyzer on the given [directory] and returns the stdout (with some lines filtered). List<String> _runAnalyzer() { _createConfigurationFiles(); // Run pub get to avoid output from getting dependencies in the analyzer // output. Process.runSync( _flutter, <String>['pub', 'get'], workingDirectory: _tempDirectory.absolute.path, ); final ProcessResult result = Process.runSync( _flutter, <String>['--no-wrap', 'analyze', '--no-preamble', '--no-congratulate', '.'], workingDirectory: _tempDirectory.absolute.path, ); final List<String> stderr = result.stderr.toString().trim().split('\n'); final List<String> stdout = result.stdout.toString().trim().split('\n'); // Remove output from building the flutter tool. stderr.removeWhere((String line) { return line.startsWith('Building flutter tool...') || line.startsWith('Waiting for another flutter command to release the startup lock...') || line.startsWith('Flutter assets will be downloaded from '); }); // Check out the stderr to see if the analyzer had it's own issues. if (stderr.isNotEmpty && stderr.first.contains(RegExp(r' issues? found\. \(ran in '))) { stderr.removeAt(0); if (stderr.isNotEmpty && stderr.last.isEmpty) { stderr.removeLast(); } } if (stderr.isNotEmpty && stderr.any((String line) => line.isNotEmpty)) { throw _SnippetCheckerException('Cannot analyze dartdocs; unexpected error output:\n$stderr'); } if (stdout.isNotEmpty && stdout.first == 'Building flutter tool...') { stdout.removeAt(0); } if (stdout.isNotEmpty && stdout.first.isEmpty) { stdout.removeAt(0); } return stdout; } } /// A class to represent a section of snippet code, marked by "```dart ... ```", that ends up /// in a file we then analyze (each snippet is in its own file). class _SnippetFile { const _SnippetFile(this.code, this.generatorComment, this.filename, this.indexLine); factory _SnippetFile.fromLines( List<_Line> code, List<_Line> headers, List<_Line> preamble, String generatorComment, String filename, ) { while (code.isNotEmpty && code.last.code.isEmpty) { code.removeLast(); } assert(code.isNotEmpty); final _Line firstLine = code.firstWhere((_Line line) => !line.generated); return _SnippetFile( <_Line>[ ...headers, const _Line.generated(), // blank line if (preamble.isNotEmpty) ...preamble, if (preamble.isNotEmpty) const _Line.generated(), // blank line _Line.generated(code: '// From: $filename:${firstLine.line}'), ...code, ], generatorComment, filename, firstLine.line, ); } factory _SnippetFile.fromStrings( _Line firstLine, List<String> code, List<_Line> headers, List<_Line> preamble, String generatorComment, String filename, { String? prefix, String? postfix, }) { final List<_Line> codeLines = <_Line>[ if (prefix != null) _Line.generated(code: prefix), for (int i = 0; i < code.length; i += 1) _Line(code: code[i], line: firstLine.line + i, indent: firstLine.indent), if (postfix != null) _Line.generated(code: postfix), ]; return _SnippetFile.fromLines(codeLines, headers, preamble, generatorComment, filename); } final List<_Line> code; final String generatorComment; final String filename; final int indexLine; } Future<void> _runInteractive({ required String? tempDirectory, required Directory flutterPackage, required String filePath, required Directory? dartUiLocation, }) async { filePath = path.isAbsolute(filePath) ? filePath : path.join(path.current, filePath); final File file = File(filePath); if (!file.existsSync()) { stderr.writeln('Specified file ${file.absolute.path} does not exist or is not a file.'); exit(1); } if (!path.isWithin(_flutterRoot, file.absolute.path) && (dartUiLocation == null || !path.isWithin(dartUiLocation.path, file.absolute.path))) { stderr.writeln( 'Specified file ${file.absolute.path} is not within the flutter root: ' "$_flutterRoot${dartUiLocation != null ? ' or the dart:ui location: $dartUiLocation' : ''}" ); exit(1); } print('Starting up in interactive mode on ${path.relative(filePath, from: _flutterRoot)} ...'); print('Type "q" to quit, or "r" to force a reload.'); final _SnippetChecker checker = _SnippetChecker(flutterPackage, tempDirectory: tempDirectory) .._createConfigurationFiles(); ProcessSignal.sigint.watch().listen((_) { checker.cleanupTempDirectory(); exit(0); }); bool busy = false; Future<void> rerun() async { assert(!busy); try { busy = true; print('\nAnalyzing...'); checker.recreateTempDirectory(); final Map<String, _SnippetFile> snippets = <String, _SnippetFile>{}; final Set<Object> errors = <Object>{}; errors.addAll(await checker._extractSnippets(<File>[file], snippetMap: snippets)); errors.addAll(checker._analyze(snippets)); stderr.writeln('\u001B[2J\u001B[H'); // Clears the old results from the terminal. if (errors.isNotEmpty) { (errors.toList()..sort()).map(_stringify).forEach(stderr.writeln); stderr.writeln('Found ${errors.length} errors.'); } else { stderr.writeln('No issues found.'); } } finally { busy = false; } } await rerun(); stdin.lineMode = false; stdin.echoMode = false; stdin.transform(utf8.decoder).listen((String input) async { switch (input.trim()) { case 'q': checker.cleanupTempDirectory(); exit(0); case 'r': if (!busy) { rerun(); } break; } }); Watcher(file.absolute.path).events.listen((_) => rerun()); } String _stringify(Object object) => object.toString();