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// To run this, from the root of the Flutter repository:
6// bin/cache/dart-sdk/bin/dart --enable-asserts dev/bots/check_code_samples.dart
7
8import 'dart:io';
9
10import 'package:args/args.dart';
11import 'package:file/file.dart';
12import 'package:file/local.dart';
13import 'package:path/path.dart' as path;
14
15import 'utils.dart';
16
17final String _scriptLocation = path.fromUri(Platform.script);
18final String _flutterRoot = path.dirname(path.dirname(path.dirname(_scriptLocation)));
19final String _exampleDirectoryPath = path.join(_flutterRoot, 'examples', 'api');
20final String _packageDirectoryPath = path.join(_flutterRoot, 'packages');
21final String _dartUIDirectoryPath = path.join(
22 _flutterRoot,
23 'bin',
24 'cache',
25 'pkg',
26 'sky_engine',
27 'lib',
28);
29
30final List<String> _knownUnlinkedExamples = <String>[
31 // These are template files that aren't expected to be linked.
32 'examples/api/lib/sample_templates/cupertino.0.dart',
33 'examples/api/lib/sample_templates/widgets.0.dart',
34 'examples/api/lib/sample_templates/material.0.dart',
35];
36
37void main(List<String> args) {
38 final ArgParser argParser = ArgParser();
39 argParser.addFlag('help', negatable: false, help: 'Print help for this command.');
40 argParser.addOption(
41 'examples',
42 valueHelp: 'path',
43 defaultsTo: _exampleDirectoryPath,
44 help: 'A location where the API doc examples are found.',
45 );
46 argParser.addOption(
47 'packages',
48 valueHelp: 'path',
49 defaultsTo: _packageDirectoryPath,
50 help: 'A location where the source code that should link the API doc examples is found.',
51 );
52 argParser.addOption(
53 'dart-ui',
54 valueHelp: 'path',
55 defaultsTo: _dartUIDirectoryPath,
56 help: 'A location where the source code that should link the API doc examples is found.',
57 );
58 argParser.addOption(
59 'flutter-root',
60 valueHelp: 'path',
61 defaultsTo: _flutterRoot,
62 help: 'The path to the root of the Flutter repo.',
63 );
64 final ArgResults parsedArgs;
65
66 void usage() {
67 print('dart --enable-asserts ${path.basename(_scriptLocation)} [options]');
68 print(argParser.usage);
69 }
70
71 try {
72 parsedArgs = argParser.parse(args);
73 } on FormatException catch (e) {
74 print(e.message);
75 usage();
76 exit(1);
77 }
78
79 if (parsedArgs['help'] as bool) {
80 usage();
81 exit(0);
82 }
83
84 const FileSystem filesystem = LocalFileSystem();
85 final Directory examples = filesystem.directory(parsedArgs['examples']! as String);
86 final Directory packages = filesystem.directory(parsedArgs['packages']! as String);
87 final Directory dartUIPath = filesystem.directory(parsedArgs['dart-ui']! as String);
88 final Directory flutterRoot = filesystem.directory(parsedArgs['flutter-root']! as String);
89
90 final SampleChecker checker = SampleChecker(
91 examples: examples,
92 packages: packages,
93 dartUIPath: dartUIPath,
94 flutterRoot: flutterRoot,
95 );
96
97 if (!checker.checkCodeSamples()) {
98 reportErrorsAndExit('Some errors were found in the API docs code samples.');
99 }
100 reportSuccessAndExit('All examples are linked and have tests.');
101}
102
103class LinkInfo {
104 const LinkInfo(this.link, this.file, this.line);
105
106 final String link;
107 final File file;
108 final int line;
109
110 @override
111 String toString() {
112 return '${file.path}:$line: $link';
113 }
114}
115
116class SampleChecker {
117 SampleChecker({
118 required this.examples,
119 required this.packages,
120 required this.dartUIPath,
121 required this.flutterRoot,
122 this.filesystem = const LocalFileSystem(),
123 });
124
125 final Directory examples;
126 final Directory packages;
127 final Directory dartUIPath;
128 final Directory flutterRoot;
129 final FileSystem filesystem;
130
131 bool checkCodeSamples() {
132 filesystem.currentDirectory = flutterRoot;
133
134 // Get a list of all the filenames in the source directory that end in "[0-9]+.dart".
135 final List<File> exampleFilenames = getExampleFilenames(examples);
136
137 // Get a list of all the example link paths that appear in the source files.
138 final (Set<String> exampleLinks, Set<LinkInfo> malformedLinks) = getExampleLinks(packages);
139 // Also add in any that might be found in the dart:ui directory.
140 final (Set<String> uiExampleLinks, Set<LinkInfo> uiMalformedLinks) = getExampleLinks(
141 dartUIPath,
142 );
143
144 exampleLinks.addAll(uiExampleLinks);
145 malformedLinks.addAll(uiMalformedLinks);
146
147 // Get a list of the filenames that were not found in the source files.
148 final List<String> missingFilenames = checkForMissingLinks(exampleFilenames, exampleLinks);
149
150 // Get a list of any tests that are missing.
151 final List<File> missingTests = checkForMissingTests(exampleFilenames);
152
153 // Remove any that we know are exceptions (examples that aren't expected to be
154 // linked into any source files). These are typically template files used to
155 // generate new examples.
156 missingFilenames.removeWhere((String file) => _knownUnlinkedExamples.contains(file));
157
158 if (missingFilenames.isEmpty && missingTests.isEmpty && malformedLinks.isEmpty) {
159 return true;
160 }
161
162 if (missingTests.isNotEmpty) {
163 final StringBuffer buffer = StringBuffer('The following example test files are missing:\n');
164 for (final File name in missingTests) {
165 buffer.writeln(' ${getRelativePath(name)}');
166 }
167 foundError(buffer.toString().trimRight().split('\n'));
168 }
169
170 if (missingFilenames.isNotEmpty) {
171 final StringBuffer buffer = StringBuffer(
172 'The following examples are not linked from any source file API doc comments:\n',
173 );
174 for (final String name in missingFilenames) {
175 buffer.writeln(' $name');
176 }
177 buffer.write('Either link them to a source file API doc comment, or remove them.');
178 foundError(buffer.toString().split('\n'));
179 }
180
181 if (malformedLinks.isNotEmpty) {
182 final StringBuffer buffer = StringBuffer(
183 'The following malformed links were found in API doc comments:\n',
184 );
185 for (final LinkInfo link in malformedLinks) {
186 buffer.writeln(' $link');
187 }
188 buffer.write(
189 'Correct the formatting of these links so that they match the exact pattern:\n'
190 r" r'\*\* See code in (?<path>.+) \*\*'",
191 );
192 foundError(buffer.toString().split('\n'));
193 }
194 return false;
195 }
196
197 String getRelativePath(File file, [Directory? root]) {
198 root ??= flutterRoot;
199 return path.relative(file.absolute.path, from: root.absolute.path);
200 }
201
202 List<File> getFiles(Directory directory, [Pattern? filenamePattern]) {
203 final List<File> filenames = directory
204 .listSync(recursive: true)
205 .map((FileSystemEntity entity) {
206 if (entity is File) {
207 return entity;
208 } else {
209 return null;
210 }
211 })
212 .where(
213 (File? filename) =>
214 filename != null &&
215 (filenamePattern == null || filename.absolute.path.contains(filenamePattern)),
216 )
217 .map<File>((File? s) => s!)
218 .toList();
219 return filenames;
220 }
221
222 List<File> getExampleFilenames(Directory directory) {
223 return getFiles(directory.childDirectory('lib'), RegExp(r'\d+\.dart$'));
224 }
225
226 (Set<String>, Set<LinkInfo>) getExampleLinks(Directory searchDirectory) {
227 final List<File> files = getFiles(searchDirectory, RegExp(r'\.dart$'));
228 final Set<String> searchStrings = <String>{};
229 final Set<LinkInfo> malformedStrings = <LinkInfo>{};
230 final RegExp validExampleRe = RegExp(r'\*\* See code in (?<path>.+) \*\*');
231 // Looks for some common broken versions of example links. This looks for
232 // something that is at minimum "///*seecode*" to indicate that it
233 // looks like an example link. It should be narrowed if we start getting false
234 // positives.
235 final RegExp malformedLinkRe = RegExp(
236 r'^(?<malformed>\s*///\s*\*\*?\s*[sS][eE][eE]\s*[Cc][Oo][Dd][Ee].+\*\*?)',
237 );
238 for (final File file in files) {
239 final String contents = file.readAsStringSync();
240 final List<String> lines = contents.split('\n');
241 int count = 0;
242 for (final String line in lines) {
243 count += 1;
244 final RegExpMatch? validMatch = validExampleRe.firstMatch(line);
245 if (validMatch != null) {
246 searchStrings.add(validMatch.namedGroup('path')!);
247 }
248 final RegExpMatch? malformedMatch = malformedLinkRe.firstMatch(line);
249 // It's only malformed if it doesn't match the valid RegExp.
250 if (malformedMatch != null && validMatch == null) {
251 malformedStrings.add(LinkInfo(malformedMatch.namedGroup('malformed')!, file, count));
252 }
253 }
254 }
255 return (searchStrings, malformedStrings);
256 }
257
258 List<String> checkForMissingLinks(List<File> exampleFilenames, Set<String> searchStrings) {
259 final List<String> missingFilenames = <String>[];
260 for (final File example in exampleFilenames) {
261 final String relativePath = getRelativePath(example);
262 if (!searchStrings.contains(relativePath)) {
263 missingFilenames.add(relativePath);
264 }
265 }
266 return missingFilenames;
267 }
268
269 String getTestNameForExample(File example, Directory examples) {
270 final String testPath = path.dirname(
271 path.join(
272 examples.absolute.path,
273 'test',
274 getRelativePath(example, examples.childDirectory('lib')),
275 ),
276 );
277 return '${path.join(testPath, path.basenameWithoutExtension(example.path))}_test.dart';
278 }
279
280 List<File> checkForMissingTests(List<File> exampleFilenames) {
281 final List<File> missingTests = <File>[];
282 for (final File example in exampleFilenames) {
283 final File testFile = filesystem.file(getTestNameForExample(example, examples));
284 if (!testFile.existsSync()) {
285 missingTests.add(testFile);
286 }
287 }
288 return missingTests;
289 }
290}
291