blob: 28bbe93cfe8dd00f9298c59a724a5eb4ea7c7652 [file] [log] [blame]
// 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:analyzer/dart/element/element2.dart';
import 'package:dartdoc/src/model/model.dart';
import 'package:dartdoc/src/warnings.dart';
const int _separatorChar = 0x3B;
/// Searches [PackageGraph.libraryExports] for a public, documented library
/// which exports this [ModelElement], ideally in its library's package.
Library? canonicalLibraryCandidate(ModelElement modelElement) {
var thisAndExported =
modelElement.packageGraph.libraryExports[modelElement.library.element];
if (thisAndExported == null) {
return null;
}
// Since we're looking for a library, go up in the tree until we find it.
var topLevelElement = modelElement.element;
while (topLevelElement.enclosingElement2 is! LibraryElement2 &&
topLevelElement.enclosingElement2 != null) {
topLevelElement = topLevelElement.enclosingElement2!;
}
var topLevelElementName = topLevelElement.name3;
if (topLevelElementName == null) {
// Any member of an unnamed extension is not public, and has no
// canonical library.
return null;
}
final candidateLibraries = thisAndExported.where((l) {
if (!l.isPublic) return false;
if (l.package.documentedWhere == DocumentLocation.missing) return false;
if (modelElement is Library) return true;
var lookup = l.element.exportNamespace.definedNames2[topLevelElementName];
return topLevelElement ==
(lookup is PropertyAccessorElement2 ? lookup.variable3 : lookup);
}).toList(growable: true);
if (candidateLibraries.isEmpty) {
return null;
}
if (candidateLibraries.length == 1) {
return candidateLibraries.single;
}
var remoteLibraries = candidateLibraries
.where((l) => l.package.documentedWhere == DocumentLocation.remote);
if (remoteLibraries.length == 1) {
// If one or more local libraries export code from a remotely documented
// library (and we're linking to remote libraries), then just use the remote
// library.
return remoteLibraries.single;
}
var topLevelModelElement =
ModelElement.forElement(topLevelElement, modelElement.packageGraph);
return _Canonicalization(topLevelModelElement)
.canonicalLibraryCandidate(candidateLibraries);
}
/// Canonicalization support in Dartdoc.
///
/// This provides heuristic scoring to determine which library a human likely
/// considers this element to be primarily 'from', and therefore, canonical.
/// Still warn if the heuristic isn't very confident.
final class _Canonicalization {
final ModelElement _modelElement;
_Canonicalization(this._modelElement);
/// Append an encoded form of the given [component] to the given [buffer].
void _encode(StringBuffer buffer, String component) {
var length = component.length;
for (var i = 0; i < length; i++) {
var currentChar = component.codeUnitAt(i);
if (currentChar == _separatorChar) {
buffer.writeCharCode(_separatorChar);
}
buffer.writeCharCode(currentChar);
}
}
String _getElementLocation(Element2 element) {
var components = <String>[];
Element2? ancestor = element;
while (ancestor != null) {
if (ancestor is LibraryElement2) {
components.insert(0, ancestor.identifier);
} else {
components.insert(0, ancestor.name3!);
}
ancestor = ancestor.enclosingElement2;
}
var buffer = StringBuffer();
var length = components.length;
for (var i = 0; i < length; i++) {
if (i > 0) {
buffer.writeCharCode(_separatorChar);
}
_encode(buffer, components[i]);
}
return buffer.toString();
}
/// Calculates a candidate for the canonical library of [_modelElement], among [libraries].
Library canonicalLibraryCandidate(Iterable<Library> libraries) {
var locationPieces = _getElementLocation(_modelElement.element)
.split(_locationSplitter)
.where((s) => s.isNotEmpty)
.toSet();
var scoredCandidates = libraries
.map((library) => _scoreElementWithLibrary(
library, _modelElement.fullyQualifiedName, locationPieces))
.toList(growable: false)
..sort();
final librariesByScore = scoredCandidates.map((s) => s.library).toList();
var secondHighestScore =
scoredCandidates[scoredCandidates.length - 2].score;
var highestScore = scoredCandidates.last.score;
var confidence = highestScore - secondHighestScore;
final canonicalLibrary = librariesByScore.last;
if (confidence <
_modelElement.config.ambiguousReexportScorerMinConfidence) {
var libraryNames = librariesByScore.map((l) => l.name);
var message = '$libraryNames -> ${canonicalLibrary.name} '
'(confidence ${confidence.toStringAsPrecision(4)})';
_modelElement.warn(PackageWarning.ambiguousReexport,
message: message, extendedDebug: scoredCandidates.map((s) => '$s'));
}
return canonicalLibrary;
}
// TODO(srawlins): This function is minimally tested; it's tricky to unit test
// because it takes a lot of elements into account, like URIs, differing
// package names, etc. Anyways, add more tests, in addition to the
// `StringName` tests in `model_test.dart`.
static _ScoredCandidate _scoreElementWithLibrary(Library library,
String elementQualifiedName, Set<String> elementLocationPieces) {
var scoredCandidate = _ScoredCandidate(library);
// Large boost for `@canonicalFor`, essentially overriding all other
// concerns.
if (library.canonicalFor.contains(elementQualifiedName)) {
scoredCandidate._alterScore(5.0, _Reason.canonicalFor);
}
// Penalty for deprecated libraries.
if (library.isDeprecated) {
scoredCandidate._alterScore(-1.0, _Reason.deprecated);
}
var libraryNamePieces = {
...library.name.split('.').where((s) => s.isNotEmpty)
};
// Give a big boost if the library has the package name embedded in it.
if (libraryNamePieces.contains(library.package.name)) {
scoredCandidate._alterScore(1.0, _Reason.packageName);
}
// Same idea as the above, for the Dart SDK.
if (library.name == 'dart:core') {
scoredCandidate._alterScore(0.9, _Reason.packageName);
}
// Give a tiny boost for libraries with long names, assuming they're
// more specific (and therefore more likely to be the owner of this symbol).
scoredCandidate._alterScore(
.01 * libraryNamePieces.length, _Reason.longName);
// If we don't know the location of this element (which shouldn't be
// possible), return our best guess.
assert(elementLocationPieces.isNotEmpty);
if (elementLocationPieces.isEmpty) return scoredCandidate;
// The more pieces we have of the location in our library name, the more we
// should boost our score.
scoredCandidate._alterScore(
libraryNamePieces.intersection(elementLocationPieces).length.toDouble() /
elementLocationPieces.length.toDouble(),
_Reason.sharedNamePart,
);
// If pieces of location at least start with elements of our library name,
// boost the score a little bit.
var scoreBoost = 0.0;
for (var piece in elementLocationPieces.expand((item) => item.split('_'))) {
for (var namePiece in libraryNamePieces) {
if (piece.startsWith(namePiece)) {
scoreBoost += 0.001;
}
}
}
scoredCandidate._alterScore(scoreBoost, _Reason.locationPartStart);
return scoredCandidate;
}
}
/// A pattern that can split [Locatable.location] strings.
final _locationSplitter = RegExp(r'(package:|[\\/;.])');
/// This class represents the score for a particular element; how likely
/// it is that this is the canonical element.
class _ScoredCandidate implements Comparable<_ScoredCandidate> {
final List<(_Reason, double)> _reasons = [];
final Library library;
/// The score accumulated so far. Higher means it is more likely that this
/// is the intended canonical Library.
double score = 0.0;
_ScoredCandidate(this.library);
void _alterScore(double scoreDelta, _Reason reason) {
score += scoreDelta;
if (scoreDelta != 0) {
_reasons.add((reason, scoreDelta));
}
}
@override
int compareTo(_ScoredCandidate other) => score.compareTo(other.score);
@override
String toString() {
var reasonText = _reasons.map((r) {
var (reason, scoreDelta) = r;
var scoreDeltaPrefix = scoreDelta >= 0 ? '+' : '';
return '$reason ($scoreDeltaPrefix${scoreDelta.toStringAsPrecision(4)})';
});
return '${library.name}: ${score.toStringAsPrecision(4)} - $reasonText';
}
}
/// A reason that a candidate's score is changed.
enum _Reason {
canonicalFor('marked @canonicalFor'),
deprecated('is deprecated'),
packageName('embeds package name'),
longName('name is long'),
sharedNamePart('element location shares parts with name'),
locationPartStart('element location parts start with parts of name');
final String text;
const _Reason(this.text);
}