mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00

This attempts to re-land #22656. There are two changes from the original: I turned off wrapping completely when not sending output to a terminal. Previously I had defaulted to wrapping at and arbitrary 100 chars in that case, just to keep long messages from being too long, but that turns out the be a bad idea because there are tests that are relying on the specific form of the output. It's also pretty arbitrary, and mostly people sending output to a non-terminal will want unwrapped text. I found a better way to terminate ANSI color/bold sequences, so that they can be embedded within each other without needed quite as complex a dance with removing redundant sequences. As part of these changes, I removed the Logger.supportsColor setter so that the one source of truth for color support is in AnsiTerminal.supportsColor. * Turn on line wrapping again in usage and status messages, adds ANSI color to doctor and analysis messages. (#22656) This turns on text wrapping for usage messages and status messages. When on a terminal, wraps to the width of the terminal. When writing to a non-terminal, wrap lines at a default column width (currently defined to be 100 chars). If --no-wrap is specified, then no wrapping occurs. If --wrap-column is specified, wraps to that column (if --wrap is on). Adds ANSI color to the doctor and analysis output on terminals. This is in this PR with the wrapping, since wrapping needs to know how to count visible characters in the presence of ANSI sequences. (This is just one more step towards re-implementing all of Curses for Flutter. :-)) Will not print ANSI sequences when sent to a non-terminal, or of --no-color is specified. Fixes ANSI color and bold sequences so that they can be combined (bold, colored text), and a small bug in indentation calculation for wrapping. Since wrapping is now turned on, also removed many redundant '\n's in the code.
365 lines
14 KiB
Dart
365 lines
14 KiB
Dart
// Copyright 2017 The Chromium Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
// This script analyzes all the sample code in API docs in the Flutter source.
|
|
//
|
|
// It uses the following conventions:
|
|
//
|
|
// Code is denoted by markdown ```dart / ``` markers.
|
|
//
|
|
// Only code in "## Sample code" or "### Sample code" sections is examined.
|
|
// Subheadings can also be specified, as in "## Sample code: foo".
|
|
//
|
|
// There are several kinds of sample code you can specify:
|
|
//
|
|
// * Constructor calls, typically showing what might exist in a build method.
|
|
// These start with "new" or "const", and will be inserted into an assignment
|
|
// expression assigning to a variable of type "dynamic" and followed by a
|
|
// semicolon, for the purposes of analysis.
|
|
//
|
|
// * Class definitions. These start with "class", and are analyzed verbatim.
|
|
//
|
|
// * Other code. It gets included verbatim, though any line that says "// ..."
|
|
// is considered to separate the block into multiple blocks to be processed
|
|
// individually.
|
|
//
|
|
// In addition, you can declare code that should be included in the analysis but
|
|
// not shown in the API docs by adding a comment "// Examples can assume:" to
|
|
// the file (usually at the top of the file, after the imports), following by
|
|
// one or more commented-out lines of code. That code is included verbatim in
|
|
// the analysis.
|
|
//
|
|
// All the sample code of every file is analyzed together. This means you can't
|
|
// have two pieces of sample code that define the same example class.
|
|
//
|
|
// Also, the above means that it's tricky to include verbatim imperative code
|
|
// (e.g. a call to a method), since it won't be valid to have such code at the
|
|
// top level. Instead, wrap it in a function or even a whole class, or make it a
|
|
// valid variable declaration.
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:path/path.dart' as path;
|
|
|
|
// To run this: bin/cache/dart-sdk/bin/dart dev/bots/analyze-sample-code.dart
|
|
|
|
final String _flutterRoot = path.dirname(path.dirname(path.dirname(path.fromUri(Platform.script))));
|
|
final String _flutter = path.join(_flutterRoot, 'bin', Platform.isWindows ? 'flutter.bat' : 'flutter');
|
|
|
|
class Line {
|
|
const Line(this.filename, this.line, this.indent);
|
|
final String filename;
|
|
final int line;
|
|
final int indent;
|
|
Line get next => this + 1;
|
|
Line operator +(int count) {
|
|
if (count == 0)
|
|
return this;
|
|
return Line(filename, line + count, indent);
|
|
}
|
|
@override
|
|
String toString([int column]) {
|
|
if (column != null)
|
|
return '$filename:$line:${column + indent}';
|
|
return '$filename:$line';
|
|
}
|
|
}
|
|
|
|
class Section {
|
|
const Section(this.start, this.preamble, this.code, this.postamble);
|
|
final Line start;
|
|
final String preamble;
|
|
final List<String> code;
|
|
final String postamble;
|
|
Iterable<String> get strings sync* {
|
|
if (preamble != null) {
|
|
assert(!preamble.contains('\n'));
|
|
yield preamble;
|
|
}
|
|
assert(!code.any((String line) => line.contains('\n')));
|
|
yield* code;
|
|
if (postamble != null) {
|
|
assert(!postamble.contains('\n'));
|
|
yield postamble;
|
|
}
|
|
}
|
|
List<Line> get lines {
|
|
final List<Line> result = List<Line>.generate(code.length, (int index) => start + index);
|
|
if (preamble != null)
|
|
result.insert(0, null);
|
|
if (postamble != null)
|
|
result.add(null);
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const String kDartDocPrefix = '///';
|
|
const String kDartDocPrefixWithSpace = '$kDartDocPrefix ';
|
|
|
|
Future<void> main(List<String> arguments) async {
|
|
final Directory tempDir = Directory.systemTemp.createTempSync('flutter_analyze_sample_code.');
|
|
int exitCode = 1;
|
|
bool keepMain = false;
|
|
final List<String> buffer = <String>[];
|
|
try {
|
|
final File mainDart = File(path.join(tempDir.path, 'main.dart'));
|
|
final File pubSpec = File(path.join(tempDir.path, 'pubspec.yaml'));
|
|
final File analysisOptions = File(path.join(tempDir.path, 'analysis_options.yaml'));
|
|
Directory flutterPackage;
|
|
if (arguments.length == 1) {
|
|
// Used for testing.
|
|
flutterPackage = Directory(arguments.single);
|
|
} else {
|
|
flutterPackage = Directory(path.join(_flutterRoot, 'packages', 'flutter', 'lib'));
|
|
}
|
|
final List<Section> sections = <Section>[];
|
|
int sampleCodeSections = 0;
|
|
for (FileSystemEntity file in flutterPackage.listSync(recursive: true, followLinks: false)) {
|
|
if (file is File && path.extension(file.path) == '.dart') {
|
|
final List<String> lines = file.readAsLinesSync();
|
|
bool inPreamble = false;
|
|
bool inSampleSection = false;
|
|
bool inDart = false;
|
|
bool foundDart = false;
|
|
int lineNumber = 0;
|
|
final List<String> block = <String>[];
|
|
Line startLine;
|
|
for (String line in lines) {
|
|
lineNumber += 1;
|
|
final String trimmedLine = line.trim();
|
|
if (inPreamble) {
|
|
if (line.isEmpty) {
|
|
inPreamble = false;
|
|
processBlock(startLine, block, sections);
|
|
} else if (!line.startsWith('// ')) {
|
|
throw '${file.path}:$lineNumber: Unexpected content in sample code preamble.';
|
|
} else {
|
|
block.add(line.substring(3));
|
|
}
|
|
} else if (inSampleSection) {
|
|
if (!trimmedLine.startsWith(kDartDocPrefix) || trimmedLine.startsWith('/// ## ')) {
|
|
if (inDart)
|
|
throw '${file.path}:$lineNumber: Dart section inexplicably unterminated.';
|
|
if (!foundDart)
|
|
throw '${file.path}:$lineNumber: No dart block found in sample code section';
|
|
inSampleSection = false;
|
|
} else {
|
|
if (inDart) {
|
|
if (trimmedLine == '/// ```') {
|
|
inDart = false;
|
|
processBlock(startLine, block, sections);
|
|
} else if (trimmedLine == kDartDocPrefix) {
|
|
block.add('');
|
|
} else {
|
|
final int index = line.indexOf(kDartDocPrefixWithSpace);
|
|
if (index < 0)
|
|
throw '${file.path}:$lineNumber: Dart section inexplicably did not contain "$kDartDocPrefixWithSpace" prefix.';
|
|
block.add(line.substring(index + 4));
|
|
}
|
|
} else if (trimmedLine == '/// ```dart') {
|
|
assert(block.isEmpty);
|
|
startLine = Line(file.path, lineNumber + 1, line.indexOf(kDartDocPrefixWithSpace) + kDartDocPrefixWithSpace.length);
|
|
inDart = true;
|
|
foundDart = true;
|
|
}
|
|
}
|
|
}
|
|
if (!inSampleSection) {
|
|
if (line == '// Examples can assume:') {
|
|
assert(block.isEmpty);
|
|
startLine = Line(file.path, lineNumber + 1, 3);
|
|
inPreamble = true;
|
|
} else if (trimmedLine == '/// ## Sample code' ||
|
|
trimmedLine.startsWith('/// ## Sample code:') ||
|
|
trimmedLine == '/// ### Sample code' ||
|
|
trimmedLine.startsWith('/// ### Sample code:')) {
|
|
inSampleSection = true;
|
|
foundDart = false;
|
|
sampleCodeSections += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
buffer.add('// generated code');
|
|
buffer.add('import \'dart:async\';');
|
|
buffer.add('import \'dart:convert\';');
|
|
buffer.add('import \'dart:math\' as math;');
|
|
buffer.add('import \'dart:typed_data\';');
|
|
buffer.add('import \'dart:ui\' as ui;');
|
|
buffer.add('import \'package:flutter_test/flutter_test.dart\';');
|
|
for (FileSystemEntity file in flutterPackage.listSync(recursive: false, followLinks: false)) {
|
|
if (file is File && path.extension(file.path) == '.dart') {
|
|
buffer.add('');
|
|
buffer.add('// ${file.path}');
|
|
buffer.add('import \'package:flutter/${path.basename(file.path)}\';');
|
|
}
|
|
}
|
|
buffer.add('');
|
|
final List<Line> lines = List<Line>.filled(buffer.length, null, growable: true);
|
|
for (Section section in sections) {
|
|
buffer.addAll(section.strings);
|
|
lines.addAll(section.lines);
|
|
}
|
|
assert(buffer.length == lines.length);
|
|
mainDart.writeAsStringSync(buffer.join('\n'));
|
|
pubSpec.writeAsStringSync('''
|
|
name: analyze_sample_code
|
|
dependencies:
|
|
flutter:
|
|
sdk: flutter
|
|
flutter_test:
|
|
sdk: flutter
|
|
''');
|
|
analysisOptions.writeAsStringSync('''
|
|
linter:
|
|
rules:
|
|
- unnecessary_const
|
|
- unnecessary_new
|
|
''');
|
|
print('Found $sampleCodeSections sample code sections.');
|
|
final Process process = await Process.start(
|
|
_flutter,
|
|
<String>['--no-wrap', 'analyze', '--no-preamble', '--no-congratulate', mainDart.parent.path],
|
|
workingDirectory: tempDir.path,
|
|
);
|
|
final List<String> errors = <String>[];
|
|
errors.addAll(await process.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).toList());
|
|
errors.add(null);
|
|
errors.addAll(await process.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).toList());
|
|
// top is stderr
|
|
if (errors.isNotEmpty && (errors.first.contains(' issues found. (ran in ') || errors.first.contains(' issue found. (ran in '))) {
|
|
errors.removeAt(0); // the "23 issues found" message goes onto stderr, which is concatenated first
|
|
if (errors.isNotEmpty && errors.last.isEmpty)
|
|
errors.removeLast(); // if there's an "issues found" message, we put a blank line on stdout before it
|
|
}
|
|
// null separates stderr from stdout
|
|
if (errors.first != null)
|
|
throw 'cannot analyze dartdocs; unexpected error output: $errors';
|
|
errors.removeAt(0);
|
|
// rest is stdout
|
|
if (errors.isNotEmpty && errors.first == 'Building flutter tool...')
|
|
errors.removeAt(0);
|
|
if (errors.isNotEmpty && errors.first.startsWith('Running "flutter packages get" in '))
|
|
errors.removeAt(0);
|
|
int errorCount = 0;
|
|
final String kBullet = Platform.isWindows ? ' - ' : ' • ';
|
|
final RegExp errorPattern = RegExp('^ +([a-z]+)$kBullet(.+)$kBullet(.+):([0-9]+):([0-9]+)$kBullet([-a-z_]+)\$', caseSensitive: false);
|
|
for (String error in errors) {
|
|
final Match parts = errorPattern.matchAsPrefix(error);
|
|
if (parts != null) {
|
|
final String message = parts[2];
|
|
final String file = parts[3];
|
|
final String line = parts[4];
|
|
final String column = parts[5];
|
|
final String errorCode = parts[6];
|
|
final int lineNumber = int.parse(line, radix: 10);
|
|
final int columnNumber = int.parse(column, radix: 10);
|
|
if (file != 'main.dart') {
|
|
keepMain = true;
|
|
throw 'cannot analyze dartdocs; analysis errors exist in $file: $error';
|
|
}
|
|
if (lineNumber < 1 || lineNumber > lines.length) {
|
|
keepMain = true;
|
|
throw 'failed to parse error message (read line number as $lineNumber; total number of lines is ${lines.length}): $error';
|
|
}
|
|
final Line actualLine = lines[lineNumber - 1];
|
|
if (errorCode == 'unused_element' || errorCode == 'unused_local_variable') {
|
|
// We don't really care if sample code isn't used!
|
|
} else if (actualLine == null) {
|
|
if (errorCode == 'missing_identifier' && lineNumber > 1 && buffer[lineNumber - 2].endsWith(',')) {
|
|
final Line actualLine = lines[lineNumber - 2];
|
|
print('${actualLine.toString(buffer[lineNumber - 2].length - 1)}: unexpected comma at end of sample code');
|
|
errorCount += 1;
|
|
} else {
|
|
print('${mainDart.path}:${lineNumber - 1}:$columnNumber: $message');
|
|
keepMain = true;
|
|
errorCount += 1;
|
|
}
|
|
} else {
|
|
print('${actualLine.toString(columnNumber)}: $message ($errorCode)');
|
|
errorCount += 1;
|
|
}
|
|
} else {
|
|
print('?? $error');
|
|
keepMain = true;
|
|
errorCount += 1;
|
|
}
|
|
}
|
|
exitCode = await process.exitCode;
|
|
if (exitCode == 1 && errorCount == 0)
|
|
exitCode = 0;
|
|
if (exitCode == 0)
|
|
print('No errors!');
|
|
} finally {
|
|
if (keepMain) {
|
|
print('Kept ${tempDir.path} because it had errors (see above).');
|
|
print('-------8<-------');
|
|
int number = 1;
|
|
for (String line in buffer) {
|
|
print('${number.toString().padLeft(6, " ")}: $line');
|
|
number += 1;
|
|
}
|
|
print('-------8<-------');
|
|
} else {
|
|
try {
|
|
tempDir.deleteSync(recursive: true);
|
|
} on FileSystemException catch (e) {
|
|
print('Failed to delete ${tempDir.path}: $e');
|
|
}
|
|
}
|
|
}
|
|
exit(exitCode);
|
|
}
|
|
|
|
final RegExp _constructorRegExp = RegExp(r'[A-Z][a-zA-Z0-9<>.]*\(');
|
|
|
|
int _expressionId = 0;
|
|
|
|
void processBlock(Line line, List<String> block, List<Section> sections) {
|
|
if (block.isEmpty)
|
|
throw '$line: Empty ```dart block in sample code.';
|
|
if (block.first.startsWith('new ') || block.first.startsWith('const ') || block.first.startsWith(_constructorRegExp)) {
|
|
_expressionId += 1;
|
|
sections.add(Section(line, 'dynamic expression$_expressionId = ', block.toList(), ';'));
|
|
} else if (block.first.startsWith('await ')) {
|
|
_expressionId += 1;
|
|
sections.add(Section(line, 'Future<Null> expression$_expressionId() async { ', block.toList(), ' }'));
|
|
} else if (block.first.startsWith('class ') || block.first.startsWith('enum ')) {
|
|
sections.add(Section(line, null, block.toList(), null));
|
|
} else if ((block.first.startsWith('_') || block.first.startsWith('final ')) && block.first.contains(' = ')) {
|
|
_expressionId += 1;
|
|
sections.add(Section(line, 'void expression$_expressionId() { ', block.toList(), ' }'));
|
|
} else {
|
|
final List<String> buffer = <String>[];
|
|
int subblocks = 0;
|
|
Line subline;
|
|
for (int index = 0; index < block.length; index += 1) {
|
|
if (block[index] == '' || block[index] == '// ...') {
|
|
if (subline == null)
|
|
throw '${line + index}: Unexpected blank line or "// ..." line near start of subblock in sample code.';
|
|
subblocks += 1;
|
|
processBlock(subline, buffer, sections);
|
|
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 + index;
|
|
buffer.add(block[index]);
|
|
}
|
|
}
|
|
if (subblocks > 0) {
|
|
if (subline != null)
|
|
processBlock(subline, buffer, sections);
|
|
} else {
|
|
sections.add(Section(line, null, block.toList(), null));
|
|
}
|
|
}
|
|
block.clear();
|
|
}
|