Implement LSP workspace/symbol

Change-Id: I4303a146dce9a5a4b40345f7ea8ef8355163337b
Reviewed-on: https://dart-review.googlesource.com/c/92602
Commit-Queue: Danny Tuppeny <dantup@google.com>
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_initialize.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_initialize.dart
index 8d9b2fb..a67c282 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_initialize.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_initialize.dart
@@ -92,7 +92,7 @@
         true, // referencesProvider
         true, // documentHighlightProvider
         true, // documentSymbolProvider
-        null,
+        true, // workspaceSymbolProvider
         // "The `CodeActionOptions` return type is only valid if the client
         // signals code action literal support via the property
         // `textDocument.codeAction.codeActionLiteralSupport`."
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart
index 36eb607..7594631 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart
@@ -25,6 +25,7 @@
 import 'package:analysis_server/src/lsp/handlers/handler_signature_help.dart';
 import 'package:analysis_server/src/lsp/handlers/handler_text_document_changes.dart';
 import 'package:analysis_server/src/lsp/handlers/handler_change_workspace_folders.dart';
+import 'package:analysis_server/src/lsp/handlers/handler_workspace_symbols.dart';
 import 'package:analysis_server/src/lsp/handlers/handlers.dart';
 import 'package:analysis_server/src/lsp/lsp_analysis_server.dart';
 
@@ -68,6 +69,7 @@
     registerHandler(new RenameHandler(server));
     registerHandler(new FoldingHandler(server));
     registerHandler(new DiagnosticServerHandler(server));
+    registerHandler(new WorkspaceSymbolHandler(server));
   }
 }
 
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_workspace_symbols.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_workspace_symbols.dart
new file mode 100644
index 0000000..03d6678
--- /dev/null
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_workspace_symbols.dart
@@ -0,0 +1,103 @@
+// Copyright (c) 2019, 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 'dart:async';
+import 'dart:collection';
+
+import 'package:analysis_server/lsp_protocol/protocol_generated.dart';
+import 'package:analysis_server/lsp_protocol/protocol_special.dart';
+import 'package:analysis_server/src/lsp/handlers/handler_document_symbols.dart'
+    show defaultSupportedSymbolKinds;
+import 'package:analysis_server/src/lsp/handlers/handlers.dart';
+import 'package:analysis_server/src/lsp/lsp_analysis_server.dart';
+import 'package:analysis_server/src/lsp/mapping.dart';
+import 'package:analyzer/source/line_info.dart';
+
+import 'package:analyzer/src/dart/analysis/search.dart' as search;
+
+class WorkspaceSymbolHandler
+    extends MessageHandler<WorkspaceSymbolParams, List<SymbolInformation>> {
+  WorkspaceSymbolHandler(LspAnalysisServer server) : super(server);
+  Method get handlesMessage => Method.workspace_symbol;
+
+  @override
+  WorkspaceSymbolParams convertParams(Map<String, dynamic> json) =>
+      WorkspaceSymbolParams.fromJson(json);
+
+  Future<ErrorOr<List<SymbolInformation>>> handle(
+      WorkspaceSymbolParams params) async {
+    // Respond to empty queries with an empty list. The spec says this should
+    // be non-empty, however VS Code's client sends empty requests (but then
+    // appears to not render the results we supply anyway).
+    final query = params?.query ?? '';
+    if (query == '') {
+      return success([]);
+    }
+
+    final symbolCapabilities = server?.clientCapabilities?.workspace?.symbol;
+
+    final clientSupportedSymbolKinds =
+        symbolCapabilities?.symbolKind?.valueSet != null
+            ? new HashSet<SymbolKind>.of(symbolCapabilities.symbolKind.valueSet)
+            : defaultSupportedSymbolKinds;
+
+    // Convert the string input into a case-insensitive regex that has wildcards
+    // between every character and at start/end to allow for fuzzy matching.
+    final fuzzyQuery = query.split('').map(RegExp.escape).join('.*');
+    final partialFuzzyQuery = '.*$fuzzyQuery.*';
+    final regex = new RegExp(partialFuzzyQuery, caseSensitive: false);
+
+    // Cap the number of results we'll return because short queries may match
+    // huge numbers on large projects.
+    var remainingResults = 500;
+
+    final declarations = <search.Declaration>[];
+    final filePathsHashSet = new LinkedHashSet<String>();
+    for (var driver in server.driverMap.values) {
+      final driverResults = await driver.search
+          .declarations(regex, remainingResults, filePathsHashSet);
+      declarations.addAll(driverResults);
+      remainingResults -= driverResults.length;
+    }
+
+    // Convert the file paths to something we can quickly index into since
+    // we'll be looking things up by index a lot.
+    final filePaths = filePathsHashSet.toList();
+    // We'll need line information to convert locations, so fetch
+    // them once and allow looking up using the file index.
+    final lineInfos = filePaths.map(server.getLineInfo).toList();
+
+    // Map the results to SymbolInformations and flatten the list of lists.
+    final symbols = declarations
+        .map((declaration) => _asSymbolInformation(
+              declaration,
+              clientSupportedSymbolKinds,
+              filePaths,
+              lineInfos,
+            ))
+        .toList();
+
+    return success(symbols);
+  }
+
+  SymbolInformation _asSymbolInformation(
+    search.Declaration declaration,
+    HashSet<SymbolKind> clientSupportedSymbolKinds,
+    List<String> filePaths,
+    List<LineInfo> lineInfos,
+  ) {
+    final filePath = filePaths[declaration.fileIndex];
+    final lineInfo = lineInfos[declaration.fileIndex];
+    return new SymbolInformation(
+        declaration.name,
+        declarationKindToSymbolKind(
+            clientSupportedSymbolKinds, declaration.kind),
+        null, // We don't have easy access to isDeprecated here.
+        new Location(
+          Uri.file(filePath).toString(),
+          toRange(lineInfo, declaration.codeOffset, declaration.codeLength),
+        ),
+        declaration.className ?? declaration.mixinName);
+  }
+}
diff --git a/pkg/analysis_server/lib/src/lsp/mapping.dart b/pkg/analysis_server/lib/src/lsp/mapping.dart
index edf1327..2f9dd65 100644
--- a/pkg/analysis_server/lib/src/lsp/mapping.dart
+++ b/pkg/analysis_server/lib/src/lsp/mapping.dart
@@ -15,6 +15,8 @@
 import 'package:analyzer/dart/analysis/results.dart' as server;
 import 'package:analyzer/error/error.dart' as server;
 import 'package:analyzer/source/line_info.dart' as server;
+import 'package:analyzer/src/dart/analysis/search.dart' as server
+    show DeclarationKind;
 import 'package:analyzer/src/generated/source.dart' as server;
 import 'package:analyzer_plugin/utilities/fixes/fixes.dart' as server;
 
@@ -47,6 +49,48 @@
           .toList());
 }
 
+lsp.SymbolKind declarationKindToSymbolKind(
+  HashSet<lsp.SymbolKind> clientSupportedSymbolKinds,
+  server.DeclarationKind kind,
+) {
+  bool isSupported(lsp.SymbolKind kind) =>
+      clientSupportedSymbolKinds.contains(kind);
+
+  List<lsp.SymbolKind> getKindPreferences() {
+    switch (kind) {
+      case server.DeclarationKind.CLASS:
+      case server.DeclarationKind.CLASS_TYPE_ALIAS:
+        return const [lsp.SymbolKind.Class];
+      case server.DeclarationKind.CONSTRUCTOR:
+        return const [lsp.SymbolKind.Constructor];
+      case server.DeclarationKind.ENUM:
+      case server.DeclarationKind.ENUM_CONSTANT:
+        return const [lsp.SymbolKind.Enum];
+      case server.DeclarationKind.FIELD:
+        return const [lsp.SymbolKind.Field];
+      case server.DeclarationKind.FUNCTION:
+        return const [lsp.SymbolKind.Function];
+      case server.DeclarationKind.FUNCTION_TYPE_ALIAS:
+        return const [lsp.SymbolKind.Class];
+      case server.DeclarationKind.GETTER:
+        return const [lsp.SymbolKind.Property];
+      case server.DeclarationKind.METHOD:
+        return const [lsp.SymbolKind.Method];
+      case server.DeclarationKind.MIXIN:
+        return const [lsp.SymbolKind.Class];
+      case server.DeclarationKind.SETTER:
+        return const [lsp.SymbolKind.Property];
+      case server.DeclarationKind.VARIABLE:
+        return const [lsp.SymbolKind.Variable];
+      default:
+        assert(false, 'Unexpected declaration kind $kind');
+        return null;
+    }
+  }
+
+  return getKindPreferences().firstWhere(isSupported, orElse: () => null);
+}
+
 lsp.CompletionItemKind elementKindToCompletionItemKind(
   HashSet<lsp.CompletionItemKind> clientSupportedCompletionKinds,
   server.ElementKind kind,
diff --git a/pkg/analysis_server/test/lsp/server_abstract.dart b/pkg/analysis_server/test/lsp/server_abstract.dart
index 94c60f2..a1735ec 100644
--- a/pkg/analysis_server/test/lsp/server_abstract.dart
+++ b/pkg/analysis_server/test/lsp/server_abstract.dart
@@ -403,6 +403,14 @@
     return expectSuccessfulResponseTo(request);
   }
 
+  Future<List<SymbolInformation>> getWorkspaceSymbols(String query) {
+    final request = makeRequest(
+      Method.workspace_symbol,
+      new WorkspaceSymbolParams(query),
+    );
+    return expectSuccessfulResponseTo(request);
+  }
+
   Future<List<FoldingRange>> getFoldingRegions(Uri uri) {
     final request = makeRequest(
       Method.textDocument_foldingRange,
diff --git a/pkg/analysis_server/test/lsp/test_all.dart b/pkg/analysis_server/test/lsp/test_all.dart
index 2745a6b..bdd25b3 100644
--- a/pkg/analysis_server/test/lsp/test_all.dart
+++ b/pkg/analysis_server/test/lsp/test_all.dart
@@ -15,6 +15,7 @@
 import 'document_highlights_test.dart' as document_highlights_test;
 import 'document_symbols_test.dart' as document_symbols_test;
 import 'file_modification_test.dart' as file_modification_test;
+import 'folding_test.dart' as folding_test;
 import 'format_test.dart' as format_test;
 import 'hover_test.dart' as hover_test;
 import 'initialization_test.dart' as initialization_test;
@@ -23,7 +24,7 @@
 import 'rename_test.dart' as rename_test;
 import 'server_test.dart' as server_test;
 import 'signature_help_test.dart' as signature_help_test;
-import 'folding_test.dart' as folding_test;
+import 'workspace_symbols_test.dart' as workspace_symbols_test;
 
 main() {
   defineReflectiveSuite(() {
@@ -46,5 +47,6 @@
     packet_transformer_tests.main();
     rename_test.main();
     folding_test.main();
+    workspace_symbols_test.main();
   }, name: 'lsp');
 }
diff --git a/pkg/analysis_server/test/lsp/workspace_symbols_test.dart b/pkg/analysis_server/test/lsp/workspace_symbols_test.dart
new file mode 100644
index 0000000..72cad45
--- /dev/null
+++ b/pkg/analysis_server/test/lsp/workspace_symbols_test.dart
@@ -0,0 +1,107 @@
+// Copyright (c) 2019, 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:analysis_server/lsp_protocol/protocol_generated.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+import 'server_abstract.dart';
+
+main() {
+  defineReflectiveSuite(() {
+    defineReflectiveTests(WorkspaceSymbolsTest);
+  });
+}
+
+@reflectiveTest
+class WorkspaceSymbolsTest extends AbstractLspAnalysisServerTest {
+  test_fullMatch() async {
+    const content = '''
+    [[String topLevel = '']];
+    class MyClass {
+      int myField;
+      MyClass(this.myField);
+      myMethod() {}
+    }
+    ''';
+    newFile(mainFilePath, content: withoutMarkers(content));
+    await initialize();
+
+    final symbols = await getWorkspaceSymbols('topLevel');
+
+    final topLevel = symbols.firstWhere((s) => s.name == 'topLevel');
+    expect(topLevel.kind, equals(SymbolKind.Variable));
+    expect(topLevel.containerName, isNull);
+    expect(topLevel.location.uri, equals(mainFileUri.toString()));
+    expect(topLevel.location.range, equals(rangeFromMarkers(content)));
+
+    // Ensure we didn't get some things that definitely do not match.
+    expect(symbols.any((s) => s.name == 'MyClass'), isFalse);
+    expect(symbols.any((s) => s.name == 'myMethod'), isFalse);
+  }
+
+  test_fuzzyMatch() async {
+    const content = '''
+    String topLevel = '';
+    class MyClass {
+      [[int myField]];
+      MyClass(this.myField);
+      myMethod() {}
+    }
+    ''';
+    newFile(mainFilePath, content: withoutMarkers(content));
+    await initialize();
+
+    // meld should match myField
+    final symbols = await getWorkspaceSymbols('meld');
+
+    final field = symbols.firstWhere((s) => s.name == 'myField');
+    expect(field.kind, equals(SymbolKind.Field));
+    expect(field.containerName, equals('MyClass'));
+    expect(field.location.uri, equals(mainFileUri.toString()));
+    expect(field.location.range, equals(rangeFromMarkers(content)));
+
+    // Ensure we didn't get some things that definitely do not match.
+    expect(symbols.any((s) => s.name == 'MyClass'), isFalse);
+    expect(symbols.any((s) => s.name == 'myMethod'), isFalse);
+  }
+
+  test_partialMatch() async {
+    const content = '''
+    String topLevel = '';
+    class MyClass {
+      [[int myField]];
+      MyClass(this.myField);
+      [[myMethod() {}]]
+    }
+    ''';
+    newFile(mainFilePath, content: withoutMarkers(content));
+    await initialize();
+
+    final symbols = await getWorkspaceSymbols('my');
+    final ranges = rangesFromMarkers(content);
+    final fieldRange = ranges[0];
+    final methodRange = ranges[1];
+
+    final field = symbols.firstWhere((s) => s.name == 'myField');
+    expect(field.kind, equals(SymbolKind.Field));
+    expect(field.containerName, equals('MyClass'));
+    expect(field.location.uri, equals(mainFileUri.toString()));
+    expect(field.location.range, equals(fieldRange));
+
+    final klass = symbols.firstWhere((s) => s.name == 'MyClass');
+    expect(klass.kind, equals(SymbolKind.Class));
+    expect(klass.containerName, isNull);
+    expect(klass.location.uri, equals(mainFileUri.toString()));
+
+    final method = symbols.firstWhere((s) => s.name == 'myMethod');
+    expect(method.kind, equals(SymbolKind.Method));
+    expect(method.containerName, equals('MyClass'));
+    expect(method.location.uri, equals(mainFileUri.toString()));
+    expect(method.location.range, equals(methodRange));
+
+    // Ensure we didn't get some things that definitely do not match.
+    expect(symbols.any((s) => s.name == 'topLevel'), isFalse);
+  }
+}
diff --git a/pkg/analysis_server/tool/lsp_spec/README.md b/pkg/analysis_server/tool/lsp_spec/README.md
index b696b00..5c0ab08 100644
--- a/pkg/analysis_server/tool/lsp_spec/README.md
+++ b/pkg/analysis_server/tool/lsp_spec/README.md
@@ -41,7 +41,7 @@
 | workspace/didChangeWorkspaceFolders | ✅ | ✅ | ✅ | ✅ |
 | workspace/configuration | | | | |
 | workspace/didChangeWatchedFiles | | | | | unused, server does own watching |
-| workspace/symbol | | | | |
+| workspace/symbol | ✅ | ✅ | ✅ | ✅ |
 | workspace/executeCommand | ✅ | ✅ | ✅ | ✅ |
 | workspace/applyEdit | ✅ | ✅ | ✅ | ✅ |
 | textDocument/didOpen | ✅ | ✅ | ✅ | ✅ |