// Copyright (c) 2014, 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:collection'; import 'package:analyzer_plugin/protocol/protocol_common.dart'; import 'package:test/test.dart'; import 'domain_completion_util.dart'; /// A base class for classes containing completion tests. abstract class CompletionTestCase extends AbstractCompletionDomainTest { static const String _cursorMarker = '!'; List get suggestedCompletions => suggestions .map((CompletionSuggestion suggestion) => suggestion.completion) .toList(); void assertHasCompletion( String completion, { ElementKind? elementKind, bool? isDeprecated, Matcher? libraryUri, }) { var expectedOffset = completion.indexOf(_cursorMarker); if (expectedOffset >= 0) { if (completion.contains(_cursorMarker, expectedOffset + 1)) { fail( "Invalid completion, contains multiple cursor positions: '$completion'", ); } completion = completion.replaceFirst(_cursorMarker, ''); } else { expectedOffset = completion.length; } CompletionSuggestion? matchingSuggestion; for (var suggestion in suggestions) { if (suggestion.completion == completion) { if (matchingSuggestion == null) { matchingSuggestion = suggestion; } else { // It is OK to have a class and its default constructor suggestions. if (matchingSuggestion.element?.kind == ElementKind.CLASS && suggestion.element?.kind == ElementKind.CONSTRUCTOR || matchingSuggestion.element?.kind == ElementKind.CONSTRUCTOR && suggestion.element?.kind == ElementKind.CLASS) { return; } fail( "Expected exactly one '$completion' but found multiple:\n $suggestedCompletions", ); } } } if (matchingSuggestion == null) { fail("Expected '$completion' but found none:\n $suggestedCompletions"); } expect(matchingSuggestion.selectionOffset, equals(expectedOffset)); expect(matchingSuggestion.selectionLength, equals(0)); if (elementKind != null) { expect(matchingSuggestion.element!.kind, elementKind); } if (isDeprecated != null) { expect(matchingSuggestion.isDeprecated, isDeprecated); } if (libraryUri != null) { expect(matchingSuggestion.libraryUri, libraryUri); } } void assertHasNoCompletion(String completion) { if (suggestions.any( (CompletionSuggestion suggestion) => suggestion.completion == completion, )) { fail( "Did not expect completion '$completion' but found:\n $suggestedCompletions", ); } } /// Discard any results that do not start with the characters the user has /// "already typed". void filterResults(String content) { var charsAlreadyTyped = content .substring(replacementOffset!, completionOffset) .toLowerCase(); suggestions = suggestions .where( (CompletionSuggestion suggestion) => suggestion.completion.toLowerCase().startsWith(charsAlreadyTyped), ) .toList(); } Future runTest( LocationSpec spec, [ Map? extraFiles, ]) async { await super.setUp(); try { extraFiles?.forEach((String fileName, String content) { newFile(fileName, content); }); newFile(testFile.path, spec.source); completionOffset = spec.testLocation; await getSuggestions( path: testFile.path, completionOffset: completionOffset, maxResults: 1 << 10, ); filterResults(spec.source); for (var result in spec.positiveResults) { assertHasCompletion(result); } for (var result in spec.negativeResults) { assertHasNoCompletion(result); } } finally { await super.tearDown(); } } } /// A specification of the completion results expected at a given location. class LocationSpec { String id; int testLocation = -1; List positiveResults = []; List negativeResults = []; late String source; new(this.id); /// Parse a set of tests from the given `originalSource`. Return a list of the /// specifications that were parsed. /// /// The source string has test locations embedded in it, which are identified /// by '!X' where X is a single character. Each X is matched to positive or /// negative results in the array of [validationStrings]. Validation strings /// contain the name of a prediction with a two character prefix. The first /// character of the prefix corresponds to an X in the [originalSource]. The /// second character is either a '+' or a '-' indicating whether the string is /// a positive or negative result. If logical not is needed in the source it /// can be represented by '!!'. /// /// The [originalSource] is the source for a test that contains test /// locations. The [validationStrings] are the positive and negative /// predictions. static List from( String originalSource, List validationStrings, ) { Map tests = HashMap(); var modifiedSource = originalSource; var modifiedPosition = 0; while (true) { var index = modifiedSource.indexOf('!', modifiedPosition); if (index < 0) { break; } var n = 1; // only delete one char for double-bangs var id = modifiedSource.substring(index + 1, index + 2); if (id != '!') { n = 2; var test = LocationSpec(id); tests[id] = test; test.testLocation = index; } else { modifiedPosition = index + 1; } modifiedSource = modifiedSource.substring(0, index) + modifiedSource.substring(index + n); } if (modifiedSource == originalSource) { throw StateError('No tests in source: $originalSource'); } for (var result in validationStrings) { if (result.length < 3) { throw StateError('Invalid location result: $result'); } var id = result.substring(0, 1); var sign = result.substring(1, 2); var value = result.substring(2); var test = tests[id]; if (test == null) { throw StateError('Invalid location result id: $id for: $result'); } test.source = modifiedSource; if (sign == '+') { test.positiveResults.add(value); } else if (sign == '-') { test.negativeResults.add(value); } else { var err = 'Invalid location result sign: $sign for: $result'; throw StateError(err); } } var badPoints = []; var badResults = []; for (var test in tests.values) { if (test.testLocation == -1) { badPoints.add(test.id); } if (test.positiveResults.isEmpty && test.negativeResults.isEmpty) { badResults.add(test.id); } } if (!(badPoints.isEmpty && badResults.isEmpty)) { var err = StringBuffer(); if (badPoints.isNotEmpty) { err.write('No test location for tests:'); for (var ch in badPoints) { err ..write(' ') ..write(ch); } err.write(' '); } if (badResults.isNotEmpty) { err.write('No results for tests:'); for (var ch in badResults) { err ..write(' ') ..write(ch); } } throw StateError(err.toString()); } return tests.values.toList(); } }