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) {