| // Copyright 2013 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 'package:file/file.dart'; | |
| import 'package:git/git.dart'; | |
| import 'package:platform/platform.dart'; | |
| import 'package:yaml/yaml.dart'; | |
| import 'common/core.dart'; | |
| import 'common/package_looping_command.dart'; | |
| import 'common/process_runner.dart'; | |
| import 'common/repository_package.dart'; | |
| const String _instructionWikiUrl = | |
| 'https://github.com/flutter/flutter/wiki/Contributing-to-Plugins-and-Packages'; | |
| /// A command to enforce README conventions across the repository. | |
| class ReadmeCheckCommand extends PackageLoopingCommand { | |
| /// Creates an instance of the README check command. | |
| ReadmeCheckCommand( | |
| Directory packagesDir, { | |
| ProcessRunner processRunner = const ProcessRunner(), | |
| Platform platform = const LocalPlatform(), | |
| GitDir? gitDir, | |
| }) : super( | |
| packagesDir, | |
| processRunner: processRunner, | |
| platform: platform, | |
| gitDir: gitDir, | |
| ) { | |
| argParser.addFlag(_requireExcerptsArg, | |
| help: 'Require that Dart code blocks be managed by code-excerpt.'); | |
| } | |
| static const String _requireExcerptsArg = 'require-excerpts'; | |
| // Standardized capitalizations for platforms that a plugin can support. | |
| static const Map<String, String> _standardPlatformNames = <String, String>{ | |
| 'android': 'Android', | |
| 'ios': 'iOS', | |
| 'linux': 'Linux', | |
| 'macos': 'macOS', | |
| 'web': 'Web', | |
| 'windows': 'Windows', | |
| }; | |
| @override | |
| final String name = 'readme-check'; | |
| @override | |
| final String description = | |
| 'Checks that READMEs follow repository conventions.'; | |
| @override | |
| bool get hasLongOutput => false; | |
| @override | |
| Future<PackageResult> runForPackage(RepositoryPackage package) async { | |
| final List<String> errors = _validateReadme(package.readmeFile, | |
| mainPackage: package, isExample: false); | |
| for (final RepositoryPackage packageToCheck in package.getExamples()) { | |
| errors.addAll(_validateReadme(packageToCheck.readmeFile, | |
| mainPackage: package, isExample: true)); | |
| } | |
| // If there's an example/README.md for a multi-example package, validate | |
| // that as well, as it will be shown on pub.dev. | |
| final Directory exampleDir = package.directory.childDirectory('example'); | |
| final File exampleDirReadme = exampleDir.childFile('README.md'); | |
| if (exampleDir.existsSync() && !isPackage(exampleDir)) { | |
| errors.addAll(_validateReadme(exampleDirReadme, | |
| mainPackage: package, isExample: true)); | |
| } | |
| return errors.isEmpty | |
| ? PackageResult.success() | |
| : PackageResult.fail(errors); | |
| } | |
| List<String> _validateReadme(File readme, | |
| {required RepositoryPackage mainPackage, required bool isExample}) { | |
| if (!readme.existsSync()) { | |
| if (isExample) { | |
| print('${indentation}No README for ' | |
| '${getRelativePosixPath(readme.parent, from: mainPackage.directory)}'); | |
| return <String>[]; | |
| } else { | |
| printError('${indentation}No README found at ' | |
| '${getRelativePosixPath(readme, from: mainPackage.directory)}'); | |
| return <String>['Missing README.md']; | |
| } | |
| } | |
| print('${indentation}Checking ' | |
| '${getRelativePosixPath(readme, from: mainPackage.directory)}...'); | |
| final List<String> readmeLines = readme.readAsLinesSync(); | |
| final List<String> errors = <String>[]; | |
| final String? blockValidationError = | |
| _validateCodeBlocks(readmeLines, mainPackage: mainPackage); | |
| if (blockValidationError != null) { | |
| errors.add(blockValidationError); | |
| } | |
| errors.addAll(_validateBoilerplate(readmeLines, | |
| mainPackage: mainPackage, isExample: isExample)); | |
| // Check if this is the main readme for a plugin, and if so enforce extra | |
| // checks. | |
| if (!isExample) { | |
| final Pubspec pubspec = mainPackage.parsePubspec(); | |
| final bool isPlugin = pubspec.flutter?['plugin'] != null; | |
| if (isPlugin && (!mainPackage.isFederated || mainPackage.isAppFacing)) { | |
| final String? error = _validateSupportedPlatforms(readmeLines, pubspec); | |
| if (error != null) { | |
| errors.add(error); | |
| } | |
| } | |
| } | |
| return errors; | |
| } | |
| /// Validates that code blocks (``` ... ```) follow repository standards. | |
| String? _validateCodeBlocks( | |
| List<String> readmeLines, { | |
| required RepositoryPackage mainPackage, | |
| }) { | |
| final RegExp codeBlockDelimiterPattern = RegExp(r'^\s*```\s*([^ ]*)\s*'); | |
| const String excerptTagStart = '<?code-excerpt '; | |
| final List<int> missingLanguageLines = <int>[]; | |
| final List<int> missingExcerptLines = <int>[]; | |
| bool inBlock = false; | |
| for (int i = 0; i < readmeLines.length; ++i) { | |
| final RegExpMatch? match = | |
| codeBlockDelimiterPattern.firstMatch(readmeLines[i]); | |
| if (match == null) { | |
| continue; | |
| } | |
| if (inBlock) { | |
| inBlock = false; | |
| continue; | |
| } | |
| inBlock = true; | |
| final int humanReadableLineNumber = i + 1; | |
| // Ensure that there's a language tag. | |
| final String infoString = match[1] ?? ''; | |
| if (infoString.isEmpty) { | |
| missingLanguageLines.add(humanReadableLineNumber); | |
| continue; | |
| } | |
| // Check for code-excerpt usage if requested. | |
| if (getBoolArg(_requireExcerptsArg) && infoString == 'dart') { | |
| if (i == 0 || !readmeLines[i - 1].trim().startsWith(excerptTagStart)) { | |
| missingExcerptLines.add(humanReadableLineNumber); | |
| } | |
| } | |
| } | |
| String? errorSummary; | |
| if (missingLanguageLines.isNotEmpty) { | |
| for (final int lineNumber in missingLanguageLines) { | |
| printError('${indentation}Code block at line $lineNumber is missing ' | |
| 'a language identifier.'); | |
| } | |
| printError( | |
| '\n${indentation}For each block listed above, add a language tag to ' | |
| 'the opening block. For instance, for Dart code, use:\n' | |
| '${indentation * 2}```dart\n'); | |
| errorSummary = 'Missing language identifier for code block'; | |
| } | |
| // If any blocks use code excerpts, make sure excerpting is configured | |
| // for the package. | |
| if (readmeLines.any((String line) => line.startsWith(excerptTagStart))) { | |
| const String buildRunnerConfigFile = 'build.excerpt.yaml'; | |
| if (!mainPackage.getExamples().any((RepositoryPackage example) => | |
| example.directory.childFile(buildRunnerConfigFile).existsSync())) { | |
| printError('code-excerpt tag found, but the package is not configured ' | |
| 'for excerpting. Follow the instructions at\n' | |
| '$_instructionWikiUrl\n' | |
| 'for setting up a build.excerpt.yaml file.'); | |
| errorSummary ??= 'Missing code-excerpt configuration'; | |
| } | |
| } | |
| if (missingExcerptLines.isNotEmpty) { | |
| for (final int lineNumber in missingExcerptLines) { | |
| printError('${indentation}Dart code block at line $lineNumber is not ' | |
| 'managed by code-excerpt.'); | |
| } | |
| printError( | |
| '\n${indentation}For each block listed above, add <?code-excerpt ...> ' | |
| 'tag on the previous line, and ensure that a build.excerpt.yaml is ' | |
| 'configured for the source example as explained at\n' | |
| '$_instructionWikiUrl'); | |
| errorSummary ??= 'Missing code-excerpt management for code block'; | |
| } | |
| return errorSummary; | |
| } | |
| /// Validates that the plugin has a supported platforms table following the | |
| /// expected format, returning an error string if any issues are found. | |
| String? _validateSupportedPlatforms( | |
| List<String> readmeLines, Pubspec pubspec) { | |
| // Example table following expected format: | |
| // | | Android | iOS | Web | | |
| // |----------------|---------|----------|------------------------| | |
| // | **Support** | SDK 21+ | iOS 10+* | [See `camera_web `][1] | | |
| final int detailsLineNumber = readmeLines | |
| .indexWhere((String line) => line.startsWith('| **Support**')); | |
| if (detailsLineNumber == -1) { | |
| return 'No OS support table found'; | |
| } | |
| final int osLineNumber = detailsLineNumber - 2; | |
| if (osLineNumber < 0 || !readmeLines[osLineNumber].startsWith('|')) { | |
| return 'OS support table does not have the expected header format'; | |
| } | |
| // Utility method to convert an iterable of strings to a case-insensitive | |
| // sorted, comma-separated string of its elements. | |
| String sortedListString(Iterable<String> entries) { | |
| final List<String> entryList = entries.toList(); | |
| entryList.sort( | |
| (String a, String b) => a.toLowerCase().compareTo(b.toLowerCase())); | |
| return entryList.join(', '); | |
| } | |
| // Validate that the supported OS lists match. | |
| final YamlMap pluginSection = pubspec.flutter!['plugin'] as YamlMap; | |
| final dynamic platformsEntry = pluginSection['platforms']; | |
| if (platformsEntry == null) { | |
| logWarning('Plugin not support any platforms'); | |
| return null; | |
| } | |
| final YamlMap platformSupportMaps = platformsEntry as YamlMap; | |
| final Set<String> actuallySupportedPlatform = | |
| platformSupportMaps.keys.toSet().cast<String>(); | |
| final Iterable<String> documentedPlatforms = readmeLines[osLineNumber] | |
| .split('|') | |
| .map((String entry) => entry.trim()) | |
| .where((String entry) => entry.isNotEmpty); | |
| final Set<String> documentedPlatformsLowercase = | |
| documentedPlatforms.map((String entry) => entry.toLowerCase()).toSet(); | |
| if (actuallySupportedPlatform.length != documentedPlatforms.length || | |
| actuallySupportedPlatform | |
| .intersection(documentedPlatformsLowercase) | |
| .length != | |
| actuallySupportedPlatform.length) { | |
| printError(''' | |
| ${indentation}OS support table does not match supported platforms: | |
| ${indentation * 2}Actual: ${sortedListString(actuallySupportedPlatform)} | |
| ${indentation * 2}Documented: ${sortedListString(documentedPlatformsLowercase)} | |
| '''); | |
| return 'Incorrect OS support table'; | |
| } | |
| // Enforce a standard set of capitalizations for the OS headings. | |
| final Iterable<String> incorrectCapitalizations = documentedPlatforms | |
| .toSet() | |
| .difference(_standardPlatformNames.values.toSet()); | |
| if (incorrectCapitalizations.isNotEmpty) { | |
| final Iterable<String> expectedVersions = incorrectCapitalizations | |
| .map((String name) => _standardPlatformNames[name.toLowerCase()]!); | |
| printError(''' | |
| ${indentation}Incorrect OS capitalization: ${sortedListString(incorrectCapitalizations)} | |
| ${indentation * 2}Please use standard capitalizations: ${sortedListString(expectedVersions)} | |
| '''); | |
| return 'Incorrect OS support formatting'; | |
| } | |
| // TODO(stuartmorgan): Add validation that the minimums in the table are | |
| // consistent with what the current implementations require. See | |
| // https://github.com/flutter/flutter/issues/84200 | |
| return null; | |
| } | |
| /// Validates [readmeLines], outputing error messages for any issue and | |
| /// returning an array of error summaries (if any). | |
| /// | |
| /// Returns an empty array if validation passes. | |
| List<String> _validateBoilerplate( | |
| List<String> readmeLines, { | |
| required RepositoryPackage mainPackage, | |
| required bool isExample, | |
| }) { | |
| final List<String> errors = <String>[]; | |
| if (_containsTemplateFlutterBoilerplate(readmeLines)) { | |
| printError('${indentation}The boilerplate section about getting started ' | |
| 'with Flutter should not be left in.'); | |
| errors.add('Contains template boilerplate'); | |
| } | |
| // Enforce a repository-standard message in implementation plugin examples, | |
| // since they aren't typical examples, which has been a source of | |
| // confusion for plugin clients who find them. | |
| if (isExample && mainPackage.isPlatformImplementation) { | |
| if (_containsExampleBoilerplate(readmeLines)) { | |
| printError('${indentation}The boilerplate should not be left in for a ' | |
| "federated plugin implementation package's example."); | |
| errors.add('Contains template boilerplate'); | |
| } | |
| if (!_containsImplementationExampleExplanation(readmeLines)) { | |
| printError('${indentation}The example README for a platform ' | |
| 'implementation package should warn readers about its intended ' | |
| 'use. Please copy the example README from another implementation ' | |
| 'package in this repository.'); | |
| errors.add('Missing implementation package example warning'); | |
| } | |
| } | |
| return errors; | |
| } | |
| /// Returns true if the README still has unwanted parts of the boilerplate | |
| /// from the `flutter create` templates. | |
| bool _containsTemplateFlutterBoilerplate(List<String> readmeLines) { | |
| return readmeLines.any((String line) => | |
| line.contains('For help getting started with Flutter')); | |
| } | |
| /// Returns true if the README still has the generic description of an | |
| /// example from the `flutter create` templates. | |
| bool _containsExampleBoilerplate(List<String> readmeLines) { | |
| return readmeLines | |
| .any((String line) => line.contains('Demonstrates how to use the')); | |
| } | |
| /// Returns true if the README contains the repository-standard explanation of | |
| /// the purpose of a federated plugin implementation's example. | |
| bool _containsImplementationExampleExplanation(List<String> readmeLines) { | |
| return readmeLines.contains('# Platform Implementation Test App') && | |
| readmeLines | |
| .any((String line) => line.contains('This is a test app for')); | |
| } | |
| } |