blob: 29d749eea9ef9fae141c66349c608af2513e04c5 [file] [log] [blame]
// 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.
import 'dart:io';
import 'dart:math' as math;
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:collection/collection.dart';
import 'package:dartdoc/src/dartdoc_options.dart';
import 'package:dartdoc/src/logging.dart';
import 'package:dartdoc/src/model/comment_referable.dart';
import 'package:dartdoc/src/model/model.dart';
import 'package:dartdoc/src/package_meta.dart';
import 'package:dartdoc/src/utils.dart';
const _namePlaceholder = '@@name@@';
mixin PackageWarningOptionContext implements DartdocOptionContextBase {
bool get allowNonLocalWarnings =>
optionSet['allowNonLocalWarnings'].valueAt(context);
// allowWarningsInPackages, ignoreWarningsInPackages, errors, warnings, and
// ignore are only used indirectly via the synthetic packageWarningOptions
// option.
PackageWarningOptions get packageWarningOptions =>
optionSet['packageWarningOptions'].valueAt(context);
bool get verboseWarnings => optionSet['verboseWarnings'].valueAt(context);
}
List<DartdocOption<Object?>> createPackageWarningOptions(
PackageMetaProvider packageMetaProvider,
) {
var resourceProvider = packageMetaProvider.resourceProvider;
return [
DartdocOptionArgOnly<bool>('allowNonLocalWarnings', false, resourceProvider,
negatable: true,
help: 'Show warnings from packages we are not documenting locally.'),
// Options for globally enabling/disabling all warnings and errors
// for individual packages are command-line only. This will allow
// meta-packages like Flutter to control whether warnings are displayed for
// packages they don't control.
DartdocOptionArgOnly<List<String>?>(
'allowWarningsInPackages', null, resourceProvider,
splitCommas: true,
help:
'Package names to display warnings for (ignore all others if set)'),
DartdocOptionArgOnly<List<String>?>(
'allowErrorsInPackages', null, resourceProvider,
splitCommas: true,
help: 'Package names to display errors for (ignore all others if set)'),
DartdocOptionArgOnly<List<String>?>(
'ignoreWarningsInPackages', null, resourceProvider,
splitCommas: true,
help: 'Package names to ignore warnings for. Takes priority over '
'allow-warnings-in-packages'),
DartdocOptionArgOnly<List<String>?>(
'ignoreErrorsInPackages', null, resourceProvider,
splitCommas: true,
help: 'Package names to ignore errors for. Takes priority over '
'allow-errors-in-packages'),
// Options for globally enabling/disabling warnings and errors across
// packages. Loaded from dartdoc_options.yaml, but command line arguments
// will override.
DartdocOptionArgFile<List<String>?>('errors', null, resourceProvider,
splitCommas: true,
help: 'Additional warning names to force as errors. Specify an empty '
'list to force defaults (overriding dartdoc_options.yaml)\n'
'Defaults:\n${PackageWarningMode.error._warningsListHelpText}'),
DartdocOptionArgFile<List<String>?>('ignore', null, resourceProvider,
splitCommas: true,
help: 'Additional warning names to ignore. Specify an empty list to '
'force defaults (overriding dartdoc_options.yaml).\n'
'Defaults:\n${PackageWarningMode.ignore._warningsListHelpText}'),
DartdocOptionArgFile<List<String>?>('warnings', null, resourceProvider,
splitCommas: true,
help:
'Additional warning names to show as warnings (instead of error or '
'ignore, if not warning by default).\n'
'Defaults:\n${PackageWarningMode.warn._warningsListHelpText}'),
// Synthetic option uses a factory to build a PackageWarningOptions from all
// the above flags.
DartdocOptionSyntheticOnly<PackageWarningOptions>(
'packageWarningOptions',
(DartdocSyntheticOption<PackageWarningOptions> option, Folder dir) =>
PackageWarningOptions.fromOptions(option, dir, packageMetaProvider),
resourceProvider,
),
];
}
/// Something that package warnings can be reported on. Optionally associated
/// with an analyzer [element].
mixin Warnable implements CommentReferable, Documentable, Locatable {
Element? get element;
void warn(
PackageWarning kind, {
String? message,
Iterable<Locatable> referredFrom = const [],
Iterable<String> extendedDebug = const [],
}) {
packageGraph.warnOnElement(this, kind,
message: message,
referredFrom: referredFrom,
extendedDebug: extendedDebug);
}
}
/// The kinds of warnings that can be displayed when documenting a package.
enum PackageWarning implements Comparable<PackageWarning> {
ambiguousDocReference(
'ambiguous-doc-reference',
'ambiguous doc reference {0}',
shortHelp:
'A comment reference could refer to two or more different objects',
),
// Fix these warnings by adding the original library exporting the symbol with
// `--include`, by using `--auto-include-dependencies`, or by using
// `--exclude` to hide one of the libraries involved.
ambiguousReexport(
'ambiguous-reexport',
'ambiguous reexport of {0}, canonicalization candidates: {1}',
shortHelp:
'A symbol is exported from private to public in more than one library '
'and dartdoc can not determine which one is canonical',
longHelp: "Use {@canonicalFor $_namePlaceholder} in the desired library's "
"documentation to resolve the ambiguity and/or override dartdoc's "
'decision, or structure your package so the reexport is less '
'ambiguous. The symbol will still be referenced in all candidates -- '
'this only controls the location where it will be written and which '
'library will be displayed in navigation for the relevant pages. The '
'flag `--ambiguous-reexport-scorer-min-confidence` allows you to set '
'the threshold at which this warning will appear.',
),
ignoredCanonicalFor(
'ignored-canonical-for',
"library says it is {@canonicalFor {0}} but {0} can't be canonical "
'there',
shortHelp:
'A @canonicalFor tag refers to a library which this symbol can not be '
'canonical for',
),
// Fix these warnings by adding libraries with `--include`, or by using
// `--auto-include-dependencies`.
// TODO(jcollins-g): pipeline references through `linkedName` for error
// messages and warn for non-public canonicalization errors.
noCanonicalFound(
'no-canonical-found',
'no canonical library found for {0}, not linking',
shortHelp:
'A symbol is part of the public interface for this package, but no '
'library documented with this package documents it so dartdoc cannot '
'link to it',
),
noDocumentableLibrariesInPackage(
'no-documentable-libraries',
'{0} has no documentable libraries',
shortHelp:
'The package is to be documented but has no Dart libraries to document',
longHelp: 'Dartdoc could not find any public libraries to document in '
"'$_namePlaceholder', but documentation was requested. This might be "
'expected for an asset-only package, in which case, disable this '
'warning in your dartdoc_options.yaml file.',
),
noLibraryLevelDocs(
'no-library-level-docs',
'{0} has no library level documentation comments',
shortHelp: 'There are no library level docs for this library',
),
packageOrderGivesMissingPackageName(
'category-order-gives-missing-package-name',
"--package-order gives invalid package name: '{0}'",
shortHelp:
'The category-order flag on the command line was given the name of a '
'nonexistent package',
),
reexportedPrivateApiAcrossPackages(
'reexported-private-api-across-packages',
'private API of {0} is reexported by libraries in other packages: ',
shortHelp:
'One or more libraries reexports private API members from outside its '
'own package',
),
unresolvedDocReference(
'unresolved-doc-reference',
'unresolved doc reference [{0}]',
shortHelp:
'A comment reference could not be found in parameters, enclosing '
'class, enclosing library, or at the top level of any documented '
'library with the package',
referredFromPrefix: 'in documentation inherited from',
),
unknownMacro(
'unknown-macro',
'undefined macro [{0}]',
shortHelp: 'A comment reference contains an unknown macro',
),
unknownHtmlFragment(
'unknown-html-fragment',
'undefined HTML fragment identifier [{0}]',
shortHelp:
'Dartdoc attempted to inject an unknown block of HTML, indicating a '
'bug in Dartdoc',
),
brokenLink(
'broken-link',
'dartdoc generated a broken link to: {0}',
shortHelp: 'Dartdoc generated a link to a non-existent file',
warnablePrefix: 'to element',
referredFromPrefix: 'linked to from',
),
duplicateFile(
'duplicate-file',
'failed to write file at: {0}',
shortHelp:
'Dartdoc is trying to write to a duplicate filename based on the names '
'of Dart symbols.',
longHelp:
'Dartdoc generates a path and filename to write to for each symbol. '
"'$_namePlaceholder' conflicts with another symbol in the generated "
'path, and therefore can not be written out. Changing the name, '
'library name, or class name (if appropriate) of one of the '
'conflicting items can resolve the conflict. Alternatively, use the '
"`@nodoc` directive in one symbol's documentation comment to hide it.",
warnablePrefix: 'for symbol',
referredFromPrefix: 'conflicting with file already generated by',
defaultWarningMode: PackageWarningMode.error,
),
orphanedFile(
'orphaned-file',
'dartdoc generated a file orphan: {0}',
shortHelp: 'Dartdoc generated files that are unreachable from the index',
),
unknownFile(
'unknown-file',
'dartdoc detected an unknown file in the doc tree: {0}',
shortHelp:
'A leftover file exists in the tree that dartdoc did not write in this '
'pass',
),
missingFromSearchIndex(
'missing-from-search-index',
'dartdoc generated a file not in the search index: {0}',
shortHelp: 'A file generated by dartdoc is not present in the generated '
'index.json file',
),
// The message for this warning can contain many punctuation and other
// symbols, so bracket with a triple quote for defense.
typeAsHtml(
'type-as-html',
'generic type handled as HTML: """{0}"""',
shortHelp:
'Use of <> in a comment for type parameters is being treated as HTML '
'by Markdown',
defaultWarningMode: PackageWarningMode.ignore,
),
invalidParameter(
'invalid-parameter',
'invalid parameter to dartdoc directive: {0}',
shortHelp: 'A parameter given to a dartdoc directive was invalid.',
defaultWarningMode: PackageWarningMode.error,
),
toolError(
'tool-error',
'tool execution failed: {0}',
shortHelp: 'Unable to execute external tool.',
defaultWarningMode: PackageWarningMode.error,
),
deprecated(
'deprecated',
'deprecated dartdoc usage: {0}',
shortHelp: 'A dartdoc directive has a deprecated format.',
),
unresolvedExport(
'unresolved-export',
'unresolved export uri: {0}',
shortHelp: 'An export refers to a URI that cannot be resolved.',
defaultWarningMode: PackageWarningMode.error,
),
missingConstantConstructor(
'missing-constant-constructor',
'constant constructor missing: {0}',
shortHelp:
'Dartdoc can not show the value of a constant because its constructor '
'could not be resolved.',
longHelp:
'To resolve a constant into its literal value, Dartdoc relies on the '
"analyzer to resolve the constructor. The analyzer didn't provide the "
"constructor for '$_namePlaceholder', which is usually due to an error "
'in the code. Use the analyzer to resolve the issue.',
// Defaults to ignore as this doesn't impact the docs severely but is
// useful for debugging package structure.
defaultWarningMode: PackageWarningMode.ignore,
),
missingExampleFile(
'missing-example-file',
'example file not found: {0}',
shortHelp:
"A file which is indicated by an '{@example}' directive could not be "
'found',
),
missingCodeBlockLanguage(
'missing-code-block-language',
'missing code block language: {0}',
shortHelp: 'A fenced code block is missing a specified language.',
longHelp:
'To enable proper syntax highlighting of Markdown code blocks, Dartdoc '
'requires code blocks to specify the language used after the initial '
'declaration. As an example, to specify Dart you would open the '
'Markdown code block with ```dart or ~~~dart.',
defaultWarningMode: PackageWarningMode.ignore,
);
/// The name which can be used at the command line to enable this warning.
final String _flagName;
/// The message template, with substitutions.
final String _template;
final String _warnablePrefix;
final String _referredFromPrefix;
final String _shortHelp;
final String _longHelp;
final PackageWarningMode _defaultWarningMode;
const PackageWarning(
this._flagName,
this._template, {
required String shortHelp,
String longHelp = '',
String warnablePrefix = 'from',
String referredFromPrefix = 'referred to by',
PackageWarningMode defaultWarningMode = PackageWarningMode.warn,
}) : _shortHelp = shortHelp,
_longHelp = longHelp,
_warnablePrefix = warnablePrefix,
_referredFromPrefix = referredFromPrefix,
_defaultWarningMode = defaultWarningMode;
static PackageWarning? _byName(String name) =>
PackageWarning.values.firstWhereOrNull((w) => w._flagName == name);
@override
int compareTo(PackageWarning other) {
return _flagName.compareTo(other._flagName);
}
String messageFor(List<String> messageParts) {
var message = _template;
for (var i = 0; i < messageParts.length; i++) {
message = message.replaceAll('{$i}', messageParts[i]);
}
return message;
}
String messageForWarnable(Warnable warnable) =>
'$_warnablePrefix ${warnable.safeWarnableName}: ${warnable.location}';
String messageForReferral(Locatable referral) =>
'$_referredFromPrefix ${referral.safeWarnableName}: ${referral.location}';
}
/// Used to declare defaults for a particular package warning.
enum PackageWarningMode {
ignore,
warn,
error;
String get _warningsListHelpText {
return (PackageWarning.values
.where((w) => w._defaultWarningMode == this)
.toList(growable: false)
..sort())
.map((w) => ' ${w._flagName}: ${w._shortHelp}')
.join('\n');
}
}
/// Warnings which are OK to skip if we can determine the warnable isn't
/// documented.
/// In particular, this set should not include warnings around public/private
/// or canonicalization problems, because those can break the `isDocumented()`
/// check.
const Set<PackageWarning> skipWarningIfNotDocumentedFor = {
PackageWarning.unresolvedDocReference,
PackageWarning.typeAsHtml
};
class PackageWarningOptions {
final Map<PackageWarning, PackageWarningMode> warningModes = {};
PackageWarningOptions() {
for (var definition in PackageWarning.values) {
switch (definition._defaultWarningMode) {
case PackageWarningMode.warn:
warn(definition);
case PackageWarningMode.error:
error(definition);
case PackageWarningMode.ignore:
ignore(definition);
}
}
}
static PackageWarningOptions fromOptions(
DartdocSyntheticOption<PackageWarningOptions> option,
Folder dir,
PackageMetaProvider packageMetaProvider,
) {
// First, initialize defaults.
var newOptions = PackageWarningOptions();
var packageMeta = packageMetaProvider.fromDir(dir)!;
// Interpret errors/warnings/ignore options. In the event of conflict,
// warning overrides error and ignore overrides warning.
var errorsForDir =
option.parent.getValueAs<List<String>?>('errors', dir) ?? [];
for (var warningName in errorsForDir) {
var packageWarning = PackageWarning._byName(warningName);
if (packageWarning != null) {
newOptions.error(packageWarning);
}
}
var warningsForDir =
option.parent.getValueAs<List<String>?>('warnings', dir) ?? [];
for (var warningName in warningsForDir) {
var packageWarning = PackageWarning._byName(warningName);
if (packageWarning != null) {
newOptions.warn(packageWarning);
}
}
var ignoredForDir =
option.parent.getValueAs<List<String>?>('ignore', dir) ?? [];
for (var warningName in ignoredForDir) {
var packageWarning = PackageWarning._byName(warningName);
if (packageWarning != null) {
newOptions.ignore(packageWarning);
}
}
// Check whether warnings are allowed at all in this package.
var allowWarningsInPackages =
option.parent.getValueAs<List<String>?>('allowWarningsInPackages', dir);
var allowErrorsInPackages =
option.parent.getValueAs<List<String>?>('allowErrorsInPackages', dir);
var ignoreWarningsInPackages = option.parent
.getValueAs<List<String>?>('ignoreWarningsInPackages', dir);
var ignoreErrorsInPackages =
option.parent.getValueAs<List<String>?>('ignoreErrorsInPackages', dir);
void ignoreWarning(PackageWarning kind) {
newOptions.ignore(kind);
}
if (allowWarningsInPackages != null &&
!allowWarningsInPackages.contains(packageMeta.name)) {
PackageWarning.values.forEach(ignoreWarning);
}
if (allowErrorsInPackages != null &&
!allowErrorsInPackages.contains(packageMeta.name)) {
PackageWarning.values.forEach(ignoreWarning);
}
if (ignoreWarningsInPackages != null &&
ignoreWarningsInPackages.contains(packageMeta.name)) {
PackageWarning.values.forEach(ignoreWarning);
}
if (ignoreErrorsInPackages != null &&
ignoreErrorsInPackages.contains(packageMeta.name)) {
PackageWarning.values.forEach(ignoreWarning);
}
return newOptions;
}
void ignore(PackageWarning kind) =>
warningModes[kind] = PackageWarningMode.ignore;
void warn(PackageWarning kind) =>
warningModes[kind] = PackageWarningMode.warn;
void error(PackageWarning kind) =>
warningModes[kind] = PackageWarningMode.error;
PackageWarningMode getMode(PackageWarning kind) => warningModes[kind]!;
}
class PackageWarningCounter {
final Map<Element?, Map<PackageWarning, Set<String>>> _countedWarnings = {};
final _items = <Jsonable>[];
final _displayedWarningCounts = <PackageWarning, int>{};
final PackageGraph packageGraph;
int _errorCount = 0;
/// The total amount of errors this package has experienced.
int get errorCount => _errorCount;
int _warningCount = 0;
/// The total amount of warnings this package has experienced.
int get warningCount => _warningCount;
/// An unmodifiable map view of all counted warnings related by their element,
/// warning type, and message.
UnmodifiableMapView<Element?, Map<PackageWarning, Set<String>>>
get countedWarnings => UnmodifiableMapView(_countedWarnings);
PackageWarningCounter(this.packageGraph);
/// Logs [packageWarning].
///
/// Assumes it is already counted with [addWarning].
void _writeWarning(PackageWarning packageWarning, PackageWarningMode? mode,
bool verboseWarnings, String name, String fullMessage) {
if (mode == PackageWarningMode.ignore) {
return;
}
var type = switch (mode) {
PackageWarningMode.error => 'error',
PackageWarningMode.warn => 'warning',
_ => null,
};
if (type != null) {
var entry = ' $type: $fullMessage';
var displayedWarningCount =
_displayedWarningCounts.increment(packageWarning);
if (displayedWarningCount == 1 &&
verboseWarnings &&
packageWarning._longHelp.isNotEmpty) {
// First time we've seen this warning. Give a little extra info.
var longHelpLines = packageWarning._longHelp
.split('\n')
.map((line) => line.replaceAll(_namePlaceholder, name))
.map((line) =>
_wrapText(line, prefix: ' ', width: _messageWidth));
var verboseOut = longHelpLines.join('\n');
entry = '$entry\n$verboseOut';
}
assert(entry == entry.trimRight());
_items.add(_JsonWarning(type, packageWarning, fullMessage, entry));
}
for (var item in _items) {
logWarning(item.toString());
}
_items.clear();
}
/// If this package has had any warnings counted.
bool get hasWarnings => _countedWarnings.isNotEmpty;
/// Whether we've already warned for this combination of [element], [kind],
/// and [message].
bool hasWarning(Warnable? element, PackageWarning kind, String message) {
if (element == null) {
return false;
}
final warning = _countedWarnings[element.element];
if (warning != null) {
final messages = warning[kind];
return messages != null && messages.contains(message);
}
return false;
}
/// Adds the warning to the counter, and writes out the fullMessage string
/// if configured to do so.
void addWarning(Warnable? element, PackageWarning kind, String message,
String fullMessage) {
assert(!hasWarning(element, kind, message));
// TODO(jcollins-g): Make addWarning not accept nulls for element.
PackageWarningOptionContext config =
element?.config ?? packageGraph.defaultPackage.config;
var isLocal = element?.package.isLocal ?? true;
var warningMode = !isLocal && !config.allowNonLocalWarnings
? PackageWarningMode.ignore
: config.packageWarningOptions.getMode(kind);
if (warningMode == PackageWarningMode.warn) {
_warningCount += 1;
} else if (warningMode == PackageWarningMode.error) {
_errorCount += 1;
}
var elementName = element == null ? '<global>' : element.fullyQualifiedName;
_countedWarnings
.putIfAbsent(element?.element, () => {})
.putIfAbsent(kind, () => {})
.add(message);
_writeWarning(
kind,
warningMode,
config.verboseWarnings,
elementName,
fullMessage,
);
}
@override
String toString() {
final errors = '$errorCount ${pluralize('error', errorCount)}';
final warnings = '$warningCount ${pluralize('warning', warningCount)}';
return '$errors, $warnings';
}
}
/// Wraps [text] to the given [width], if provided.
///
/// This function is taken from the 'dartdev' package, which has tests.
String _wrapText(String text, {required int width, String prefix = ''}) {
// For convenience, the caller specifies the line width, but effectively, we
// subtract out the width of the prefix.
width = width - prefix.length;
var buffer = StringBuffer(prefix);
var lineMaxEndIndex = width;
var lineStartIndex = 0;
while (true) {
if (lineMaxEndIndex >= text.length) {
buffer.write(text.substring(lineStartIndex, text.length));
break;
} else {
var lastSpaceIndex = text.lastIndexOf(' ', lineMaxEndIndex);
if (lastSpaceIndex == -1 || lastSpaceIndex <= lineStartIndex) {
// No space between [lineStartIndex] and [lineMaxEndIndex]. Get the
// _next_ space.
lastSpaceIndex = text.indexOf(' ', lineMaxEndIndex);
if (lastSpaceIndex == -1) {
// No space at all after [lineStartIndex].
lastSpaceIndex = text.length;
buffer.write(text.substring(lineStartIndex, lastSpaceIndex));
break;
}
}
buffer.write(text.substring(lineStartIndex, lastSpaceIndex));
buffer.writeln();
buffer.write(prefix);
lineStartIndex = lastSpaceIndex + 1;
}
lineMaxEndIndex = lineStartIndex + width;
}
return buffer.toString();
}
/// The width that messages in the terminal should be limited to.
///
/// If `stdout` has a terminal, use that terminal's width capped to 120
/// characters wide. Otherwise, use a width of 80.
final _messageWidth =
stdout.hasTerminal ? math.min(stdout.terminalColumns, 120) : 80;
class _JsonWarning extends Jsonable {
final String type;
final PackageWarning kind;
final String message;
@override
final String text;
_JsonWarning(this.type, this.kind, this.message, this.text);
@override
Map<String, dynamic> toJson() => {
'type': type,
'kind': kind._flagName,
'message': message,
'text': text,
};
}
extension on Map<PackageWarning, int> {
int increment(PackageWarning kind) {
if (this[kind] == null) {
this[kind] = 1;
return 1;
} else {
this[kind] = this[kind]! + 1;
return this[kind]!;
}
}
}