blob: 75a0c896e2cd7bf9623e6c4fe42d8b11a86bbf8e [file] [log] [blame]
// 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.
import 'dart:convert';
import 'dart:io' as io;
import 'package:dart_style/dart_style.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:path/path.dart' as path;
import 'configuration.dart';
import 'data_types.dart';
import 'import_sorter.dart';
import 'util.dart';
/// Generates the snippet HTML, as well as saving the output snippet main to
/// the output directory.
class SnippetGenerator {
SnippetGenerator(
{SnippetConfiguration? configuration,
FileSystem filesystem = const LocalFileSystem(),
Directory? flutterRoot})
: flutterRoot =
flutterRoot ?? FlutterInformation.instance.getFlutterRoot(),
configuration = configuration ??
FlutterRepoSnippetConfiguration(
filesystem: filesystem,
flutterRoot: flutterRoot ??
FlutterInformation.instance.getFlutterRoot());
final Directory flutterRoot;
/// The configuration used to determine where to get/save data for the
/// snippet.
final SnippetConfiguration configuration;
static const JsonEncoder jsonEncoder = JsonEncoder.withIndent(' ');
/// A Dart formatted used to format the snippet code and finished application
/// code.
static DartFormatter formatter =
DartFormatter(pageWidth: 80, fixes: StyleFix.all);
/// Interpolates the [injections] into an HTML skeleton file.
///
/// The order of the injections is important.
///
/// Takes into account the [type] and doesn't substitute in the id and the app
/// if not a [SnippetType.sample] snippet.
String interpolateSkeleton(
CodeSample sample,
String skeleton,
) {
final List<String> codeParts = <String>[];
const HtmlEscape htmlEscape = HtmlEscape();
String? language;
for (final SkeletonInjection injection in sample.parts) {
if (!injection.name.startsWith('code')) {
continue;
}
codeParts.addAll(injection.stringContents);
if (injection.language.isNotEmpty) {
language = injection.language;
}
codeParts.addAll(<String>['', '// ...', '']);
}
if (codeParts.length > 3) {
codeParts.removeRange(codeParts.length - 3, codeParts.length);
}
// Only insert a div for the description if there actually is some text there.
// This means that the {{description}} marker in the skeleton needs to
// be inside of an {@inject-html} block.
final String description = sample.description.trim().isNotEmpty
? '<div class="snippet-description">{@end-inject-html}${sample.description.trim()}{@inject-html}</div>'
: '';
// DartPad only supports stable or main as valid channels. Use main
// if not on stable so that local runs will work (although they will
// still take their sample code from the master docs server).
final String channel =
sample.metadata['channel'] == 'stable' ? 'stable' : 'main';
final Map<String, String> substitutions = <String, String>{
'description': description,
'code': htmlEscape.convert(codeParts.join('\n')),
'language': language ?? 'dart',
'serial': '',
'id': sample.metadata['id']! as String,
'channel': channel,
'element': sample.metadata['element'] as String? ?? sample.element,
'app': '',
};
if (sample is ApplicationSample) {
substitutions
..['serial'] = sample.metadata['serial']?.toString() ?? '0'
..['app'] = htmlEscape.convert(sample.output);
}
return skeleton.replaceAllMapped(
RegExp('{{(${substitutions.keys.join('|')})}}'), (Match match) {
return substitutions[match[1]]!;
});
}
/// Consolidates all of the snippets and the assumptions into one snippet, in
/// order to create a compilable result.
Iterable<SourceLine> consolidateSnippets(List<CodeSample> samples,
{bool addMarkers = false}) {
if (samples.isEmpty) {
return <SourceLine>[];
}
final Iterable<SnippetSample> snippets = samples.whereType<SnippetSample>();
final List<SourceLine> snippetLines = <SourceLine>[
...snippets.first.assumptions,
];
for (final SnippetSample sample in snippets) {
parseInput(sample);
snippetLines.addAll(_processBlocks(sample));
}
return snippetLines;
}
/// A RegExp that matches a Dart constructor.
static final RegExp _constructorRegExp =
RegExp(r'(const\s+)?_*[A-Z][a-zA-Z0-9<>._]*\(');
/// A serial number so that we can create unique expression names when we
/// generate them.
int _expressionId = 0;
List<SourceLine> _surround(
String prefix, Iterable<SourceLine> body, String suffix) {
return <SourceLine>[
if (prefix.isNotEmpty) SourceLine(prefix),
...body,
if (suffix.isNotEmpty) SourceLine(suffix),
];
}
/// Process one block of sample code (the part inside of "```" markers).
/// Splits any sections denoted by "// ..." into separate blocks to be
/// processed separately. Uses a primitive heuristic to make sample blocks
/// into valid Dart code.
List<SourceLine> _processBlocks(CodeSample sample) {
final List<SourceLine> block = sample.parts
.expand<SourceLine>((SkeletonInjection injection) => injection.contents)
.toList();
if (block.isEmpty) {
return <SourceLine>[];
}
return _processBlock(block);
}
List<SourceLine> _processBlock(List<SourceLine> block) {
final String firstLine = block.first.text;
if (firstLine.startsWith('new ') ||
firstLine.startsWith(_constructorRegExp)) {
_expressionId += 1;
return _surround('dynamic expression$_expressionId = ', block, ';');
} else if (firstLine.startsWith('await ')) {
_expressionId += 1;
return _surround(
'Future<void> expression$_expressionId() async { ', block, ' }');
} else if (block.first.text.startsWith('class ') ||
block.first.text.startsWith('enum ')) {
return block;
} else if ((block.first.text.startsWith('_') ||
block.first.text.startsWith('final ')) &&
block.first.text.contains(' = ')) {
_expressionId += 1;
return _surround(
'void expression$_expressionId() { ', block.toList(), ' }');
} else {
final List<SourceLine> buffer = <SourceLine>[];
int blocks = 0;
SourceLine? subLine;
final List<SourceLine> subsections = <SourceLine>[];
for (int index = 0; index < block.length; index += 1) {
// Each section of the dart code that is either split by a blank line, or with
// '// ...' is treated as a separate code block.
if (block[index].text.trim().isEmpty || block[index].text == '// ...') {
if (subLine == null) {
continue;
}
blocks += 1;
subsections.addAll(_processBlock(buffer));
buffer.clear();
assert(buffer.isEmpty);
subLine = null;
} else if (block[index].text.startsWith('// ')) {
if (buffer.length > 1) {
// don't include leading comments
// so that it doesn't start with "// " and get caught in this again
buffer.add(SourceLine('/${block[index].text}'));
}
} else {
subLine ??= block[index];
buffer.add(block[index]);
}
}
if (blocks > 0) {
if (subLine != null) {
subsections.addAll(_processBlock(buffer));
}
// Combine all of the subsections into one section, now that they've been processed.
return subsections;
} else {
return block;
}
}
}
/// Parses the input for the various code and description segments, and
/// returns a set of skeleton injections in the order found.
List<SkeletonInjection> parseInput(CodeSample sample) {
bool inCodeBlock = false;
final List<SourceLine> description = <SourceLine>[];
final List<SkeletonInjection> components = <SkeletonInjection>[];
String? language;
final RegExp codeStartEnd =
RegExp(r'^\s*```(?<language>[-\w]+|[-\w]+ (?<section>[-\w]+))?\s*$');
for (final SourceLine line in sample.input) {
final RegExpMatch? match = codeStartEnd.firstMatch(line.text);
if (match != null) {
// If we saw the start or end of a code block
inCodeBlock = !inCodeBlock;
if (match.namedGroup('language') != null) {
language = match[1];
if (match.namedGroup('section') != null) {
components.add(SkeletonInjection(
'code-${match.namedGroup('section')}', <SourceLine>[],
language: language!));
} else {
components.add(
SkeletonInjection('code', <SourceLine>[], language: language!));
}
} else {
language = null;
}
continue;
}
if (!inCodeBlock) {
description.add(line);
} else {
assert(language != null);
components.last.contents.add(line);
}
}
final List<String> descriptionLines = <String>[];
bool lastWasWhitespace = false;
for (final String line in description
.map<String>((SourceLine line) => line.text.trimRight())) {
final bool onlyWhitespace = line.trim().isEmpty;
if (onlyWhitespace && descriptionLines.isEmpty) {
// Don't add whitespace lines until we see something without whitespace.
lastWasWhitespace = onlyWhitespace;
continue;
}
if (onlyWhitespace && lastWasWhitespace) {
// Don't add more than one whitespace line in a row.
continue;
}
descriptionLines.add(line);
lastWasWhitespace = onlyWhitespace;
}
sample.description = descriptionLines.join('\n').trimRight();
sample.parts = <SkeletonInjection>[
if (sample is SnippetSample)
SkeletonInjection('#assumptions', sample.assumptions),
...components,
];
return sample.parts;
}
String _loadFileAsUtf8(File file) {
return file.readAsStringSync();
}
/// Generate the HTML using the skeleton file for the type of the given sample.
///
/// Returns a string with the HTML needed to embed in a web page for showing a
/// sample on the web page.
String generateHtml(CodeSample sample) {
final String skeleton =
_loadFileAsUtf8(configuration.getHtmlSkeletonFile(sample.type));
return interpolateSkeleton(sample, skeleton);
}
// Sets the description string on the sample and in the sample metadata to a
// comment version of the description.
// Trims lines of extra whitespace, and strips leading and trailing blank
// lines.
String _getDescription(CodeSample sample) {
return sample.description.splitMapJoin(
'\n',
onMatch: (Match match) => match.group(0)!,
onNonMatch: (String nonmatch) =>
nonmatch.trimRight().isEmpty ? '//' : '// ${nonmatch.trimRight()}',
);
}
/// The main routine for generating code samples from the source code doc comments.
///
/// The `sample` is the block of sample code from a dartdoc comment.
///
/// The optional `output` is the file to write the generated sample code to.
///
/// If `includeAssumptions` is true, then the block in the "Examples can
/// assume:" block will also be included in the output.
///
/// Returns a string containing the resulting code sample.
String generateCode(
CodeSample sample, {
File? output,
String? copyright,
String? description,
bool formatOutput = true,
bool includeAssumptions = false,
}) {
sample.metadata['copyright'] ??= copyright;
final List<SkeletonInjection> snippetData = parseInput(sample);
sample.description = description ?? sample.description;
sample.metadata['description'] = _getDescription(sample);
switch (sample) {
case DartpadSample _:
case ApplicationSample _:
final String app = sample.sourceFileContents;
sample.output = app;
if (formatOutput) {
final DartFormatter formatter =
DartFormatter(pageWidth: 80, fixes: StyleFix.all);
try {
sample.output = formatter.format(sample.output);
} on FormatterException catch (exception) {
io.stderr
.write('Code to format:\n${_addLineNumbers(sample.output)}\n');
errorExit('Unable to format sample code: $exception');
}
sample.output = sortImports(sample.output);
}
if (output != null) {
output.writeAsStringSync(sample.output);
final File metadataFile = configuration.filesystem.file(path.join(
path.dirname(output.path),
'${path.basenameWithoutExtension(output.path)}.json'));
sample.metadata['file'] = path.basename(output.path);
final Map<String, Object?> metadata = sample.metadata;
if (metadata.containsKey('description')) {
metadata['description'] = (metadata['description']! as String)
.replaceAll(RegExp(r'^// ?', multiLine: true), '');
}
metadataFile.writeAsStringSync(jsonEncoder.convert(metadata));
}
case SnippetSample _:
String app;
if (sample.sourceFile == null) {
String templateContents;
if (includeAssumptions) {
templateContents =
'${headers.map<String>((SourceLine line) {
return line.text;
}).join('\n')}\n{{#assumptions}}\n{{description}}\n{{code}}';
} else {
templateContents = '{{description}}\n{{code}}';
}
app = interpolateTemplate(
snippetData,
templateContents,
sample.metadata,
addCopyright: copyright != null,
);
} else {
app = sample.inputAsString;
}
sample.output = app;
}
return sample.output;
}
String _addLineNumbers(String code) {
final StringBuffer buffer = StringBuffer();
int count = 0;
for (final String line in code.split('\n')) {
count++;
buffer.writeln('${count.toString().padLeft(5)}: $line');
}
return buffer.toString();
}
/// Computes the headers needed for each snippet file.
///
/// Not used for "sample" and "dartpad" samples, which use their own template.
List<SourceLine> get headers {
return _headers ??= <String>[
'// generated code',
'// ignore_for_file: unused_import',
'// ignore_for_file: unused_element',
'// ignore_for_file: unused_local_variable',
"import 'dart:async';",
"import 'dart:convert';",
"import 'dart:math' as math;",
"import 'dart:typed_data';",
"import 'dart:ui' as ui;",
"import 'package:flutter_test/flutter_test.dart';",
for (final File file in _listDartFiles(FlutterInformation.instance
.getFlutterRoot()
.childDirectory('packages')
.childDirectory('flutter')
.childDirectory('lib'))) ...<String>[
'',
'// ${file.path}',
"import 'package:flutter/${path.basename(file.path)}';",
],
].map<SourceLine>((String code) => SourceLine(code)).toList();
}
List<SourceLine>? _headers;
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();
}
}