| // Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file |
| // for details. 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:analyzer/analysis_rule/rule_state.dart'; |
| import 'package:analyzer/src/lint/io.dart'; |
| import 'package:collection/collection.dart'; |
| import 'package:pub_semver/pub_semver.dart'; |
| import 'package:yaml/yaml.dart'; |
| |
| import 'util/path_utils.dart'; |
| |
| const _categoryNames = { |
| 'binarySize', |
| 'brevity', |
| 'documentationCommentMaintenance', |
| 'effectiveDart', |
| 'errorProne', |
| 'flutter', |
| 'languageFeatureUsage', |
| 'memoryLeaks', |
| 'nonPerformant', |
| 'pub', |
| 'publicInterface', |
| 'style', |
| 'unintentional', |
| 'unusedCode', |
| 'web', |
| }; |
| |
| const String _messagesFileName = 'pkg/linter/messages.yaml'; |
| |
| const _stateNames = { |
| 'experimental', |
| 'stable', |
| 'internal', |
| 'deprecated', |
| 'removed', |
| }; |
| |
| final Map<String, RuleInfo> messagesRuleInfo = () { |
| var messagesYaml = loadYamlNode(readFile(_messagesYamlPath)); |
| if (messagesYaml is! YamlMap) { |
| throw StateError("The '$_messagesFileName' file is not a YAML map."); |
| } |
| var lintCodes = messagesYaml['LintCode'] as YamlMap?; |
| if (lintCodes == null) { |
| throw StateError( |
| "The '_messagesFileName' file does not have a 'LintCode' section.", |
| ); |
| } |
| |
| { |
| var lintCodeKeys = lintCodes.keys.cast<String>().toList(growable: false); |
| var lintCodeKeysSorted = lintCodeKeys.sorted(); |
| for (var i = 0; i < lintCodeKeys.length; i++) { |
| if (lintCodeKeys[i] != lintCodeKeysSorted[i]) { |
| throw StateError( |
| "The LintCode entries in '_messagesFileName' " |
| "are not sorted alphabetically, starting at '${lintCodeKeys[i]}'.", |
| ); |
| } |
| } |
| } |
| |
| var builders = <String, _RuleBuilder>{}; |
| for (var MapEntry(key: String uniqueName, value: YamlMap data) |
| in lintCodes.entries) { |
| String sharedName; |
| if (data.containsKey('sharedName')) { |
| sharedName = data['sharedName'] as String; |
| } else { |
| sharedName = uniqueName; |
| } |
| var rule = builders.putIfAbsent(sharedName, () => _RuleBuilder(sharedName)); |
| rule.addEntry(uniqueName, data); |
| } |
| |
| return builders.map((key, value) => MapEntry(key, value.build())); |
| }(); |
| |
| final String _messagesYamlPath = pathRelativeToPackageRoot(['messages.yaml']); |
| |
| class CodeInfo { |
| final String uniqueName; |
| final String problemMessage; |
| final String? correctionMessage; |
| |
| CodeInfo( |
| this.uniqueName, { |
| required this.problemMessage, |
| this.correctionMessage, |
| }); |
| } |
| |
| class RuleInfo { |
| final String name; |
| final List<CodeInfo> codes; |
| final List<RuleState> states; |
| final Set<String> categories; |
| final bool hasPublishedDocs; |
| final String? documentation; |
| final String deprecatedDetails; |
| final bool removed; |
| |
| RuleInfo({ |
| required this.name, |
| required this.codes, |
| required this.categories, |
| required this.hasPublishedDocs, |
| required this.documentation, |
| required this.deprecatedDetails, |
| required this.states, |
| required this.removed, |
| }); |
| } |
| |
| // TODO(parlough): Clean up and simplify this validation |
| // once the `messages.yaml` format is more stabilized. |
| class _RuleBuilder { |
| final String sharedName; |
| final List< |
| ({String uniqueName, String? problemMessage, String? correctionMessage}) |
| > |
| _codes = []; |
| List<({String name, Version version})>? _stateEntries; |
| Set<String>? _categories; |
| bool? _hasPublishedDocs; |
| String? _documentation; |
| String? _deprecatedDetails; |
| |
| _RuleBuilder(this.sharedName); |
| |
| bool get _wasRemoved => |
| _stateEntries?.any((state) => state.name == 'removed') ?? false; |
| |
| void addEntry(String uniqueName, YamlMap data) { |
| _addCode(uniqueName, data); |
| |
| _setStates(data); |
| _setCategories(data); |
| _setDeprecatedDetails(data); |
| _setDocumentation(data); |
| _setHasPublishedDocs(data); |
| } |
| |
| RuleInfo build() => RuleInfo( |
| name: sharedName, |
| codes: _validateCodes(), |
| states: _validateStates(), |
| categories: _requireSpecified( |
| 'categories', |
| _categories, |
| ifNotRemovedFallback: const {}, |
| ), |
| hasPublishedDocs: _hasPublishedDocs ?? false, |
| documentation: _documentation, |
| deprecatedDetails: _requireSpecified( |
| 'deprecatedDetails', |
| _deprecatedDetails, |
| ), |
| removed: _wasRemoved, |
| ); |
| |
| void _addCode(String name, Map<Object?, Object?> data) { |
| if (_codes.map((code) => code.uniqueName).any((n) => n == name)) { |
| _throwLintError( |
| "Has more than one LintCode with '$name' as its 'uniqueName'.", |
| ); |
| } |
| |
| String? problemMessage; |
| if (data.containsKey('problemMessage')) { |
| problemMessage = _requireType('problemMessage', data['problemMessage']); |
| } |
| |
| String? correctionMessage; |
| if (data.containsKey('correctionMessage')) { |
| correctionMessage = _requireType( |
| 'correctionMessage', |
| data['correctionMessage'], |
| ); |
| } |
| |
| _codes.add(( |
| uniqueName: name, |
| problemMessage: problemMessage, |
| correctionMessage: correctionMessage, |
| )); |
| } |
| |
| Never _alreadySpecified(String propertyName) { |
| _throwLintError( |
| "More than one LintCode specified the '$propertyName' property.", |
| ); |
| } |
| |
| void _requireNotEmpty(String propertyName, String value) { |
| if (value.trim().isEmpty) { |
| _throwLintError("The '$propertyName' value must not be empty."); |
| } |
| } |
| |
| T _requireSpecified<T extends Object>( |
| String propertyName, |
| T? value, { |
| T? ifNotRemovedFallback, |
| }) { |
| if (value == null) { |
| if (_wasRemoved && ifNotRemovedFallback != null) { |
| return ifNotRemovedFallback; |
| } |
| _throwLintError("The '$propertyName' property must be specified."); |
| } |
| |
| return value; |
| } |
| |
| T _requireType<T extends Object?>(String propertyName, Object? value) { |
| if (value is! T) { |
| _throwLintError("The '$propertyName' property must be of type '$T'."); |
| } |
| |
| return value; |
| } |
| |
| Iterable<T> _requireTypeForItems<T extends Object?>( |
| String propertyName, |
| Iterable<Object?> items, |
| ) { |
| for (var item in items) { |
| if (item is! T) { |
| _throwLintError( |
| "The items in the '$propertyName' collection must " |
| "each be of type '$T'.", |
| ); |
| } |
| } |
| |
| return items.cast<T>(); |
| } |
| |
| void _setCategories(Map<Object?, Object?> data) { |
| const propertyName = 'categories'; |
| if (!data.containsKey(propertyName)) return; |
| |
| var value = data[propertyName]; |
| if (_categories != null) _alreadySpecified(propertyName); |
| |
| var categoryValues = _requireType<Iterable<Object?>>(propertyName, value); |
| var categoryStrings = _requireTypeForItems<String>( |
| propertyName, |
| categoryValues, |
| ); |
| |
| var countWithDuplicates = categoryStrings.length; |
| var categoriesSet = categoryStrings.toSet(); |
| if (countWithDuplicates != categoriesSet.length) { |
| _throwLintError("The '$propertyName' property must not have duplicates."); |
| } |
| |
| for (var category in categoriesSet) { |
| if (!_categoryNames.contains(category)) { |
| _throwLintError("The specified '$category' category is invalid."); |
| } |
| } |
| |
| _categories = categoriesSet; |
| } |
| |
| void _setDeprecatedDetails(Map<Object?, Object?> data) { |
| const propertyName = 'deprecatedDetails'; |
| if (!data.containsKey(propertyName)) return; |
| |
| var value = data[propertyName]; |
| if (_deprecatedDetails != null) _alreadySpecified(propertyName); |
| |
| var deprecatedDetails = _requireType<String>(propertyName, value); |
| _requireNotEmpty(propertyName, deprecatedDetails); |
| _deprecatedDetails = deprecatedDetails; |
| } |
| |
| void _setDocumentation(Map<Object?, Object?> data) { |
| const propertyName = 'documentation'; |
| if (!data.containsKey(propertyName)) return; |
| |
| var value = data[propertyName]; |
| if (_documentation != null) _alreadySpecified(propertyName); |
| |
| var documentationValue = _requireType<String>(propertyName, value); |
| _requireNotEmpty(propertyName, documentationValue); |
| _documentation = documentationValue; |
| } |
| |
| void _setHasPublishedDocs(Map<Object?, Object?> data) { |
| const propertyName = 'hasPublishedDocs'; |
| if (!data.containsKey(propertyName)) return; |
| |
| var value = data[propertyName]; |
| var hasPublishedValue = _requireType<bool>(propertyName, value); |
| _hasPublishedDocs = hasPublishedValue || (_hasPublishedDocs ?? false); |
| } |
| |
| void _setStates(Map<Object?, Object?> data) { |
| const propertyName = 'state'; |
| if (!data.containsKey(propertyName)) return; |
| |
| var value = data[propertyName]; |
| if (_stateEntries != null) _alreadySpecified(propertyName); |
| |
| var stateValue = _requireType<Map<Object?, Object?>>(propertyName, value); |
| |
| _stateEntries = |
| stateValue.entries.map((state) { |
| var stateName = state.key; |
| var version = state.value; |
| if (stateName is! String || version is! String) { |
| _throwLintError('Each state key and value must be a string.'); |
| } |
| |
| if (!_stateNames.contains(stateName)) { |
| _throwLintError('$stateName is not a valid state name.'); |
| } |
| |
| try { |
| var parsedVersion = Version.parse('$version.0'); |
| return (name: stateName, version: parsedVersion); |
| } on Exception { |
| _throwLintError( |
| 'The state versions must be in ' |
| "'major.minor' format, but found '$version'.", |
| ); |
| } |
| }).toList(); |
| } |
| |
| Never _throwLintError(String message) { |
| throw StateError('$sharedName - $message'); |
| } |
| |
| List<CodeInfo> _validateCodes() { |
| if (_wasRemoved) return const []; |
| |
| if (_codes.isEmpty) { |
| throw StateError('Tried to call build a RuleInfo without a code added!'); |
| } |
| |
| var codeInfos = <CodeInfo>[]; |
| for (var code in _codes) { |
| var problemMessage = code.problemMessage; |
| if (problemMessage == null) { |
| _throwLintError( |
| "'LintCode.${code.uniqueName}' is missing a 'problemMessage'.", |
| ); |
| } |
| |
| // TODO(parlough): Eventually require that codes have a correction message. |
| // var correctionMessage = code.correctionMessage; |
| // if (code.correctionMessage == null) { |
| // _throwLintError("'LintCode.${code.uniqueName}' is missing a 'correctionMessage'."); |
| // } |
| |
| codeInfos.add( |
| CodeInfo( |
| code.uniqueName, |
| problemMessage: problemMessage, |
| correctionMessage: code.correctionMessage, |
| ), |
| ); |
| } |
| |
| return codeInfos; |
| } |
| |
| List<RuleState> _validateStates() { |
| var states = _stateEntries; |
| if (states == null || states.isEmpty) { |
| throw StateError('Tried to build a RuleInfo without a state added!'); |
| } |
| |
| var sortedStates = states |
| .map( |
| (state) => switch (state.name) { |
| 'experimental' => RuleState.experimental(since: state.version), |
| 'stable' => RuleState.stable(since: state.version), |
| 'internal' => RuleState.internal(since: state.version), |
| 'deprecated' => RuleState.deprecated(since: state.version), |
| 'removed' => RuleState.removed(since: state.version), |
| _ => _throwLintError('Unexpected state name: ${state.name}.'), |
| }, |
| ) |
| .sortedBy<VersionRange>((state) => state.since ?? Version.none); |
| |
| return sortedStates; |
| } |
| } |