| 1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | import 'dart:convert'; |
| 6 | |
| 7 | import 'package:file/file.dart' ; |
| 8 | import 'package:file/local.dart' ; |
| 9 | import 'package:path/path.dart' as path; |
| 10 | |
| 11 | import 'configuration.dart'; |
| 12 | import 'data_types.dart'; |
| 13 | import 'util.dart'; |
| 14 | |
| 15 | /// Generates the snippet HTML, as well as saving the output snippet main to |
| 16 | /// the output directory. |
| 17 | class SnippetGenerator { |
| 18 | SnippetGenerator({ |
| 19 | SnippetConfiguration? configuration, |
| 20 | FileSystem filesystem = const LocalFileSystem(), |
| 21 | Directory? flutterRoot, |
| 22 | }) : flutterRoot = flutterRoot ?? FlutterInformation.instance.getFlutterRoot(), |
| 23 | configuration = |
| 24 | configuration ?? |
| 25 | FlutterRepoSnippetConfiguration( |
| 26 | filesystem: filesystem, |
| 27 | flutterRoot: flutterRoot ?? FlutterInformation.instance.getFlutterRoot(), |
| 28 | ); |
| 29 | |
| 30 | final Directory flutterRoot; |
| 31 | |
| 32 | /// The configuration used to determine where to get/save data for the |
| 33 | /// snippet. |
| 34 | final SnippetConfiguration configuration; |
| 35 | |
| 36 | static const JsonEncoder jsonEncoder = JsonEncoder.withIndent(' ' ); |
| 37 | |
| 38 | /// Interpolates the [injections] into an HTML skeleton file. |
| 39 | /// |
| 40 | /// The order of the injections is important. |
| 41 | /// |
| 42 | /// Takes into account the [type] and doesn't substitute in the id and the app |
| 43 | /// if not a [SnippetType.sample] snippet. |
| 44 | String interpolateSkeleton(CodeSample sample, String skeleton) { |
| 45 | final List<String> codeParts = <String>[]; |
| 46 | const HtmlEscape htmlEscape = HtmlEscape(); |
| 47 | String? language; |
| 48 | for (final SkeletonInjection injection in sample.parts) { |
| 49 | if (!injection.name.startsWith('code' )) { |
| 50 | continue; |
| 51 | } |
| 52 | codeParts.addAll(injection.stringContents); |
| 53 | if (injection.language.isNotEmpty) { |
| 54 | language = injection.language; |
| 55 | } |
| 56 | codeParts.addAll(<String>['' , '// ...', '']); |
| 57 | } |
| 58 | if (codeParts.length > 3) { |
| 59 | codeParts.removeRange(codeParts.length - 3, codeParts.length); |
| 60 | } |
| 61 | // Only insert a div for the description if there actually is some text there. |
| 62 | // This means that the {{description}} marker in the skeleton needs to |
| 63 | // be inside of an {@inject-html} block. |
| 64 | final String description = sample.description.trim().isNotEmpty |
| 65 | ? '<div class="snippet-description">{@end-inject-html}${sample.description.trim()}{@inject-html}</div>' |
| 66 | : ''; |
| 67 | |
| 68 | // DartPad only supports stable or main as valid channels. Use main |
| 69 | // if not on stable so that local runs will work (although they will |
| 70 | // still take their sample code from the master docs server). |
| 71 | final String channel = sample.metadata['channel'] == 'stable' ? 'stable' : 'main'; |
| 72 | |
| 73 | final Map<String, String> substitutions = <String, String>{ |
| 74 | 'description': description, |
| 75 | 'code': htmlEscape.convert(codeParts.join('\n')), |
| 76 | 'language': language ?? 'dart', |
| 77 | 'serial': '', |
| 78 | 'id': sample.metadata['id']! as String, |
| 79 | 'channel': channel, |
| 80 | 'element': sample.metadata['element'] as String? ?? sample.element, |
| 81 | 'app': '', |
| 82 | }; |
| 83 | if (sample is ApplicationSample) { |
| 84 | substitutions |
| 85 | ..['serial'] = sample.metadata['serial']?.toString() ?? '0' |
| 86 | ..['app'] = htmlEscape.convert(sample.output); |
| 87 | } |
| 88 | return skeleton.replaceAllMapped(RegExp('{{(${substitutions.keys.join('|')})}}'), ( |
| 89 | Match match, |
| 90 | ) { |
| 91 | return substitutions[match[1]]!; |
| 92 | }); |
| 93 | } |
| 94 | |
| 95 | /// Consolidates all of the snippets and the assumptions into one snippet, in |
| 96 | /// order to create a compilable result. |
| 97 | Iterable<SourceLine> consolidateSnippets(List<CodeSample> samples, {bool addMarkers = false}) { |
| 98 | if (samples.isEmpty) { |
| 99 | return <SourceLine>[]; |
| 100 | } |
| 101 | final Iterable<SnippetSample> snippets = samples.whereType<SnippetSample>(); |
| 102 | final List<SourceLine> snippetLines = <SourceLine>[...snippets.first.assumptions]; |
| 103 | for (final SnippetSample sample in snippets) { |
| 104 | parseInput(sample); |
| 105 | snippetLines.addAll(_processBlocks(sample)); |
| 106 | } |
| 107 | return snippetLines; |
| 108 | } |
| 109 | |
| 110 | /// A RegExp that matches a Dart constructor. |
| 111 | static final RegExp _constructorRegExp = RegExp(r'(const\s+)?_*[A-Z][a-zA-Z0-9<>._]*\('); |
| 112 | |
| 113 | /// A serial number so that we can create unique expression names when we |
| 114 | /// generate them. |
| 115 | int _expressionId = 0; |
| 116 | |
| 117 | List<SourceLine> _surround(String prefix, Iterable<SourceLine> body, String suffix) { |
| 118 | return <SourceLine>[ |
| 119 | if (prefix.isNotEmpty) SourceLine(prefix), |
| 120 | ...body, |
| 121 | if (suffix.isNotEmpty) SourceLine(suffix), |
| 122 | ]; |
| 123 | } |
| 124 | |
| 125 | /// Process one block of sample code (the part inside of "```" markers). |
| 126 | /// Splits any sections denoted by "// ..." into separate blocks to be |
| 127 | /// processed separately. Uses a primitive heuristic to make sample blocks |
| 128 | /// into valid Dart code. |
| 129 | List<SourceLine> _processBlocks(CodeSample sample) { |
| 130 | final List<SourceLine> block = sample.parts |
| 131 | .expand<SourceLine>((SkeletonInjection injection) => injection.contents) |
| 132 | .toList(); |
| 133 | if (block.isEmpty) { |
| 134 | return <SourceLine>[]; |
| 135 | } |
| 136 | return _processBlock(block); |
| 137 | } |
| 138 | |
| 139 | List<SourceLine> _processBlock(List<SourceLine> block) { |
| 140 | final String firstLine = block.first.text; |
| 141 | if (firstLine.startsWith('new ') || firstLine.startsWith(_constructorRegExp)) { |
| 142 | _expressionId += 1; |
| 143 | return _surround('dynamic expression$_expressionId = ', block, ';'); |
| 144 | } else if (firstLine.startsWith('await ')) { |
| 145 | _expressionId += 1; |
| 146 | return _surround('Future<void> expression$_expressionId() async { ', block, ' }'); |
| 147 | } else if (block.first.text.startsWith('class ') || block.first.text.startsWith('enum ')) { |
| 148 | return block; |
| 149 | } else if ((block.first.text.startsWith('_') || block.first.text.startsWith('final ')) && |
| 150 | block.first.text.contains(' = ')) { |
| 151 | _expressionId += 1; |
| 152 | return _surround('void expression$_expressionId() { ', block.toList(), ' }'); |
| 153 | } else { |
| 154 | final List<SourceLine> buffer = <SourceLine>[]; |
| 155 | int blocks = 0; |
| 156 | SourceLine? subLine; |
| 157 | final List<SourceLine> subsections = <SourceLine>[]; |
| 158 | for (int index = 0; index < block.length; index += 1) { |
| 159 | // Each section of the dart code that is either split by a blank line, or with |
| 160 | // '// ...' is treated as a separate code block. |
| 161 | if (block[index].text.trim().isEmpty || block[index].text == '// ...') { |
| 162 | if (subLine == null) { |
| 163 | continue; |
| 164 | } |
| 165 | blocks += 1; |
| 166 | subsections.addAll(_processBlock(buffer)); |
| 167 | buffer.clear(); |
| 168 | assert(buffer.isEmpty); |
| 169 | subLine = null; |
| 170 | } else if (block[index].text.startsWith('// ')) { |
| 171 | if (buffer.length > 1) { |
| 172 | // don't include leading comments |
| 173 | // so that it doesn't start with "// " and get caught in this again |
| 174 | buffer.add(SourceLine('/${block[index].text}')); |
| 175 | } |
| 176 | } else { |
| 177 | subLine ??= block[index]; |
| 178 | buffer.add(block[index]); |
| 179 | } |
| 180 | } |
| 181 | if (blocks > 0) { |
| 182 | if (subLine != null) { |
| 183 | subsections.addAll(_processBlock(buffer)); |
| 184 | } |
| 185 | // Combine all of the subsections into one section, now that they've been processed. |
| 186 | return subsections; |
| 187 | } else { |
| 188 | return block; |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// Parses the input for the various code and description segments, and |
| 194 | /// returns a set of skeleton injections in the order found. |
| 195 | List<SkeletonInjection> parseInput(CodeSample sample) { |
| 196 | bool inCodeBlock = false; |
| 197 | final List<SourceLine> description = <SourceLine>[]; |
| 198 | final List<SkeletonInjection> components = <SkeletonInjection>[]; |
| 199 | String? language; |
| 200 | final RegExp codeStartEnd = RegExp( |
| 201 | r'^\s*```(?<language>[-\w]+|[-\w]+ (?<section>[-\w]+))?\s*$', |
| 202 | ); |
| 203 | for (final SourceLine line in sample.input) { |
| 204 | final RegExpMatch? match = codeStartEnd.firstMatch(line.text); |
| 205 | if (match != null) { |
| 206 | // If we saw the start or end of a code block |
| 207 | inCodeBlock = !inCodeBlock; |
| 208 | if (match.namedGroup('language') != null) { |
| 209 | language = match[1]; |
| 210 | if (match.namedGroup('section') != null) { |
| 211 | components.add( |
| 212 | SkeletonInjection( |
| 213 | 'code-${match.namedGroup('section')}', |
| 214 | <SourceLine>[], |
| 215 | language: language!, |
| 216 | ), |
| 217 | ); |
| 218 | } else { |
| 219 | components.add(SkeletonInjection('code', <SourceLine>[], language: language!)); |
| 220 | } |
| 221 | } else { |
| 222 | language = null; |
| 223 | } |
| 224 | continue; |
| 225 | } |
| 226 | if (!inCodeBlock) { |
| 227 | description.add(line); |
| 228 | } else { |
| 229 | assert(language != null); |
| 230 | components.last.contents.add(line); |
| 231 | } |
| 232 | } |
| 233 | final List<String> descriptionLines = <String>[]; |
| 234 | bool lastWasWhitespace = false; |
| 235 | for (final String line in description.map<String>((SourceLine line) => line.text.trimRight())) { |
| 236 | final bool onlyWhitespace = line.trim().isEmpty; |
| 237 | if (onlyWhitespace && descriptionLines.isEmpty) { |
| 238 | // Don't add whitespace lines until we see something without whitespace. |
| 239 | lastWasWhitespace = onlyWhitespace; |
| 240 | continue; |
| 241 | } |
| 242 | if (onlyWhitespace && lastWasWhitespace) { |
| 243 | // Don't add more than one whitespace line in a row. |
| 244 | continue; |
| 245 | } |
| 246 | descriptionLines.add(line); |
| 247 | lastWasWhitespace = onlyWhitespace; |
| 248 | } |
| 249 | sample.description = descriptionLines.join('\n').trimRight(); |
| 250 | sample.parts = <SkeletonInjection>[ |
| 251 | if (sample is SnippetSample) SkeletonInjection('#assumptions', sample.assumptions), |
| 252 | ...components, |
| 253 | ]; |
| 254 | return sample.parts; |
| 255 | } |
| 256 | |
| 257 | String _loadFileAsUtf8(File file) { |
| 258 | return file.readAsStringSync(); |
| 259 | } |
| 260 | |
| 261 | /// Generate the HTML using the skeleton file for the type of the given sample. |
| 262 | /// |
| 263 | /// Returns a string with the HTML needed to embed in a web page for showing a |
| 264 | /// sample on the web page. |
| 265 | String generateHtml(CodeSample sample) { |
| 266 | final String skeleton = _loadFileAsUtf8(configuration.getHtmlSkeletonFile(sample.type)); |
| 267 | return interpolateSkeleton(sample, skeleton); |
| 268 | } |
| 269 | |
| 270 | // Sets the description string on the sample and in the sample metadata to a |
| 271 | // comment version of the description. |
| 272 | // Trims lines of extra whitespace, and strips leading and trailing blank |
| 273 | // lines. |
| 274 | String _getDescription(CodeSample sample) { |
| 275 | return sample.description.splitMapJoin( |
| 276 | '\n', |
| 277 | onMatch: (Match match) => match.group(0)!, |
| 278 | onNonMatch: (String nonmatch) => |
| 279 | nonmatch.trimRight().isEmpty ? '//' : '// ${nonmatch.trimRight()}', |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | /// The main routine for generating code samples from the source code doc comments. |
| 284 | /// |
| 285 | /// The `sample` is the block of sample code from a dartdoc comment. |
| 286 | /// |
| 287 | /// The optional `output` is the file to write the generated sample code to. |
| 288 | /// |
| 289 | /// If `includeAssumptions` is true, then the block in the "Examples can |
| 290 | /// assume:" block will also be included in the output. |
| 291 | /// |
| 292 | /// Returns a string containing the resulting code sample. |
| 293 | String generateCode( |
| 294 | CodeSample sample, { |
| 295 | File? output, |
| 296 | String? copyright, |
| 297 | String? description, |
| 298 | bool includeAssumptions = false, |
| 299 | }) { |
| 300 | sample.metadata['copyright'] ??= copyright; |
| 301 | final List<SkeletonInjection> snippetData = parseInput(sample); |
| 302 | sample.description = description ?? sample.description; |
| 303 | sample.metadata['description'] = _getDescription(sample); |
| 304 | switch (sample) { |
| 305 | case DartpadSample _: |
| 306 | case ApplicationSample _: |
| 307 | final String app = sample.sourceFileContents; |
| 308 | sample.output = app; |
| 309 | if (output != null) { |
| 310 | output.writeAsStringSync(sample.output); |
| 311 | |
| 312 | final File metadataFile = configuration.filesystem.file( |
| 313 | path.join( |
| 314 | path.dirname(output.path), |
| 315 | '${path.basenameWithoutExtension(output.path)}.json', |
| 316 | ), |
| 317 | ); |
| 318 | sample.metadata['file'] = path.basename(output.path); |
| 319 | final Map<String, Object?> metadata = sample.metadata; |
| 320 | if (metadata.containsKey('description')) { |
| 321 | metadata['description'] = (metadata['description']! as String).replaceAll( |
| 322 | RegExp(r'^// ?', multiLine: true), |
| 323 | '', |
| 324 | ); |
| 325 | } |
| 326 | metadataFile.writeAsStringSync(jsonEncoder.convert(metadata)); |
| 327 | } |
| 328 | case SnippetSample _: |
| 329 | String app; |
| 330 | if (sample.sourceFile == null) { |
| 331 | String templateContents; |
| 332 | if (includeAssumptions) { |
| 333 | templateContents = |
| 334 | '${headers.map<String>((SourceLine line) { |
| 335 | return line.text; |
| 336 | }).join('\n')}\n{{#assumptions}}\n{{description}}\n{{code}}'; |
| 337 | } else { |
| 338 | templateContents = '{{description}}\n{{code}}'; |
| 339 | } |
| 340 | app = interpolateTemplate( |
| 341 | snippetData, |
| 342 | templateContents, |
| 343 | sample.metadata, |
| 344 | addCopyright: copyright != null, |
| 345 | ); |
| 346 | } else { |
| 347 | app = sample.inputAsString; |
| 348 | } |
| 349 | sample.output = app; |
| 350 | } |
| 351 | return sample.output; |
| 352 | } |
| 353 | |
| 354 | /// Computes the headers needed for each snippet file. |
| 355 | /// |
| 356 | /// Not used for "sample" and "dartpad" samples, which use their own template. |
| 357 | List<SourceLine> get headers { |
| 358 | return _headers ??= <String>[ |
| 359 | '// generated code', |
| 360 | '// ignore_for_file: unused_import', |
| 361 | '// ignore_for_file: unused_element', |
| 362 | '// ignore_for_file: unused_local_variable', |
| 363 | "import 'dart:async';", |
| 364 | "import 'dart:convert';", |
| 365 | "import 'dart:math' as math;", |
| 366 | "import 'dart:typed_data';", |
| 367 | "import 'dart:ui' as ui;", |
| 368 | "import 'package:flutter_test/flutter_test.dart';", |
| 369 | for (final File file in _listDartFiles( |
| 370 | FlutterInformation.instance |
| 371 | .getFlutterRoot() |
| 372 | .childDirectory('packages') |
| 373 | .childDirectory('flutter') |
| 374 | .childDirectory('lib'), |
| 375 | )) ...<String>[ |
| 376 | '', |
| 377 | '// ${file.path}', |
| 378 | "import 'package:flutter/${path.basename(file.path)}';", |
| 379 | ], |
| 380 | ].map<SourceLine>((String code) => SourceLine(code)).toList(); |
| 381 | } |
| 382 | |
| 383 | List<SourceLine>? _headers; |
| 384 | |
| 385 | static List<File> _listDartFiles(Directory directory, {bool recursive = false}) { |
| 386 | return directory |
| 387 | .listSync(recursive: recursive, followLinks: false) |
| 388 | .whereType<File>() |
| 389 | .where((File file) => path.extension(file.path) == '.dart') |
| 390 | .toList(); |
| 391 | } |
| 392 | } |
| 393 | |