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