blob: 6d74a3fdb1580ecd24a93a3b150837a11f190756 [file] [log] [blame]
// 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.
/// A documentation generator for Dart.
///
/// Library interface is still experimental.
@experimental
library dartdoc;
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform, exitCode, stderr;
import 'package:analyzer/file_system/file_system.dart';
import 'package:dartdoc/options.dart';
import 'package:dartdoc/src/dartdoc_options.dart';
import 'package:dartdoc/src/failure.dart';
import 'package:dartdoc/src/generator/empty_generator.dart';
import 'package:dartdoc/src/generator/generator.dart';
import 'package:dartdoc/src/generator/html_generator.dart';
import 'package:dartdoc/src/generator/markdown_generator.dart';
import 'package:dartdoc/src/logging.dart';
import 'package:dartdoc/src/matching_link_result.dart';
import 'package:dartdoc/src/model/model.dart';
import 'package:dartdoc/src/package_meta.dart';
import 'package:dartdoc/src/tuple.dart';
import 'package:dartdoc/src/utils.dart';
import 'package:dartdoc/src/version.dart';
import 'package:dartdoc/src/warnings.dart';
import 'package:html/parser.dart' show parse;
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;
export 'package:dartdoc/src/dartdoc_options.dart';
export 'package:dartdoc/src/element_type.dart';
export 'package:dartdoc/src/generator/generator.dart';
export 'package:dartdoc/src/model/model.dart';
export 'package:dartdoc/src/package_config_provider.dart';
export 'package:dartdoc/src/package_meta.dart';
const String programName = 'dartdoc';
// Update when pubspec version changes by running `pub run build_runner build`
const String dartdocVersion = packageVersion;
class DartdocFileWriter implements FileWriter {
final String _outputDir;
@override
final ResourceProvider resourceProvider;
final Map<String, Warnable> _fileElementMap = {};
@override
final Set<String> writtenFiles = {};
DartdocFileWriter(this._outputDir, this.resourceProvider);
@override
void writeBytes(
String filePath,
List<int> content, {
bool allowOverwrite = false,
}) {
// Replace '/' separators with proper separators for the platform.
var outFile = path.joinAll(filePath.split('/'));
if (!allowOverwrite) {
_warnAboutOverwrite(outFile, null);
}
_fileElementMap[outFile] = null;
var file = _getFile(outFile);
file.writeAsBytesSync(content);
writtenFiles.add(outFile);
logProgress(outFile);
}
@override
void write(String filePath, String content, {Warnable element}) {
// Replace '/' separators with proper separators for the platform.
var outFile = path.joinAll(filePath.split('/'));
_warnAboutOverwrite(outFile, element);
_fileElementMap[outFile] = element;
var file = _getFile(outFile);
file.writeAsStringSync(content);
writtenFiles.add(outFile);
logProgress(outFile);
}
void _warnAboutOverwrite(String outFile, Warnable element) {
if (_fileElementMap.containsKey(outFile)) {
assert(element != null,
'Attempted overwrite of $outFile without corresponding element');
var originalElement = _fileElementMap[outFile];
var referredFrom = originalElement != null ? [originalElement] : null;
element?.warn(PackageWarning.duplicateFile,
message: outFile, referredFrom: referredFrom);
}
}
/// Returns the file at [outFile] relative to [_outputDir], creating the
/// parent directory if necessary.
File _getFile(String outFile) {
var file = resourceProvider
.getFile(resourceProvider.pathContext.join(_outputDir, outFile));
var parent = file.parent2;
if (!parent.exists) {
parent.create();
}
return file;
}
}
/// Generates Dart documentation for all public Dart libraries in the given
/// directory.
class Dartdoc {
Generator _generator;
final PackageBuilder packageBuilder;
final DartdocOptionContext config;
final Set<String> _writtenFiles = {};
Folder _outputDir;
// Fires when the self checks make progress.
final StreamController<String> _onCheckProgress =
StreamController(sync: true);
Dartdoc._(this.config, this._generator, this.packageBuilder) {
_outputDir = config.resourceProvider
.getFolder(config.resourceProvider.pathContext.absolute(config.output))
..create();
}
// TODO(srawlins): Remove when https://github.com/dart-lang/linter/issues/2706
// is fixed.
// ignore: unnecessary_getters_setters
Generator get generator => _generator;
@visibleForTesting
// TODO(srawlins): Remove when https://github.com/dart-lang/linter/issues/2706
// is fixed.
// ignore: unnecessary_getters_setters
set generator(Generator newGenerator) => _generator = newGenerator;
/// Asynchronous factory method that builds Dartdoc with an empty generator.
static Future<Dartdoc> withEmptyGenerator(
DartdocOptionContext config,
PackageBuilder packageBuilder,
) async {
return Dartdoc._(
config,
await initEmptyGenerator(config),
packageBuilder,
);
}
/// Asynchronous factory method that builds Dartdoc with a generator
/// determined by the given context.
static Future<Dartdoc> fromContext(
DartdocGeneratorOptionContext context,
PackageBuilder packageBuilder,
) async {
Generator generator;
switch (context.format) {
case 'html':
generator = await initHtmlGenerator(context);
break;
case 'md':
generator = await initMarkdownGenerator(context);
break;
default:
throw DartdocFailure('Unsupported output format: ${context.format}');
}
return Dartdoc._(
context,
generator,
packageBuilder,
);
}
Stream<String> get onCheckProgress => _onCheckProgress.stream;
@visibleForTesting
Future<DartdocResults> generateDocsBase() async {
var stopwatch = Stopwatch()..start();
var packageGraph = await packageBuilder.buildPackageGraph();
var seconds = stopwatch.elapsedMilliseconds / 1000.0;
var libs = packageGraph.libraries.length;
logInfo("Initialized dartdoc with $libs librar${libs == 1 ? 'y' : 'ies'} "
'in ${seconds.toStringAsFixed(1)} seconds');
stopwatch.reset();
// Create the out directory.
if (!_outputDir.exists) _outputDir.create();
var writer = DartdocFileWriter(_outputDir.path, config.resourceProvider);
await generator.generate(packageGraph, writer);
_writtenFiles.addAll(writer.writtenFiles);
if (config.validateLinks && _writtenFiles.isNotEmpty) {
_validateLinks(packageGraph, _outputDir.path);
}
var warnings = packageGraph.packageWarningCounter.warningCount;
var errors = packageGraph.packageWarningCounter.errorCount;
if (warnings == 0 && errors == 0) {
logInfo('no issues found');
} else {
logWarning("Found $warnings ${pluralize('warning', warnings)} "
"and $errors ${pluralize('error', errors)}.");
}
seconds = stopwatch.elapsedMilliseconds / 1000.0;
libs = packageGraph.localPublicLibraries.length;
logInfo("Documented $libs public librar${libs == 1 ? 'y' : 'ies'} "
'in ${seconds.toStringAsFixed(1)} seconds');
if (config.showStats) {
logInfo(markdownStats.buildReport());
}
return DartdocResults(config.topLevelPackageMeta, packageGraph, _outputDir);
}
/// Generate Dartdoc documentation.
///
/// [DartdocResults] is returned if dartdoc succeeds. [DartdocFailure] is
/// thrown if dartdoc fails in an expected way, for example if there is an
/// analysis error in the code.
Future<DartdocResults> generateDocs() async {
DartdocResults dartdocResults;
try {
logInfo('Documenting ${config.topLevelPackageMeta}...');
dartdocResults = await generateDocsBase();
if (dartdocResults.packageGraph.localPublicLibraries.isEmpty) {
logWarning('dartdoc could not find any libraries to document');
}
final errorCount =
dartdocResults.packageGraph.packageWarningCounter.errorCount;
if (errorCount > 0) {
throw DartdocFailure('encountered $errorCount errors');
}
var outDirPath = config.resourceProvider.pathContext
.absolute(dartdocResults.outDir.path);
logInfo('Success! Docs generated into $outDirPath');
return dartdocResults;
} finally {
dartdocResults?.packageGraph?.dispose();
}
}
/// Warn on file paths.
void _warn(PackageGraph packageGraph, PackageWarning kind, String warnOn,
String origin,
{String referredFrom}) {
// Ordinarily this would go in [Package.warn], but we don't actually know what
// ModelElement to warn on yet.
Warnable warnOnElement;
var referredFromElements = <Warnable>{};
Set<Warnable> warnOnElements;
// Make all paths relative to origin.
if (path.isWithin(origin, warnOn)) {
warnOn = path.relative(warnOn, from: origin);
}
if (referredFrom != null) {
if (path.isWithin(origin, referredFrom)) {
referredFrom = path.relative(referredFrom, from: origin);
}
// Source paths are always relative.
if (_hrefs[referredFrom] != null) {
referredFromElements.addAll(_hrefs[referredFrom]);
}
}
warnOnElements = _hrefs[warnOn];
if (referredFromElements.any((e) => e.isCanonical)) {
referredFromElements.removeWhere((e) => !e.isCanonical);
}
if (warnOnElements != null) {
warnOnElement = warnOnElements.firstWhere((e) => e.isCanonical,
orElse: () => warnOnElements.isEmpty ? null : warnOnElements.first);
}
if (referredFromElements.isEmpty && referredFrom == 'index.html') {
referredFromElements.add(packageGraph.defaultPackage);
}
var message = warnOn;
if (referredFrom == 'index.json') message = '$warnOn (from index.json)';
packageGraph.warnOnElement(warnOnElement, kind,
message: message, referredFrom: referredFromElements);
}
void _doOrphanCheck(
PackageGraph packageGraph, String origin, Set<String> visited) {
var normalOrigin = path.normalize(origin);
var staticAssets = path.joinAll([normalOrigin, 'static-assets', '']);
var indexJson = path.joinAll([normalOrigin, 'index.json']);
var foundIndexJson = false;
void checkDirectory(Folder dir) {
for (var f in dir.getChildren()) {
if (f is Folder) {
checkDirectory(f);
continue;
}
var fullPath = path.normalize(f.path);
if (fullPath.startsWith(staticAssets)) {
continue;
}
if (path.equals(fullPath, indexJson)) {
foundIndexJson = true;
_onCheckProgress.add(fullPath);
continue;
}
if (visited.contains(fullPath)) continue;
var relativeFullPath = path.relative(fullPath, from: normalOrigin);
if (!_writtenFiles.contains(relativeFullPath)) {
// This isn't a file we wrote (this time); don't claim we did.
_warn(
packageGraph, PackageWarning.unknownFile, fullPath, normalOrigin);
} else {
// Error messages are orphaned by design and do not appear in the search
// index.
if (const {'__404error.html', 'categories.json'}.contains(fullPath)) {
_warn(packageGraph, PackageWarning.orphanedFile, fullPath,
normalOrigin);
}
}
_onCheckProgress.add(fullPath);
}
}
checkDirectory(config.resourceProvider.getFolder(normalOrigin));
if (!foundIndexJson) {
_warn(packageGraph, PackageWarning.brokenLink, indexJson, normalOrigin);
_onCheckProgress.add(indexJson);
}
}
// This is extracted to save memory during the check; be careful not to hang
// on to anything referencing the full file and doc tree.
Tuple2<Iterable<String>, String> _getStringLinksAndHref(String fullPath) {
var file = config.resourceProvider.getFile(fullPath);
if (!file.exists) {
return null;
}
// TODO(srawlins): It is possible that instantiating an HtmlParser using
// `lowercaseElementName: false` and `lowercaseAttrName: false` may save
// time or memory.
var doc = parse(file.readAsBytesSync());
var base = doc.querySelector('base');
String baseHref;
if (base != null) {
baseHref = base.attributes['href'];
}
var links = doc.querySelectorAll('a');
var stringLinks = links
.map((link) => link.attributes['href'])
.where((href) => href != null && href != '')
.toList();
return Tuple2(stringLinks, baseHref);
}
void _doSearchIndexCheck(
PackageGraph packageGraph, String origin, Set<String> visited) {
var fullPath = path.joinAll([origin, 'index.json']);
var indexPath = path.joinAll([origin, 'index.html']);
var file = config.resourceProvider.getFile(fullPath);
if (!file.exists) {
return null;
}
var decoder = JsonDecoder();
List<Object> jsonData = decoder.convert(file.readAsStringSync());
var found = <String>{};
found.add(fullPath);
// The package index isn't supposed to be in the search, so suppress the
// warning.
found.add(indexPath);
for (Map<String, dynamic> entry in jsonData) {
if (entry.containsKey('href')) {
var entryPath =
path.joinAll([origin, ...path.posix.split(entry['href'])]);
if (!visited.contains(entryPath)) {
_warn(packageGraph, PackageWarning.brokenLink, entryPath,
path.normalize(origin),
referredFrom: fullPath);
}
found.add(entryPath);
}
}
// Missing from search index
var missingFromSearch = visited.difference(found);
for (var s in missingFromSearch) {
_warn(packageGraph, PackageWarning.missingFromSearchIndex, s,
path.normalize(origin),
referredFrom: fullPath);
}
}
void _doCheck(PackageGraph packageGraph, String origin, Set<String> visited,
String pathToCheck,
[String source, String fullPath]) {
fullPath ??= path.normalize(path.joinAll([origin, pathToCheck]));
var stringLinksAndHref = _getStringLinksAndHref(fullPath);
if (stringLinksAndHref == null) {
_warn(packageGraph, PackageWarning.brokenLink, pathToCheck,
path.normalize(origin),
referredFrom: source);
_onCheckProgress.add(pathToCheck);
// Remove so that we properly count that the file doesn't exist for
// the orphan check.
visited.remove(fullPath);
return null;
}
visited.add(fullPath);
var stringLinks = stringLinksAndHref.item1;
var baseHref = stringLinksAndHref.item2;
// Prevent extremely large stacks by storing the paths we are using
// here instead -- occasionally, very large jobs have overflowed
// the stack without this.
// (newPathToCheck, newFullPath)
var toVisit = <Tuple2<String, String>>{};
final ignoreHyperlinks = RegExp(r'^(https:|http:|mailto:|ftp:)');
for (final href in stringLinks) {
if (!href.startsWith(ignoreHyperlinks)) {
final uri = Uri.tryParse(href);
if (uri == null || !uri.hasAuthority && !uri.hasFragment) {
String full;
if (baseHref != null) {
full = '${path.dirname(pathToCheck)}/$baseHref/$href';
} else {
full = '${path.dirname(pathToCheck)}/$href';
}
final newPathToCheck = path.normalize(full);
final newFullPath =
path.normalize(path.joinAll([origin, newPathToCheck]));
if (!visited.contains(newFullPath)) {
toVisit.add(Tuple2(newPathToCheck, newFullPath));
visited.add(newFullPath);
}
}
}
}
for (var visitPaths in toVisit) {
_doCheck(packageGraph, origin, visited, visitPaths.item1, pathToCheck,
visitPaths.item2);
}
_onCheckProgress.add(pathToCheck);
}
Map<String, Set<ModelElement>> _hrefs;
/// Don't call this method more than once, and only after you've
/// generated all docs for the Package.
void _validateLinks(PackageGraph packageGraph, String origin) {
assert(_hrefs == null);
_hrefs = packageGraph.allHrefs;
final visited = <String>{};
logInfo('Validating docs...');
_doCheck(packageGraph, origin, visited, 'index.html');
_doOrphanCheck(packageGraph, origin, visited);
_doSearchIndexCheck(packageGraph, origin, visited);
}
/// Runs [generateDocs] function and properly handles the errors.
///
/// Passing in a [postProcessCallback] to do additional processing after
/// the documentation is generated.
void executeGuarded([
Future<void> Function(DartdocOptionContext) postProcessCallback,
]) {
onCheckProgress.listen(logProgress);
// This function should *never* await `runZonedGuarded` because the errors
// thrown in generateDocs are uncaught. We want this because uncaught errors
// cause IDE debugger to automatically stop at the exception.
//
// If you await the zone, the code that comes after the await is not
// executed if the zone dies due to uncaught error. To avoid this confusion,
// never await `runZonedGuarded` and never change the return value of
// [executeGuarded].
runZonedGuarded(
() async {
await generateDocs();
await postProcessCallback?.call(config);
},
(e, chain) {
if (e is DartdocFailure) {
stderr.writeln('\n$_dartdocFailedMessage: $e.');
exitCode = 1;
} else {
stderr.writeln('\n$_dartdocFailedMessage: $e\n$chain');
exitCode = 255;
}
},
zoneSpecification: ZoneSpecification(
print: (_, __, ___, String line) => logPrint(line),
),
);
}
}
/// The results of a [Dartdoc.generateDocs] call.
class DartdocResults {
final PackageMeta packageMeta;
final PackageGraph packageGraph;
final Folder outDir;
DartdocResults(this.packageMeta, this.packageGraph, this.outDir);
}
String get _dartdocFailedMessage =>
'dartdoc $packageVersion (${Platform.script.path}) failed';