
## Description This fixes the API doc generation that I broke when I moved the snippets tool into the framework. It removes the last of the template support (properly this time), and makes sure all of the tests pass. ## Related Issues - https://github.com/flutter/flutter/issues/144408 - https://github.com/flutter/flutter/issues/147609 - https://github.com/flutter/flutter/pull/147645 ## Tests - Fixed tests, including smoke test of doc generation.
430 lines
15 KiB
Dart
430 lines
15 KiB
Dart
// 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();
|
|
}
|
|
}
|