Add support for parsing Maps from the LSP spec Change-Id: Ieafcacb3ba2f6be8d3232025d22fa5ec94de5352 Reviewed-on: https://dart-review.googlesource.com/c/85768 Commit-Queue: Danny Tuppeny <dantup@google.com> Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/lsp_protocol/protocol_generated.dart b/pkg/analysis_server/lib/lsp_protocol/protocol_generated.dart index 376fb50..a0fd8a8 100644 --- a/pkg/analysis_server/lib/lsp_protocol/protocol_generated.dart +++ b/pkg/analysis_server/lib/lsp_protocol/protocol_generated.dart
@@ -14,7 +14,7 @@ import 'dart:convert' show JsonEncoder; import 'package:analysis_server/lsp_protocol/protocol_special.dart'; import 'package:analysis_server/src/protocol/protocol_internal.dart' - show listEqual; + show listEqual, mapEqual; import 'package:analyzer/src/generated/utilities_general.dart'; const jsonEncoder = const JsonEncoder.withIndent(' '); @@ -10916,9 +10916,14 @@ class WorkspaceEdit implements ToJsonable { WorkspaceEdit(this.changes, this.documentChanges); static WorkspaceEdit fromJson(Map<String, dynamic> json) { - final changes = json['changes'] != null - ? WorkspaceEditChanges.fromJson(json['changes']) - : null; + final changes = json['changes'] + ?.map((key, value) => new MapEntry( + key, + value + ?.map((item) => item != null ? TextEdit.fromJson(item) : null) + ?.cast<TextEdit>() + ?.toList())) + ?.cast<String, List<TextEdit>>(); final documentChanges = (json['documentChanges'] is List && (json['documentChanges'].length == 0 || json['documentChanges'].every((item) => TextDocumentEdit.canParse(item)))) ? new Either2<List<TextDocumentEdit>, List<Either4<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>>>.t1(json['documentChanges'] ?.map( @@ -10941,7 +10946,7 @@ } /// Holds changes to existing resources. - final WorkspaceEditChanges changes; + final Map<String, List<TextEdit>> changes; /// Depending on the client capability /// `workspace.workspaceEdit.resourceOperations` document changes are either @@ -10978,7 +10983,8 @@ @override bool operator ==(other) { if (other is WorkspaceEdit) { - return changes == other.changes && + return mapEqual(changes, other.changes, + (List<TextEdit> a, List<TextEdit> b) => a == b) && documentChanges == other.documentChanges && true; } @@ -10997,38 +11003,6 @@ String toString() => jsonEncoder.convert(toJson()); } -class WorkspaceEditChanges implements ToJsonable { - static WorkspaceEditChanges fromJson(Map<String, dynamic> json) { - return new WorkspaceEditChanges(); - } - - Map<String, dynamic> toJson() { - Map<String, dynamic> __result = {}; - return __result; - } - - static bool canParse(Object obj) { - return obj is Map<String, dynamic>; - } - - @override - bool operator ==(other) { - if (other is WorkspaceEditChanges) { - return true; - } - return false; - } - - @override - int get hashCode { - int hash = 0; - return JenkinsSmiHash.finish(hash); - } - - @override - String toString() => jsonEncoder.convert(toJson()); -} - class WorkspaceFolder implements ToJsonable { WorkspaceFolder(this.uri, this.name) { if (uri == null) {
diff --git a/pkg/analysis_server/test/tool/lsp_spec/json_test.dart b/pkg/analysis_server/test/tool/lsp_spec/json_test.dart index 451d05c..b352d18 100644 --- a/pkg/analysis_server/test/tool/lsp_spec/json_test.dart +++ b/pkg/analysis_server/test/tool/lsp_spec/json_test.dart
@@ -153,4 +153,21 @@ expect(restoredObj.endCharacter, equals(obj.endCharacter)); expect(restoredObj.kind, equals(obj.kind)); }); + + test('objects with maps can round-trip through to json and back', () { + final start = new Position(1, 1); + final end = new Position(2, 2); + final range = new Range(start, end); + final obj = new WorkspaceEdit(<String, List<TextEdit>>{ + 'fileA': [new TextEdit(range, 'text A')], + 'fileB': [new TextEdit(range, 'text B')] + }, null); + final String json = jsonEncode(obj); + final restoredObj = WorkspaceEdit.fromJson(jsonDecode(json)); + + expect(restoredObj.documentChanges, equals(obj.documentChanges)); + expect(restoredObj.changes, equals(obj.changes)); + expect(restoredObj.changes.keys, equals(obj.changes.keys)); + expect(restoredObj.changes.values, equals(obj.changes.values)); + }); }
diff --git a/pkg/analysis_server/test/tool/lsp_spec/matchers.dart b/pkg/analysis_server/test/tool/lsp_spec/matchers.dart index 06397bc..72ee321 100644 --- a/pkg/analysis_server/test/tool/lsp_spec/matchers.dart +++ b/pkg/analysis_server/test/tool/lsp_spec/matchers.dart
@@ -45,7 +45,7 @@ } Description describe(Description description) => - description.add('an ArrayType').addDescriptionOf(_elementTypeMatcher); + description.add('an array of ').addDescriptionOf(_elementTypeMatcher); Description describeMismatch( item, Description mismatchDescription, Map matchState, bool verbose) { @@ -58,5 +58,25 @@ } } +Matcher isMapOf(Matcher indexMatcher, Matcher valueMatcher) => + new MapTypeMatcher(wrapMatcher(indexMatcher), wrapMatcher(valueMatcher)); + +class MapTypeMatcher extends Matcher { + final Matcher _indexMatcher, _valueMatcher; + const MapTypeMatcher(this._indexMatcher, this._valueMatcher); + + bool matches(item, Map matchState) { + return item is MapType && + _indexMatcher.matches(item.indexType, matchState) && + _valueMatcher.matches(item.valueType, matchState); + } + + Description describe(Description description) => description + .add('a MapType where index is ') + .addDescriptionOf(_indexMatcher) + .add(' and value is ') + .addDescriptionOf(_valueMatcher); +} + Matcher isResponseError(ErrorCodes code) => const TypeMatcher<ResponseError>() .having((e) => e.code, 'code', equals(code));
diff --git a/pkg/analysis_server/test/tool/lsp_spec/typescript_test.dart b/pkg/analysis_server/test/tool/lsp_spec/typescript_test.dart index 18c6a34..d03b5fb 100644 --- a/pkg/analysis_server/test/tool/lsp_spec/typescript_test.dart +++ b/pkg/analysis_server/test/tool/lsp_spec/typescript_test.dart
@@ -146,6 +146,24 @@ expect(union.types[1], isSimpleType('object')); }); + test('parses an interface with a map into a MapType', () { + final String input = ''' +export interface WorkspaceEdit { + changes: { [uri: string]: TextEdit[]; }; +} + '''; + final List<AstNode> output = parseFile(input); + expect(output, hasLength(1)); + expect(output[0], const TypeMatcher<Interface>()); + final Interface interface = output[0]; + expect(interface.members, hasLength(1)); + final Field field = interface.members.first; + expect(field, const TypeMatcher<Field>()); + expect(field.name, equals('changes')); + expect(field.type, + isMapOf(isSimpleType('string'), isArrayOf(isSimpleType('TextEdit')))); + }); + test('flags nullable undefined values', () { final String input = ''' export interface A {
diff --git a/pkg/analysis_server/tool/lsp_spec/codegen_dart.dart b/pkg/analysis_server/tool/lsp_spec/codegen_dart.dart index cc4d7d0..c95ca9c 100644 --- a/pkg/analysis_server/tool/lsp_spec/codegen_dart.dart +++ b/pkg/analysis_server/tool/lsp_spec/codegen_dart.dart
@@ -290,6 +290,11 @@ final elementDartType = elementType.dartTypeWithTypeArgs; buffer.write( 'listEqual(${field.name}, other.${field.name}, ($elementDartType a, $elementDartType b) => a == b) && '); + } else if (type is MapType) { + final valueType = type.valueType; + final valueDartType = valueType.dartTypeWithTypeArgs; + buffer.write( + 'mapEqual(${field.name}, other.${field.name}, ($valueDartType a, $valueDartType b) => a == b) && '); } else { buffer.write('${field.name} == other.${field.name} && '); } @@ -323,12 +328,22 @@ buffer.write( "$valueCode != null ? ${type.dartType}.fromJson${type.typeArgsString}($valueCode) : null"); } else if (type is ArrayType) { - // Lists need to be mapped so we can recursively call (they may need fromJson). + // Lists need to be map()'d so we can recursively call writeFromJsonCode + // as they may need fromJson on each element. buffer.write("$valueCode?.map((item) => "); _writeFromJsonCode(buffer, type.elementType, 'item', allowsNull: allowsNull); buffer .write(')?.cast<${type.elementType.dartTypeWithTypeArgs}>()?.toList()'); + } else if (type is MapType) { + // Maps need to be map()'d so we can recursively call writeFromJsonCode as + // they may need fromJson on each key or value. + buffer.write('$valueCode?.map((key, value) => new MapEntry('); + _writeFromJsonCode(buffer, type.indexType, 'key', allowsNull: allowsNull); + buffer.write(', '); + _writeFromJsonCode(buffer, type.valueType, 'value', allowsNull: allowsNull); + buffer.write( + '))?.cast<${type.indexType.dartTypeWithTypeArgs}, ${type.valueType.dartTypeWithTypeArgs}>()'); } else if (type is UnionType) { _writeFromJsonCodeForUnion(buffer, type, valueCode, allowsNull: allowsNull); } else { @@ -548,6 +563,18 @@ buffer.write('))'); } buffer.write(')'); + } else if (type is MapType) { + buffer.write('($valueCode is Map'); + if (resolvedDartType != 'dynamic') { + buffer + ..write(' && ($valueCode.length == 0 || (') + ..write('$valueCode.keys.every((item) => '); + _writeTypeCheckCondition(buffer, 'item', type.indexType); + buffer..write('&& $valueCode.values.every((item) => '); + _writeTypeCheckCondition(buffer, 'item', type.valueType); + buffer.write(')))'); + } + buffer.write(')'); } else if (type is UnionType) { // To type check a union, we just recursively check against each of its types. buffer.write('(');
diff --git a/pkg/analysis_server/tool/lsp_spec/generate_all.dart b/pkg/analysis_server/tool/lsp_spec/generate_all.dart index 3746824..8e93a31 100644 --- a/pkg/analysis_server/tool/lsp_spec/generate_all.dart +++ b/pkg/analysis_server/tool/lsp_spec/generate_all.dart
@@ -79,7 +79,7 @@ import 'dart:core' as core show deprecated; import 'dart:convert' show JsonEncoder; import 'package:analysis_server/lsp_protocol/protocol_special.dart'; -import 'package:analysis_server/src/protocol/protocol_internal.dart' show listEqual; +import 'package:analysis_server/src/protocol/protocol_internal.dart' show listEqual, mapEqual; import 'package:analyzer/src/generated/utilities_general.dart'; const jsonEncoder = const JsonEncoder.withIndent(' ');
diff --git a/pkg/analysis_server/tool/lsp_spec/typescript_parser.dart b/pkg/analysis_server/tool/lsp_spec/typescript_parser.dart index 25c6eb7..5a6a844 100644 --- a/pkg/analysis_server/tool/lsp_spec/typescript_parser.dart +++ b/pkg/analysis_server/tool/lsp_spec/typescript_parser.dart
@@ -16,6 +16,10 @@ bool isUndefinedType(TypeBase t) => t is Type && t.name == 'undefined'; +/// A fabricated field name for indexers in case they result in generation +/// of type names for inline types. +const fieldNameForIndexer = 'indexer'; + List<AstNode> parseFile(String input) { final scanner = new Scanner(input); final tokens = scanner.scan(); @@ -43,6 +47,19 @@ String get typeArgsString => '<${elementType.dartTypeWithTypeArgs}>'; } +class MapType extends TypeBase { + final TypeBase indexType; + final TypeBase valueType; + + MapType(this.indexType, this.valueType); + + @override + String get dartType => 'Map'; + @override + String get typeArgsString => + '<${indexType.dartTypeWithTypeArgs}, ${valueType.dartTypeWithTypeArgs}>'; +} + class AstNode { final Comment commentNode; final bool isDeprecated; @@ -117,6 +134,18 @@ ) : super(null, new Token.identifier(name), [], [], members); } +class Indexer extends Member { + final TypeBase indexType; + final TypeBase valueType; + Indexer( + Comment comment, + this.indexType, + this.valueType, + ) : super(comment); + + String get name => fieldNameForIndexer; +} + class Interface extends AstNode { final Token nameToken; final List<Token> typeArgs; @@ -202,6 +231,20 @@ return Const(leadingComment, name, type, value); } + Indexer _indexer(String containerName, Comment leadingComment) { + final indexer = _field(containerName, leadingComment); + _consume(TokenType.RIGHT_BRACKET, 'Expected ]'); + _consume(TokenType.COLON, 'Expected :'); + + TypeBase type; + type = _type(containerName, fieldNameForIndexer, improveTypes: true); + + //_consume(TokenType.RIGHT_BRACE, 'Expected }'); + _match([TokenType.SEMI_COLON]); + + return new Indexer(leadingComment, indexer.type, type); + } + /// Ensures the next token is [type] and moves to the next, throwing [message] /// if not. Token _consume(TokenType type, String message) { @@ -259,13 +302,8 @@ _consume(TokenType.COLON, 'Expected :'); TypeBase type; Token value; - type = _type(containerName, name.lexeme, includeUndefined: canBeUndefined); - - // Handle improved type mappings for things that aren't very tight in the spec. - final improvedTypeName = getImprovedType(containerName, name.lexeme); - if (improvedTypeName != null) { - type = new Type.identifier(improvedTypeName); - } + type = _type(containerName, name.lexeme, + includeUndefined: canBeUndefined, improveTypes: true); // Some fields have weird comments like this in the spec: // {@link MessageType} @@ -373,14 +411,7 @@ if (_match([TokenType.CONST_KEYWORD])) { return _const(containerName, leadingComment); } else if (_match([TokenType.LEFT_BRACKET])) { - // TODO(dantup): Support (or not?) indexers... - while (true) { - if (_match([TokenType.SEMI_COLON])) { - break; - } - _advance(); - } - return null; + return _indexer(containerName, leadingComment); } else { return _field(containerName, leadingComment); } @@ -428,8 +459,12 @@ } } - TypeBase _type(String containerName, String fieldName, - {bool includeUndefined = false}) { + TypeBase _type( + String containerName, + String fieldName, { + bool includeUndefined = false, + bool improveTypes = false, + }) { var types = <TypeBase>[]; if (includeUndefined) { types.add(Type.Undefined); @@ -451,11 +486,17 @@ // Some of the inline interfaces have trailing commas (and some do not!) _match([TokenType.COMMA]); - // Add a synthetic interface to the parsers list of nodes to represent this type. - final generatedName = _joinNames(containerName, fieldName); - _nodes.add(new InlineInterface(generatedName, members)); - // Record the type as a simple type that references this interface. - type = new Type.identifier(generatedName); + // If we have a single member that is an indexer type, we can use a Map. + if (members.length == 1 && members.single is Indexer) { + Indexer indexer = members.single; + type = new MapType(indexer.indexType, indexer.valueType); + } else { + // Add a synthetic interface to the parsers list of nodes to represent this type. + final generatedName = _joinNames(containerName, fieldName); + _nodes.add(new InlineInterface(generatedName, members)); + // Record the type as a simple type that references this interface. + type = new Type.identifier(generatedName); + } } else if (_match([TokenType.LEFT_PAREN])) { // Some types are in (parens), so we just parse the contents as a nested type. type = _type(containerName, fieldName); @@ -511,17 +552,26 @@ break; } } + // Remove any duplicate types (for ex. if we map multiple types into dynamic) // we don't want to end up with `dynamic | dynamic`. Key on dartType to // ensure we different types that will map down to the same type. final uniqueTypes = new Map.fromEntries( types.map((t) => new MapEntry(t.dartTypeWithTypeArgs, t)), ).values.toList(); - if (uniqueTypes.length == 1) { - return uniqueTypes.single; - } else { - return new UnionType(uniqueTypes); + + var type = uniqueTypes.length == 1 + ? uniqueTypes.single + : new UnionType(uniqueTypes); + + // Handle improved type mappings for things that aren't very tight in the spec. + if (improveTypes) { + final improvedTypeName = getImprovedType(containerName, fieldName); + if (improvedTypeName != null) { + type = new Type.identifier(improvedTypeName); + } } + return type; } TypeAlias _typeAlias(Comment leadingComment) {