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';
import 'package:yaml_edit/yaml_edit.dart' show YamlEditor;
const String _pubspecName = 'pubspec.yaml';
final String _flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script))));
final String _packageFlutter = 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 {
var 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(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);
}
final List<Directory> flutterPackages;
if (parsedArguments.rest.length == 1) {
flutterPackages = <Directory>[Directory(parsedArguments.rest.single)];
} else {
flutterPackages = <Directory>[];
final String packagesRoot = path.join(_flutterRoot, 'packages');
for (final FileSystemEntity entity in Directory(packagesRoot).listSync()) {
if (entity is! Directory) {
continue;
}
final pubspec = File(path.join(entity.path, _pubspecName));
if (!pubspec.existsSync()) {
throw StateError("Unexpected package '${entity.path}' found in packages directory");
}
if (!pubspec.readAsStringSync().contains('nodoc: true')) {
flutterPackages.add(Directory(path.join(entity.path, 'lib')));
}
}
assert(flutterPackages.length >= 4);
}
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(
flutterPackages: flutterPackages,
tempDirectory: parsedArguments['temp'] as String?,
filePath: parsedArguments['interactive'] as String,
dartUiLocation: includeDartUi ? dartUiLocation : null,
);
} else {
if (await _SnippetChecker(
flutterPackages,
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);
}
}
}
@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 fileStr = file == null ? '' : '$file:';
final 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);
}
@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);
}
class _SnippetChecker {
_SnippetChecker(
this._flutterPackages, {
String? tempDirectory,
this.verbose = false,
Directory? dartUiLocation,
}) : _tempDirectory = _createTempDirectory(tempDirectory),
_keepTmp = tempDirectory != null,
_dartUiLocation = dartUiLocation;
static const String _dartDocPrefix = '///';
static const String _dartDocPrefixWithSpace = '$_dartDocPrefix ';
static final RegExp _dartDocSnippetBeginRegex = RegExp(r'{@tool ([^ }]+)(?:| ([^}]*))}');
static final RegExp _dartDocSnippetEndRegex = RegExp(r'{@end-tool}');
static final RegExp _codeBlockStartRegex = RegExp(r'^ */// *```dart$');
static final RegExp _uncheckedCodeBlockStartRegex = RegExp(r'^ *// *```dart$');
static final RegExp _codeBlockEndRegex = RegExp(r'^ */// *``` *$');
static final RegExp _nonCodeRegExp = RegExp(r'^ *(//|@)');
static final RegExp _maybeFunctionDeclarationRegExp = RegExp(
r'^([A-Z][A-Za-z0-9_<>, ?]*|int|double|num|bool|void)\?? (_?[a-z][A-Za-z0-9_<>]*)\(.*',
);
static final RegExp _maybeGetterDeclarationRegExp = RegExp(
r'^([A-Z][A-Za-z0-9_<>?]*|int|double|num|bool)\?? get (_?[a-z][A-Za-z0-9_<>]*) (?:=>|{).*',
);
static final RegExp _namedArgumentRegExp = RegExp(r'^(?: )?([a-zA-Z0-9_]+): ');
static final RegExp _topLevelDeclarationRegExp = RegExp(
r'^(abstract|class|mixin|enum|typedef|final|extension) ',
);
static final RegExp _statementRegExp = RegExp(r'^(if|while|for|try) ');
static final RegExp _classDeclarationRegExp = RegExp(r'^(static) ');
static final RegExp _trailingCommaRegExp = RegExp(r'^(.*),(| *//.*)$');
static final RegExp _trailingSemicolonRegExp = RegExp(r'^(.*);(| *//.*)$');
static final RegExp _trailingCloseBraceRegExp = RegExp(r'^(.*)}(| *//.*)$');
static final RegExp _ellipsisRegExp = RegExp(r'^( *)(// \.\.\.)( *)$');
final bool verbose;
final bool _keepTmp;
final Directory _tempDirectory;
Directory get _contentDirectory {
final directory = Directory(path.join(_tempDirectory.path, 'packages'));
if (!directory.existsSync()) {
directory.createSync();
}
return directory;
}
final List<Directory> _flutterPackages;
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: directives_ordering',
'// ignore_for_file: duplicate_ignore',
'// ignore_for_file: invalid_annotation_target',
'// ignore_for_file: no_leading_underscores_for_local_identifiers',
'// ignore_for_file: override_on_non_overriding_member',
'// 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_element_parameter',
'// ignore_for_file: unused_local_variable',
];
List<_Line> get headersWithoutImports {
return _headersWithoutImports ??= ignoresDirectives
.map<_Line>((String code) => _Line.generated(code: code))
.toList();
}
List<_Line>? _headersWithoutImports;
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(_packageFlutter)))
"import 'package:flutter/${path.basename(file.path)}';",
].map<_Line>((String code) => _Line.generated(code: code)).toList();
}
List<_Line>? _headersWithImports;
Future<bool> checkSnippets() async {
final snippets = <String, _SnippetFile>{};
if (_dartUiLocation != null && !_dartUiLocation.existsSync()) {
stderr.writeln('Unable to analyze engine dart snippets at ${_dartUiLocation.path}.');
}
final filesToAnalyze = <File>[
for (final Directory flutterPackage in _flutterPackages)
..._listDartFiles(flutterPackage, recursive: true),
if (_dartUiLocation != null && _dartUiLocation.existsSync())
..._listDartFiles(_dartUiLocation, recursive: true),
];
final 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 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}.');
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');
}
}
}
String _createNameFromSource(String prefix, String filename, int start) {
String snippetId = path.split(filename).join('.');
snippetId = path.basenameWithoutExtension(snippetId);
snippetId = '$prefix.$snippetId.$start';
return snippetId;
}
Future<List<Object>> _extractSnippets(
List<File> files, {
required Map<String, _SnippetFile> snippetMap,
}) async {
final errors = <Object>[];
_SnippetFile? lastExample;
for (final file in files) {
try {
final String relativeFilePath = path.relative(file.path, from: _flutterRoot);
final List<String> fileLines = file.readAsLinesSync();
final ignorePreambleLinesOnly = <_Line>[];
final preambleLines = <_Line>[];
final customImports = <_Line>[];
var inExamplesCanAssumePreamble =
false;
var inToolSection = false;
var inDartSection = false;
var inOtherBlock = false;
var lineNumber = 0;
final block = <String>[];
late _Line startLine;
for (final line in fileLines) {
lineNumber += 1;
final String trimmedLine = line.trim();
if (inExamplesCanAssumePreamble) {
if (line.isEmpty) {
inExamplesCanAssumePreamble = false;
} else if (!line.startsWith('// ')) {
throw _SnippetCheckerException(
'Unexpected content in snippet code preamble.',
file: relativeFilePath,
line: lineNumber,
);
} else {
final newLine = _Line(line: lineNumber, indent: 3, code: line.substring(3));
if (newLine.code.startsWith('import ')) {
customImports.add(newLine);
} else {
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,
customImports,
);
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('```json') ||
line.contains('```csv') ||
line.contains('```sh')) {
inOtherBlock = true;
} else if (line.startsWith(_uncheckedCodeBlockStartRegex)) {
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;
}
_SnippetFile _processBlock(
_Line startingLine,
List<String> block,
List<_Line> assumptions,
List<_Line> ignoreAssumptionsOnly,
String filename,
_SnippetFile? lastExample,
List<_Line> customImports,
) {
if (block.isEmpty) {
throw _SnippetCheckerException(
'${startingLine.asLocation(filename, 0)}: Empty ```dart block in snippet code.',
);
}
var hasEllipsis = false;
for (var index = 0; index < block.length; index += 1) {
final Match? match = _ellipsisRegExp.matchAsPrefix(block[index]);
if (match != null) {
hasEllipsis =
true;
break;
}
}
var hasStatefulWidgetComment = false;
var importPreviousExample = false;
int index = startingLine.line;
for (final 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,
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 ')) {
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(),
headersWithoutImports,
<_Line>[
...ignoreAssumptionsOnly,
if (hasEllipsis)
const _Line.generated(
code: '// ignore_for_file: non_abstract_class_inherits_abstract_member',
),
],
'self-contained program',
filename,
);
}
final List<_Line> headers = switch ((importPreviousExample, customImports.length)) {
(true, _) => <_Line>[],
(false, 0) => headersWithImports,
(false, _) => <_Line>[
...headersWithoutImports,
const _Line.generated(code: '// ignore_for_file: unused_import'),
...customImports,
],
};
if (hasStatefulWidgetComment) {
return _SnippetFile.fromStrings(
startingLine,
prefix: 'class _State extends State<StatefulWidget> {',
block.toList(),
postfix: '}',
headers,
preamble,
'stateful widget',
filename,
);
} else if (firstCodeLine.startsWith(_maybeGetterDeclarationRegExp) ||
(firstCodeLine.startsWith(_maybeFunctionDeclarationRegExp) &&
lastCodeLine.startsWith(_trailingCloseBraceRegExp)) ||
block.any((String line) => line.startsWith(_topLevelDeclarationRegExp))) {
return _SnippetFile.fromStrings(
startingLine,
block.toList(),
headers,
preamble,
'top-level declaration',
filename,
);
} else if (lastCodeLine.startsWith(_trailingSemicolonRegExp) ||
block.any((String line) => line.startsWith(_statementRegExp))) {
return _SnippetFile.fromStrings(
startingLine,
prefix: 'Future<void> function() async {',
block.toList(),
postfix: '}',
headers,
preamble,
'statement',
filename,
);
} else if (firstCodeLine.startsWith(_classDeclarationRegExp)) {
return _SnippetFile.fromStrings(
startingLine,
prefix: 'class Class {',
block.toList(),
postfix: '}',
headers,
<_Line>[
...preamble,
const _Line.generated(code: '// ignore_for_file: avoid_classes_with_only_static_members'),
],
'class declaration',
filename,
);
} else {
if (firstCodeLine.startsWith(_namedArgumentRegExp)) {
for (var 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;
}
}
}
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: ';',
headers,
preamble,
'expression',
filename,
);
}
}
void _createConfigurationFiles() {
final String targetWorkspacePubspecPath = path.join(_tempDirectory.path, _pubspecName);
_copyPubspec(targetWorkspacePubspecPath, path.join(_flutterRoot, _pubspecName));
final targetWorkspacePubspec = File(targetWorkspacePubspecPath);
final String pubspec = targetWorkspacePubspec.readAsStringSync();
final yamlEditor = YamlEditor(pubspec);
yamlEditor.update(<String>['workspace'], <String>['packages']);
targetWorkspacePubspec.writeAsStringSync(yamlEditor.toString());
_copyPubspec(
path.join(_contentDirectory.path, _pubspecName),
path.join(_flutterRoot, 'packages', 'flutter', 'examples', 'api', _pubspecName),
);
final targetAnalysisOptions = File(path.join(_contentDirectory.path, 'analysis_options.yaml'));
if (!targetAnalysisOptions.existsSync()) {
final sourceAnalysisOptions = File(
path.join(_flutterRoot, 'packages', 'flutter', '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}');
}
}
void _copyPubspec(String targetPath, String sourcePath) {
final targetPubSpec = File(targetPath);
if (!targetPubSpec.existsSync()) {
final sourcePubSpec = File(sourcePath);
if (!sourcePubSpec.existsSync()) {
throw 'Cannot find pubspec.yaml at ${sourcePubSpec.path}, which is also used to analyze code snippets.';
}
targetPubSpec
..createSync(recursive: true)
..writeAsStringSync(sourcePubSpec.readAsStringSync());
}
}
File _writeSnippetFile(_SnippetFile snippetFile) {
final String snippetFileId = _createNameFromSource(
'snippet',
snippetFile.filename,
snippetFile.indexLine,
);
final outputFile = File(path.join(_contentDirectory.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;
}
List<Object> _analyze(Map<String, _SnippetFile> snippets) {
final List<String> analyzerOutput = _runAnalyzer();
final errors = <Object>[];
final kBullet = Platform.isWindows ? ' - ' : ' • ';
final errorPattern = RegExp(
'^ *(?<type>[a-z]+)'
'$kBullet(?<description>.+)'
'$kBullet(?<file>.+):(?<line>[0-9]+):(?<column>[0-9]+)'
'$kBullet(?<code>[-a-z_]+)\$',
caseSensitive: false,
);
for (final 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(path.join(_contentDirectory.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;
var delta = 0;
while (true) {
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;
}
List<String> _runAnalyzer() {
_createConfigurationFiles();
Process.runSync(_flutter, <String>[
'pub',
'get',
], workingDirectory: _contentDirectory.absolute.path);
final ProcessResult result = Process.runSync(_flutter, <String>[
'--no-wrap',
'analyze',
'--no-congratulate',
'.',
], workingDirectory: _contentDirectory.absolute.path);
final List<String> stderr = result.stderr.toString().trim().split('\n');
final List<String> stdout = result.stdout.toString().trim().split('\n');
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 ');
});
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');
}
return stdout
.skipWhile((String line) => !line.startsWith('Analyzing packages...'))
.skip(1)
.skipWhile((String line) => line.isEmpty)
.toList();
}
}
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(),
if (preamble.isNotEmpty) ...preamble,
if (preamble.isNotEmpty) const _Line.generated(),
_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 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 List<Directory> flutterPackages,
required String filePath,
required Directory? dartUiLocation,
}) async {
filePath = path.isAbsolute(filePath) ? filePath : path.join(path.current, filePath);
final 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 checker = _SnippetChecker(flutterPackages, tempDirectory: tempDirectory)
.._createConfigurationFiles();
ProcessSignal.sigint.watch().listen((_) {
checker.cleanupTempDirectory();
exit(0);
});
var busy = false;
Future<void> rerun() async {
assert(!busy);
try {
busy = true;
print('\nAnalyzing...');
checker.recreateTempDirectory();
final snippets = <String, _SnippetFile>{};
final errors = <Object>{};
errors.addAll(await checker._extractSnippets(<File>[file], snippetMap: snippets));
errors.addAll(checker._analyze(snippets));
stderr.writeln('\u001B[2J\u001B[H');
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' when !busy:
await rerun();
}
});
Watcher(file.absolute.path).events.listen((_) => rerun());
}
String _stringify(Object object) => object.toString();