| // 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:yaml/yaml.dart'; |
| |
| import 'common/core.dart'; |
| import 'common/output_utils.dart'; |
| import 'common/package_looping_command.dart'; |
| import 'common/repository_package.dart'; |
| |
| const String _instructionUrl = |
| 'https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md'; |
| |
| /// A command to enforce README conventions across the repository. |
| class ReadmeCheckCommand extends PackageLoopingCommand { |
| /// Creates an instance of the README check command. |
| ReadmeCheckCommand( |
| super.packagesDir, { |
| super.processRunner, |
| super.platform, |
| super.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 |
| List<String> get aliases => <String>['check-readme']; |
| |
| @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 (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, as explained at\n' |
| '$_instructionUrl'); |
| 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'))) || |
| (readmeLines.contains('# Platform Implementation Test Apps') && |
| readmeLines.any( |
| (String line) => line.contains('These are test apps for'))); |
| } |
| } |