diff --git a/pkg/analysis_server/lib/src/computer/computer_lazy_type_hierarchy.dart b/pkg/analysis_server/lib/src/computer/computer_lazy_type_hierarchy.dart new file mode 100644 index 00000000000..d6eaea6ced9 --- /dev/null +++ b/pkg/analysis_server/lib/src/computer/computer_lazy_type_hierarchy.dart @@ -0,0 +1,173 @@ +// Copyright (c) 2022, 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/src/services/search/search_engine.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:analyzer/source/source_range.dart'; +import 'package:analyzer/src/dart/ast/utilities.dart'; +import 'package:analyzer/src/dart/element/element.dart'; +import 'package:collection/collection.dart'; + +/// A lazy computer for Type Hierarchies. +/// +/// Unlike [TypeHierarchyComputer], this class computes hierarchies lazily and +/// roughly follow the LSP model, which is: +/// +/// 1. Client calls `findTarget(int offset)` to locate a starting point for +/// navigation. +/// 2. Client passes the returned target into `findSubtypes()` or +/// `findSupertypes()` to find it's immediate sub/super types. This can be +/// repeated recursively. +/// +/// It is expected that clients will call the methods above at different times +/// (as the user expands items in a tree). It is up to the caller to handle +/// cases where a target may no longer be valid (due to file modifications) that +/// may result in inconsistent results. +class DartLazyTypeHierarchyComputer { + final ResolvedUnitResult _result; + + DartLazyTypeHierarchyComputer(this._result); + + /// Finds subtypes for [target]. + Future?> findSubtypes( + TypeHierarchyItem target, + SearchEngine searchEngine, + ) async { + final targetElement = _findTargetElement(target); + if (targetElement is! InterfaceElement) { + return null; + } + + final subtypes = await _getSubtypes(targetElement, searchEngine); + return _convert(subtypes); + } + + /// Finds supertypes for [target]. + Future?> findSupertypes( + TypeHierarchyItem target, + ) async { + final targetElement = _findTargetElement(target); + if (targetElement == null) { + return null; + } + + final supertypes = _getSupertypes(targetElement); + return _convert(supertypes); + } + + /// Finds a target for starting type hierarchy navigation at [offset]. + TypeHierarchyItem? findTarget(int offset) { + final node = NodeLocator2(offset).searchWithin(_result.unit); + + Element? element; + + // Try named types. + final type = node?.thisOrAncestorOfType(); + element = type?.name.staticElement; + + if (element == null) { + // Try enclosing class/mixins. + final Declaration? declaration = node + ?.thisOrAncestorMatching((node) => _isValidTargetDeclaration(node)); + element = declaration?.declaredElement; + } + + return element != null ? TypeHierarchyItem.forElement(element) : null; + } + + List _convert(List types) => + types.map((type) => TypeHierarchyItem.forElement(type.element)).toList(); + + Element? _findTargetElement(TypeHierarchyItem target) { + assert(target.file == _result.path); + // Locate the element by name instead of offset since this call my occur + // much later than the original find target request and it's possible the + // user has made changes since. + final targetElement = _result.unit.declarations + .where(_isValidTargetDeclaration) + .map((declaration) => declaration.declaredElement) + .firstWhereOrNull((element) => element?.name == target.displayName); + return targetElement; + } + + /// Gets immediate sub types for the class/mixin [element]. + Future> _getSubtypes( + InterfaceElement element, SearchEngine searchEngine) async { + var matches = await searchEngine.searchSubtypes(element); + return matches + .map((match) => (match.element as InterfaceElement).thisType) + .toList(); + } + + /// Gets immediate super types for the class/mixin [element]. + /// + /// Includes all elements that contribute implementation to the type + /// such as supertypes and mixins, but not interfaces, constraints or + /// extended types. + List _getSupertypes(Element element) { + final supertype = element is InterfaceElement ? element.supertype : null; + final mixins = + element is InterfaceOrAugmentationElement ? element.mixins : null; + return [ + if (supertype != null) supertype, + ...?mixins, + ]; + } + + /// Returns whether [declaration] is a valid target for type hierarchy + /// navigation. + bool _isValidTargetDeclaration(AstNode? declaration) => + // TODO(dantup): Should we handle `ClassAugmentationDeclaration`s? + declaration is ClassDeclaration || declaration is MixinDeclaration; +} + +/// An item that can appear in a Type Hierarchy. +class TypeHierarchyItem { + /// The user-visible name for this item in the Type Hierarchy. + final String displayName; + + /// The file that contains the declaration of this item. + final String file; + + /// The range of the name at the declaration of this item. + final SourceRange nameRange; + + /// The range of the code for the declaration of this item. + final SourceRange codeRange; + + TypeHierarchyItem({ + required this.displayName, + required this.file, + required this.nameRange, + required this.codeRange, + }); + + TypeHierarchyItem.forElement(Element element) + : displayName = element.displayName, + nameRange = _nameRangeForElement(element), + codeRange = _codeRangeForElement(element), + file = element.source!.fullName; + + /// Returns the [SourceRange] of the code for [element]. + static SourceRange _codeRangeForElement(Element element) { + // Non-synthetic elements should always have code locations. + final elementImpl = element.nonSynthetic as ElementImpl; + return SourceRange(elementImpl.codeOffset!, elementImpl.codeLength!); + } + + /// Returns the [SourceRange] of the name for [element]. + static SourceRange _nameRangeForElement(Element element) { + element = element.nonSynthetic; + + // Some non-synthetic items can still have invalid nameOffsets (for example + // a compilation unit). This should never happen here, but guard against it. + assert(element.nameOffset != -1); + return element.nameOffset == -1 + ? SourceRange(0, 0) + : SourceRange(element.nameOffset, element.nameLength); + } +} diff --git a/pkg/analysis_server/test/src/computer/test_all.dart b/pkg/analysis_server/test/src/computer/test_all.dart index 65087cc9665..c9d19921f6f 100644 --- a/pkg/analysis_server/test/src/computer/test_all.dart +++ b/pkg/analysis_server/test/src/computer/test_all.dart @@ -13,6 +13,7 @@ import 'import_elements_computer_test.dart' as import_elements_computer; import 'imported_elements_computer_test.dart' as imported_elements_computer; import 'outline_computer_test.dart' as outline_computer; import 'selection_range_computer_test.dart' as selection_range; +import 'type_hierarchy_computer_test.dart' as type_hierarchy_computer_test; void main() { defineReflectiveSuite(() { @@ -25,5 +26,6 @@ void main() { imported_elements_computer.main(); outline_computer.main(); selection_range.main(); + type_hierarchy_computer_test.main(); }); } diff --git a/pkg/analysis_server/test/src/computer/type_hierarchy_computer_test.dart b/pkg/analysis_server/test/src/computer/type_hierarchy_computer_test.dart new file mode 100644 index 00000000000..e7dbc1427ca --- /dev/null +++ b/pkg/analysis_server/test/src/computer/type_hierarchy_computer_test.dart @@ -0,0 +1,383 @@ +// Copyright (c) 2022, 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/src/computer/computer_lazy_type_hierarchy.dart'; +import 'package:analysis_server/src/services/search/search_engine.dart'; +import 'package:analysis_server/src/services/search/search_engine_internal.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/source/source_range.dart'; +import 'package:test/test.dart'; +import 'package:test_reflective_loader/test_reflective_loader.dart'; + +import '../../abstract_single_unit.dart'; +import '../../utils/test_code_format.dart'; + +void main() { + defineReflectiveSuite(() { + defineReflectiveTests(TypeHierarchyComputerFindTargetTest); + defineReflectiveTests(TypeHierarchyComputerFindSupertypesTest); + defineReflectiveTests(TypeHierarchyComputerFindSubtypesTest); + }); +} + +abstract class AbstractTypeHierarchyTest extends AbstractSingleUnitTest { + final startOfFile = SourceRange(0, 0); + late TestCode code; + + /// Matches a [TypeHierarchyItem] for [Object]. + Matcher get _isObject => TypeMatcher() + .having((e) => e.displayName, 'displayName', 'Object') + // Check some basic things without hard-coding values that will make + // this test brittle. + .having((e) => e.file, 'file', convertPath('/sdk/lib/core/core.dart')) + .having((e) => e.nameRange.offset, 'nameRange.offset', isPositive) + .having((e) => e.nameRange.length, 'nameRange.length', 'Object'.length) + .having((e) => e.codeRange.offset, 'codeRange.offset', isPositive) + .having((e) => e.codeRange.length, 'codeRange.length', + greaterThan('class Object {}'.length)); + + @override + void addTestSource(String content) { + code = TestCode.parse(content); + super.addTestSource(code.code); + } + + Future findTarget() async { + expect(code, isNotNull, reason: 'addTestSource should be called first'); + final result = await getResolvedUnit(testFile); + return DartLazyTypeHierarchyComputer(result) + .findTarget(code.position.offset); + } + + Future getResolvedUnit(String file) async => + await (await session).getResolvedUnit(file) as ResolvedUnitResult; + + /// Matches a [TypeHierarchyItem] with the given name/kind/file. + Matcher _isItem( + String displayName, + String file, { + required SourceRange nameRange, + required SourceRange codeRange, + }) => + TypeMatcher() + .having((e) => e.displayName, 'displayName', displayName) + .having((e) => e.file, 'file', file) + .having((e) => e.nameRange, 'nameRange', nameRange) + .having((e) => e.codeRange, 'codeRange', codeRange); +} + +@reflectiveTest +class TypeHierarchyComputerFindSubtypesTest extends AbstractTypeHierarchyTest { + late SearchEngine searchEngine; + + Future?> findSubtypes( + TypeHierarchyItem target) async { + final result = await getResolvedUnit(target.file); + return DartLazyTypeHierarchyComputer(result) + .findSubtypes(target, searchEngine); + } + + @override + void setUp() { + super.setUp(); + searchEngine = SearchEngineImpl([ + driverFor(testPackageRootPath), + ]); + } + + Future test_class_mixins() async { + final content = ''' +/*[0*/class /*[1*/MyClass1/*1]*/ with MyMixin1 {}/*0]*/ +/*[2*/class /*[3*/MyClass2/*3]*/ with MyMixin1 {}/*2]*/ +mixin MyMi^xin1 {} + '''; + + addTestSource(content); + final target = await findTarget(); + final subtypes = await findSubtypes(target!); + expect(subtypes, [ + _isItem( + 'MyClass1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + _isItem( + 'MyClass2', + testFile, + codeRange: code.ranges[2].sourceRange, + nameRange: code.ranges[3].sourceRange, + ), + ]); + } + + Future test_class_superclass() async { + final content = ''' +class ^MyClass1 {} +/*[0*/class /*[1*/MyClass2/*1]*/ extends MyClass1 {}/*0]*/ + '''; + + addTestSource(content); + final target = await findTarget(); + final supertypes = await findSubtypes(target!); + expect(supertypes, [ + _isItem( + 'MyClass2', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ]); + } + + Future test_mixin() async { + final content = ''' +class MyCl^ass1 {} +/*[0*/mixin /*[1*/MyMixin1/*1]*/ on MyClass1 {}/*0]*/ + '''; + + addTestSource(content); + final target = await findTarget(); + final supertypes = await findSubtypes(target!); + expect(supertypes, [ + _isItem( + 'MyMixin1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ]); + } +} + +@reflectiveTest +class TypeHierarchyComputerFindSupertypesTest + extends AbstractTypeHierarchyTest { + Future?> findSupertypes( + TypeHierarchyItem target) async { + final result = await getResolvedUnit(target.file); + return DartLazyTypeHierarchyComputer(result).findSupertypes(target); + } + + /// Test that if the file is modified between fetching a target and it's + /// sub/supertypes it can still be located (by name). + Future test_class_afterModification() async { + final content = ''' +/*[0*/class /*[1*/MyClass1/*1]*/ {}/*0]*/ +class ^MyClass2 extends MyClass1 {} + '''; + + addTestSource(content); + final target = await findTarget(); + + // Update the content so that offsets have changed since we got `target`. + addTestSource('// extra\n$content'); + + final supertypes = await findSupertypes(target!); + expect(supertypes, [ + _isItem( + 'MyClass1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ]); + } + + Future test_class_mixins() async { + final content = ''' +/*[0*/mixin /*[1*/MyMixin1/*1]*/ {}/*0]*/ +/*[2*/mixin /*[3*/MyMixin2/*3]*/ {}/*2]*/ +class ^MyClass1 with MyMixin1, MyMixin2 {} + '''; + + addTestSource(content); + final target = await findTarget(); + final supertypes = await findSupertypes(target!); + expect(supertypes, [ + _isObject, + _isItem( + 'MyMixin1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + _isItem( + 'MyMixin2', + testFile, + codeRange: code.ranges[2].sourceRange, + nameRange: code.ranges[3].sourceRange, + ), + ]); + } + + Future test_class_superclass() async { + final content = ''' +/*[0*/class /*[1*/MyClass1/*1]*/ {}/*0]*/ +class ^MyClass2 extends MyClass1 {} + '''; + + addTestSource(content); + final target = await findTarget(); + final supertypes = await findSupertypes(target!); + expect(supertypes, [ + _isItem( + 'MyClass1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ]); + } + + /// Mixins have no supertypes and are provided only for subtype search, + /// mirroring that they appear in the supertypes list of classes. + Future test_mixin() async { + final content = ''' +class MyClass1 {} +mixin MyMix^in2 on MyClass1 {} + '''; + + addTestSource(content); + final target = await findTarget(); + final supertypes = await findSupertypes(target!); + expect(supertypes, isEmpty); + } +} + +@reflectiveTest +class TypeHierarchyComputerFindTargetTest extends AbstractTypeHierarchyTest { + Future expectNoTarget() async { + await expectTarget(isNull); + } + + Future expectTarget(Matcher matcher) async { + final target = await findTarget(); + expect(target, matcher); + } + + Future test_class_body() async { + final content = ''' +/*[0*/class /*[1*/MyClass1/*1]*/ { + int? a^; +}/*0]*/ + '''; + + addTestSource(content); + await expectTarget( + _isItem( + 'MyClass1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ); + } + + Future test_class_keyword() async { + final content = ''' +/*[0*/cla^ss /*[1*/MyClass1/*1]*/ { +}/*0]*/ + '''; + + addTestSource(content); + await expectTarget( + _isItem( + 'MyClass1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ); + } + + Future test_class_name() async { + final content = ''' +/*[0*/class /*[1*/MyCla^ss1/*1]*/ { +}/*0]*/ + '''; + + addTestSource(content); + await expectTarget( + _isItem( + 'MyClass1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ); + } + + Future test_invalid_topLevel_nonClass() async { + final content = ''' +int? a^; +'''; + + addTestSource(content); + await expectNoTarget(); + } + + Future test_invalid_topLevel_whitespace() async { + final content = ''' +int? a; +^ +int? b; +'''; + + addTestSource(content); + await expectNoTarget(); + } + + Future test_mixin_body() async { + final content = ''' +/*[0*/mixin /*[1*/MyMixin1/*1]*/ { + ^ +}/*0]*/ + '''; + + addTestSource(content); + await expectTarget( + _isItem( + 'MyMixin1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ); + } + + Future test_mixin_keyword() async { + final content = ''' +/*[0*/mi^xin /*[1*/MyMixin1/*1]*/ { +}/*0]*/ + '''; + + addTestSource(content); + await expectTarget( + _isItem( + 'MyMixin1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ); + } + + Future test_mixinName() async { + final content = ''' +/*[0*/mixin /*[1*/MyMix^in1/*1]*/ { +}/*0]*/ + '''; + + addTestSource(content); + await expectTarget( + _isItem( + 'MyMixin1', + testFile, + codeRange: code.ranges[0].sourceRange, + nameRange: code.ranges[1].sourceRange, + ), + ); + } +}