Statement completion framework with a few examples

R=brianwilkerson@google.com, scheglov@google.com

Review-Url: https://codereview.chromium.org/2803313002 .
This commit is contained in:
Steve Messick
2017-04-07 13:55:50 -07:00
parent daddec8308
commit eadc6af226
7 changed files with 855 additions and 0 deletions
@@ -82,6 +82,7 @@ const String DISPLAY_NAME = 'displayName';
const String EDIT_FORMAT = 'edit.format';
const String EDIT_GET_ASSISTS = 'edit.getAssists';
const String EDIT_GET_AVAILABLE_REFACTORINGS = 'edit.getAvailableRefactorings';
const String EDIT_GET_STATEMENT_COMPLETION = "edit.getStatementCompletion";
//
// Execution methods
@@ -14,6 +14,7 @@ import 'package:analysis_server/src/analysis_server.dart';
import 'package:analysis_server/src/collections.dart';
import 'package:analysis_server/src/constants.dart';
import 'package:analysis_server/src/protocol_server.dart' hide Element;
import 'package:analysis_server/src/services/completion/statement/statement_completion.dart';
import 'package:analysis_server/src/services/correction/assist.dart';
import 'package:analysis_server/src/services/correction/assist_internal.dart';
import 'package:analysis_server/src/services/correction/fix.dart';
@@ -249,6 +250,38 @@ class EditDomainHandler implements RequestHandler {
new EditGetFixesResult(errorFixesList).toResponse(request.id));
}
Future getStatementCompletion(Request request) async {
var params = new EditGetStatementCompletionParams.fromRequest(request);
SourceChange change;
AnalysisResult result = await server.getAnalysisResult(params.file);
if (result != null) {
CompilationUnit unit = result.unit;
CompilationUnitElement unitElement =
resolutionMap.elementDeclaredByCompilationUnit(unit);
if (unitElement.context != null) {
StatementCompletionContext context = new StatementCompletionContext(
params.file,
result.lineInfo,
params.offset,
unit,
unitElement,
result.errors);
StatementCompletionProcessor processor =
new StatementCompletionProcessor(context);
StatementCompletion completion = await processor.compute();
change = completion.change;
}
}
if (change == null) {
change = new SourceChange("", edits: []);
}
Response response = new EditGetStatementCompletionResult(change, false)
.toResponse(request.id);
server.sendResponse(response);
}
@override
Response handleRequest(Request request) {
try {
@@ -271,6 +304,9 @@ class EditDomainHandler implements RequestHandler {
} else if (requestName == EDIT_SORT_MEMBERS) {
sortMembers(request);
return Response.DELAYED_RESPONSE;
} else if (requestName == EDIT_GET_STATEMENT_COMPLETION) {
getStatementCompletion(request);
return Response.DELAYED_RESPONSE;
}
} on RequestFailure catch (exception) {
return exception.response;
@@ -0,0 +1,410 @@
// Copyright (c) 2017, 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.
library services.src.completion.statement;
import 'dart:async';
import 'package:analysis_server/plugin/protocol/protocol.dart';
import 'package:analysis_server/src/protocol_server.dart' hide Element;
import 'package:analysis_server/src/services/correction/source_buffer.dart';
import 'package:analysis_server/src/services/correction/source_range.dart';
import 'package:analysis_server/src/services/correction/util.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/error/error.dart' as engine;
import 'package:analyzer/src/dart/ast/utilities.dart';
import 'package:analyzer/src/dart/error/hint_codes.dart';
import 'package:analyzer/src/dart/error/syntactic_errors.dart';
import 'package:analyzer/src/generated/engine.dart';
import 'package:analyzer/src/generated/java_core.dart';
import 'package:analyzer/src/generated/source.dart';
/**
* An enumeration of possible statement completion kinds.
*/
class DartStatementCompletion {
static const NO_COMPLETION =
const StatementCompletionKind('No_COMPLETION', 'No completion available');
static const PLAIN_OLE_ENTER = const StatementCompletionKind(
'PLAIN_OLE_ENTER', "Insert a newline at the end of the current line");
static const SIMPLE_SEMICOLON = const StatementCompletionKind(
'SIMPLE_SEMICOLON', "Add a semicolon and newline");
static const COMPLETE_IF_STMT = const StatementCompletionKind(
'COMPLETE_IF_STMT', "Complete if-statement");
static const COMPLETE_WHILE_STMT = const StatementCompletionKind(
'COMPLETE_WHILE_STMT', "Complete while-statement");
}
/**
* A description of a statement completion.
*
* Clients may not extend, implement or mix-in this class.
*/
class StatementCompletion {
/**
* A description of the assist being proposed.
*/
final StatementCompletionKind kind;
/**
* The change to be made in order to apply the assist.
*/
final SourceChange change;
/**
* Initialize a newly created completion to have the given [kind] and [change].
*/
StatementCompletion(this.kind, this.change);
}
/**
* The context for computing a statement completion.
*/
class StatementCompletionContext {
final String file;
final LineInfo lineInfo;
final int selectionOffset;
final CompilationUnit unit;
final CompilationUnitElement unitElement;
final List<engine.AnalysisError> errors;
StatementCompletionContext(this.file, this.lineInfo, this.selectionOffset,
this.unit, this.unitElement, this.errors) {
if (unitElement.context == null) {
throw new Error(); // not reached; see getStatementCompletion()
}
}
}
/**
* A description of a class of statement completions. Instances are intended to
* hold the information that is common across a number of completions and to be
* shared by those completions.
*
* Clients may not extend, implement or mix-in this class.
*/
class StatementCompletionKind {
/**
* The name of this kind of statement completion, used for debugging.
*/
final String name;
/**
* A human-readable description of the changes that will be applied by this
* kind of statement completion.
*/
final String message;
/**
* Initialize a newly created kind of statement completion to have the given
* [name] and [message].
*/
const StatementCompletionKind(this.name, this.message);
@override
String toString() => name;
}
/**
* The computer for Dart statement completions.
*/
class StatementCompletionProcessor {
static final NO_COMPLETION = new StatementCompletion(
DartStatementCompletion.NO_COMPLETION, new SourceChange("", edits: []));
final StatementCompletionContext statementContext;
final AnalysisContext analysisContext;
final CorrectionUtils utils;
int fileStamp;
AstNode node;
StatementCompletion completion;
SourceChange change = new SourceChange('statement-completion');
List errors = <engine.AnalysisError>[];
final Map<String, LinkedEditGroup> linkedPositionGroups =
<String, LinkedEditGroup>{};
Position exitPosition = null;
StatementCompletionProcessor(this.statementContext)
: analysisContext = statementContext.unitElement.context,
utils = new CorrectionUtils(statementContext.unit) {
fileStamp = analysisContext.getModificationStamp(source);
}
String get eol => utils.endOfLine;
String get file => statementContext.file;
LineInfo get lineInfo => statementContext.lineInfo;
int get requestLine => lineInfo.getLocation(selectionOffset).lineNumber;
int get selectionOffset => statementContext.selectionOffset;
Source get source => statementContext.unitElement.source;
CompilationUnit get unit => statementContext.unit;
CompilationUnitElement get unitElement => statementContext.unitElement;
Future<StatementCompletion> compute() async {
// If the source was changed between the constructor and running
// this asynchronous method, it is not safe to use the unit.
if (analysisContext.getModificationStamp(source) != fileStamp) {
return NO_COMPLETION;
}
node = new NodeLocator(selectionOffset).searchWithin(unit);
if (node == null) {
return NO_COMPLETION;
}
// TODO(messick): This needs to work for declarations.
node = node.getAncestor((n) => n is Statement);
for (engine.AnalysisError error in statementContext.errors) {
if (error.offset >= node.offset &&
error.offset <= node.offset + node.length) {
if (error.errorCode is! HintCode) {
errors.add(error);
}
}
}
if (_complete_ifStatement() ||
_complete_whileStatement() ||
_complete_simpleSemicolon() ||
_complete_plainOleEnter()) {
return completion;
}
return NO_COMPLETION;
}
void _addIndentEdit(SourceRange range, String oldIndent, String newIndent) {
SourceEdit edit = utils.createIndentEdit(range, oldIndent, newIndent);
doSourceChange_addElementEdit(change, unitElement, edit);
}
void _addInsertEdit(int offset, String text) {
SourceEdit edit = new SourceEdit(offset, 0, text);
doSourceChange_addElementEdit(change, unitElement, edit);
}
void _addReplaceEdit(SourceRange range, String text) {
SourceEdit edit = new SourceEdit(range.offset, range.length, text);
doSourceChange_addElementEdit(change, unitElement, edit);
}
void _appendEmptyBraces(SourceBuilder sb, [bool needsExitMark = false]) {
sb.append(' {');
sb.append(eol);
String indent = utils.getLinePrefix(selectionOffset);
sb.append(indent);
sb.append(utils.getIndent(1));
if (needsExitMark) {
sb.setExitOffset();
}
sb.append(eol);
sb.append(indent);
sb.append('}');
}
int _appendNewlinePlusIndent() {
// Append a newline plus proper indent and another newline.
// Return the position before the second newline.
String indent = utils.getLinePrefix(selectionOffset);
int loc = utils.getLineNext(selectionOffset);
_addInsertEdit(loc, indent + eol);
return loc + indent.length;
}
bool _complete_ifOrWhileStatement(
_IfWhileStructure statement, StatementCompletionKind kind) {
String text = utils.getNodeText(node);
if (text.endsWith(eol)) {
text = text.substring(0, text.length - eol.length);
}
SourceBuilder sb;
bool needsExit = false;
if (statement.leftParenthesis.lexeme.isEmpty) {
if (!statement.rightParenthesis.lexeme.isEmpty) {
// Quite unlikely to see this so don't try to fix it.
return false;
}
int len = statement.keyword.length;
if (text.length == len ||
!text.substring(len, len + 1).contains(new RegExp(r'\s'))) {
sb = new SourceBuilder(file, statement.offset + len);
sb.append(' ');
} else {
sb = new SourceBuilder(file, statement.offset + len + 1);
}
sb.append('(');
sb.setExitOffset();
sb.append(')');
} else {
if (_isEmptyExpression(statement.condition)) {
exitPosition = _newPosition(statement.leftParenthesis.offset + 1);
sb = new SourceBuilder(file, statement.rightParenthesis.offset + 1);
} else {
sb = new SourceBuilder(file, statement.rightParenthesis.offset + 1);
needsExit = true;
}
}
if (statement.block is EmptyStatement) {
_appendEmptyBraces(sb, needsExit);
}
_insertBuilder(sb);
_setCompletion(kind);
return true;
}
bool _complete_ifStatement() {
if (errors.isEmpty || node is! IfStatement) {
return false;
}
IfStatement ifNode = node;
if (ifNode != null) {
if (ifNode.elseKeyword != null) {
return false;
}
var stmt = new _IfWhileStructure(ifNode.ifKeyword, ifNode.leftParenthesis,
ifNode.condition, ifNode.rightParenthesis, ifNode.thenStatement);
return _complete_ifOrWhileStatement(
stmt, DartStatementCompletion.COMPLETE_IF_STMT);
}
return false;
}
bool _complete_plainOleEnter() {
int offset;
if (!errors.isEmpty) {
offset = selectionOffset;
} else {
String indent = utils.getLinePrefix(selectionOffset);
int loc = utils.getLineNext(selectionOffset);
_addInsertEdit(loc, indent + eol);
offset = loc + indent.length + eol.length;
}
_setCompletionAt(DartStatementCompletion.PLAIN_OLE_ENTER, offset);
return true;
}
bool _complete_simpleSemicolon() {
if (errors.length != 1) {
return false;
}
var error = _findError(ParserErrorCode.EXPECTED_TOKEN, partialMatch: "';'");
if (error != null) {
int insertOffset = error.offset + error.length;
_addInsertEdit(insertOffset, ';');
int offset = _appendNewlinePlusIndent() + 1 /* ';' */;
_setCompletionAt(DartStatementCompletion.SIMPLE_SEMICOLON, offset);
return true;
}
return false;
}
bool _complete_whileStatement() {
if (errors.isEmpty || node is! WhileStatement) {
return false;
}
WhileStatement whileNode = node;
if (whileNode != null) {
var stmt = new _IfWhileStructure(
whileNode.whileKeyword,
whileNode.leftParenthesis,
whileNode.condition,
whileNode.rightParenthesis,
whileNode.body);
return _complete_ifOrWhileStatement(
stmt, DartStatementCompletion.COMPLETE_WHILE_STMT);
}
return false;
}
engine.AnalysisError _findError(ErrorCode code, {partialMatch: null}) {
var error =
errors.firstWhere((err) => err.errorCode == code, orElse: () => null);
if (error != null) {
if (partialMatch != null) {
return error.message.contains(partialMatch) ? error : null;
}
return error;
}
return null;
}
LinkedEditGroup _getLinkedPosition(String groupId) {
LinkedEditGroup group = linkedPositionGroups[groupId];
if (group == null) {
group = new LinkedEditGroup.empty();
linkedPositionGroups[groupId] = group;
}
return group;
}
void _insertBuilder(SourceBuilder builder, [int length = 0]) {
{
SourceRange range = rangeStartLength(builder.offset, length);
String text = builder.toString();
_addReplaceEdit(range, text);
}
// add linked positions
builder.linkedPositionGroups.forEach((String id, LinkedEditGroup group) {
LinkedEditGroup fixGroup = _getLinkedPosition(id);
group.positions.forEach((Position position) {
fixGroup.addPosition(position, group.length);
});
group.suggestions.forEach((LinkedEditSuggestion suggestion) {
fixGroup.addSuggestion(suggestion);
});
});
// add exit position
{
int exitOffset = builder.exitOffset;
if (exitOffset != null) {
exitPosition = _newPosition(exitOffset);
}
}
}
bool _isEmptyExpression(Expression expr) {
if (expr is! SimpleIdentifier) {
return false;
}
SimpleIdentifier id = expr as SimpleIdentifier;
return id.length == 0;
}
Position _newPosition(int offset) {
return new Position(file, offset);
}
void _setCompletion(StatementCompletionKind kind, [List args]) {
assert(exitPosition != null);
change.selection = exitPosition;
change.message = formatList(kind.message, args);
linkedPositionGroups.values
.forEach((group) => change.addLinkedEditGroup(group));
completion = new StatementCompletion(kind, change);
}
void _setCompletionAt(StatementCompletionKind kind, int offset, [List args]) {
exitPosition = _newPosition(offset);
_setCompletion(kind, args);
}
}
// Encapsulate common structure of if-statement and while-statement.
class _IfWhileStructure {
final Token keyword;
final Token leftParenthesis, rightParenthesis;
final Expression condition;
final Statement block;
_IfWhileStructure(this.keyword, this.leftParenthesis, this.condition,
this.rightParenthesis, this.block);
int get offset => keyword.offset;
}
@@ -0,0 +1,130 @@
// Copyright (c) 2017, 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.
library test.edit.statement_completion;
import 'package:analysis_server/plugin/protocol/protocol.dart';
import 'package:analysis_server/src/edit/edit_domain.dart';
import 'package:plugin/manager.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import '../analysis_abstract.dart';
main() {
defineReflectiveSuite(() {
defineReflectiveTests(StatementCompletionTest);
});
}
@reflectiveTest
class StatementCompletionTest extends AbstractAnalysisTest {
SourceChange change;
@override
void setUp() {
enableNewAnalysisDriver = true;
super.setUp();
createProject();
ExtensionManager manager = new ExtensionManager();
manager.processPlugins([server.serverPlugin]);
handler = new EditDomainHandler(server);
}
test_plainEnterFromStart() async {
addTestFile('''
main() {
int v = 1;
}
''');
await waitForTasksFinished();
await _prepareCompletion('v = 1;', atStart: true);
_assertHasChange(
'Insert a newline at the end of the current line',
'''
main() {
int v = 1;
/*caret*/
}
''');
}
test_plainOleEnter() async {
addTestFile('''
main() {
int v = 1;
}
''');
await waitForTasksFinished();
await _prepareCompletion('v = 1;', atEnd: true);
_assertHasChange(
'Insert a newline at the end of the current line',
'''
main() {
int v = 1;
/*caret*/
}
''');
}
test_plainOleEnterWithError() async {
addTestFile('''
main() {
int v =
}
''');
await waitForTasksFinished();
String match = 'v =';
await _prepareCompletion(match, atEnd: true);
_assertHasChange(
'Insert a newline at the end of the current line',
'''
main() {
int v =
x
}
''',
(s) => s.indexOf(match) + match.length); // Ensure cursor after '='.
}
void _assertHasChange(String message, String expectedCode, [Function cmp]) {
if (change.message == message) {
if (!change.edits.isEmpty) {
String resultCode =
SourceEdit.applySequence(testCode, change.edits[0].edits);
expect(resultCode, expectedCode.replaceAll('/*caret*/', ''));
if (cmp != null) {
int offset = cmp(resultCode);
expect(change.selection.offset, offset);
}
} else {
if (cmp != null) {
int offset = cmp(testCode);
expect(change.selection.offset, offset);
}
}
return;
}
fail("Expected to find |$message| but got: " + change.message);
}
_prepareCompletion(String search,
{bool atStart: false, bool atEnd: false, int delta: 0}) async {
int offset = findOffset(search);
if (atStart) {
delta = 0;
} else if (atEnd) {
delta = search.length;
}
await _prepareCompletionAt(offset + delta);
}
_prepareCompletionAt(int offset) async {
Request request =
new EditGetStatementCompletionParams(testFile, offset).toRequest('0');
Response response = await waitResponse(request);
var result = new EditGetStatementCompletionResult.fromResponse(response);
change = result.change;
}
}
@@ -12,6 +12,7 @@ import 'format_test.dart' as format_test;
import 'organize_directives_test.dart' as organize_directives_test;
import 'refactoring_test.dart' as refactoring_test;
import 'sort_members_test.dart' as sort_members_test;
import 'statement_completion_test.dart' as statement_completion_test;
/**
* Utility for manually running all tests.
@@ -24,5 +25,6 @@ main() {
organize_directives_test.main();
refactoring_test.main();
sort_members_test.main();
statement_completion_test.main();
}, name: 'edit');
}
@@ -0,0 +1,274 @@
// Copyright (c) 2017, 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.
library test.services.completion.statement;
import 'package:analysis_server/src/protocol_server.dart';
import 'package:analysis_server/src/services/completion/statement/statement_completion.dart';
import 'package:analyzer/src/dart/analysis/driver.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';
import '../../../abstract_single_unit.dart';
main() {
defineReflectiveSuite(() {
defineReflectiveTests(StatementCompletionTest);
});
}
@reflectiveTest
class StatementCompletionTest extends AbstractSingleUnitTest {
SourceChange change;
bool get enableNewAnalysisDriver => true;
test_completeIfEmptyCondition() async {
await _prepareCompletion(
'if ()',
'''
main() {
if ()
}
''',
atEnd: true);
_assertHasChange(
'Complete if-statement',
'''
main() {
if () {
////
}
}
''',
(s) => s.indexOf('if (') + 'if ('.length);
}
test_completeIfKeywordOnly() async {
await _prepareCompletion(
'if',
'''
main() {
if ////
}
''',
atEnd: true);
_assertHasChange(
'Complete if-statement',
'''
main() {
if () {
////
}
}
''',
(s) => s.indexOf('if (') + 'if ('.length);
}
test_completeIfWithCondition() async {
await _prepareCompletion(
'if (tr', // Trigger completion from within expression.
'''
main() {
if (true)
}
''',
atEnd: true);
_assertHasChange(
'Complete if-statement',
'''
main() {
if (true) {
////
}
}
''',
(s) => s.indexOf(' ') + ' '.length);
}
test_completeIfAfterCondition_BAD() async {
// TODO(messick): Fix the code to make this like test_completeIfWithCondition.
// Recap: Finding the node at the selectionOffset returns the block, not the
// if-statement. Need to understand if that only happens when the if-statement
// is the only statement in the block, or perhaps first or last? And what
// happens when it is in the middle of other statements?
await _prepareCompletion(
'if (true) ', // Trigger completion after space.
'''
main() {
if (true) ////
}
''',
atEnd: true);
_assertHasChange(
// Note: This is not what we want.
'Insert a newline at the end of the current line',
'''
main() {
if (true) ////
}
}
''',
(s) => s.indexOf('if (true) ') + 'if (true) '.length);
}
test_completeIfWithElse_BAD() async {
await _prepareCompletion(
'if ()',
'''
main() {
if ()
else
}
''',
atEnd: true);
_assertHasChange(
// Note: if-statement completion should not trigger.
'Insert a newline at the end of the current line',
'''
main() {
if ()
else
}
}
''',
(s) => s.indexOf('if ()') + 'if ()'.length);
}
test_completeIfWithinEmptyCondition() async {
await _prepareCompletion(
'if (',
'''
main() {
if ()
}
''',
atEnd: true);
_assertHasChange(
'Complete if-statement',
'''
main() {
if () {
////
}
}
''',
(s) => s.indexOf('if (') + 'if ('.length);
}
test_completeWhileKeywordOnly() async {
await _prepareCompletion(
'while',
'''
main() {
while ////
}
''',
atEnd: true);
_assertHasChange(
'Complete while-statement',
'''
main() {
while () {
////
}
}
''',
(s) => s.indexOf('while (') + 'while ('.length);
}
test_simpleEnter() async {
await _prepareCompletion(
'v = 1;',
'''
main() {
int v = 1;
}
''',
atEnd: true);
_assertHasChange(
'Insert a newline at the end of the current line',
'''
main() {
int v = 1;
////
}
''');
}
test_simpleSemicolon() async {
await _prepareCompletion(
'v = 1',
'''
main() {
int v = 1
}
''',
atEnd: true);
_assertHasChange(
'Add a semicolon and newline',
'''
main() {
int v = 1;
////
}
''',
(s) => s.lastIndexOf(' ') + ' '.length);
}
void _assertHasChange(String message, String expectedCode, [Function cmp]) {
if (change.message == message) {
if (!change.edits.isEmpty) {
String resultCode =
SourceEdit.applySequence(testCode, change.edits[0].edits);
expect(resultCode, expectedCode.replaceAll('////', ''));
if (cmp != null) {
int offset = cmp(resultCode);
expect(change.selection.offset, offset);
}
} else {
if (cmp != null) {
int offset = cmp(testCode);
expect(change.selection.offset, offset);
}
}
return;
}
fail("Expected to find |$message| but got: " + change.message);
}
_computeCompletion(int offset) async {
driver.changeFile(testFile);
AnalysisResult result = await driver.getResult(testFile);
StatementCompletionContext context = new StatementCompletionContext(
testFile,
result.lineInfo,
offset,
testUnit,
testUnitElement,
result.errors);
StatementCompletionProcessor processor =
new StatementCompletionProcessor(context);
StatementCompletion completion = await processor.compute();
change = completion.change;
}
_prepareCompletion(String search, String sourceCode,
{bool atStart: false, bool atEnd: false, int delta: 0}) async {
testCode = sourceCode.replaceAll('////', '');
int offset = findOffset(search);
if (atStart) {
delta = 0;
} else if (atEnd) {
delta = search.length;
}
await _prepareCompletionAt(offset + delta, testCode);
}
_prepareCompletionAt(int offset, String sourceCode) async {
verifyNoTestUnitErrors = false;
await resolveTestUnit(sourceCode);
await _computeCompletion(offset);
}
}
@@ -8,11 +8,13 @@ import 'package:test_reflective_loader/test_reflective_loader.dart';
import 'completion_target_test.dart' as completion_target_test;
import 'dart/test_all.dart' as dart_contributor_tests;
import 'statement/statement_completion_test.dart' as statement_completion_test;
/// Utility for manually running all tests.
main() {
defineReflectiveSuite(() {
completion_target_test.main();
dart_contributor_tests.main();
statement_completion_test.main();
}, name: 'completion');
}