| 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 | |
| 8 | import 'dart:io'; |
| 9 | |
| 10 | import 'package:args/args.dart' ; |
| 11 | import 'package:file/file.dart' ; |
| 12 | import 'package:file/local.dart' ; |
| 13 | import 'package:path/path.dart' as path; |
| 14 | |
| 15 | import 'utils.dart'; |
| 16 | |
| 17 | final String _scriptLocation = path.fromUri(Platform.script); |
| 18 | final String _flutterRoot = path.dirname(path.dirname(path.dirname(_scriptLocation))); |
| 19 | final String _exampleDirectoryPath = path.join(_flutterRoot, 'examples' , 'api' ); |
| 20 | final String _packageDirectoryPath = path.join(_flutterRoot, 'packages' ); |
| 21 | final String _dartUIDirectoryPath = path.join( |
| 22 | _flutterRoot, |
| 23 | 'bin' , |
| 24 | 'cache' , |
| 25 | 'pkg' , |
| 26 | 'sky_engine' , |
| 27 | 'lib' , |
| 28 | ); |
| 29 | |
| 30 | final 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 | |
| 37 | void 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 | |
| 103 | class 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 | |
| 116 | class 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 | |