| // 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. |
| |
| // See ../snippets/README.md for documentation. |
| |
| // To run this, from the root of the Flutter repository: |
| // bin/cache/dart-sdk/bin/dart dev/bots/analyze_snippet_code.dart |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:args/args.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 { |
| final ArgParser argParser = ArgParser(); |
| argParser.addOption( |
| 'temp', |
| 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, |
| 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', |
| help: 'Analyzes the snippet code in the specified file interactively.', |
| ); |
| |
| final ArgResults parsedArguments = argParser.parse(arguments); |
| |
| if (parsedArguments['help'] as bool) { |
| print(argParser.usage); |
| print('See dev/snippets/README.md for documentation.'); |
| 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); |
| } |
| |
| Directory? tempDirectory; |
| if (parsedArguments.wasParsed('temp')) { |
| final String tempArg = parsedArguments['temp'] as String; |
| 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(); |
| } |
| |
| if (parsedArguments['interactive'] != null) { |
| await _runInteractive( |
| tempDir: tempDirectory, |
| flutterPackage: flutterPackage, |
| filePath: parsedArguments['interactive'] as String, |
| dartUiLocation: includeDartUi ? dartUiLocation : null, |
| ); |
| } else { |
| try { |
| exitCode = await _SnippetChecker( |
| flutterPackage, |
| tempDirectory: tempDirectory, |
| verbose: parsedArguments['verbose'] as bool, |
| dartUiLocation: includeDartUi ? dartUiLocation : null, |
| ).checkSnippets(); |
| } on _SnippetCheckerException catch (e) { |
| stderr.write(e); |
| exit(1); |
| } |
| } |
| } |
| |
| class _SnippetCheckerException implements Exception { |
| _SnippetCheckerException(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'; |
| } |
| } |
| } |
| |
| class _AnalysisResult { |
| const _AnalysisResult(this.exitCode, this.errors); |
| final int exitCode; |
| final Map<String, List<_AnalysisError>> errors; |
| } |
| |
| /// 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, { |
| Directory? tempDirectory, |
| this.verbose = false, |
| Directory? dartUiLocation, |
| }) : _tempDirectory = tempDirectory ?? Directory.systemTemp.createTempSync('flutter_analyze_snippet_code.'), |
| _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 snippet(?:| ([^}]*))}'); |
| |
| /// 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'///\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<>._]*\('); |
| |
| /// A RegExp that matches a dart version specification in an example preamble. |
| static final RegExp _dartVersionRegExp = RegExp(r'\/\/ \/\/ @dart = ([0-9]+\.[0-9]+)'); |
| |
| /// 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. |
| 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; |
| |
| /// A serial number so that we can create unique expression names when we |
| /// generate them. |
| int _expressionId = 0; |
| |
| 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 snippet file. |
| List<_Line> get headers { |
| return _headers ??= <String>[ |
| '// ignore_for_file: directives_ordering', |
| '// ignore_for_file: unnecessary_import', |
| '// ignore_for_file: unused_import', |
| '// ignore_for_file: unused_element', |
| '// ignore_for_file: unused_local_variable', |
| "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 (final File file in _listDartFiles(Directory(_defaultFlutterPackage))) |
| "import 'package:flutter/${path.basename(file.path)}';", |
| ].map<_Line>((String code) => _Line.generated(code: code, filename: 'headers')).toList(); |
| } |
| |
| List<_Line>? _headers; |
| |
| /// Checks all the snippets in the Dart files in [_flutterPackage] for errors. |
| Future<int> checkSnippets() async { |
| _AnalysisResult? analysisResult; |
| try { |
| final Map<String, _Section> sections = <String, _Section>{}; |
| 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), |
| ]; |
| await _extractSnippets(filesToAnalyze, sectionMap: sections); |
| analysisResult = _analyze(_tempDirectory, sections); |
| } finally { |
| if (analysisResult != null && analysisResult.errors.isNotEmpty) { |
| for (final String filePath in analysisResult.errors.keys) { |
| analysisResult.errors[filePath]!.forEach(stderr.writeln); |
| } |
| stderr.writeln('\nFound ${analysisResult.errors.length} snippet 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'); |
| } |
| } |
| } |
| return analysisResult.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; |
| } |
| |
| /// Extracts the snippets from the Dart files in [files], writes them |
| /// to disk, and adds them to the [sectionMap]. |
| Future<void> _extractSnippets( |
| List<File> files, { |
| required Map<String, _Section> sectionMap, |
| bool silent = false, |
| }) async { |
| final List<_Section> sections = <_Section>[]; |
| |
| for (final File file in files) { |
| final String relativeFilePath = path.relative(file.path, from: _flutterRoot); |
| final List<String> snippetLine = 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 snippet |
| bool inSnippetSection = false; |
| // Whether or not we're in a '```dart' segment. |
| bool inDart = false; |
| String? dartVersionOverride; |
| int lineNumber = 0; |
| final List<String> block = <String>[]; |
| late _Line startLine; |
| for (final String line in snippetLine) { |
| lineNumber += 1; |
| final String trimmedLine = line.trim(); |
| if (inPreamble) { |
| if (line.isEmpty) { |
| inPreamble = false; |
| // If there's only a dartVersionOverride in the preamble, don't add |
| // it as a section. The dartVersionOverride was processed below. |
| if (dartVersionOverride == null || block.isNotEmpty) { |
| preambleSections.add(_processBlock(startLine, block)); |
| } |
| block.clear(); |
| } else if (!line.startsWith('// ')) { |
| throw _SnippetCheckerException('Unexpected content in snippet code preamble.', file: relativeFilePath, line: lineNumber); |
| } else if (_dartVersionRegExp.hasMatch(line)) { |
| dartVersionOverride = line.substring(3); |
| } else { |
| block.add(line.substring(3)); |
| } |
| } else if (inSnippetSection) { |
| if (_dartDocSnippetEndRegex.hasMatch(trimmedLine)) { |
| if (inDart) { |
| throw _SnippetCheckerException("Dart section didn't terminate before end of snippet", file: relativeFilePath, line: lineNumber); |
| } |
| inSnippetSection = false; |
| } |
| if (inDart) { |
| if (_codeBlockEndRegex.hasMatch(trimmedLine)) { |
| inDart = false; |
| final _Section processed = _processBlock(startLine, block); |
| final _Section combinedSection = preambleSections.isEmpty ? processed : _Section.combine(preambleSections..add(processed)); |
| sections.add(combinedSection.copyWith(dartVersionOverride: dartVersionOverride)); |
| block.clear(); |
| } 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 (_codeBlockStartRegex.hasMatch(trimmedLine)) { |
| assert(block.isEmpty); |
| startLine = _Line( |
| filename: relativeFilePath, |
| line: lineNumber + 1, |
| indent: line.indexOf(_dartDocPrefixWithSpace) + _dartDocPrefixWithSpace.length, |
| ); |
| inDart = true; |
| } |
| } |
| if (!inSnippetSection) { |
| final RegExpMatch? snippetMatch = _dartDocSnippetBeginRegex.firstMatch(trimmedLine); |
| if (line == '// Examples can assume:') { |
| assert(block.isEmpty); |
| startLine = _Line.generated(filename: relativeFilePath, line: lineNumber + 1, indent: 3); |
| inPreamble = true; |
| } else if (snippetMatch != null) { |
| inSnippetSection = true; |
| } else if (RegExp(r'///\s*#+\s+[Ss]ample\s+[Cc]ode:?$').hasMatch(trimmedLine)) { |
| throw _SnippetCheckerException( |
| "Found deprecated '## Sample code' section: use {@tool snippet}...{@end-tool} instead.", |
| file: relativeFilePath, |
| line: lineNumber, |
| ); |
| } |
| } |
| } |
| } |
| if (!silent) |
| print('Found ${sections.length} snippet code blocks'); |
| for (final _Section section in sections) { |
| final String path = _writeSection(section).path; |
| if (sectionMap != null) |
| sectionMap[path] = section; |
| } |
| } |
| |
| /// Creates the configuration files necessary for the analyzer to consider |
| /// the temporary directory a package, and sets which lint rules to enforce. |
| void _createConfigurationFiles(Directory directory) { |
| final File targetPubSpec = File(path.join(directory.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(directory.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 _writeSection(_Section section) { |
| final String sectionId = _createNameFromSource('snippet', section.start.filename, section.start.line); |
| final File outputFile = File(path.join(_tempDirectory.path, '$sectionId.dart'))..createSync(recursive: true); |
| final List<_Line> mainContents = <_Line>[ |
| _Line.generated(code: section.dartVersionOverride ?? '', filename: section.start.filename), |
| ...headers, |
| _Line.generated(filename: section.start.filename), |
| _Line.generated(code: '// From: ${section.start.filename}:${section.start.line}', filename: section.start.filename), |
| ...section.code, |
| _Line.generated(filename: section.start.filename), // empty line at EOF |
| ]; |
| outputFile.writeAsStringSync(mainContents.map<String>((_Line line) => line.code).join('\n')); |
| return outputFile; |
| } |
| |
| /// Invokes the analyzer on the given [directory] and returns the stdout. |
| int _runAnalyzer(Directory directory, {bool silent = true, required List<String> output}) { |
| if (!silent) |
| print('Starting analysis of code snippets.'); |
| _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'); |
| // 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...'); |
| }); |
| // 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 '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); |
| } |
| output.addAll(stdout); |
| return result.exitCode; |
| } |
| |
| /// Starts the analysis phase of checking the snippets by invoking the analyzer |
| /// and parsing its output to create a map of filename to [_AnalysisError]s. |
| _AnalysisResult _analyze( |
| Directory directory, |
| Map<String, _Section> sections, { |
| bool silent = false, |
| }) { |
| final List<String> errors = <String>[]; |
| int exitCode = _runAnalyzer(directory, silent: silent, output: errors); |
| |
| 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( |
| '^ +(?<type>[a-z]+)' |
| '$kBullet(?<description>.+)' |
| '$kBullet(?<file>.+):(?<line>[0-9]+):(?<column>[0-9]+)' |
| '$kBullet(?<code>[-a-z_]+)\$', |
| caseSensitive: false, |
| ); |
| bool unknownAnalyzerErrors = false; |
| final int headerLength = headers.length + 3; |
| for (final String error in errors) { |
| final RegExpMatch? match = errorPattern.firstMatch(error); |
| if (match == null) { |
| stderr.writeln('Analyzer output: $error'); |
| unknownAnalyzerErrors = true; |
| continue; |
| } |
| final String type = match.namedGroup('type')!; |
| 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) - headerLength; |
| final int columnNumber = int.parse(columnString, radix: 10); |
| |
| if (lineNumber < 1 || lineNumber > fileContents.length) { |
| addAnalysisError( |
| file, |
| _AnalysisError( |
| type, |
| lineNumber, |
| columnNumber, |
| message, |
| errorCode, |
| _Line(filename: file.path, line: lineNumber), |
| ), |
| ); |
| throw _SnippetCheckerException('Failed to parse error message: $error', file: file.path, line: lineNumber); |
| } |
| |
| final _Section actualSection = sections[file.path]!; |
| if (actualSection == null) { |
| throw _SnippetCheckerException( |
| "Unknown section for ${file.path}. Maybe the temporary directory wasn't empty?", |
| file: file.path, |
| line: lineNumber, |
| ); |
| } |
| final _Line actualLine = actualSection.code[lineNumber - 1]; |
| |
| late int line; |
| late int column; |
| String errorMessage = message; |
| _Line source = actualLine; |
| if (actualLine.generated) { |
| // Since generated lines don't appear in the original, we just provide the line |
| // in the generated file. |
| line = lineNumber - 1; |
| column = columnNumber; |
| if (errorCode == 'missing_identifier' && lineNumber > 1) { |
| // For a missing identifier on a generated line, it is very often because of a |
| // trailing comma on the previous line, and so we want to provide a better message |
| // and the previous line as the error location, since that appears in the original |
| // source, and can be more easily located. |
| final _Line previousCodeLine = sections[file.path]!.code[lineNumber - 2]; |
| if (previousCodeLine.code.contains(RegExp(r',\s*$'))) { |
| line = previousCodeLine.line; |
| column = previousCodeLine.indent + previousCodeLine.code.length - 1; |
| errorMessage = 'Unexpected comma at end of snippet code.'; |
| source = previousCodeLine; |
| } |
| } |
| } else { |
| line = actualLine.line; |
| column = actualLine.indent + columnNumber; |
| } |
| addAnalysisError( |
| file, |
| _AnalysisError( |
| type, |
| line, |
| column, |
| errorMessage, |
| errorCode, |
| source, |
| ), |
| ); |
| } |
| if (exitCode == 1 && analysisErrors.isEmpty && !unknownAnalyzerErrors) { |
| exitCode = 0; |
| } |
| if (exitCode == 0) { |
| if (!silent) |
| print('No analysis errors in snippets!'); |
| assert(analysisErrors.isEmpty); |
| } |
| return _AnalysisResult(exitCode, analysisErrors); |
| } |
| |
| /// Process one block of snippet code (the part inside of "```" markers). |
| /// Splits any sections denoted by "// ..." into separate blocks to be |
| /// processed separately. Uses a primitive heuristic to make snippet blocks |
| /// into valid Dart code. |
| _Section _processBlock(_Line line, List<String> block) { |
| if (block.isEmpty) { |
| throw _SnippetCheckerException('$line: Empty ```dart block in snippet 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 _SnippetCheckerException('${_Line(filename: line.filename, line: line.line + index, indent: line.indent)}: ' |
| 'Unexpected blank line or "// ..." line near start of subblock in snippet 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( |
| code: 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 = '', required this.filename, this.line = -1, this.indent = 0}) |
| : generated = false; |
| const _Line.generated({this.code = '', required this.filename, this.line = -1, this.indent = 0}) |
| : generated = true; |
| |
| /// The file that this line came from, or the file that the line was generated for, if [generated] is true. |
| final String filename; |
| final int line; |
| final int indent; |
| final String code; |
| final bool generated; |
| |
| 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 snippet code, marked by "{@tool snippet}...{@end-tool}". |
| class _Section { |
| const _Section(this.code, {this.dartVersionOverride}); |
| 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: 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: code[i], |
| filename: firstLine.filename, |
| line: firstLine.line + i, |
| indent: firstLine.indent, |
| ), |
| ); |
| } |
| return _Section(<_Line>[ |
| _Line.generated(code: prefix, filename: firstLine.filename, line: 0), |
| ...codeLines, |
| _Line.generated(code: postfix, filename: firstLine.filename, line: 0), |
| ]); |
| } |
| _Line get start => code.firstWhere((_Line line) => !line.generated); |
| final List<_Line> code; |
| final String? dartVersionOverride; |
| |
| _Section copyWith({String? dartVersionOverride}) { |
| return _Section(code, dartVersionOverride: dartVersionOverride ?? this.dartVersionOverride); |
| } |
| } |
| |
| /// 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.type, |
| this.line, |
| this.column, |
| this.message, |
| this.errorCode, |
| this.source, |
| ); |
| |
| final String type; |
| final int line; |
| final int column; |
| final String message; |
| final String errorCode; |
| final _Line? source; |
| |
| @override |
| String toString() { |
| if (source != null) { |
| return '${source!.toStringWithColumn(column)}\n>>> $type: $message ($errorCode)'; |
| } else { |
| return '<source unknown>:$line:$column\n>>> $type: $message ($errorCode)'; |
| } |
| } |
| } |
| |
| Future<void> _runInteractive({ |
| required Directory? tempDir, |
| 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()) { |
| throw 'Path ${file.absolute.path} does not exist ($filePath).'; |
| } |
| if (!path.isWithin(_flutterRoot, file.absolute.path) && |
| (dartUiLocation == null || !path.isWithin(dartUiLocation.path, file.absolute.path))) { |
| throw 'Path ${file.absolute.path} is not within the flutter root: ' |
| '$_flutterRoot${dartUiLocation != null ? ' or the dart:ui location: $dartUiLocation' : ''}'; |
| } |
| |
| if (tempDir == null) { |
| tempDir = Directory.systemTemp.createTempSync('flutter_analyze_snippet_code.'); |
| ProcessSignal.sigint.watch().listen((_) { |
| print('Deleting temp files...'); |
| tempDir!.deleteSync(recursive: true); |
| exit(0); |
| }); |
| print('Using temp dir ${tempDir.path}'); |
| } |
| print('Starting up in interactive mode on ${path.relative(filePath, from: _flutterRoot)} ...'); |
| |
| Future<void> analyze(_SnippetChecker checker, File file) async { |
| final Map<String, _Section> sections = <String, _Section>{}; |
| await checker._extractSnippets(<File>[file], silent: true, sectionMap: sections); |
| final _AnalysisResult analysisResult = checker._analyze(checker._tempDirectory, sections, silent: true); |
| stderr.writeln('\u001B[2J\u001B[H'); // Clears the old results from the terminal. |
| if (analysisResult.errors.isNotEmpty) { |
| for (final String filePath in analysisResult.errors.keys) { |
| analysisResult.errors[filePath]!.forEach(stderr.writeln); |
| } |
| stderr.writeln('\nFound ${analysisResult.errors.length} errors.'); |
| } else { |
| stderr.writeln('\nNo issues found.'); |
| } |
| } |
| |
| final _SnippetChecker checker = _SnippetChecker(flutterPackage, tempDirectory: tempDir) |
| .._createConfigurationFiles(tempDir); |
| await analyze(checker, file); |
| |
| print('Type "q" to quit, or "r" to delete temp dir and manually reload.'); |
| |
| void rerun() { |
| print('\n\nRerunning...'); |
| try { |
| analyze(checker, file); |
| } on _SnippetCheckerException catch (e) { |
| print('Caught Exception (${e.runtimeType}), press "r" to retry:\n$e'); |
| } |
| } |
| |
| stdin.lineMode = false; |
| stdin.echoMode = false; |
| stdin.transform(utf8.decoder).listen((String input) { |
| switch (input) { |
| case 'q': |
| print('Exiting...'); |
| exit(0); |
| case 'r': |
| print('Deleting temp files...'); |
| tempDir!.deleteSync(recursive: true); |
| rerun(); |
| break; |
| } |
| }); |
| |
| Watcher(file.absolute.path).events.listen((_) => rerun()); |
| } |