blob: 9ce1bfc51e1cbde0e255f10a85b3966cd73500be [file] [log] [blame]
// Copyright (c) 2025, 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.
/// Generates the file `api.txt`, which describes a package's public API.
library;
import 'dart:collection';
import 'package:analyzer/dart/analysis/analysis_context.dart';
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/constant/value.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/nullability_suffix.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer_utilities/tools.dart';
import 'package:collection/collection.dart';
/// A list of all targets generated by this code generator.
final List<GeneratedContent> allTargets = [
GeneratedFile('api.txt', computeApiTxtContents),
];
/// Computes what should be written to the `api.txt` file.
Future<String> computeApiTxtContents(String pkgPath) async {
var provider = PhysicalResourceProvider.INSTANCE;
var pathContext = provider.pathContext;
var pkgName = pathContext.basename(pkgPath);
var collection = AnalysisContextCollection(
includedPaths: [pkgPath],
resourceProvider: provider,
);
var context = collection.contexts.first;
var publicApi = ApiDescription(pkgName);
var stringBuffer = StringBuffer();
_printNodes(stringBuffer, await publicApi.build(context));
return stringBuffer.toString();
}
/// Outputs the contents of [nodes] to [sink], prepending [prefix] to every
/// line.
void _printNodes<SortKey extends Comparable<SortKey>>(
StringSink sink,
List<(SortKey, Node)> nodes, {
String prefix = '',
}) {
for (var entry in nodes.sortedBy((n) => n.$1)) {
var node = entry.$2;
sink.writeln('$prefix${node.text.join()}');
node.printChildren(sink, prefix: '$prefix ');
}
}
/// Data structure keeping track of a package's API while walking it to produce
/// `api.txt`.
class ApiDescription {
final String _pkgName;
/// Top level elements that have already had their child elements dumped.
///
/// If an element is seen again in a different library, it will be followed
/// with `(see above)` (rather than having its child elements dumped twice).
final _dumpedTopLevelElements = <Element>{};
/// Top level elements that have been referenced so far and haven't yet been
/// processed by [build].
///
/// This is used to ensure that all elements referred to by the public API
/// (e.g., by being mentioned in the type of an API element) also show up in
/// the output.
final _potentiallyDanglingReferences = Queue<Element>();
final _uniqueNamer = UniqueNamer();
/// Cache of values returned by [_getOrComputeImmediateSubinterfaceMap], to
/// avoid unnecessary recomputation.
final _immediateSubinterfaceCache =
<LibraryElement, Map<ClassElement, Set<InterfaceElement>>>{};
ApiDescription(String pkgName) : _pkgName = pkgName;
/// Builds a list of [Node] objects representing all the libraries that are
/// relevant to the package's public API.
///
/// This includes libraries that are in the package's public API as well as
/// libraries that are referenced by the package's public API (either by being
/// re-exported as part of the package's public API, or by being used as part
/// of the type of something in the public API).
///
/// Each library node is pared with a [UriSortKey] indicating the order in
/// which the nodes should be output.
Future<List<(UriSortKey, Node)>> build(AnalysisContext context) async {
var nodes = <Uri, Node<MemberSortKey>>{};
for (var file in context.contextRoot.analyzedFiles().sorted()) {
if (!file.endsWith('.dart')) continue;
var fileResult = context.currentSession.getFile(file) as FileResult;
var uri = fileResult.uri;
if (fileResult.isLibrary && uri.isInPublicLibOf(_pkgName)) {
var resolvedLibraryResult =
(await context.currentSession.getResolvedLibrary(file))
as ResolvedLibraryResult;
var node = nodes[uri] = Node<MemberSortKey>();
_dumpLibrary(resolvedLibraryResult.element2, node);
}
}
// Then dump anything referenced by public libraries.
while (_potentiallyDanglingReferences.isNotEmpty) {
var element = _potentiallyDanglingReferences.removeFirst();
if (!_dumpedTopLevelElements.add(element)) continue;
var containingLibraryUri = element.library2!.uri;
var childNode =
Node<MemberSortKey>()..text.add(_uniqueNamer.name(element));
_dumpElement(element, childNode);
(nodes[containingLibraryUri] ??=
Node<MemberSortKey>()..text.add('$containingLibraryUri:'))
.childNodes
.add((MemberSortKey(element), childNode));
}
return [
for (var entry in nodes.entries)
(UriSortKey(entry.key, _pkgName), entry.value),
];
}
/// Creates a list of objects which, when their string representations are
/// concatenated, describes [type].
///
/// The reason we use this method rather than [DartType.toString] is to make
/// sure that (a) every element mentioned by the type is added to
/// [_potentiallyDanglingReferences], and (b) if an ambiguous name is used,
/// the ambiguity will be taken care of by [_uniqueNamer].
List<Object?> _describeType(DartType type) {
var suffix = switch (type.nullabilitySuffix) {
NullabilitySuffix.none => '',
NullabilitySuffix.star => '*',
NullabilitySuffix.question => '?',
};
switch (type) {
case DynamicType():
return ['dynamic'];
case FunctionType(
:var returnType,
:var typeParameters,
:var formalParameters,
):
var params = <List<Object?>>[];
var optionalParams = <List<Object?>>[];
var namedParams = <String, List<Object?>>{};
for (var formalParameter in formalParameters) {
if (formalParameter.isNamed) {
namedParams[formalParameter.name3!] = [
if (formalParameter.isRequired) 'required ',
..._describeType(formalParameter.type),
];
} else if (formalParameter.isOptional) {
optionalParams.add(_describeType(formalParameter.type));
} else {
params.add(_describeType(formalParameter.type));
}
}
if (optionalParams.isNotEmpty) {
params.add(optionalParams.separate(prefix: '[', suffix: ']'));
}
if (namedParams.isNotEmpty) {
params.add(
namedParams.entries
.sortedBy((e) => e.key)
.map((e) => [...e.value, ' ${e.key}'])
.separate(prefix: '{', suffix: '}'),
);
}
return <Object?>[
..._describeType(returnType),
' Function',
if (typeParameters.isNotEmpty)
...typeParameters
.map(_describeTypeParameter)
.separate(prefix: '<', suffix: '>'),
'(',
...params.separate(),
')',
suffix,
];
case InterfaceType(:var element3, :var typeArguments):
_potentiallyDanglingReferences.addLast(element3);
return [
_uniqueNamer.name(element3),
if (typeArguments.isNotEmpty)
...typeArguments
.map(_describeType)
.separate(prefix: '<', suffix: '>'),
suffix,
];
case RecordType(:var positionalFields, :var namedFields):
if (positionalFields.length == 1 && namedFields.isEmpty) {
return [
'(',
..._describeType(positionalFields[0].type),
',)',
suffix,
];
}
return [
...[
for (var positionalField in positionalFields)
_describeType(positionalField.type),
if (namedFields.isNotEmpty)
namedFields
.sortedBy((f) => f.name)
.map((f) => [..._describeType(f.type), ' ', f.name])
.separate(prefix: '{', suffix: '}'),
].separate(prefix: '(', suffix: ')'),
suffix,
];
case TypeParameterType(:var element3):
return [element3.name3!, suffix];
case VoidType():
return ['void'];
case dynamic(:var runtimeType):
throw UnimplementedError('Unexpected type: $runtimeType');
}
}
/// Creates a list of objects which, when their string representations are
/// concatenated, describes [typeParameter].
List<Object?> _describeTypeParameter(TypeParameterElement typeParameter) {
return [
typeParameter.name3!,
if (typeParameter.bound case var bound?) ...[
' extends ',
..._describeType(bound),
],
];
}
/// Appends information to [node] describing [element].
void _dumpElement(Element element, Node<MemberSortKey> node) {
var enclosingElement = element.enclosingElement2;
if (enclosingElement is LibraryElement &&
!element.isInPublicApiOf(_pkgName)) {
if (!enclosingElement.uri.isIn(_pkgName)) {
node.text.add(' (referenced)');
} else {
node.text.add(' (non-public)');
}
return;
}
var parentheticals = <List<Object?>>[];
switch (element) {
case TypeAliasElement(:var aliasedType, :var typeParameters2):
List<Object?> description = ['type alias'];
if (typeParameters2.isNotEmpty) {
description.addAll(
typeParameters2
.map(_describeTypeParameter)
.separate(prefix: '<', suffix: '>'),
);
}
description.addAll([' for ', ..._describeType(aliasedType)]);
parentheticals.add(description);
case InstanceElement():
switch (element) {
case InterfaceElement(
:var typeParameters2,
:var supertype,
:var interfaces,
):
List<Object?> instanceDescription = [
switch (element) {
ClassElement() => 'class',
EnumElement() => 'enum',
MixinElement() => 'mixin',
dynamic(:var runtimeType) => 'TODO: $runtimeType',
},
];
if (typeParameters2.isNotEmpty) {
instanceDescription.addAll(
typeParameters2
.map(_describeTypeParameter)
.separate(prefix: '<', suffix: '>'),
);
}
if (element is! EnumElement && supertype != null) {
instanceDescription.addAll([
' extends ',
..._describeType(supertype),
]);
}
if (element is MixinElement &&
element.superclassConstraints.isNotEmpty) {
instanceDescription.addAll(
element.superclassConstraints
.map(_describeType)
.separate(prefix: ' on '),
);
}
if (interfaces.isNotEmpty) {
instanceDescription.addAll(
interfaces.map(_describeType).separate(prefix: ' implements '),
);
}
parentheticals.add(instanceDescription);
if (element is ClassElement && element.isSealed) {
var parenthetical = <Object>['sealed'];
parentheticals.add(parenthetical);
if (_getOrComputeImmediateSubinterfaceMap(
element.library2,
)[element]
case var subinterfaces?) {
parenthetical.add(' (immediate subtypes: ');
// Note: it's tempting to just do
// `subinterfaces.map(_uniqueNamer.name).join(', ')`, but that
// won't work, because the names returned by
// `UniqueName.toString()` aren't finalized until we've visited
// the entire API and seen if there are class names that need to
// be disambiguated. So we accumulate the `UniqueName` objects
// into the `parenthetical` list and rely on `printNodes`
// converting everything to a string when the final API
// description is being output.
var commaNeeded = false;
for (var subinterface in subinterfaces) {
if (commaNeeded) {
parenthetical.add(', ');
} else {
commaNeeded = true;
}
parenthetical.add(_uniqueNamer.name(subinterface));
}
parenthetical.add(')');
}
}
case ExtensionElement(:var extendedType):
parentheticals.add([
'extension on ',
..._describeType(extendedType),
]);
case dynamic(:var runtimeType):
throw UnimplementedError('Unexpected element: $runtimeType');
}
for (var member in element.children2.sortedBy((m) => m.name3 ?? '')) {
if (member.name3 case var name? when name.startsWith('_')) {
// Ignore private members
continue;
}
if (member is FieldElement) {
// Ignore fields; we care about the getters and setters they induce.
continue;
}
if (member is ConstructorElement &&
element is ClassElement &&
element.isAbstract &&
(element.isFinal || element.isInterface || element.isSealed)) {
// The class can't be constructed from outside of the library that
// declares it, so its constructors aren't part of the public API.
continue;
}
if (member is ConstructorElement && element is EnumElement) {
// Enum constructors can't be called from outside the enum itself,
// so they aren't part of the public API.
continue;
}
var childNode = Node<MemberSortKey>();
childNode.text.add(member.apiName);
_dumpElement(member, childNode);
node.childNodes.add((MemberSortKey(member), childNode));
}
case TopLevelFunctionElement(:var type):
parentheticals.add(['function: ', ..._describeType(type)]);
case ExecutableElement(:var isStatic):
String maybeStatic = isStatic ? 'static ' : '';
switch (element) {
case GetterElement(:var type):
parentheticals.add([
'${maybeStatic}getter: ',
..._describeType(type.returnType),
]);
case SetterElement(:var type):
parentheticals.add([
'${maybeStatic}setter: ',
..._describeType(type.formalParameters.single.type),
]);
case MethodElement(:var type):
parentheticals.add([
'${maybeStatic}method: ',
..._describeType(type),
]);
case ConstructorElement(:var type):
parentheticals.add(['constructor: ', ..._describeType(type)]);
case dynamic(:var runtimeType):
throw UnimplementedError('Unexpected element: $runtimeType');
}
case dynamic(:var runtimeType):
throw UnimplementedError('Unexpected element: $runtimeType');
}
if (element case Annotatable element) {
if (element.metadata2.hasDeprecated) {
parentheticals.add(['deprecated']);
}
if (element.metadata2.hasExperimental) {
parentheticals.add(['experimental']);
}
}
if (parentheticals.isNotEmpty) {
node.text.addAll(parentheticals.separate(prefix: ' (', suffix: ')'));
}
if (node.childNodes.isNotEmpty) {
node.text.add(':');
}
}
/// Appends information to [node] describing [element].
void _dumpLibrary(LibraryElement library, Node<MemberSortKey> node) {
var uri = library.uri;
node.text.addAll([uri, ':']);
var definedNames = library.exportNamespace.definedNames2;
for (var key in definedNames.keys.sorted()) {
var element = definedNames[key]!;
var childNode =
Node<MemberSortKey>()..text.add(_uniqueNamer.name(element));
if (!_dumpedTopLevelElements.add(element)) {
childNode.text.add(' (see above)');
} else {
_dumpElement(element, childNode);
}
node.childNodes.add((MemberSortKey(element), childNode));
}
}
/// Returns a map from each sealed class in [library] to the set of its
/// immediate sub-interfaces.
///
/// If this method has been called before with the same [library], a cached
/// map is returned from [_immediateSubinterfaceCache]. Otherwise a fresh map
/// is computed.
Map<ClassElement, Set<InterfaceElement>>
_getOrComputeImmediateSubinterfaceMap(LibraryElement library) {
if (_immediateSubinterfaceCache[library] case var m?) return m;
var result = <ClassElement, Set<InterfaceElement>>{};
for (var interface in [
...library.classes,
...library.mixins,
...library.enums,
...library.extensionTypes,
]..sortBy((e) => e.name3!)) {
for (var superinterface in [
interface.supertype,
...interface.interfaces,
...interface.mixins,
if (interface is MixinElement) ...interface.superclassConstraints,
]) {
if (superinterface == null) continue;
var superinterfaceElement = superinterface.element3;
if (superinterfaceElement is ClassElement &&
superinterfaceElement.isSealed) {
(result[superinterfaceElement] ??= {}).add(interface);
}
}
}
_immediateSubinterfaceCache[library] = result;
return result;
}
}
/// Element categorization used by [MemberSortKey].
enum MemberCategory {
constructor,
propertyAccessor,
topLevelFunctionOrMethod,
interface,
extension,
typeAlias,
}
/// Sort key used to sort elements in the output.
class MemberSortKey implements Comparable<MemberSortKey> {
final bool _isInstanceMember;
final MemberCategory _category;
final String _name;
MemberSortKey(Element element)
: _isInstanceMember = _computeIsInstanceMember(element),
_category = _computeCategory(element),
_name = element.name3!;
@override
int compareTo(MemberSortKey other) {
if ((_isInstanceMember ? 1 : 0).compareTo(other._isInstanceMember ? 1 : 0)
case var value when value != 0) {
return value;
}
if (_category.index.compareTo(other._category.index) case var value
when value != 0) {
return value;
}
return _name.compareTo(other._name);
}
static MemberCategory _computeCategory(Element element) => switch (element) {
ConstructorElement() => MemberCategory.constructor,
PropertyAccessorElement() => MemberCategory.propertyAccessor,
TopLevelFunctionElement() => MemberCategory.topLevelFunctionOrMethod,
MethodElement() => MemberCategory.topLevelFunctionOrMethod,
InterfaceElement() => MemberCategory.interface,
ExtensionElement() => MemberCategory.extension,
TypeAliasElement() => MemberCategory.typeAlias,
dynamic(:var runtimeType) =>
throw UnimplementedError('Unexpected element: $runtimeType'),
};
static bool _computeIsInstanceMember(Element element) =>
element.enclosingElement2 is InstanceElement &&
switch (element) {
ExecutableElement(:var isStatic) => !isStatic,
dynamic(:var runtimeType) =>
throw UnimplementedError('Unexpected element: $runtimeType'),
};
}
/// A node to be printed to the output.
class Node<ChildSortKey extends Comparable<ChildSortKey>> {
/// A list of objects which, when their string representations are
/// concatenated, is the text that should be displayed on the first line of
/// the node.
///
/// The reason this is a list rather than a single string is to allow elements
/// of the list to be [UniqueName] objects, which may acquire a disambiguation
/// suffix at a later time.
final text = <Object?>[];
/// A list of child nodes, paired with a sort key indicating the order in
/// which they should be output.
final childNodes = <(ChildSortKey, Node)>[];
/// Outputs [childNodes], prepending [prefix] to every line.
void printChildren(StringSink sink, {required String prefix}) {
_printNodes(sink, childNodes, prefix: prefix);
}
}
/// Object that will have a unique string representation within the context of a
/// given [UniqueNamer] instance.
///
/// If two or more [UniqueName] objects are constructed with reference to the
/// same [UniqueNamer], and they have the same [_nameHint], then all such
/// objects' [toString] methods will append a unique suffix of the form
/// `@INTEGER`, so that the resulting strings are unique.
class UniqueName {
/// The name that will be returned by [toString] if no disambiguation is
/// needed.
final String _nameHint;
/// If not `Null`, the integer that [toString] will use to disambiguate this
/// [UniqueName] from other ones with the same [_nameHint].
int? _disambiguator;
UniqueName(UniqueNamer uniqueNamer, this._nameHint) {
// The uniqueness guarantee depends on `_nameHint` not containing an `@`.
assert(!_nameHint.contains('@'));
var conflicts = uniqueNamer._conflicts[_nameHint] ??= [];
if (conflicts.length == 1) {
conflicts[0]._disambiguator = 1;
}
conflicts.add(this);
if (conflicts.length > 1) {
_disambiguator = conflicts.length;
}
}
@override
String toString() =>
[
_nameHint,
if (_disambiguator case var disambiguator?) '@$disambiguator',
].join();
}
/// Manager of unique names for elements.
class UniqueNamer {
final _names = <Element, UniqueName>{};
final _conflicts = <String, List<UniqueName>>{};
/// Returns a [UniqueName] object whose [toString] method will produce a
/// unique name for [element].
UniqueName name(Element element) =>
_names[element] ??= UniqueName(this, element.apiName);
}
/// URI categorization used by [UriSortKey].
enum UriCategory { inPackage, notInPackage }
/// Sort key used to sort libraries in the output.
///
/// Libraries in the specified package will be output first (sorted by URI),
/// followed by libraries not in the package.
class UriSortKey implements Comparable<UriSortKey> {
final UriCategory _category;
final String _uriString;
UriSortKey(Uri uri, String pkgName)
: _category =
uri.isIn(pkgName) ? UriCategory.inPackage : UriCategory.notInPackage,
_uriString = uri.toString();
@override
int compareTo(UriSortKey other) {
if (_category.index.compareTo(other._category.index) case var value
when value != 0) {
return value;
}
return _uriString.compareTo(other._uriString);
}
}
extension on Iterable<Iterable<Object?>> {
/// Forms a list containing [prefix], followed by the elements of `this`
/// (separated by [separator]), followed by [suffix].
List<Object?> separate({
String separator = ', ',
String prefix = '',
String suffix = '',
}) {
var result = <Object?>[prefix];
var first = true;
for (var item in this) {
if (first) {
first = false;
} else {
result.add(separator);
}
result.addAll(item);
}
result.add(suffix);
return result;
}
}
extension on Element {
/// Returns the appropriate name for describing the element in `api.txt`.
///
/// The name is the same as [name3], but with `=` appended for setters.
String get apiName {
var apiName = name3!;
if (this is SetterElement) {
apiName += '=';
}
return apiName;
}
bool isInPublicApiOf(String packageName) {
if (this case PropertyAccessorElement(
isSynthetic: true,
:var variable3?,
) when variable3.isInPublicApiOf(packageName)) {
return true;
}
if (this case Annotatable(
metadata2: Metadata(:var annotations),
) when annotations.any(_isPublicApiAnnotation)) {
return true;
}
if (name3 case var name? when !name.isPublic) return false;
if (library2!.uri.isInPublicLibOf(packageName)) return true;
return false;
}
bool _isPublicApiAnnotation(ElementAnnotation annotation) {
if (annotation.computeConstantValue() case DartObject(
type: InterfaceType(
element3: InterfaceElement(name3: 'AnalyzerPublicApi'),
),
)) {
return true;
} else {
return false;
}
}
}
extension on String {
bool get isPublic => !startsWith('_');
}
extension on Uri {
bool isIn(String packageName) =>
scheme == 'package' &&
pathSegments.isNotEmpty &&
pathSegments[0] == packageName;
bool isInPublicLibOf(String packageName) =>
scheme == 'package' &&
pathSegments.length > 1 &&
pathSegments[0] == packageName &&
pathSegments[1] != 'src';
}