blob: a5e0bf77657438b0724618395dc6a9715eea35ba [file] [log] [blame]
// 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;
}
}