| // Copyright (c) 2022, 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:collection/collection.dart'; |
| |
| import 'meta_model.dart'; |
| |
| /// Helper methods to clean the meta model to produce better Dart classes. |
| /// |
| /// Cleaning includes: |
| /// |
| /// - Unwrapping comments that have been wrapped in the source model |
| /// - Removing relative hyperlinks from comments that assume rendering in an |
| /// HTML page with anchors |
| /// - Merging types that are distinct in the meta model but we want as one |
| /// - Removing types in the spec that we will never use |
| /// - Renaming types that may have long or sub-optimal generated names |
| /// - Simplifying union types that contain duplicates/overlaps |
| class LspMetaModelCleaner { |
| /// A pattern to match newlines in source comments that are likely for |
| /// wrapping and not formatting. This allows us to rewrap based on our indent |
| /// level/line length without potentially introducing very short lines. |
| final _sourceCommentWrappingNewlinesPattern = |
| RegExp(r'\w[`\]\)\}.]*\n[`\[\{\(]*\w'); |
| |
| /// A pattern matching the spec's older HTML links that we can extract type |
| /// references from. |
| /// |
| /// A description of [SomeType[]] (#SomeType). |
| final _sourceCommentDocumentLinksPattern = |
| RegExp(r'\[`?([\w \-.]+)(?:\[\])?`?\]\s?\((#[^)]+)\)'); |
| |
| /// A pattern matching references in the LSP meta model comments that |
| /// reference other types. |
| /// |
| /// {@link TypeName description} |
| /// |
| /// Type names may have suffixes that shouldn't be included in the group such |
| /// as |
| /// |
| /// {@link TypeName[] description} |
| final _sourceCommentReferencesPattern = |
| RegExp(r'{@link\s+([\w.]+)[\[\]]*(?:\s+[\w`. ]+[\[\]]*)?}'); |
| |
| /// A pattern that matches references in the LSP meta model comments to the |
| /// Thenable or Promise types. |
| final _sourceCommentThenablePromisePattern = |
| RegExp(r'\b(?:Thenable|Promise)\b'); |
| |
| /// Whether to include proposed types and fields in generated code. |
| /// |
| /// The LSP meta model is often regenerated from the latest code and includes |
| /// unreleased/proposed APIs that we usually don't want to pollute the |
| /// generated code with until they are finalized. |
| final includeProposed = false; |
| |
| /// Cleans an entire [LspMetaModel]. |
| LspMetaModel cleanModel(LspMetaModel model) { |
| var types = cleanTypes(model.types); |
| return LspMetaModel( |
| types: types, |
| methods: model.methods.where(_includeEntityInOutput).toList(), |
| ); |
| } |
| |
| /// Cleans a List of types. |
| List<LspEntity> cleanTypes(List<LspEntity> types) { |
| types = _mergeTypes(types); |
| types = types.where(_includeEntityInOutput).map(_clean).toList(); |
| types = _renameTypes(types).where(_includeEntityInOutput).toList(); |
| return types; |
| } |
| |
| /// Whether a type should be retained type signatures in generated code. |
| bool _allowTypeInUnions(TypeBase type) { |
| // Don't allow arrays of MarkedStrings, but do allow simple MarkedStrings. |
| // The only place that uses these are Hovers and we only send one value |
| // (to match the MarkupString equiv) so the array just makes the types |
| // unnecessarily complicated. |
| if (type is ArrayType) { |
| // TODO(dantup): Consider removing this, it's not adding much. |
| var elementType = type.elementType; |
| if (elementType is TypeReference && elementType.name == 'MarkedString') { |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| /// Cleans a single [LspEntity]. |
| LspEntity _clean(LspEntity type) { |
| if (type is Interface) { |
| return _cleanInterface(type); |
| } else if (type is LspEnum) { |
| return _cleanNamespace(type); |
| } else if (type is TypeAlias) { |
| return _cleanTypeAlias(type); |
| } else { |
| throw 'Cleaning $type is not implemented.'; |
| } |
| } |
| |
| String? _cleanComment(String? text) { |
| if (text == null) { |
| return text; |
| } |
| |
| // Unwrap any wrapping in the source by replacing any matching newlines with |
| // spaces. |
| text = text.replaceAllMapped( |
| _sourceCommentWrappingNewlinesPattern, |
| (match) => match.group(0)!.replaceAll('\n', ' '), |
| ); |
| |
| // Replace any references to other types with a format that's valid for |
| // Dart. |
| text = text.replaceAllMapped( |
| _sourceCommentDocumentLinksPattern, |
| (match) => '[${match.group(1)!}]', |
| ); |
| text = text.replaceAllMapped( |
| _sourceCommentReferencesPattern, |
| (match) => '[${match.group(1)!}]', |
| ); |
| |
| // Replace any references to Thenable/Promise to Future. |
| text = text.replaceAllMapped( |
| _sourceCommentThenablePromisePattern, |
| (match) => '[Future]', |
| ); |
| |
| return text; |
| } |
| |
| Constant _cleanConst(Constant const_) { |
| return Constant( |
| name: const_.name, |
| comment: _cleanComment(const_.comment), |
| type: _cleanType(const_.type), |
| value: const_.value, |
| ); |
| } |
| |
| Field _cleanField(String parentName, Field field) { |
| var improvedType = _getImprovedType(parentName, field.name); |
| var type = improvedType ?? field.type; |
| |
| return Field( |
| name: field.name, |
| comment: _cleanComment(field.comment), |
| type: _cleanType(type), |
| allowsNull: field.allowsNull, |
| allowsUndefined: field.allowsUndefined, |
| ); |
| } |
| |
| Interface _cleanInterface(Interface interface) { |
| return Interface( |
| name: interface.name, |
| comment: _cleanComment(interface.comment), |
| isProposed: interface.isProposed, |
| baseTypes: interface.baseTypes |
| .where((type) => _includeTypeInOutput(type.name)) |
| .toList(), |
| members: interface.members |
| .where(_includeEntityInOutput) |
| .map((member) => _cleanMember(interface.name, member)) |
| .toList(), |
| ); |
| } |
| |
| Member _cleanMember(String parentName, Member member) { |
| if (member is Field) { |
| return _cleanField(parentName, member); |
| } else if (member is Constant) { |
| return _cleanConst(member); |
| } else { |
| throw 'Cleaning $member is not implemented.'; |
| } |
| } |
| |
| LspEnum _cleanNamespace(LspEnum namespace) { |
| return LspEnum( |
| name: namespace.name, |
| comment: _cleanComment(namespace.comment), |
| isProposed: namespace.isProposed, |
| typeOfValues: namespace.typeOfValues, |
| members: namespace.members |
| .where(_includeEntityInOutput) |
| .map((member) => _cleanMember(namespace.name, member)) |
| .toList(), |
| ); |
| } |
| |
| TypeBase _cleanType(TypeBase type) { |
| if (type is UnionType) { |
| return _cleanUnionType(type); |
| } else if (type is ArrayType) { |
| return ArrayType(_cleanType(type.elementType)); |
| } else { |
| return type; |
| } |
| } |
| |
| TypeAlias _cleanTypeAlias(TypeAlias typeAlias) { |
| return TypeAlias( |
| name: typeAlias.name, |
| comment: _cleanComment(typeAlias.comment), |
| baseType: _cleanType(typeAlias.baseType), |
| renameReferences: typeAlias.renameReferences, |
| ); |
| } |
| |
| /// Removes any duplicate types in a union. |
| /// |
| /// For example, if we map multiple types into `Object?` we don't want to end |
| /// up with `Either2<Object?, Object?>`. |
| /// |
| /// Key on `dartType` to ensure we combine different types that will map down |
| /// to the same type. |
| TypeBase _cleanUnionType(UnionType type) { |
| var uniqueTypes = Map.fromEntries( |
| type.types |
| .where(_allowTypeInUnions) |
| .map((t) => MapEntry(t.uniqueTypeIdentifier, t)), |
| ).values.toList(); |
| |
| // If our list includes something that maps to Object? as well as other |
| // types, we should just treat the whole thing as Object? as we get no value |
| // typing Either4<bool, String, num, Object?> but it becomes much more |
| // difficult to use. |
| if (uniqueTypes.any(isNullableAnyType)) { |
| return uniqueTypes.firstWhere(isNullableAnyType); |
| } |
| |
| // Finally, sort the types by name so that we always generate the same type |
| // for the same combination to improve reuse of helper methods used in |
| // multiple handlers. |
| uniqueTypes.sort((t1, t2) => t1.dartType.compareTo(t2.dartType)); |
| |
| // Recursively clean the inner types. |
| uniqueTypes = uniqueTypes.map(_cleanType).toList(); |
| |
| if (uniqueTypes.length == 1) { |
| return uniqueTypes.single; |
| } else if (uniqueTypes.every(isLiteralType)) { |
| return LiteralUnionType(uniqueTypes.cast<LiteralType>()); |
| } else if (uniqueTypes.any(isNullType)) { |
| var remainingTypes = uniqueTypes.whereNot(isNullType).toList(); |
| var nonNullType = remainingTypes.length == 1 |
| ? remainingTypes.single |
| : UnionType(remainingTypes); |
| return NullableType(nonNullType); |
| } else { |
| return UnionType(uniqueTypes); |
| } |
| } |
| |
| /// Improves types in code generated from the LSP model, including: |
| /// |
| /// - Making some untyped fields (like `CompletionItem.data`) strong typed for |
| /// our use. |
| /// |
| /// - Simplifying unions for types generated only by the server to avoid a lot |
| /// of wrapping in `EitherX<Y,Z>.tX()`. |
| TypeBase? _getImprovedType(String interfaceName, String? fieldName) { |
| const improvedTypeMappings = <String, Map<String, String>>{ |
| 'Diagnostic': { |
| 'code': 'String', |
| }, |
| 'CompletionItem': { |
| 'data': 'CompletionItemResolutionInfo', |
| }, |
| 'ParameterInformation': { |
| 'label': 'String', |
| }, |
| 'TextDocumentEdit': { |
| 'edits': 'TextDocumentEditEdits', |
| }, |
| 'TypeHierarchyItem': { |
| 'data': 'TypeHierarchyItemInfo', |
| }, |
| }; |
| |
| var interface = improvedTypeMappings[interfaceName]; |
| |
| var improvedTypeName = interface != null ? interface[fieldName] : null; |
| |
| return improvedTypeName != null |
| ? improvedTypeName.endsWith('[]') |
| ? ArrayType(TypeReference( |
| improvedTypeName.substring(0, improvedTypeName.length - 2))) |
| : improvedTypeName.endsWith('?') |
| ? NullableType(TypeReference( |
| improvedTypeName.substring(0, improvedTypeName.length - 1))) |
| : TypeReference(improvedTypeName) |
| : null; |
| } |
| |
| /// Some types are merged together. This method returns the type that [name]s |
| /// members should be merged into. |
| String? _getMergeTarget(String name) { |
| switch (name) { |
| // The meta model defines both `LSPErrorCodes` and `ErrorCodes`. The |
| // intention was that one is JSONRPC and one is LSP codes, but some codes |
| // were defined in the wrong enum with the wrong values, but kept for |
| // backwards compatibility. For simplicity, we merge them all into `ErrorCodes`. |
| case 'LSPErrorCodes': |
| return 'ErrorCodes'; |
| // In the model, `InitializeParams` is defined as by two classes, |
| // `_InitializeParams` and `WorkspaceFoldersInitializeParams`. This |
| // split doesn't add anything but makes the types less clear so we |
| // merge them into `InitializeParams`. |
| case '_InitializeParams': |
| return 'InitializeParams'; |
| case 'WorkspaceFoldersInitializeParams': |
| return 'InitializeParams'; |
| default: |
| return null; |
| } |
| } |
| |
| /// Returns whether an entity should be included in the generated code. |
| bool _includeEntityInOutput(LspEntity entity) { |
| if (!includeProposed && entity.isProposed) { |
| return false; |
| } |
| |
| if (entity is Interface || entity is LspEnum || entity is TypeAlias) { |
| if (!_includeTypeInOutput(entity.name)) { |
| return false; |
| } |
| } |
| |
| return true; |
| } |
| |
| /// Returns whether the type with [name] should be included in generated code. |
| /// |
| /// For [LspEntity]s, [_includeEntityInOutput] should be used instead which |
| /// includes this check and others. |
| bool _includeTypeInOutput(String name) { |
| const ignoredTypes = { |
| // InitializeError is not used for v3.0 (Feb 2017) and by dropping it we |
| // don't have to handle any cases where both a namespace and interfaces |
| // are declared with the same name. |
| 'InitializeError', |
| // Merged into InitializeParams. |
| '_InitializeParams', |
| 'WorkspaceFoldersInitializeParams', |
| // We don't use these classes and they weren't in the TS version of the |
| // spec so continue to not generate them until required. |
| 'DidChangeConfigurationRegistrationOptions', |
| // LSPAny/LSPObject are used by the LSP spec for unions of basic types. |
| // We map these onto Object? and don't use this type (and don't support |
| // unions with so many types). |
| 'LSPAny', |
| 'LSPObject', |
| 'LSPUri', |
| // The meta model currently includes an unwanted type named 'T' that we |
| // don't want to create a class for. |
| // TODO(dantup): Remove this once it's gone from the JSON model. |
| 'T', |
| }; |
| const ignoredPrefixes = { |
| // We don't emit MarkedString because it gets mapped to a simple String |
| // when getting the .dartType for it. |
| 'MarkedString' |
| }; |
| var shouldIgnore = ignoredTypes.contains(name) || |
| ignoredPrefixes.any((ignore) => name.startsWith(ignore)); |
| return !shouldIgnore; |
| } |
| |
| LspEntity _merge(LspEntity source, LspEntity dest) { |
| if (source.runtimeType != dest.runtimeType) { |
| throw 'Cannot merge ${source.runtimeType} into ${dest.runtimeType}'; |
| } |
| var comment = dest.comment ?? source.comment; |
| if (source is LspEnum && dest is LspEnum) { |
| return LspEnum( |
| name: dest.name, |
| comment: comment, |
| isProposed: dest.isProposed, |
| typeOfValues: dest.typeOfValues, |
| members: [...dest.members, ...source.members], |
| ); |
| } else if (source is Interface && dest is Interface) { |
| return Interface( |
| name: dest.name, |
| comment: comment, |
| isProposed: dest.isProposed, |
| baseTypes: [...dest.baseTypes, ...source.baseTypes], |
| members: [...dest.members, ...source.members], |
| ); |
| } |
| throw 'Merging ${source.runtimeType}s is not yet supported'; |
| } |
| |
| List<LspEntity> _mergeTypes(List<LspEntity> types) { |
| var typesByName = { |
| for (final type in types) type.name: type, |
| }; |
| assert(types.length == typesByName.length); |
| var typeNames = typesByName.keys.toList(); |
| for (var typeName in typeNames) { |
| var targetName = _getMergeTarget(typeName); |
| if (targetName != null) { |
| var type = typesByName[typeName]!; |
| var target = typesByName[targetName]!; |
| typesByName[targetName] = _merge(type, target); |
| typesByName.remove(typeName); |
| } |
| } |
| return typesByName.values.toList(); |
| } |
| |
| /// Renames types that may have been generated with bad (or long) names. |
| Iterable<LspEntity> _renameTypes(List<LspEntity> types) sync* { |
| const renames = <String, String>{ |
| 'CompletionClientCapabilitiesCompletionItemInsertTextModeSupport': |
| 'CompletionItemInsertTextModeSupport', |
| 'CompletionClientCapabilitiesCompletionItemTagSupport': |
| 'CompletionItemTagSupport', |
| 'CompletionClientCapabilitiesCompletionItemResolveSupport': |
| 'CompletionItemResolveSupport', |
| 'SignatureHelpClientCapabilitiesSignatureInformationParameterInformation': |
| 'SignatureInformationParameterInformation', |
| 'Pattern': 'LspPattern', |
| 'URI': 'LSPUri', |
| |
| // In LSP 3.18, many types that were previously inline and got generated |
| // names have been extracted to their own definitions with hand-written |
| // names. |
| // To reduce the size of the upcoming diff when this happens, these |
| // renames change our 3.17 generated names to those that will be used |
| // for 3.18. |
| // TODO(dantup): Remove these once LSP 3.18 release and we regenerate |
| // using it. |
| 'TextDocumentFilter2': 'TextDocumentFilterScheme', |
| 'PrepareRenameResult1': 'PrepareRenamePlaceholder', |
| 'TextDocumentContentChangeEvent1': 'TextDocumentContentChangePartial', |
| 'TextDocumentContentChangeEvent2': |
| 'TextDocumentContentChangeWholeDocument', |
| 'CompletionListItemDefaults': 'CompletionItemDefaults', |
| 'InitializeParamsClientInfo': 'ClientInfo', |
| 'SemanticTokensClientCapabilitiesRequests': |
| 'ClientSemanticTokensRequestOptions', |
| 'TraceValues': 'TraceValue', |
| 'SemanticTokensOptionsFull': 'SemanticTokensFullDelta', |
| 'CompletionListItemDefaultsEditRange': 'EditRangeWithInsertReplace', |
| 'ServerCapabilitiesWorkspace': 'WorkspaceOptions', |
| 'CodeActionClientCapabilitiesCodeActionLiteralSupportCodeActionKind': |
| 'ClientCodeActionKindOptions', |
| 'CompletionClientCapabilitiesCompletionItemKind': |
| 'ClientCompletionItemOptionsKind', |
| 'CompletionOptionsCompletionItem': 'ServerCompletionItemOptions', |
| 'InitializeResultServerInfo': 'ServerInfo', |
| // End of temporary renames |
| }; |
| |
| // Temporary aliases for old names used in g3 |
| // TODO(dantup): Remove these once the internal code has been updated. |
| var typeNames = types.map((type) => type.name).toSet(); |
| if (typeNames.contains('TextDocumentContentChangeEvent2')) { |
| yield TypeAlias( |
| name: 'TextDocumentContentChangeEvent2', |
| baseType: TypeReference('TextDocumentContentChangeWholeDocument'), |
| renameReferences: true, |
| generateTypeDef: true, |
| ); |
| } |
| if (typeNames.contains( |
| 'CodeActionClientCapabilitiesCodeActionLiteralSupportCodeActionKind')) { |
| yield TypeAlias( |
| name: 'CodeActionLiteralSupportCodeActionKind', |
| baseType: TypeReference('ClientCodeActionKindOptions'), |
| renameReferences: true, |
| generateTypeDef: true, |
| ); |
| } |
| // End temporary aliases |
| |
| for (var type in types) { |
| var newName = renames[type.name]; |
| if (newName == null) { |
| yield type; |
| continue; |
| } |
| |
| // Add a TypeAlias for the old name. |
| yield TypeAlias( |
| name: type.name, |
| comment: type.comment, |
| isProposed: type.isProposed, |
| baseType: TypeReference(newName), |
| renameReferences: true, |
| ); |
| |
| // Replace the type with an equivalent with the same name. |
| if (type is Interface) { |
| yield Interface( |
| name: newName, |
| comment: type.comment, |
| isProposed: type.isProposed, |
| baseTypes: type.baseTypes, |
| members: type.members, |
| ); |
| } else if (type is TypeAlias) { |
| yield TypeAlias( |
| name: newName, |
| comment: type.comment, |
| isProposed: type.isProposed, |
| baseType: type.baseType, |
| renameReferences: type.renameReferences, |
| ); |
| } else if (type is LspEnum) { |
| yield LspEnum( |
| name: newName, |
| comment: type.comment, |
| isProposed: type.isProposed, |
| typeOfValues: type.typeOfValues, |
| members: type.members, |
| ); |
| } else { |
| throw 'Renaming ${type.runtimeType} is not implemented'; |
| } |
| } |
| } |
| } |