From b33c7891c173a9f6e9467cdffb8468f660ad96a3 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 22 Apr 2021 19:14:02 +0000 Subject: [PATCH] Fix the sample analyzer to analyze dart:ui and make the analyzer null safe (#80742) --- dev/bots/analyze_sample_code.dart | 198 +++++++++++++----- .../analyze-sample-code-test-dart-ui/ui.dart | 33 +++ dev/bots/test/analyze_sample_code_test.dart | 51 +++-- dev/snippets/lib/snippets.dart | 4 +- 4 files changed, 215 insertions(+), 71 deletions(-) create mode 100644 dev/bots/test/analyze-sample-code-test-dart-ui/ui.dart diff --git a/dev/bots/analyze_sample_code.dart b/dev/bots/analyze_sample_code.dart index c6f3e464f8f..052bf4c49f7 100644 --- a/dev/bots/analyze_sample_code.dart +++ b/dev/bots/analyze_sample_code.dart @@ -7,6 +7,8 @@ // To run this, from the root of the Flutter repository: // bin/cache/dart-sdk/bin/dart dev/bots/analyze_sample_code.dart +// @dart= 2.12 + import 'dart:convert'; import 'dart:io'; @@ -16,6 +18,7 @@ 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'); void main(List arguments) { @@ -33,6 +36,20 @@ void main(List arguments) { 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, + negatable: true, + help: 'Includes the dart:ui code supplied by the engine in the analysis.', + ); argParser.addFlag( 'help', defaultsTo: false, @@ -61,7 +78,20 @@ void main(List arguments) { flutterPackage = Directory(_defaultFlutterPackage); } - Directory tempDirectory; + 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))); @@ -78,13 +108,19 @@ void main(List arguments) { } if (parsedArguments['interactive'] != null) { - _runInteractive(tempDirectory, flutterPackage, parsedArguments['interactive'] as String); + _runInteractive( + tempDir: tempDirectory, + flutterPackage: flutterPackage, + filePath: parsedArguments['interactive'] as String, + dartUiLocation: includeDartUi ? dartUiLocation : null, + ); } else { try { exitCode = SampleChecker( flutterPackage, tempDirectory: tempDirectory, verbose: parsedArguments['verbose'] as bool, + dartUiLocation: includeDartUi ? dartUiLocation : null, ).checkSamples(); } on SampleCheckerException catch (e) { stderr.write(e); @@ -96,8 +132,8 @@ void main(List arguments) { class SampleCheckerException implements Exception { SampleCheckerException(this.message, {this.file, this.line}); final String message; - final String file; - final int line; + final String? file; + final int? line; @override String toString() { @@ -126,11 +162,30 @@ class SampleCheckerException implements Exception { /// don't necessarily match. It does, however, print the source of the /// problematic line. class SampleChecker { - SampleChecker(this._flutterPackage, {Directory tempDirectory, this.verbose = false}) - : _tempDirectory = tempDirectory, - _keepTmp = tempDirectory != null { - _tempDirectory ??= Directory.systemTemp.createTempSync('flutter_analyze_sample_code.'); - } + /// Creates a [SampleChecker]. + /// + /// The positional argument is the path to the 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. "/bin/cache/pkg/sky_engine/lib/ui"). + SampleChecker( + this._flutterPackage, { + Directory? tempDirectory, + this.verbose = false, + Directory? dartUiLocation, + }) : _tempDirectory = tempDirectory ?? Directory.systemTemp.createTempSync('flutter_analyze_sample_code.'), + _keepTmp = tempDirectory != null, + _dartUiLocation = dartUiLocation; /// The prefix of each comment line static const String _dartDocPrefix = '///'; @@ -166,11 +221,18 @@ class SampleChecker { /// The temporary directory where all output is written. This will be deleted /// automatically if there are no errors. - Directory _tempDirectory; + 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; @@ -180,7 +242,7 @@ class SampleChecker { // Once the snippets tool has been precompiled by Dart, this contains the AOT // snapshot. - String _snippetsSnapshotPath; + String? _snippetsSnapshotPath; /// Finds the location of the snippets script. String get _snippetsExecutable { @@ -219,7 +281,7 @@ class SampleChecker { ].map((String code) => Line(code)).toList(); } - List _headers; + List? _headers; /// Checks all the samples in the Dart files in [_flutterPackage] for errors. int checkSamples() { @@ -228,12 +290,19 @@ class SampleChecker { try { final Map sections = {}; final Map snippets = {}; - _extractSamples(_listDartFiles(_flutterPackage, recursive: true), sectionMap: sections, sampleMap: snippets); + if (_dartUiLocation != null && !_dartUiLocation!.existsSync()) { + stderr.writeln('Unable to analyze engine dart samples at ${_dartUiLocation!.path}.'); + } + final List filesToAnalyze = [ + ..._listDartFiles(_flutterPackage, recursive: true), + if (_dartUiLocation != null && _dartUiLocation!.existsSync()) ... _listDartFiles(_dartUiLocation!, recursive: true), + ]; + _extractSamples(filesToAnalyze, sectionMap: sections, sampleMap: snippets); errors = _analyze(_tempDirectory, sections, snippets); } finally { if (errors.isNotEmpty) { for (final String filePath in errors.keys) { - errors[filePath].forEach(stderr.writeln); + errors[filePath]!.forEach(stderr.writeln); } stderr.writeln('\nFound ${errors.length} sample code errors.'); } @@ -248,7 +317,7 @@ class SampleChecker { } // If we made a snapshot, remove it (so as not to clutter up the tree). if (_snippetsSnapshotPath != null) { - final File snapshot = File(_snippetsSnapshotPath); + final File snapshot = File(_snippetsSnapshotPath!); if (snapshot.existsSync()) { snapshot.deleteSync(); } @@ -285,7 +354,7 @@ class SampleChecker { } else { return Process.runSync( _dartExecutable, - [path.canonicalize(_snippetsSnapshotPath), ...args], + [path.canonicalize(_snippetsSnapshotPath!), ...args], workingDirectory: workingDirectory, ); } @@ -307,7 +376,7 @@ class SampleChecker { ...sample.args, ]; if (verbose) - print('Generating sample for ${sample.start?.filename}:${sample.start?.line}'); + print('Generating sample for ${sample.start.filename}:${sample.start.line}'); final ProcessResult process = _runSnippetsScript(args); if (verbose) stderr.write('${process.stderr}'); @@ -324,14 +393,19 @@ class SampleChecker { /// Extracts the samples from the Dart files in [files], writes them /// to disk, and adds them to the appropriate [sectionMap] or [sampleMap]. - void _extractSamples(List files, {Map sectionMap, Map sampleMap, bool silent = false}) { + void _extractSamples( + List files, { + required Map sectionMap, + required Map sampleMap, + bool silent = false, + }) { final List
sections =
[]; final List samples = []; int dartpadCount = 0; int sampleCount = 0; for (final File file in files) { - final String relativeFilePath = path.relative(file.path, from: _flutterPackage.path); + final String relativeFilePath = path.relative(file.path, from: _flutterRoot); final List sampleLines = file.readAsLinesSync(); final List
preambleSections =
[]; // Whether or not we're in the file-wide preamble section ("Examples can assume"). @@ -342,11 +416,11 @@ class SampleChecker { bool inSnippet = false; // Whether or not we're in a '```dart' segment. bool inDart = false; - String dartVersionOverride; + String? dartVersionOverride; int lineNumber = 0; final List block = []; List snippetArgs = []; - Line startLine; + late Line startLine; for (final String line in sampleLines) { lineNumber += 1; final String trimmedLine = line.trim(); @@ -425,7 +499,7 @@ class SampleChecker { } } if (!inSampleSection) { - final Match sampleMatch = _dartDocSampleBeginRegex.firstMatch(trimmedLine); + final RegExpMatch? sampleMatch = _dartDocSampleBeginRegex.firstMatch(trimmedLine); if (line == '// Examples can assume:') { assert(block.isEmpty); startLine = Line('', filename: relativeFilePath, line: lineNumber + 1, indent: 3); @@ -447,7 +521,7 @@ class SampleChecker { ); if (sampleMatch[2] != null) { // There are arguments to the snippet tool to keep track of. - snippetArgs = _splitUpQuotedArgs(sampleMatch[2]).toList(); + snippetArgs = _splitUpQuotedArgs(sampleMatch[2]!).toList(); } else { snippetArgs = []; } @@ -507,7 +581,7 @@ class SampleChecker { // by an equals sign), add a "--" in front so that they parse as options. return matches.map((Match match) { String option = ''; - if (match[1] != null && !match[1].startsWith('-')) { + if (match[1] != null && !match[1]!.startsWith('-')) { option = '--'; } if (match[2] != null) { @@ -543,7 +617,7 @@ dependencies: 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 mainContents = [ - if (section.dartVersionOverride != null) Line(section.dartVersionOverride) else const Line(''), + if (section.dartVersionOverride != null) Line(section.dartVersionOverride!) else const Line(''), ...headers, const Line(''), Line('// From: ${section.start.filename}:${section.start.line}'), @@ -554,7 +628,7 @@ dependencies: } /// Invokes the analyzer on the given [directory] and returns the stdout. - List _runAnalyzer(Directory directory, {bool silent}) { + List _runAnalyzer(Directory directory, {bool silent = true}) { if (!silent) print('Starting analysis of code samples.'); _createConfigurationFiles(directory); @@ -603,7 +677,7 @@ dependencies: final Map> analysisErrors = >{}; void addAnalysisError(File file, AnalysisError error) { if (analysisErrors.containsKey(file.path)) { - analysisErrors[file.path].add(error); + analysisErrors[file.path]!.add(error); } else { analysisErrors[file.path] = [error]; } @@ -621,21 +695,21 @@ dependencies: bool unknownAnalyzerErrors = false; final int headerLength = headers.length + 3; for (final String error in errors) { - final RegExpMatch match = errorPattern.firstMatch(error); + 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 String type = match.namedGroup('type')!; + final String message = match.namedGroup('description')!; final File file = File(path.join(_tempDirectory.path, match.namedGroup('file'))); final List fileContents = file.readAsLinesSync(); final bool isSnippet = path.basename(file.path).startsWith('snippet.'); final bool isSample = path.basename(file.path).startsWith('sample.'); - final String line = match.namedGroup('line'); - final String column = match.namedGroup('column'); - final String errorCode = match.namedGroup('code'); + final String line = match.namedGroup('line')!; + final String column = match.namedGroup('column')!; + final String errorCode = match.namedGroup('code')!; final int lineNumber = int.parse(line, radix: 10) - (isSnippet ? headerLength : 0); final int columnNumber = int.parse(column, radix: 10); @@ -692,7 +766,7 @@ dependencies: throw SampleCheckerException('Failed to parse error message: $error', file: file.path, line: lineNumber); } - final Section actualSection = sections[file.path]; + final Section actualSection = sections[file.path]!; if (actualSection == null) { throw SampleCheckerException( "Unknown section for ${file.path}. Maybe the temporary directory wasn't empty?", @@ -702,10 +776,10 @@ dependencies: } final Line actualLine = actualSection.code[lineNumber - 1]; - if (actualLine?.filename == null) { + if (actualLine.filename == null) { if (errorCode == 'missing_identifier' && lineNumber > 1) { if (fileContents[lineNumber - 2].endsWith(',')) { - final Line actualLine = sections[file.path].code[lineNumber - 2]; + final Line actualLine = sections[file.path]!.code[lineNumber - 2]; addAnalysisError( file, AnalysisError( @@ -779,7 +853,7 @@ dependencies: } else { final List buffer = []; int subblocks = 0; - Line subline; + Line? subline; final List
subsections =
[]; 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 @@ -821,7 +895,7 @@ dependencies: /// A class to represent a line of input code. class Line { - const Line(this.code, {this.filename, this.line, this.indent}); + const Line(this.code, {this.filename = 'unknown', this.line = -1, this.indent = 0}); final String filename; final int line; final int indent; @@ -883,10 +957,10 @@ class Section { } Line get start => code.firstWhere((Line line) => line.filename != null); final List code; - final String dartVersionOverride; + final String? dartVersionOverride; - Section copyWith({String dartVersionOverride}) { - return Section(code, dartVersionOverride: dartVersionOverride); + Section copyWith({String? dartVersionOverride}) { + return Section(code, dartVersionOverride: dartVersionOverride ?? this.dartVersionOverride); } } @@ -895,10 +969,14 @@ class Section { /// regular snippets, because they must be injected into templates in order to be /// analyzed. class Sample { - Sample({this.start, List input, List args, this.serial}) { - this.input = input.toList(); - this.args = args.toList(); - } + Sample({ + required this.start, + required List input, + required List args, + required this.serial, + }) : input = input.toList(), + args = args.toList(), + contents = []; final Line start; final int serial; List input; @@ -936,16 +1014,16 @@ class AnalysisError { final int column; final String message; final String errorCode; - final Line source; - final Sample sample; + final Line? source; + final Sample? sample; @override String toString() { if (source != null) { - return '${source.toStringWithColumn(column)}\n>>> $type: $message ($errorCode)'; + return '${source!.toStringWithColumn(column)}\n>>> $type: $message ($errorCode)'; } else if (sample != null) { return 'In sample starting at ' - '${sample.start.filename}:${sample.start.line}:${sample.contents[line - 1]}\n' + '${sample!.start.filename}:${sample!.start.line}:${sample!.contents[line - 1]}\n' '>>> $type: $message ($errorCode)'; } else { return ':$line:$column\n>>> $type: $message ($errorCode)'; @@ -953,21 +1031,28 @@ class AnalysisError { } } -Future _runInteractive(Directory tempDir, Directory flutterPackage, String filePath) async { +Future _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.'; + throw 'Path ${file.absolute.path} does not exist ($filePath).'; } - if (!path.isWithin(_flutterRoot, file.absolute.path)) { - throw 'Path ${file.absolute.path} is not within the flutter root: $_flutterRoot'; + 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_sample_code.'); ProcessSignal.sigint.watch().listen((_) { print('Deleting temp files...'); - tempDir.deleteSync(recursive: true); + tempDir!.deleteSync(recursive: true); exit(0); }); print('Using temp dir ${tempDir.path}'); @@ -982,7 +1067,7 @@ Future _runInteractive(Directory tempDir, Directory flutterPackage, String stderr.writeln('\u001B[2J\u001B[H'); // Clears the old results from the terminal. if (errors.isNotEmpty) { for (final String filePath in errors.keys) { - errors[filePath].forEach(stderr.writeln); + errors[filePath]!.forEach(stderr.writeln); } stderr.writeln('\nFound ${errors.length} errors.'); } else { @@ -1012,10 +1097,9 @@ Future _runInteractive(Directory tempDir, Directory flutterPackage, String case 'q': print('Exiting...'); exit(0); - break; case 'r': print('Deleting temp files...'); - tempDir.deleteSync(recursive: true); + tempDir!.deleteSync(recursive: true); rerun(); break; } diff --git a/dev/bots/test/analyze-sample-code-test-dart-ui/ui.dart b/dev/bots/test/analyze-sample-code-test-dart-ui/ui.dart new file mode 100644 index 00000000000..922c8c7c12c --- /dev/null +++ b/dev/bots/test/analyze-sample-code-test-dart-ui/ui.dart @@ -0,0 +1,33 @@ +// 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. + +// @dart = 2.12 + +// This is a dummy dart:ui package for the sample code analyzer tests to use. + +library dart.ui; + +/// Annotation used by Flutter's Dart compiler to indicate that an +/// [Object.toString] override should not be replaced with a supercall. +/// +/// {@tool sample} +/// A sample if using keepToString to prevent replacement by a supercall. +/// +/// ```dart +/// class MyStringBuffer { +/// StringBuffer _buffer = StringBuffer(); +/// +/// @keepToString +/// @override +/// String toString() { +/// return _buffer.toString(); +/// } +/// } +/// ``` +/// {@end-tool} +const _KeepToString keepToString = _KeepToString(); + +class _KeepToString { + const _KeepToString(); +} \ No newline at end of file diff --git a/dev/bots/test/analyze_sample_code_test.dart b/dev/bots/test/analyze_sample_code_test.dart index e5ad7519d1e..0482f50d5bb 100644 --- a/dev/bots/test/analyze_sample_code_test.dart +++ b/dev/bots/test/analyze_sample_code_test.dart @@ -7,35 +7,42 @@ import 'dart:io'; import 'common.dart'; void main() { - test('analyze_sample_code', () { + // These tests don't run on Windows because the sample analyzer doesn't + // support Windows as a platform, since it is only run on Linux in the + // continuous integration tests. + if (Platform.isWindows) { + return; + } + + test('analyze_sample_code smoke test', () { final ProcessResult process = Process.runSync( '../../bin/cache/dart-sdk/bin/dart', - ['analyze_sample_code.dart', 'test/analyze-sample-code-test-input'], + ['analyze_sample_code.dart', '--no-include-dart-ui', 'test/analyze-sample-code-test-input'], ); final List stdoutLines = process.stdout.toString().split('\n'); final List stderrLines = process.stderr.toString().split('\n') ..removeWhere((String line) => line.startsWith('Analyzer output:') || line.startsWith('Building flutter tool...')); expect(process.exitCode, isNot(equals(0))); expect(stderrLines, [ - 'In sample starting at known_broken_documentation.dart:117:bool? _visible = true;', + 'In sample starting at dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:117:bool? _visible = true;', '>>> info: Use late for private members with non-nullable type (use_late_for_private_fields_and_variables)', - 'In sample starting at known_broken_documentation.dart:117: child: Text(title),', + 'In sample starting at dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:117: child: Text(title),', '>>> error: The final variable \'title\' can\'t be read because it is potentially unassigned at this point (read_potentially_unassigned_final)', - 'known_broken_documentation.dart:30:9: new Opacity(', + 'dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:30:9: new Opacity(', '>>> info: Unnecessary new keyword (unnecessary_new)', - 'known_broken_documentation.dart:62:9: new Opacity(', + 'dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:62:9: new Opacity(', '>>> info: Unnecessary new keyword (unnecessary_new)', - 'known_broken_documentation.dart:95:9: const text0 = Text(\'Poor wandering ones!\');', + 'dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:95:9: const text0 = Text(\'Poor wandering ones!\');', '>>> info: Specify type annotations (always_specify_types)', - 'known_broken_documentation.dart:103:9: const text1 = _Text(\'Poor wandering ones!\');', + 'dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:103:9: const text1 = _Text(\'Poor wandering ones!\');', '>>> info: Specify type annotations (always_specify_types)', - 'known_broken_documentation.dart:111:9: final String? bar = \'Hello\';', + 'dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:111:9: final String? bar = \'Hello\';', '>>> info: Prefer const over final for declarations (prefer_const_declarations)', - 'known_broken_documentation.dart:111:23: final String? bar = \'Hello\';', + 'dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:111:23: final String? bar = \'Hello\';', '>>> info: Use a non-nullable type for a final variable initialized with a non-nullable value (unnecessary_nullable_for_final_variable_declarations)', - 'known_broken_documentation.dart:112:9: final int foo = null;', + 'dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:112:9: final int foo = null;', '>>> info: Prefer const over final for declarations (prefer_const_declarations)', - 'known_broken_documentation.dart:112:25: final int foo = null;', + 'dev/bots/test/analyze-sample-code-test-input/known_broken_documentation.dart:112:25: final int foo = null;', '>>> error: A value of type \'Null\' can\'t be assigned to a variable of type \'int\' (invalid_assignment)', '', 'Found 2 sample code errors.', @@ -46,5 +53,23 @@ void main() { 'Starting analysis of code samples.', '', ]); - }, skip: Platform.isWindows); + }); + test('Analyzes dart:ui code', () { + final ProcessResult process = Process.runSync( + '../../bin/cache/dart-sdk/bin/dart', + [ + 'analyze_sample_code.dart', + '--dart-ui-location', + 'test/analyze-sample-code-test-dart-ui', + 'test/analyze-sample-code-test-input', + ], + ); + final List stdoutLines = process.stdout.toString().split('\n'); + expect(process.exitCode, isNot(equals(0))); + expect(stdoutLines, equals([ + // There is one sample code section in the test's dummy dart:ui code. + 'Found 8 snippet code blocks, 1 sample code sections, and 2 dartpad sections.', + '', + ])); + }); } diff --git a/dev/snippets/lib/snippets.dart b/dev/snippets/lib/snippets.dart index 023b6cb135c..4816cc5a07f 100644 --- a/dev/snippets/lib/snippets.dart +++ b/dev/snippets/lib/snippets.dart @@ -179,7 +179,9 @@ class SnippetGenerator { description.add(line); } else { assert(language != null); - components.last.contents.add(line); + if (components.isNotEmpty) { + components.last.contents.add(line); + } } } return <_ComponentTuple>[