Unverified Commit b33c7891 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Fix the sample analyzer to analyze dart:ui and make the analyzer null safe (#80742)

parent a84ac2ec
......@@ -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<String> arguments) {
......@@ -33,6 +36,20 @@ void main(List<String> 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<String> 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<String> 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<String> 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. "<flutter repo>/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<Line>((String code) => Line(code)).toList();
}
List<Line> _headers;
List<Line>? _headers;
/// Checks all the samples in the Dart files in [_flutterPackage] for errors.
int checkSamples() {
......@@ -228,12 +290,19 @@ class SampleChecker {
try {
final Map<String, Section> sections = <String, Section>{};
final Map<String, Sample> snippets = <String, Sample>{};
_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<File> filesToAnalyze = <File>[
..._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,
<String>[path.canonicalize(_snippetsSnapshotPath), ...args],
<String>[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<File> files, {Map<String, Section> sectionMap, Map<String, Sample> sampleMap, bool silent = false}) {
void _extractSamples(
List<File> files, {
required Map<String, Section> sectionMap,
required Map<String, Sample> sampleMap,
bool silent = false,
}) {
final List<Section> sections = <Section>[];
final List<Sample> samples = <Sample>[];
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<String> sampleLines = file.readAsLinesSync();
final List<Section> preambleSections = <Section>[];
// 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<String> block = <String>[];
List<String> snippetArgs = <String>[];
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 = <String>[];
}
......@@ -507,7 +581,7 @@ class SampleChecker {
// by an equals sign), add a "--" in front so that they parse as options.
return matches.map<String>((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<Line> mainContents = <Line>[
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<String> _runAnalyzer(Directory directory, {bool silent}) {
List<String> _runAnalyzer(Directory directory, {bool silent = true}) {
if (!silent)
print('Starting analysis of code samples.');
_createConfigurationFiles(directory);
......@@ -603,7 +677,7 @@ dependencies:
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);
analysisErrors[file.path]!.add(error);
} else {
analysisErrors[file.path] = <AnalysisError>[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<String> 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<String> buffer = <String>[];
int subblocks = 0;
Line subline;
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
......@@ -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<Line> 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<String> input, List<String> args, this.serial}) {
this.input = input.toList();
this.args = args.toList();
}
Sample({
required this.start,
required List<String> input,
required List<String> args,
required this.serial,
}) : input = input.toList(),
args = args.toList(),
contents = <String>[];
final Line start;
final int serial;
List<String> 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 '<source unknown>:$line:$column\n>>> $type: $message ($errorCode)';
......@@ -953,21 +1031,28 @@ class AnalysisError {
}
}
Future<void> _runInteractive(Directory tempDir, Directory flutterPackage, String filePath) async {
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.';
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<void> _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<void> _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;
}
......
// 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
......@@ -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',
<String>['analyze_sample_code.dart', 'test/analyze-sample-code-test-input'],
<String>['analyze_sample_code.dart', '--no-include-dart-ui', 'test/analyze-sample-code-test-input'],
);
final List<String> stdoutLines = process.stdout.toString().split('\n');
final List<String> 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, <String>[
'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',
<String>[
'analyze_sample_code.dart',
'--dart-ui-location',
'test/analyze-sample-code-test-dart-ui',
'test/analyze-sample-code-test-input',
],
);
final List<String> stdoutLines = process.stdout.toString().split('\n');
expect(process.exitCode, isNot(equals(0)));
expect(stdoutLines, equals(<String>[
// 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.',
'',
]));
});
}
......@@ -179,9 +179,11 @@ class SnippetGenerator {
description.add(line);
} else {
assert(language != null);
if (components.isNotEmpty) {
components.last.contents.add(line);
}
}
}
return <_ComponentTuple>[
_ComponentTuple('description', description),
...components,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment