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
5import 'dart:convert';
6
7import 'package:file/file.dart';
8import 'package:file/local.dart';
9import 'package:path/path.dart' as path;
10
11import 'configuration.dart';
12import 'data_types.dart';
13import 'util.dart';
14
15/// Generates the snippet HTML, as well as saving the output snippet main to
16/// the output directory.
17class 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