Add implementation/tests for LSP rename
Change-Id: I28e45cb7c979edb3d64b17e86ad0e03b7cd746cd
Reviewed-on: https://dart-review.googlesource.com/c/89361
Commit-Queue: Danny Tuppeny <dantup@google.com>
Reviewed-by: Brian Wilkerson <brianwilkerson@google.com>
diff --git a/pkg/analysis_server/lib/src/lsp/constants.dart b/pkg/analysis_server/lib/src/lsp/constants.dart
index 7674d6f..2110d4d 100644
--- a/pkg/analysis_server/lib/src/lsp/constants.dart
+++ b/pkg/analysis_server/lib/src/lsp/constants.dart
@@ -40,6 +40,7 @@
static const FileNotAnalyzed = const ErrorCodes(-32007);
static const FileHasErrors = const ErrorCodes(-32008);
static const ClientFailedToApplyEdit = const ErrorCodes(-32009);
+ static const FatalRefactoringProblem = const ErrorCodes(-32010);
/// An error raised when the server detects that the server and client are out
/// of sync and cannot recover. For example if a textDocument/didChange notification
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 858e383..e1a72f6 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_initialize.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_initialize.dart
@@ -44,6 +44,9 @@
final codeActionLiteralSupport =
params.capabilities.textDocument?.codeAction?.codeActionLiteralSupport;
+ final renameOptionsSupport =
+ params.capabilities.textDocument?.rename?.prepareSupport ?? false;
+
server.capabilities = new ServerCapabilities(
Either2<TextDocumentSyncOptions, num>.t1(new TextDocumentSyncOptions(
true,
@@ -86,7 +89,9 @@
true, // documentFormattingProvider
false, // documentRangeFormattingProvider
new DocumentOnTypeFormattingOptions('}', [';']),
- null,
+ renameOptionsSupport
+ ? Either2<bool, RenameOptions>.t2(new RenameOptions(true))
+ : Either2<bool, RenameOptions>.t1(true),
null,
null,
null,
diff --git a/pkg/analysis_server/lib/src/lsp/handlers/handler_rename.dart b/pkg/analysis_server/lib/src/lsp/handlers/handler_rename.dart
new file mode 100644
index 0000000..090d560
--- /dev/null
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_rename.dart
@@ -0,0 +1,139 @@
+// 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:analysis_server/lsp_protocol/protocol_special.dart';
+import 'package:analysis_server/src/lsp/constants.dart';
+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:analysis_server/src/services/refactoring/refactoring.dart';
+
+class PrepareRenameHandler
+ extends MessageHandler<TextDocumentPositionParams, RangeAndPlaceholder> {
+ PrepareRenameHandler(LspAnalysisServer server) : super(server);
+ Method get handlesMessage => Method.textDocument_prepareRename;
+
+ @override
+ TextDocumentPositionParams convertParams(Map<String, dynamic> json) =>
+ TextDocumentPositionParams.fromJson(json);
+
+ @override
+ Future<ErrorOr<RangeAndPlaceholder>> handle(
+ TextDocumentPositionParams params) async {
+ final pos = params.position;
+ final path = pathOfDoc(params.textDocument);
+ final unit = await path.mapResult(requireUnit);
+ final offset = await unit.mapResult((unit) => toOffset(unit.lineInfo, pos));
+
+ return offset.mapResult((offset) async {
+ final node = await server.getNodeAtOffset(path.result, offset);
+ final element = server.getElementOfNode(node);
+ if (node == null || element == null) {
+ return success(null);
+ }
+
+ final refactorDetails =
+ RenameRefactoring.getElementToRename(node, element);
+ final refactoring = new RenameRefactoring(server.refactoringWorkspace,
+ unit.result.session, refactorDetails.element);
+
+ // Check the rename is valid here.
+ final initStatus = await refactoring.checkInitialConditions();
+ if (initStatus.hasFatalError) {
+ return error(ServerErrorCodes.FatalRefactoringProblem,
+ initStatus.problem.message, null);
+ }
+
+ return success(new RangeAndPlaceholder(
+ toRange(
+ unit.result.lineInfo,
+ // If the offset is set to -1 it means there is no location for the
+ // old name. However since we must provide a range for LSP, we'll use
+ // a 0-character span at the originally requested location to ensure
+ // it's valid.
+ refactorDetails.offset == -1 ? offset : refactorDetails.offset,
+ refactorDetails.length,
+ ),
+ refactoring.oldName,
+ ));
+ });
+ }
+}
+
+class RenameHandler extends MessageHandler<RenameParams, WorkspaceEdit> {
+ RenameHandler(LspAnalysisServer server) : super(server);
+
+ Method get handlesMessage => Method.textDocument_rename;
+
+ @override
+ RenameParams convertParams(Map<String, dynamic> json) =>
+ RenameParams.fromJson(json);
+
+ @override
+ Future<ErrorOr<WorkspaceEdit>> handle(RenameParams params) async {
+ final pos = params.position;
+ final path = pathOfDoc(params.textDocument);
+ // If the client provided us a version doc identifier, we'll use it to ensure
+ // we're not computing a rename for an old document. If not, we'll just assume
+ // the version the server had at the time of recieving the request is valid
+ // and then use it to verify the document hadn't changed again before we
+ // send the edits.
+ final docIdentifier = await path.mapResult((path) => success(
+ params.textDocument is VersionedTextDocumentIdentifier
+ ? params.textDocument
+ : server.getVersionedDocumentIdentifier(path)));
+ final unit = await path.mapResult(requireUnit);
+ final offset = await unit.mapResult((unit) => toOffset(unit.lineInfo, pos));
+
+ return offset.mapResult((offset) async {
+ final node = await server.getNodeAtOffset(path.result, offset);
+ final element = server.getElementOfNode(node);
+ if (node == null || element == null) {
+ return success(null);
+ }
+
+ final refactorDetails =
+ RenameRefactoring.getElementToRename(node, element);
+ final refactoring = new RenameRefactoring(server.refactoringWorkspace,
+ unit.result.session, refactorDetails.element);
+
+ // Check the rename is valid here.
+ final initStatus = await refactoring.checkInitialConditions();
+ if (initStatus.hasFatalError) {
+ return error(ServerErrorCodes.FatalRefactoringProblem,
+ initStatus.problem.message, null);
+ }
+
+ // Check the name is valid.
+ refactoring.newName = params.newName;
+ final optionsStatus = refactoring.checkNewName();
+ if (optionsStatus.hasFatalError) {
+ return error(ServerErrorCodes.FatalRefactoringProblem,
+ optionsStatus.problem.message, null);
+ }
+
+ // Final validation.
+ final finalStatus = await refactoring.checkFinalConditions();
+ if (finalStatus.hasFatalError) {
+ return error(ServerErrorCodes.FatalRefactoringProblem,
+ finalStatus.problem.message, null);
+ }
+
+ // Compute the actual change.
+ final change = await refactoring.createChange();
+
+ // Before we send anything back, ensure the original file didn't change
+ // while we were computing changes.
+ if (server.getVersionedDocumentIdentifier(path.result) !=
+ docIdentifier.result) {
+ return error(ErrorCodes.ContentModified,
+ 'Document was modified while rename was being computed', null);
+ }
+
+ final workspaceEdit = createWorkspaceEdit(server, change);
+ return success(workspaceEdit);
+ });
+ }
+}
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 7e86071..9375b8b 100644
--- a/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart
+++ b/pkg/analysis_server/lib/src/lsp/handlers/handler_states.dart
@@ -19,6 +19,7 @@
import 'package:analysis_server/src/lsp/handlers/handler_initialize.dart';
import 'package:analysis_server/src/lsp/handlers/handler_initialized.dart';
import 'package:analysis_server/src/lsp/handlers/handler_references.dart';
+import 'package:analysis_server/src/lsp/handlers/handler_rename.dart';
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';
@@ -61,6 +62,8 @@
registerHandler(new CodeActionHandler(server));
registerHandler(new ExecuteCommandHandler(server));
registerHandler(new WorkspaceFoldersHandler(server));
+ registerHandler(new PrepareRenameHandler(server));
+ registerHandler(new RenameHandler(server));
}
}
diff --git a/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart b/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart
index 20950cc..925e5a7 100644
--- a/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart
+++ b/pkg/analysis_server/lib/src/lsp/lsp_analysis_server.dart
@@ -24,6 +24,7 @@
import 'package:analysis_server/src/protocol_server.dart' as protocol;
import 'package:analysis_server/src/services/completion/completion_performance.dart'
show CompletionPerformance;
+import 'package:analysis_server/src/services/refactoring/refactoring.dart';
import 'package:analysis_server/src/services/search/search_engine.dart';
import 'package:analysis_server/src/services/search/search_engine_internal.dart';
import 'package:analysis_server/src/utilities/null_string_sink.dart';
@@ -79,6 +80,15 @@
final AnalysisOptionsImpl defaultContextOptions = new AnalysisOptionsImpl();
/**
+ * The workspace for rename refactorings. Should be accessed through the
+ * refactoringWorkspace getter to be automatically created (lazily).
+ */
+ RefactoringWorkspace _refactoringWorkspace;
+
+ RefactoringWorkspace get refactoringWorkspace => _refactoringWorkspace ??=
+ new RefactoringWorkspace(driverMap.values, searchEngine);
+
+ /**
* The versions of each document known to the server (keyed by path), used to
* send back to the client for server-initiated edits so that the client can
* ensure they have a matching version of the document before applying them.
diff --git a/pkg/analysis_server/test/lsp/rename_test.dart b/pkg/analysis_server/test/lsp/rename_test.dart
new file mode 100644
index 0000000..8e984f2
--- /dev/null
+++ b/pkg/analysis_server/test/lsp/rename_test.dart
@@ -0,0 +1,295 @@
+// 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:analysis_server/src/lsp/constants.dart';
+import 'package:test/test.dart';
+import 'package:test_reflective_loader/test_reflective_loader.dart';
+
+import 'server_abstract.dart';
+
+main() {
+ defineReflectiveSuite(() {
+ defineReflectiveTests(RenameTest);
+ });
+}
+
+@reflectiveTest
+class RenameTest extends AbstractLspAnalysisServerTest {
+ // TODO(dantup): send a rename without a version
+ // TODO(dantup): file changes during computation?
+ // TODO(dantup): send an old version of the doc?
+ // TODO(dantup): check the version return matches?
+ // TODO(dantup): a rename that fails on options step
+ // TODO(dantup): a rename that fails on final step
+ // TODO(dantup): renames across multiple files
+
+ test_prepare_class() {
+ const content = '''
+ class MyClass {}
+ final a = new [[My^Class]]();
+ ''';
+
+ return _test_prepare(content, 'MyClass');
+ }
+
+ test_prepare_classNewKeyword() async {
+ const content = '''
+ class MyClass {}
+ final a = n^ew [[MyClass]]();
+ ''';
+
+ return _test_prepare(content, 'MyClass');
+ }
+
+ test_prepare_importPrefix() async {
+ const content = '''
+ import 'dart:async' as [[myPr^efix]];
+ ''';
+
+ return _test_prepare(content, 'myPrefix');
+ }
+
+ test_prepare_importWithoutPrefix() async {
+ const content = '''
+ imp[[^]]ort 'dart:async';
+ ''';
+
+ return _test_prepare(content, '');
+ }
+
+ test_prepare_importWithPrefix() async {
+ const content = '''
+ imp^ort 'dart:async' as [[myPrefix]];
+ ''';
+
+ return _test_prepare(content, 'myPrefix');
+ }
+
+ test_prepare_invalidRenameLocation() async {
+ const content = '''
+ main() {
+ // comm^ent
+ }
+ ''';
+
+ return _test_prepare(content, null);
+ }
+
+ test_prepare_sdkClass() async {
+ const content = '''
+ final a = new [[Ob^ject]]();
+ ''';
+
+ await initialize();
+ await openFile(mainFileUri, withoutMarkers(content));
+
+ final request = makeRequest(
+ Method.textDocument_prepareRename,
+ new TextDocumentPositionParams(
+ new TextDocumentIdentifier(mainFileUri.toString()),
+ positionFromMarker(content),
+ ),
+ );
+ final response = await channel.sendRequestToServer(request);
+
+ expect(response.id, equals(request.id));
+ expect(response.result, isNull);
+ expect(response.error, isNotNull);
+ expect(response.error.code, ServerErrorCodes.FatalRefactoringProblem);
+ expect(response.error.message, contains('is defined in the SDK'));
+ }
+
+ test_prepare_variable() async {
+ const content = '''
+ main() {
+ var variable = 0;
+ print([[vari^able]]);
+ }
+ ''';
+
+ return _test_prepare(content, 'variable');
+ }
+
+ test_rename_class() {
+ const content = '''
+ class MyClass {}
+ final a = new [[My^Class]]();
+ ''';
+ const expectedContent = '''
+ class MyNewClass {}
+ final a = new MyNewClass();
+ ''';
+ return _test_rename_withDocumentChanges(
+ content, 'MyNewClass', expectedContent);
+ }
+
+ test_rename_classNewKeyword() async {
+ const content = '''
+ class MyClass {}
+ final a = n^ew MyClass();
+ ''';
+ const expectedContent = '''
+ class MyNewClass {}
+ final a = new MyNewClass();
+ ''';
+ return _test_rename_withDocumentChanges(
+ content, 'MyNewClass', expectedContent);
+ }
+
+ test_rename_importPrefix() {
+ const content = '''
+ import 'dart:async' as myPr^efix;
+ ''';
+ const expectedContent = '''
+ import 'dart:async' as myNewPrefix;
+ ''';
+ return _test_rename_withDocumentChanges(
+ content, 'myNewPrefix', expectedContent);
+ }
+
+ test_rename_importWithoutPrefix() {
+ const content = '''
+ imp^ort 'dart:async';
+ ''';
+ const expectedContent = '''
+ import 'dart:async' as myAddedPrefix;
+ ''';
+ return _test_rename_withDocumentChanges(
+ content, 'myAddedPrefix', expectedContent);
+ }
+
+ test_rename_importWithPrefix() {
+ const content = '''
+ imp^ort 'dart:async' as myPrefix;
+ ''';
+ const expectedContent = '''
+ import 'dart:async' as myNewPrefix;
+ ''';
+ return _test_rename_withDocumentChanges(
+ content, 'myNewPrefix', expectedContent);
+ }
+
+ test_rename_invalidRenameLocation() {
+ const content = '''
+ main() {
+ // comm^ent
+ }
+ ''';
+ return _test_rename_withDocumentChanges(content, 'MyNewClass', null);
+ }
+
+ test_rename_sdkClass() async {
+ const content = '''
+ final a = new [[Ob^ject]]();
+ ''';
+
+ await newFile(mainFilePath, content: withoutMarkers(content));
+ await initialize();
+
+ final request = makeRequest(
+ Method.textDocument_rename,
+ new RenameParams(
+ new TextDocumentIdentifier(mainFileUri.toString()),
+ positionFromMarker(content),
+ 'Object2',
+ ),
+ );
+ final response = await channel.sendRequestToServer(request);
+
+ expect(response.id, equals(request.id));
+ expect(response.result, isNull);
+ expect(response.error, isNotNull);
+ expect(response.error.code, ServerErrorCodes.FatalRefactoringProblem);
+ expect(response.error.message, contains('is defined in the SDK'));
+ }
+
+ test_rename_usingLegacyChangeInterface() async {
+ // This test initializes without support for DocumentChanges (versioning)
+ // whereas the other tests all use DocumentChanges support (preferred).
+ const content = '''
+ class MyClass {}
+ final a = new My^Class();
+ ''';
+ const expectedContent = '''
+ class MyNewClass {}
+ final a = new MyNewClass();
+ ''';
+
+ await initialize();
+ await openFile(mainFileUri, withoutMarkers(content), version: 222);
+
+ final result = await rename(
+ mainFileUri,
+ 222,
+ positionFromMarker(content),
+ 'MyNewClass',
+ );
+
+ // Ensure applying the changes will give us the expected content.
+ final contents = {
+ mainFilePath: withoutMarkers(content),
+ };
+ applyChanges(contents, result.changes);
+ expect(contents[mainFilePath], equals(expectedContent));
+ }
+
+ test_rename_variable() {
+ const content = '''
+ main() {
+ var variable = 0;
+ print([[vari^able]]);
+ }
+ ''';
+ const expectedContent = '''
+ main() {
+ var foo = 0;
+ print(foo);
+ }
+ ''';
+ return _test_rename_withDocumentChanges(content, 'foo', expectedContent);
+ }
+
+ _test_prepare(String content, String expectedPlaceholder) async {
+ await initialize();
+ await openFile(mainFileUri, withoutMarkers(content));
+
+ final result =
+ await prepareRename(mainFileUri, positionFromMarker(content));
+
+ if (expectedPlaceholder == null) {
+ expect(result, isNull);
+ } else {
+ expect(result.range, equals(rangeFromMarkers(content)));
+ expect(result.placeholder, equals(expectedPlaceholder));
+ }
+ }
+
+ _test_rename_withDocumentChanges(
+ String content, String newName, String expectedContent) async {
+ await initialize(
+ workspaceCapabilities:
+ withDocumentChangesSupport(emptyWorkspaceClientCapabilities),
+ );
+ await openFile(mainFileUri, withoutMarkers(content), version: 222);
+
+ final result = await rename(
+ mainFileUri,
+ 222,
+ positionFromMarker(content),
+ newName,
+ );
+
+ if (expectedContent == null) {
+ expect(result, isNull);
+ } else {
+ // Ensure applying the changes will give us the expected content.
+ final contents = {
+ mainFilePath: withoutMarkers(content),
+ };
+ applyDocumentChanges(contents, result.documentChanges);
+ expect(contents[mainFilePath], equals(expectedContent));
+ }
+ }
+}
diff --git a/pkg/analysis_server/test/lsp/server_abstract.dart b/pkg/analysis_server/test/lsp/server_abstract.dart
index b8f21a0..1ccbbb1 100644
--- a/pkg/analysis_server/test/lsp/server_abstract.dart
+++ b/pkg/analysis_server/test/lsp/server_abstract.dart
@@ -469,6 +469,17 @@
return toPosition(lineInfo.getLocation(offset));
}
+ Future<RangeAndPlaceholder> prepareRename(Uri uri, Position pos) {
+ final request = makeRequest(
+ Method.textDocument_prepareRename,
+ new TextDocumentPositionParams(
+ new TextDocumentIdentifier(uri.toString()),
+ pos,
+ ),
+ );
+ return expectSuccessfulResponseTo<RangeAndPlaceholder>(request);
+ }
+
/// Returns the range surrounded by `[[markers]]` in the provided string,
/// excluding the markers themselves (as well as position markers `^` from
/// the offsets).
@@ -520,6 +531,22 @@
return rangesFromMarkersImpl(content).toList();
}
+ Future<WorkspaceEdit> rename(
+ Uri uri,
+ int version,
+ Position pos,
+ String newName,
+ ) {
+ final docIdentifier = version != null
+ ? new VersionedTextDocumentIdentifier(version, uri.toString())
+ : new TextDocumentIdentifier(uri.toString());
+ final request = makeRequest(
+ Method.textDocument_rename,
+ new RenameParams(docIdentifier, pos, newName),
+ );
+ return expectSuccessfulResponseTo<WorkspaceEdit>(request);
+ }
+
Future replaceFile(int newVersion, Uri uri, String content) {
return changeFile(
newVersion,
diff --git a/pkg/analysis_server/test/lsp/test_all.dart b/pkg/analysis_server/test/lsp/test_all.dart
index 003a401..d1e29ad 100644
--- a/pkg/analysis_server/test/lsp/test_all.dart
+++ b/pkg/analysis_server/test/lsp/test_all.dart
@@ -12,14 +12,15 @@
import 'completion_test.dart' as completion_test;
import 'definition_test.dart' as definition_test;
import 'diagnostic_test.dart' as diagnostic_test;
-import 'document_symbols_test.dart' as document_symbols_test;
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 'format_test.dart' as format_test;
import 'hover_test.dart' as hover_test;
import 'initialization_test.dart' as initialization_test;
import 'priority_files_test.dart' as priority_files_test;
import 'references_test.dart' as references_test;
+import 'rename_test.dart' as rename_test;
import 'server_test.dart' as server_test;
import 'signature_help_test.dart' as signature_help_test;
@@ -42,5 +43,6 @@
fixes_code_action_tests.main();
assists_code_action_tests.main();
packet_transformer_tests.main();
+ rename_test.main();
}, name: 'lsp');
}
diff --git a/pkg/analysis_server/tool/lsp_spec/README.md b/pkg/analysis_server/tool/lsp_spec/README.md
index 5258007..fe5bf80 100644
--- a/pkg/analysis_server/tool/lsp_spec/README.md
+++ b/pkg/analysis_server/tool/lsp_spec/README.md
@@ -70,7 +70,7 @@
| textDocument/formatting | ✅ | ✅ | ✅ | ✅ |
| textDocument/rangeFormatting | | | | | requires support from dart_style?
| textDocument/onTypeFormatting | ✅ | ✅ | ✅ | ✅ |
-| textDocument/rename | | | | |
+| textDocument/rename | ✅ | ✅ | Incomplete! | |
| textDocument/prepareRename | | | | |
| textDocument/foldingRange | | | | |