| // Copyright (c) 2025, 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. |
| |
| /// @docImport 'package:analyzer/src/fasta/error_converter.dart'; |
| library; |
| |
| import 'dart:convert'; |
| import 'dart:io'; |
| |
| import 'package:analyzer_testing/package_root.dart' as pkg_root; |
| import 'package:analyzer_utilities/analyzer_messages.dart'; |
| import 'package:analyzer_utilities/extensions/string.dart'; |
| import 'package:analyzer_utilities/located_error.dart'; |
| import 'package:collection/collection.dart'; |
| import 'package:path/path.dart'; |
| import 'package:source_span/source_span.dart'; |
| import 'package:yaml/yaml.dart' |
| show YamlMap, YamlScalar, YamlNode, loadYamlNode; |
| |
| const Map<String, String> severityEnumNames = <String, String>{ |
| 'CONTEXT': 'context', |
| 'IGNORED': 'ignored', |
| 'INTERNAL_PROBLEM': 'internalProblem', |
| 'WARNING': 'warning', |
| 'INFO': 'info', |
| }; |
| |
| /// A set of tables derived from shared, CFE, analyzer, and linter diagnostics. |
| /// |
| /// For details see the documentation for fields in the [DiagnosticTables] |
| /// class. |
| final DiagnosticTables diagnosticTables = DiagnosticTables._([ |
| ...frontEndMessages, |
| ...feAnalyzerSharedMessages, |
| ...analyzerMessages, |
| ...analysisServerMessages, |
| ...lintMessages, |
| ]); |
| |
| /// Decoded messages from the `_fe_analyzer_shared` package's `messages.yaml` |
| /// file. |
| final List<SharedMessage> feAnalyzerSharedMessages = _loadCfeStyleMessages( |
| feAnalyzerSharedPkgPath, |
| decodeMessage: SharedMessage.new, |
| ); |
| |
| /// The path to the `fe_analyzer_shared` package. |
| final String feAnalyzerSharedPkgPath = normalize( |
| join(pkg_root.packageRoot, '_fe_analyzer_shared'), |
| ); |
| |
| /// Decoded messages from the front end's `messages.yaml` file. |
| final List<FrontEndMessage> frontEndMessages = _loadCfeStyleMessages( |
| frontEndPkgPath, |
| decodeMessage: FrontEndMessage.new, |
| ); |
| |
| /// The path to the `front_end` package. |
| final String frontEndPkgPath = normalize( |
| join(pkg_root.packageRoot, 'front_end'), |
| ); |
| |
| /// Pattern formerly used by the analyzer to identify placeholders in error |
| /// message strings. |
| /// |
| /// (This pattern is still used internally by the analyzer implementation, but |
| /// it is no longer supported in `messages.yaml`.) |
| final RegExp oldPlaceholderPattern = RegExp(r'\{\d+\}'); |
| |
| /// Pattern for placeholders in error message strings. |
| final RegExp placeholderPattern = RegExp( |
| '#([-a-zA-Z0-9_]+)(?:%([0-9]*).([0-9]+))?', |
| ); |
| |
| /// Converts a template to an analyzer internal template string (which uses |
| /// placeholders like `{0}`). |
| String convertTemplate(List<TemplatePart> template) { |
| return template |
| .map( |
| (part) => switch (part) { |
| TemplateLiteralPart(:var text) => text, |
| TemplateParameterPart(:var parameter) => '{${parameter.index}}', |
| }, |
| ) |
| .join(); |
| } |
| |
| /// Decodes a YAML object (in CFE style `messages.yaml` format) into a list of |
| /// [CfeStyleMessage]s. |
| List<T> decodeCfeStyleMessagesYaml<T extends CfeStyleMessage>( |
| YamlNode yaml, { |
| required T Function(MessageYaml) decodeMessage, |
| required String path, |
| }) { |
| var result = <T>[]; |
| if (yaml is! YamlMap) { |
| throw LocatedError('root node is not a map', span: yaml.span); |
| } |
| for (var entry in yaml.nodes.entries) { |
| var keyNode = entry.key as YamlScalar; |
| var diagnosticName = keyNode.value; |
| if (diagnosticName is! String) { |
| throw LocatedError( |
| 'non-string diagnostic key ${json.encode(diagnosticName)}', |
| span: keyNode.span, |
| ); |
| } |
| var diagnosticValue = entry.value; |
| if (diagnosticValue is! YamlMap) { |
| throw LocatedError( |
| 'value associated with diagnostic $diagnosticName is not a map', |
| span: diagnosticValue.span, |
| ); |
| } |
| result.add( |
| MessageYaml.decode( |
| key: keyNode, |
| value: diagnosticValue, |
| decoder: decodeMessage, |
| ), |
| ); |
| } |
| return result; |
| } |
| |
| /// Loads messages in CFE style `messages.yaml` format. |
| List<T> _loadCfeStyleMessages<T extends CfeStyleMessage>( |
| String packagePath, { |
| required T Function(MessageYaml) decodeMessage, |
| }) { |
| var path = join(packagePath, 'messages.yaml'); |
| var messagesYaml = loadYamlNode( |
| File(path).readAsStringSync(), |
| sourceUrl: Uri.file(path), |
| ); |
| return decodeCfeStyleMessagesYaml( |
| messagesYaml, |
| decodeMessage: decodeMessage, |
| path: path, |
| ); |
| } |
| |
| /// An analyzer error code, consisting of an optional class name and an error |
| /// name. |
| /// |
| /// This class implements [operator==] and [hashCode] so it can be used as a map |
| /// key or in a set. |
| /// |
| /// This class implements [Comparable], so lists of it can be safely |
| /// [List.sort]ed. |
| class AnalyzerCode implements Comparable<AnalyzerCode> { |
| /// The class containing the constant for this diagnostic. |
| final DiagnosticClassInfo diagnosticClass; |
| |
| /// The diagnostic name. |
| /// |
| /// The diagnostic name is in "snake case", meaning it consists of words |
| /// separated by underscores. Those words might be lower case or upper case. |
| /// |
| // TODO(paulberry): change `messages.yaml` to consistently use lower snake |
| // case. |
| final String snakeCaseName; |
| |
| AnalyzerCode({required this.diagnosticClass, required this.snakeCaseName}); |
| |
| /// The string that should be generated into analyzer source code to refer to |
| /// this diagnostic code. |
| String get analyzerCodeReference => ['diag', camelCaseName].join('.'); |
| |
| /// The diagnostic name, converted to camel case. |
| String get camelCaseName => snakeCaseName.toCamelCase(); |
| |
| @override |
| int get hashCode => Object.hash(diagnosticClass, snakeCaseName); |
| |
| /// The diagnostic name, converted to PascalCase. |
| String get pascalCaseName => snakeCaseName.toPascalCase(); |
| |
| @override |
| bool operator ==(Object other) => |
| other is AnalyzerCode && |
| diagnosticClass == other.diagnosticClass && |
| snakeCaseName == other.snakeCaseName; |
| |
| @override |
| int compareTo(AnalyzerCode other) { |
| // Compare the diagnostic classes by name. This works because we know that |
| // the diagnostic classes are unique (this is verified by the |
| // `DiagnosticClassInfo.byName` method). |
| var className = diagnosticClass.name; |
| var otherClassName = other.diagnosticClass.name; |
| if (className.compareTo(otherClassName) case var result when result != 0) { |
| return result; |
| } |
| return snakeCaseName.compareTo(other.snakeCaseName); |
| } |
| |
| @override |
| String toString() => [diagnosticClass.name, snakeCaseName].join('.'); |
| } |
| |
| /// In-memory representation of diagnostic information obtained from a |
| /// `messages.yaml` file in `pkg/front_end` or `pkg/_fe_analyzer_shared`. |
| abstract class CfeStyleMessage extends Message { |
| /// The name of the [CfeSeverity] constant describing this diagnostic's CFE |
| /// severity. |
| final String? cfeSeverity; |
| |
| /// The name used by the front end to refer to this diagnostic. |
| /// |
| /// This is the key corresponding to the diagnostic's entry in |
| /// `messages.yaml`. |
| final String frontEndCode; |
| |
| CfeStyleMessage(MessageYaml messageYaml) |
| : cfeSeverity = messageYaml.get( |
| 'severity', |
| decode: (node) { |
| switch (node) { |
| case YamlScalar(:var value): |
| if (value == 'ERROR') { |
| throw 'The "ERROR" severity is the default and not necessary.'; |
| } |
| return severityEnumNames[value] ?? |
| (throw "Unknown severity '$value'"); |
| default: |
| return throw 'Bad severity type: ${node.runtimeType}'; |
| } |
| }, |
| ifAbsent: () => null, |
| ), |
| frontEndCode = messageYaml.keyString, |
| super(messageYaml, requireProblemMessage: true) { |
| // Ignore extra keys related to front end example-based tests. |
| messageYaml.allowExtraKeys({ |
| 'bytes', |
| 'declaration', |
| 'exampleAllowMultipleReports', |
| 'exampleAllowOtherCodes', |
| 'experiments', |
| 'expression', |
| 'external', |
| 'includeErrorContext', |
| 'script', |
| 'statement', |
| }); |
| } |
| } |
| |
| /// Information about how to convert the CFE's internal representation of a |
| /// template parameter to a string. |
| /// |
| /// Instances of this class should implement [==] and [hashCode] so that they |
| /// can be used as keys in a [Map]. |
| sealed class Conversion { |
| /// Returns Dart code that applies the conversion to a template parameter |
| /// having the given [name] and [type]. |
| /// |
| /// If no conversion is needed, returns `null`. |
| String? toCode({required String name, required DiagnosticParameterType type}); |
| } |
| |
| /// In-memory representation of a single key/value pair from the `parameters` |
| /// map for a diagnostic. |
| class DiagnosticParameter { |
| final String name; |
| final DiagnosticParameterType type; |
| final String comment; |
| final int index; |
| |
| DiagnosticParameter({ |
| required this.name, |
| required this.type, |
| required this.comment, |
| required this.index, |
| }); |
| } |
| |
| /// In-memory representation of the type of a single diagnostic code's |
| /// parameter. |
| enum DiagnosticParameterType { |
| character( |
| messagesYamlName: 'Character', |
| cfeName: 'String', |
| cfeConversion: SimpleConversion('validateCharacter'), |
| ), |
| constant( |
| messagesYamlName: 'Constant', |
| cfeName: 'Constant', |
| cfeConversion: LabelerConversion('labelConstant'), |
| ), |
| element(messagesYamlName: 'Element', analyzerName: 'Element'), |
| int(messagesYamlName: 'int', analyzerName: 'int', cfeName: 'int'), |
| name( |
| messagesYamlName: 'Name', |
| cfeName: 'String', |
| cfeConversion: SimpleConversion('validateAndDemangleName'), |
| ), |
| nameOKEmpty( |
| messagesYamlName: 'NameOKEmpty', |
| cfeName: 'String', |
| cfeConversion: SimpleConversion('nameOrUnnamed'), |
| ), |
| names( |
| messagesYamlName: 'Names', |
| cfeName: 'List<String>', |
| cfeConversion: SimpleConversion('validateAndItemizeNames'), |
| ), |
| num( |
| messagesYamlName: 'num', |
| cfeName: 'num', |
| cfeConversion: SimpleConversion('formatNumber'), |
| ), |
| object(messagesYamlName: 'Object', analyzerName: 'Object'), |
| string( |
| messagesYamlName: 'String', |
| analyzerName: 'String', |
| cfeName: 'String', |
| cfeConversion: SimpleConversion('validateString'), |
| ), |
| stringOKEmpty( |
| messagesYamlName: 'StringOKEmpty', |
| analyzerName: 'String', |
| cfeName: 'String', |
| cfeConversion: SimpleConversion('stringOrEmpty'), |
| ), |
| token( |
| messagesYamlName: 'Token', |
| cfeName: 'Token', |
| cfeConversion: SimpleConversion('tokenToLexeme'), |
| ), |
| type( |
| messagesYamlName: 'Type', |
| analyzerName: 'DartType', |
| cfeName: 'DartType', |
| cfeConversion: LabelerConversion('labelType'), |
| ), |
| unicode( |
| messagesYamlName: 'Unicode', |
| cfeName: 'int', |
| cfeConversion: SimpleConversion('codePointToUnicode'), |
| ), |
| uri( |
| messagesYamlName: 'Uri', |
| analyzerName: 'Uri', |
| cfeName: 'Uri', |
| cfeConversion: SimpleConversion('relativizeUri'), |
| ); |
| |
| /// Map from [messagesYamlName] to the enum constant. |
| /// |
| /// Used for decoding parameter types from `messages.yaml`. |
| static final _messagesYamlNameToValue = { |
| for (var value in values) value.messagesYamlName: value, |
| }; |
| |
| /// Name of this type as it appears in `messages.yaml`. |
| final String messagesYamlName; |
| |
| /// Name of this type as it appears in analyzer source code. |
| /// |
| /// If `null`, diagnostic messages using parameters of this type are not yet |
| /// supported by the analyzer (see [isSupportedByAnalyzer]) |
| final String? _analyzerName; |
| |
| /// Name of this type as it appears in CFE source code. |
| /// |
| /// If `null`, diagnostic messages using parameters of this type are not |
| /// supported by the CFE. |
| final String? cfeName; |
| |
| /// How to convert the CFE's internal representation of a template parameter |
| /// to a string. |
| /// |
| /// This field will be `null` if either: |
| /// - Diagnostic messages using parameters of this type are not supported by |
| /// the CFE (and hence no CFE conversion is needed), or |
| /// - No CFE conversion is needed because the type's `toString` method is |
| /// sufficient. |
| final Conversion? cfeConversion; |
| |
| const DiagnosticParameterType({ |
| required this.messagesYamlName, |
| String? analyzerName, |
| this.cfeName, |
| this.cfeConversion, |
| }) : _analyzerName = analyzerName; |
| |
| /// Decodes a type name from `messages.yaml` into a [DiagnosticParameterType]. |
| factory DiagnosticParameterType.fromMessagesYamlName(String name) => |
| _messagesYamlNameToValue[name] ?? (throw 'Unknown type name: $name'); |
| |
| String get analyzerName => |
| _analyzerName ?? |
| (throw 'No analyzer support for type ${json.encode(messagesYamlName)}'); |
| |
| /// Whether giatnostic messages using parameters of this type are supported by |
| /// the analyzer. |
| bool get isSupportedByAnalyzer => _analyzerName != null; |
| } |
| |
| /// A set of tables derived from shared, CFE, analyzer, and linter diagnostics. |
| class DiagnosticTables { |
| /// List of shared diagnostics for which analyzer diagnostics should be |
| /// automatically generated, sorted by [AnalyzerCode.camelCaseName]. |
| final List<SharedMessage> sortedSharedDiagnostics = []; |
| |
| /// List of front end diagnostics, sorted by front end code. |
| final List<CfeStyleMessage> sortedFrontEndDiagnostics = []; |
| |
| /// Map from [AnalyzerDiagnosticPackage] to the list of active diagnostic |
| /// messages for that package. |
| /// |
| /// A message is considered active is [MessageWithAnalyzerCode.isRemoved] is |
| /// `false` and the message is not an [AliasMessage]. |
| /// |
| /// Each list is sorted by [AnalyzerCode.camelCaseName]. |
| final Map<AnalyzerDiagnosticPackage, List<MessageWithAnalyzerCode>> |
| activeMessagesByPackage = {}; |
| |
| DiagnosticTables._(List<Message> messages) { |
| var frontEndCodeDuplicateChecker = _DuplicateChecker<String>( |
| kind: 'Front end code', |
| ); |
| var analyzerCodeDuplicateChecker = _DuplicateChecker<AnalyzerCode>( |
| kind: 'Analyzer code', |
| ); |
| var analyzerCodeCamelCaseNameDuplicateChecker = _DuplicateChecker<String>( |
| kind: 'Analyzer code camelCase name', |
| ); |
| var analyzerSharedNameToMessages = |
| <String, List<MessageWithAnalyzerCode>>{}; |
| for (var message in messages) { |
| if (message is CfeStyleMessage) { |
| var frontEndCode = message.frontEndCode; |
| frontEndCodeDuplicateChecker[frontEndCode] = message; |
| sortedFrontEndDiagnostics.add(message); |
| } |
| if (message is SharedMessage) { |
| sortedSharedDiagnostics.add(message); |
| } |
| if (message is MessageWithAnalyzerCode) { |
| var analyzerCode = message.analyzerCode; |
| analyzerCodeDuplicateChecker[analyzerCode] = message; |
| analyzerCodeCamelCaseNameDuplicateChecker[analyzerCode.camelCaseName] = |
| message; |
| (analyzerSharedNameToMessages[message.sharedName ?? |
| analyzerCode.snakeCaseName] ??= |
| []) |
| .add(message); |
| var package = message.package; |
| if (analyzerCode.diagnosticClass.file.package != package) { |
| throw LocatedError( |
| 'Expected to generate this analyzer code to ' |
| 'package:${package.dirName}, but its diagnostic class ' |
| '(${analyzerCode.diagnosticClass.name}) lives in ' |
| 'package:${analyzerCode.diagnosticClass.file.package.dirName}', |
| span: message.keySpan, |
| ); |
| } |
| if (!message.isRemoved && message is! AliasMessage) { |
| (activeMessagesByPackage[package] ??= []).add(message); |
| } |
| } |
| } |
| |
| analyzerCodeDuplicateChecker.check(); |
| analyzerCodeCamelCaseNameDuplicateChecker.check(); |
| frontEndCodeDuplicateChecker.check(); |
| _checkSharedNames(analyzerSharedNameToMessages); |
| |
| sortedSharedDiagnostics.sortBy((e) => e.analyzerCode.camelCaseName); |
| sortedFrontEndDiagnostics.sortBy((e) => e.frontEndCode); |
| for (var value in activeMessagesByPackage.values) { |
| value.sortBy((e) => e.analyzerCode.camelCaseName); |
| } |
| } |
| |
| static void _checkSharedNames( |
| Map<String, List<MessageWithAnalyzerCode>> analyzerSharedNameToMessages, |
| ) { |
| for (var MapEntry(key: sharedName, value: messages) |
| in analyzerSharedNameToMessages.entries) { |
| if (messages case [ |
| var message, |
| ] when sharedName != message.analyzerCode.snakeCaseName) { |
| var sharedNameJson = json.encode(sharedName); |
| throw LocatedError( |
| 'This is the only message that uses shared name ' |
| '$sharedNameJson. The message should be renamed to $sharedNameJson.', |
| span: message.keySpan, |
| ); |
| } |
| } |
| } |
| } |
| |
| /// In-memory representation of diagnostic information obtained from the file |
| /// `pkg/front_end/messages.yaml`. |
| class FrontEndMessage extends CfeStyleMessage { |
| /// The value of the `pseudoSharedCode` property in |
| /// `pkg/front_end/messages.yaml`. |
| /// |
| /// Messages with this property set are not shared; they have separately |
| /// declared analyzer and CFE codes. However, they are reported by code |
| /// defined in `pkg/_fe_analyzer_shared` using the CFE error reporting |
| /// mechanism. When running under the analyzer, they are then translated |
| /// into the associated analyzer error using [FastaErrorReporter]. |
| // TODO(paulberry): migrate all pseudo-shared error codes to shared error |
| // codes. |
| final String? pseudoSharedCode; |
| |
| FrontEndMessage(super.messageYaml) |
| : pseudoSharedCode = messageYaml.getOptionalString('pseudoSharedCode'); |
| } |
| |
| /// Representation of a single file containing generated diagnostics. |
| class GeneratedDiagnosticFile { |
| /// The file path (relative to the SDK's `pkg` directory) of the generated |
| /// file. |
| final String path; |
| |
| /// The URI of the library that the generated file will be a part of. |
| final String parentLibrary; |
| |
| /// The package that the generated file will be part of. |
| final AnalyzerDiagnosticPackage package; |
| |
| const GeneratedDiagnosticFile({ |
| required this.path, |
| required this.parentLibrary, |
| this.package = AnalyzerDiagnosticPackage.analyzer, |
| }); |
| } |
| |
| /// A [Conversion] that makes use of the [TypeLabeler] class. |
| class LabelerConversion implements Conversion { |
| /// The name of the [TypeLabeler] method to call. |
| final String methodName; |
| |
| const LabelerConversion(this.methodName); |
| |
| @override |
| int get hashCode => Object.hash(runtimeType, methodName.hashCode); |
| |
| @override |
| bool operator ==(Object other) => |
| other is LabelerConversion && other.methodName == methodName; |
| |
| @override |
| String toCode({ |
| required String name, |
| required DiagnosticParameterType type, |
| }) => 'labeler.$methodName($name)'; |
| } |
| |
| /// In-memory representation of diagnostic information obtained from either the |
| /// analyzer or the front end's `messages.yaml` file. This class contains the |
| /// common functionality supported by both formats. |
| abstract class Message { |
| /// If present, a documentation comment that should be associated with the |
| /// diagnostic in code generated output. |
| final String? comment; |
| |
| /// If the diagnostic has an associated correctionMessage, the template for |
| /// it. |
| final List<TemplatePart>? correctionMessage; |
| |
| /// If non-null, the deprecation message for this error code. |
| final String? deprecatedMessage; |
| |
| /// If present, user-facing documentation for the error. |
| final String? documentation; |
| |
| /// Indicates whether this error is caused by an unresolved identifier. |
| final bool isUnresolvedIdentifier; |
| |
| /// The problemMessage for the error code. |
| final List<TemplatePart> problemMessage; |
| |
| /// If present, the SDK version this error code stopped being reported in. |
| /// If not null, error codes will not be generated for this error. |
| final String? removedIn; |
| |
| /// If present, indicates that this error code has a special name for |
| /// presentation to the user, that is potentially shared with other error |
| /// codes. |
| final String? sharedName; |
| |
| /// If present, indicates that this error code has been renamed from |
| /// [previousName] to its current name (or [sharedName]). |
| final String? previousName; |
| |
| /// Map describing the parameters for this diagnostic, obtained from the |
| /// `parameters` entry in the yaml file. |
| /// |
| /// Map keys are parameter names. Map values are [DiagnosticParameter] objects. |
| final Map<String, DiagnosticParameter> parameters; |
| |
| /// SourceSpan for the YAML value that this [Message] was parsed from. |
| /// |
| /// This exists to make it easier for automated scripts to edit the YAML |
| /// source. |
| final SourceSpan valueSpan; |
| |
| /// SourceSpan for the YAML key that this [Message] was parsed from. |
| /// |
| /// This exists to make it easier for automated scripts to edit the YAML |
| /// source. |
| final SourceSpan keySpan; |
| |
| /// The key from the key/value pair that defines the message, expressed as a |
| /// [String]. |
| final String keyString; |
| |
| /// Decodes a [Message] object from its YAML representation. |
| Message(MessageYaml messageYaml, {bool requireProblemMessage = false}) |
| : comment = messageYaml.getOptionalString('comment'), |
| correctionMessage = messageYaml.getMessageTemplate( |
| 'correctionMessage', |
| isRequired: false, |
| ), |
| deprecatedMessage = messageYaml.getOptionalString('deprecatedMessage'), |
| documentation = messageYaml.getOptionalString('documentation'), |
| isUnresolvedIdentifier = |
| messageYaml.getOptionalBool('isUnresolvedIdentifier') ?? false, |
| problemMessage = |
| messageYaml.getMessageTemplate( |
| 'problemMessage', |
| isRequired: requireProblemMessage, |
| ) ?? |
| [], |
| sharedName = messageYaml.getOptionalString('sharedName'), |
| removedIn = messageYaml.getOptionalString('removedIn'), |
| previousName = messageYaml.getOptionalString('previousName'), |
| parameters = messageYaml.parameters, |
| valueSpan = messageYaml.valueSpan, |
| keySpan = messageYaml.keySpan, |
| keyString = messageYaml.keyString; |
| |
| /// If this diagnostic is no longer reported and |
| /// its diagnostic codes should no longer be generated. |
| bool get isRemoved => removedIn != null; |
| |
| /// A string suitable for identifying the location of this message's key node |
| /// in the source YAML file. |
| String get location => keySpan.location; |
| } |
| |
| /// The raw YAML key/value pair representing a single diagnostic message. |
| /// |
| /// This class provides methods and getters for validating that the message is |
| /// well-formed and decoding it. |
| class MessageYaml { |
| /// The YAML key node from the key/value pair that defines the message. |
| final YamlScalar _key; |
| |
| /// The YAML value node from key/value pair that defines the message. This is |
| /// always a [YamlMap]. |
| final YamlMap _map; |
| |
| /// The parameters listed under the `parameters` key, decoded into a map. |
| late final Map<String, DiagnosticParameter> parameters = get( |
| 'parameters', |
| decode: _decodeParameters, |
| ); |
| |
| /// The set of keys that the message is permitted to contain. |
| /// |
| /// Keys are added to this set while the message is being decoded. At the end |
| /// of the [decode] method, if there are any keys in [_map] that aren't in |
| /// this set, an exception will be thrown to report them as unexpected keys. |
| final Set<String> _permittedKeys = {}; |
| |
| MessageYaml._(this._key, this._map); |
| |
| /// The span of the YAML key node from the key/value pair that defines the |
| /// message. |
| SourceSpan get keySpan => _key.span; |
| |
| /// The key from the key/value pair that defines the message, expressed as a |
| /// [String]. |
| String get keyString => _key.value.toString(); |
| |
| /// The span of the YAML value node from the key/value pair that defines the |
| /// message. |
| SourceSpan get valueSpan => _map.span; |
| |
| /// Adds [extraKeys] to the set of keys that the message is allowed to |
| /// contain. |
| /// |
| /// At the end of the [decode] method, if there are any keys that haven't been |
| /// passed to either this method or one of the `get` methods, an exception |
| /// will be thrown to report them as unexpected keys. |
| void allowExtraKeys(Iterable<String> extraKeys) { |
| _permittedKeys.addAll(extraKeys); |
| } |
| |
| /// Attempts to decode the YAML value associated with [key]. |
| /// |
| /// If an entry is present with the given [key], the corresponding value |
| /// [YamlNode] is passed to the [decode] callback for decoding. |
| /// |
| /// If no entry is present and a non-null value was supplied for the |
| /// [ifAbsent] callback, it is invoked to obtain the default value. |
| /// |
| /// If no entry is present and [ifAbsent] is `null` (the default), an |
| /// exception is thrown. |
| /// |
| /// Any exceptions that occur during execution of the [decode] or [ifAbsent] |
| /// callback are converted to [LocatedError] (if necessary), and tagged with |
| /// an appropriate location in the source YAML file. |
| T get<T>( |
| String key, { |
| required T Function(YamlNode) decode, |
| T Function()? ifAbsent, |
| }) { |
| _permittedKeys.add(key); |
| if (_map.nodes[key] case var node?) { |
| return LocatedError.wrap(() => decode(node), span: node.span); |
| } else { |
| return LocatedError.wrap( |
| ifAbsent ?? (() => throw 'Missing key ${json.encode(key)}'), |
| span: keySpan, |
| ); |
| } |
| } |
| |
| /// Attempts to decode the YAML value associated with [key] as a boolean. |
| /// |
| /// If there is no entry present with the given [key], an exception is thrown. |
| bool getBool(String key) => get(key, decode: _decodeBool); |
| |
| /// Attempts to decode the YAML value associated with [key] as a message |
| /// template. |
| /// |
| /// If [isRequired] is `true` and there is no entry present with the given |
| /// [key], an exception is thrown. |
| List<TemplatePart>? getMessageTemplate( |
| String key, { |
| required bool isRequired, |
| }) => get( |
| key, |
| decode: (node) { |
| switch (node) { |
| case YamlScalar(:String value): |
| // Remove trailing whitespace. This is necessary for templates defined |
| // with `|` (verbatim) as they always contain a trailing newline that we |
| // don't want. |
| var text = value.trimRight(); |
| if (text.contains(oldPlaceholderPattern)) { |
| throw '$key contains an old-style analyzer placeholder pattern. ' |
| 'Please convert to #NAME format.'; |
| } |
| |
| var template = <TemplatePart>[]; |
| var i = 0; |
| for (var match in placeholderPattern.allMatches(text)) { |
| var matchStart = match.start; |
| if (matchStart > i) { |
| template.add(TemplateLiteralPart(text.substring(i, matchStart))); |
| } |
| template.add( |
| TemplateParameterPart.fromMatch(match, parameters: parameters), |
| ); |
| i = match.end; |
| } |
| if (text.length > i) { |
| template.add(TemplateLiteralPart(text.substring(i))); |
| } |
| return template; |
| default: |
| throw 'Bad message type: ${node.runtimeType}'; |
| } |
| }, |
| ifAbsent: isRequired ? null : () => null, |
| ); |
| |
| /// Attempts to decode the YAML value associated with [key] as a boolean. |
| /// |
| /// If there is no entry present with the given [key], `null` is returned. |
| bool? getOptionalBool(String key) => |
| get(key, decode: _decodeBool, ifAbsent: () => null); |
| |
| /// Attempts to decode the YAML value associated with [key] as a string. |
| /// |
| /// If there is no entry present with the given [key], `null` is returned. |
| String? getOptionalString(String key) => get( |
| key, |
| decode: (node) => switch (node) { |
| YamlScalar(:String value) => value, |
| _ => throw 'Must be a string', |
| }, |
| ifAbsent: () => null, |
| ); |
| |
| /// Decodes a YAML [key]/[value] pair into a diagnostic message by invoking |
| /// the given [decoder]. |
| /// |
| /// Any exceptions that occur during execution of the [decoder] are converted |
| /// to [LocatedError] (if necessary), and tagged with an appropriate location |
| /// in the source YAML file. |
| static T decode<T extends Message>({ |
| required YamlScalar key, |
| required YamlMap value, |
| required T Function(MessageYaml) decoder, |
| }) { |
| return LocatedError.wrap(() { |
| var messageYaml = MessageYaml._(key, value); |
| var result = decoder(messageYaml); |
| for (var key in value.nodes.keys) { |
| key as YamlScalar; |
| if (!messageYaml._permittedKeys.contains(key.value)) { |
| throw LocatedError( |
| 'Unexpected key ${json.encode(key.value)}', |
| span: key.span, |
| ); |
| } |
| } |
| return result; |
| }, span: key.span); |
| } |
| |
| static bool _decodeBool(YamlNode node) => switch (node) { |
| YamlScalar(:bool value) => value, |
| _ => throw 'Must be a bool', |
| }; |
| |
| static Map<String, DiagnosticParameter> _decodeParameters(YamlNode yaml) { |
| switch (yaml) { |
| case YamlScalar(value: 'none'): |
| return const {}; |
| case YamlMap(:var nodes): |
| var result = <String, DiagnosticParameter>{}; |
| var index = 0; |
| for (var MapEntry(:key, :value) in nodes.entries) { |
| var keyNode = key as YamlScalar; |
| LocatedError.wrap(span: keyNode.span, () { |
| switch ((keyNode.value as String).split(' ')) { |
| case [var type, var name]: |
| if (result.containsKey(name)) { |
| throw 'Duplicate parameter name: $name'; |
| } |
| result[name] = DiagnosticParameter( |
| name: name, |
| type: DiagnosticParameterType.fromMessagesYamlName(type), |
| comment: value.value as String, |
| index: index++, |
| ); |
| default: |
| throw 'Malformed parameter key (should be `TYPE NAME`): ' |
| '${json.encode(key.value)}'; |
| } |
| }); |
| } |
| return result; |
| } |
| throw 'Must be a map or "none".'; |
| } |
| } |
| |
| /// A [Conversion] that acts on [num], applying formatting parameters specified |
| /// in the template. |
| class NumericConversion implements Conversion { |
| /// If non-null, the number of digits to show after the decimal point. |
| final int? fractionDigits; |
| |
| /// The minimum number of characters of output to be generated. |
| /// |
| /// If the number does not require this many characters to display, extra |
| /// padding characters are inserted to the left. |
| final int padWidth; |
| |
| /// If `true`, '0' is used for padding (see [padWidth]); otherwise ' ' is |
| /// used. |
| final bool padWithZeros; |
| |
| NumericConversion({ |
| required this.fractionDigits, |
| required this.padWidth, |
| required this.padWithZeros, |
| }); |
| |
| @override |
| int get hashCode => Object.hash( |
| runtimeType, |
| fractionDigits.hashCode, |
| padWidth.hashCode, |
| padWithZeros.hashCode, |
| ); |
| |
| @override |
| bool operator ==(Object other) => |
| other is NumericConversion && |
| other.fractionDigits == fractionDigits && |
| other.padWidth == padWidth && |
| other.padWithZeros == padWithZeros; |
| |
| @override |
| String? toCode({ |
| required String name, |
| required DiagnosticParameterType type, |
| }) { |
| if (type != DiagnosticParameterType.num) { |
| throw 'format suffix may only be applied to parameters of type num'; |
| } |
| return 'conversions.formatNumber($name, fractionDigits: $fractionDigits, ' |
| 'padWidth: $padWidth, padWithZeros: $padWithZeros)'; |
| } |
| |
| /// Creates a [NumericConversion] from the given regular expression [match]. |
| /// |
| /// [match] should be the result of matching [placeholderPattern] to the |
| /// template string. |
| /// |
| /// Returns `null` if no special numeric conversion is needed. |
| static NumericConversion? from(Match match) { |
| String? padding = match[2]; |
| String? fractionDigitsStr = match[3]; |
| |
| int? fractionDigits = fractionDigitsStr == null |
| ? null |
| : int.parse(fractionDigitsStr); |
| if (padding != null && padding.isNotEmpty) { |
| return NumericConversion( |
| fractionDigits: fractionDigits, |
| padWidth: int.parse(padding), |
| padWithZeros: padding.startsWith('0'), |
| ); |
| } else if (fractionDigits != null) { |
| return NumericConversion( |
| fractionDigits: fractionDigits, |
| padWidth: 0, |
| padWithZeros: false, |
| ); |
| } else { |
| return null; |
| } |
| } |
| } |
| |
| /// In-memory representation of diagnostic information obtained from the file |
| /// `pkg/_fe_analyzer_shared/messages.yaml`. |
| class SharedMessage extends CfeStyleMessage with MessageWithAnalyzerCode { |
| /// The analyzer diagnostic code that corresponds to this shared diagnostic. |
| /// |
| /// Shared diagnostics are required to have exactly one analyzer code |
| /// associated with them. |
| @override |
| final AnalyzerCode analyzerCode; |
| |
| @override |
| final bool hasPublishedDocs; |
| |
| SharedMessage(super.messageYaml) |
| : analyzerCode = messageYaml.get( |
| 'analyzerCode', |
| decode: _decodeAnalyzerCode, |
| ), |
| hasPublishedDocs = messageYaml.getBool('hasPublishedDocs'); |
| |
| @override |
| AnalyzerDiagnosticPackage get package => AnalyzerDiagnosticPackage.analyzer; |
| |
| static AnalyzerCode _decodeAnalyzerCode(YamlNode node) { |
| switch (node) { |
| case YamlScalar(value: String s): |
| switch (s.split('.')) { |
| case [var className, var snakeCaseName] |
| when snakeCaseName == snakeCaseName.toUpperCase(): |
| return AnalyzerCode( |
| diagnosticClass: DiagnosticClassInfo.byName(className), |
| snakeCaseName: snakeCaseName, |
| ); |
| } |
| } |
| throw 'Analyzer codes must take the form ClassName.DIAGNOSTIC_NAME.'; |
| } |
| } |
| |
| /// A [Conversion] that invokes a top level function via the `conversions` |
| /// import prefix. |
| class SimpleConversion implements Conversion { |
| /// The name of the function to be invoked. |
| final String functionName; |
| |
| const SimpleConversion(this.functionName); |
| |
| @override |
| int get hashCode => Object.hash(runtimeType, functionName.hashCode); |
| |
| @override |
| bool operator ==(Object other) => |
| other is SimpleConversion && other.functionName == functionName; |
| |
| @override |
| String toCode({ |
| required String name, |
| required DiagnosticParameterType type, |
| }) => 'conversions.$functionName($name)'; |
| } |
| |
| /// [TemplatePart] representing a literal string of characters, with no |
| /// parameter substitutions. |
| class TemplateLiteralPart implements TemplatePart { |
| /// The literal text. |
| final String text; |
| |
| TemplateLiteralPart(this.text); |
| } |
| |
| /// [TemplatePart] representing a parameter to be substituted into the |
| /// diagnostic message. |
| class TemplateParameterPart implements TemplatePart { |
| /// The parameter to be substituted. |
| final DiagnosticParameter parameter; |
| |
| /// The conversion to apply to the parameter. |
| /// |
| /// If `null`, the default conversion for the parameter's type will be used. |
| final Conversion? conversionOverride; |
| |
| /// Builds a [TemplateParameterPart] from the given [match] of |
| /// [placeholderPattern]. |
| factory TemplateParameterPart.fromMatch( |
| Match match, { |
| required Map<String, DiagnosticParameter> parameters, |
| }) { |
| String name = match[1]!; |
| var parameter = parameters[name]; |
| if (parameter == null) { |
| throw 'Placeholder ${json.encode(name)} not declared as a parameter'; |
| } |
| |
| return TemplateParameterPart._( |
| parameter: parameter, |
| conversionOverride: NumericConversion.from(match), |
| ); |
| } |
| |
| TemplateParameterPart._({ |
| required this.parameter, |
| required this.conversionOverride, |
| }); |
| |
| @override |
| int get hashCode => Object.hash(parameter, conversionOverride); |
| |
| @override |
| bool operator ==(Object other) => |
| other is TemplateParameterPart && |
| other.parameter == parameter && |
| other.conversionOverride == conversionOverride; |
| } |
| |
| /// A part of a parsed template string. |
| /// |
| /// Each `problemMessage` and `correctionMessage` template string in a |
| /// `messages.yaml` file is decoded into a list of [TemplatePart]. |
| sealed class TemplatePart {} |
| |
| class _DuplicateChecker<Code> { |
| final Map<Code, List<Message>> _codeToMessages = {}; |
| final String kind; |
| |
| _DuplicateChecker({required this.kind}); |
| |
| void operator []=(Code code, Message message) { |
| (_codeToMessages[code] ??= []).add(message); |
| } |
| |
| void check() { |
| for (var MapEntry(key: code, value: messages) in _codeToMessages.entries) { |
| if (messages.length != 1) { |
| throw [ |
| '$kind $code used for multiple diagnostics:', |
| for (var message in messages) |
| '${message.location}: ${message.keyString}', |
| ].join('\n'); |
| } |
| } |
| } |
| } |