| // 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(); |
| } |
| } |