// 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 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:path/path.dart' as path; import 'configuration.dart'; import 'data_types.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(' '); /// 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 codeParts = []; 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(['', '// ...', '']); } 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 ? '
{@end-inject-html}${sample.description.trim()}{@inject-html}
' : ''; // 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 substitutions = { '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 consolidateSnippets(List samples, {bool addMarkers = false}) { if (samples.isEmpty) { return []; } final Iterable snippets = samples.whereType(); final List snippetLines = [...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 _surround(String prefix, Iterable body, String suffix) { return [ 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 _processBlocks(CodeSample sample) { final List block = sample.parts .expand((SkeletonInjection injection) => injection.contents) .toList(); if (block.isEmpty) { return []; } return _processBlock(block); } List _processBlock(List 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 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 buffer = []; int blocks = 0; SourceLine? 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 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 parseInput(CodeSample sample) { bool inCodeBlock = false; final List description = []; final List components = []; String? language; final RegExp codeStartEnd = RegExp( r'^\s*```(?[-\w]+|[-\w]+ (?
[-\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')}', [], language: language!, ), ); } else { components.add(SkeletonInjection('code', [], language: language!)); } } else { language = null; } continue; } if (!inCodeBlock) { description.add(line); } else { assert(language != null); components.last.contents.add(line); } } final List descriptionLines = []; bool lastWasWhitespace = false; for (final String line in description.map((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 = [ 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 includeAssumptions = false, }) { sample.metadata['copyright'] ??= copyright; final List 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 (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 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((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; } /// Computes the headers needed for each snippet file. /// /// Not used for "sample" and "dartpad" samples, which use their own template. List get headers { return _headers ??= [ '// 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'), )) ...[ '', '// ${file.path}', "import 'package:flutter/${path.basename(file.path)}';", ], ].map((String code) => SourceLine(code)).toList(); } List? _headers; static List _listDartFiles(Directory directory, {bool recursive = false}) { return directory .listSync(recursive: recursive, followLinks: false) .whereType() .where((File file) => path.extension(file.path) == '.dart') .toList(); } }