blob: ebe1ce78b67bb66a2a617aa22ddcabdfd9b53a9b [file] [log] [blame]
// Copyright (c) 2021, 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.
// Read the ../README.md file for the recognized syntax.
import 'dart:collection';
import 'dart:io';
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/features.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/analysis/utilities.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/diagnostic/diagnostic.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/file_system/overlay_file_system.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/src/error/codes.dart';
import 'package:analyzer/src/util/comment.dart';
import 'package:path/path.dart' as path;
final libDir = Directory(path.join('sdk', 'lib'));
void main(List<String> args) async {
if (!libDir.existsSync()) {
print('Please run this tool from the root of the sdk repository.');
exit(1);
}
print('Validating the dartdoc code samples from the dart: libraries.');
print('');
print('To run this tool, run `dart tools/verify_docs/bin/verify_docs.dart`.');
print('');
print('For documentation about how to author dart: code samples,'
' see tools/verify_docs/README.md.');
print('');
final coreLibraries = args.isEmpty
? libDir.listSync().whereType<Directory>().toList()
: args.map(parseArg).toList();
coreLibraries.sort((a, b) => a.path.compareTo(b.path));
// Skip some dart: libraries.
const skipLibraries = {
'html',
'indexed_db',
'svg',
'vmservice',
'web_audio',
'web_gl',
'web_sql'
};
coreLibraries.removeWhere(
(lib) => skipLibraries.contains(path.basename(lib.path)),
);
var hadErrors = false;
for (final dir in coreLibraries) {
hadErrors |= !(await validateLibrary(dir));
}
exitCode = hadErrors ? 1 : 0;
}
Future<bool> validateLibrary(Directory dir) async {
final libName = path.basename(dir.path);
final analysisHelper = AnalysisHelper(libName);
print('## dart:$libName');
print('');
var validDocs = true;
for (final file in dir
.listSync(recursive: true)
.whereType<File>()
.where((file) => file.path.endsWith('.dart'))) {
validDocs &= await verifyFile(analysisHelper, libName, file, dir);
}
return validDocs;
}
Future<bool> verifyFile(
AnalysisHelper analysisHelper,
String coreLibName,
File file,
Directory parent,
) async {
final text = file.readAsStringSync();
var parseResult = parseString(
content: text,
featureSet: FeatureSet.latestLanguageVersion(),
path: file.path,
throwIfDiagnostics: false,
);
// Throw if there are syntactic errors.
var syntacticErrors = parseResult.errors.where((error) {
return error.errorCode.type == ErrorType.SYNTACTIC_ERROR;
}).toList();
if (syntacticErrors.isNotEmpty) {
throw Exception(syntacticErrors);
}
final sampleAssumptions = findFileAssumptions(text);
var visitor = ValidateCommentCodeSamplesVisitor(
analysisHelper,
coreLibName,
file.path,
parseResult.lineInfo,
sampleAssumptions,
);
await visitor.process(parseResult);
return !visitor.hadErrors;
}
/// Visit a compilation unit and validate the code samples found in dartdoc
/// comments.
class ValidateCommentCodeSamplesVisitor extends GeneralizingAstVisitor {
final AnalysisHelper analysisHelper;
final String coreLibName;
final String filePath;
final LineInfo lineInfo;
final String? sampleAssumptions;
final List<CodeSample> samples = [];
bool hadErrors = false;
ValidateCommentCodeSamplesVisitor(
this.analysisHelper,
this.coreLibName,
this.filePath,
this.lineInfo,
this.sampleAssumptions,
);
Future process(ParseStringResult parseResult) async {
// collect code samples
visitCompilationUnit(parseResult.unit);
// analyze them
for (CodeSample sample in samples) {
await validateCodeSample(sample);
}
}
@override
void visitAnnotatedNode(AnnotatedNode node) {
_handleDocumentableNode(node);
super.visitAnnotatedNode(node);
}
void _handleDocumentableNode(AnnotatedNode node) {
final docComment = node.documentationComment;
if (docComment == null || !docComment.isDocumentation) {
return;
}
const sampleStart = '```dart';
const sampleEnd = '```';
final text = getCommentNodeRawText(docComment)!;
final commentOffset = docComment.offset;
final commentLineStart = lineInfo.getLocation(commentOffset).lineNumber;
if (!text.contains(sampleStart)) {
return;
}
var offset = text.indexOf(sampleStart);
while (offset != -1) {
// Collect template directives, like "```dart import:async".
final codeFenceSuffix = text
.substring(offset + sampleStart.length, text.indexOf('\n', offset))
.trim();
offset = text.indexOf('\n', offset) + 1;
final end = text.indexOf(sampleEnd, offset);
var snippet = text.substring(offset, end);
snippet = snippet.substring(0, snippet.lastIndexOf('\n'));
List<String> lines = snippet.split('\n');
var startLineNumber = commentLineStart +
text.substring(0, offset - 1).split('\n').length -
1;
if (codeFenceSuffix == "continued") {
if (samples.isEmpty) {
throw "Continued code block without previous code";
}
samples.last = samples.last.append(lines, startLineNumber);
} else {
final directives = Set.unmodifiable(codeFenceSuffix.split(' '));
samples.add(
CodeSample(
[for (var e in lines) ' ${cleanDocLine(e)}'],
coreLibName: coreLibName,
directives: directives,
lineStartOffset: startLineNumber,
),
);
}
offset = text.indexOf(sampleStart, offset);
}
}
// RegExp detecting various top-level declarations or `main(`.
//
// If the top-level declaration is `library` or `import`,
// then match 1 (`libdecl`) will be non-null.
// This is a sign that no auto-imports should be added.
//
// If an import declaration is included in the sample, no
// assumed-declarations are added.
// Use the `import:foo` template to import other `dart:` libraries
// instead of writing them explicitly to.
//
// Captures:
// 1/libdecl: Non-null if mathcing a `library` declaration.
// 2: Internal use, quote around import URI.
// 3/importuri: Import URI.
final _toplevelDeclarationRE = RegExp(r'^\s*(?:'
r'library\b(?<libdecl>)|'
r'''import (['"])(?<importuri>.*?)\2|'''
r'class\b|mixin\b|enum\b|extension\b|typedef\b|.*\bmain\('
r')');
validateCodeSample(CodeSample sample) async {
final lines = sample.lines;
// The default imports includes the library itself
// and any import directives.
Set<String> autoImports = sample.imports;
// One of 'none', 'top, 'main', or 'expression'.
String template;
bool hasImport = false;
final templateDirective = sample.templateDirective;
if (templateDirective != null) {
template = templateDirective;
} else {
// Scan lines for top-level declarations.
bool hasTopDeclaration = false;
bool hasLibraryDeclaration = false;
for (var line in lines) {
var topDeclaration = _toplevelDeclarationRE.firstMatch(line);
if (topDeclaration != null) {
hasTopDeclaration = true;
hasLibraryDeclaration |=
(topDeclaration.namedGroup("libdecl") != null);
var importDecl = topDeclaration.namedGroup("importuri");
if (importDecl != null) {
hasImport = true;
if (importDecl.startsWith('dart:')) {
// Remove explicit imports from automatic imports
// to avoid duplicate import warnings.
autoImports.remove(importDecl.substring('dart:'.length));
}
}
}
}
if (hasLibraryDeclaration) {
template = 'none';
} else if (hasTopDeclaration) {
template = 'top';
} else if (lines.length == 1 && !lines.first.contains(';')) {
// If single line with no `;`, assume expression.
template = 'expression';
} else {
// Otherwise default to `main`.
template = 'main';
}
}
var buffer = StringBuffer();
if (template != 'none') {
for (var library in autoImports) {
buffer.writeln("import 'dart:$library';");
}
if (!hasImport) {
buffer.write(sampleAssumptions ?? '');
}
}
if (template == 'none' || template == 'top') {
buffer.writeAllLines(lines);
} else if (template == 'main') {
buffer
..writeln('void main() async {')
..writeAllLines(lines)
..writeln('}');
} else if (template == 'expression') {
assert(lines.length >= 1);
buffer
..writeln('void main() async =>')
..writeAllLines(lines.take(lines.length - 1))
..writeln("${lines.last.trimRight()};");
} else {
throw 'unexpected template directive: $template';
}
final text = buffer.toString();
final result = await analysisHelper.resolveFile(text);
if (result is ResolvedUnitResult) {
var errors = SplayTreeSet<AnalysisError>.from(
result.errors,
(a, b) {
var value = a.offset.compareTo(b.offset);
if (value == 0) {
value = a.message.compareTo(b.message);
}
return value;
},
);
// Filter out unused imports, since we speculatively add imports to some
// samples.
errors.removeWhere(
(e) => e.errorCode == HintCode.UNUSED_IMPORT,
);
// Also, don't worry about 'unused_local_variable' and related; this may
// be intentional in samples.
errors.removeWhere(
(e) =>
e.errorCode == HintCode.UNUSED_LOCAL_VARIABLE ||
e.errorCode == HintCode.UNUSED_ELEMENT,
);
// Remove warnings about deprecated member use from the same library.
errors.removeWhere(
(e) =>
e.errorCode == HintCode.DEPRECATED_MEMBER_USE_FROM_SAME_PACKAGE ||
e.errorCode ==
HintCode.DEPRECATED_MEMBER_USE_FROM_SAME_PACKAGE_WITH_MESSAGE,
);
// Handle edge case around dart:_http
errors.removeWhere((e) {
if (e.message.contains("'dart:_http'")) {
return e.errorCode == HintCode.UNNECESSARY_IMPORT ||
e.errorCode == CompileTimeErrorCode.IMPORT_INTERNAL_LIBRARY;
}
return false;
});
if (errors.isNotEmpty) {
print('$filePath:${sample.lineStartOffset}: ${errors.length} errors');
hadErrors = true;
for (final error in errors) {
final location = result.lineInfo.getLocation(error.offset);
print(
' ${_severity(error.severity)}: ${error.message} '
'[$location] (${error.errorCode.name.toLowerCase()})',
);
}
print('');
// Print out the code sample.
print(sample.lines
.map((line) =>
' >${line.length >= 5 ? line.substring(5) : line.trimLeft()}')
.join('\n'));
print('');
}
} else {
throw 'unexpected result type: ${result}';
}
return;
}
}
String cleanDocLine(String line) {
var copy = line.trimLeft();
if (copy.startsWith('///')) {
copy = copy.substring(3);
} else if (copy.startsWith('*')) {
copy = copy.substring(1);
}
return copy.padLeft(line.length, ' ');
}
class CodeSample {
/// Currently valid template names.
static const validTemplates = ['none', 'top', 'main', 'expression'];
final String coreLibName;
final Set<String> directives;
final List<String> lines;
final int lineStartOffset;
CodeSample(
this.lines, {
required this.coreLibName,
this.directives = const {},
required this.lineStartOffset,
});
String get text => lines.join('\n');
bool get hasTemplateDirective => templateDirective != null;
/// The specified template, or `null` if no template is specified.
///
/// A specified template must be of [validTemplates].
String? get templateDirective {
const prefix = 'template:';
for (var directive in directives) {
if (directive.startsWith(prefix)) {
var result = directive.substring(prefix.length);
if (!validTemplates.contains(result)) {
throw "Invalid template name: $result";
}
return result;
}
}
return null;
}
/// The implicit or explicitly requested imports.
Set<String> get imports => {
if (coreLibName != 'internal' && coreLibName != 'core') coreLibName,
for (var directive in directives)
if (directive.startsWith('import:'))
directive.substring('import:'.length)
};
/// Creates a new code sample by appending [lines] to this sample.
///
/// The new sample only differs from this sample in that it has
/// more lines appended, first `this.lines`, then a gap of ` //` lines
/// and then [lines].
CodeSample append(List<String> lines, int lineStartOffset) {
var gapSize = lineStartOffset - (this.lineStartOffset + this.lines.length);
return CodeSample(
[...this.lines, for (var i = 0; i < gapSize; i++) " //", ...lines],
coreLibName: coreLibName,
directives: directives,
lineStartOffset: this.lineStartOffset);
}
}
/// Find and return any '// Examples can assume:' sample text.
String? findFileAssumptions(String text) {
var inAssumptions = false;
var assumptions = <String>[];
for (final line in text.split('\n')) {
if (line == '// Examples can assume:') {
inAssumptions = true;
} else if (line.trim().isEmpty && inAssumptions) {
inAssumptions = false;
} else if (inAssumptions) {
assumptions.add(line.substring('// '.length));
}
}
return '${assumptions.join('\n')}\n';
}
String _severity(Severity severity) {
switch (severity) {
case Severity.info:
return 'info';
case Severity.warning:
return 'warning';
case Severity.error:
return 'error';
}
}
class AnalysisHelper {
final String libraryName;
final resourceProvider =
OverlayResourceProvider(PhysicalResourceProvider.INSTANCE);
final pathRoot = Platform.isWindows ? r'c:\' : '/';
late AnalysisContextCollection collection;
int index = 0;
AnalysisHelper(this.libraryName) {
resourceProvider.pathContext;
collection = AnalysisContextCollection(
includedPaths: ['$pathRoot$libraryName'],
resourceProvider: resourceProvider,
);
}
Future<SomeResolvedUnitResult> resolveFile(String contents) async {
final samplePath =
'$pathRoot$libraryName${resourceProvider.pathContext.separator}'
'sample_${index++}.dart';
resourceProvider.setOverlay(
samplePath,
content: contents,
modificationStamp: 0,
);
var analysisContext = collection.contextFor(samplePath);
var analysisSession = analysisContext.currentSession;
return await analysisSession.getResolvedUnit(samplePath);
}
}
// Helper function to make things easier to read.
extension on StringBuffer {
/// Write every line, right-trimmed, of [lines] with a newline after.
void writeAllLines(Iterable<String> lines) {
for (var line in lines) {
this.writeln(line.trimRight());
}
}
}
/// Interprets [arg] as directory containing a platform library.
///
/// If [arg] is `dart:foo`, the directory is the default directory for
/// the `dart:foo` library source.
/// Otherwise, if [arg] is a directory (relative to the current directory)
/// which exists, that is the result.
/// Otherwise, if [arg] is the name of a platform library,
/// like `foo` where `dart:foo` is a platform library,
/// the result is the default directory for that library's source.
/// Otherwise it's treated as a directory relative to the current directory,
/// which doesn't exist (but that's what the error will refer to).
Directory parseArg(String arg) {
if (arg.startsWith('dart:')) {
return Directory(path.join(libDir.path, arg.substring('dart:'.length)));
}
var dir = Directory(arg);
if (dir.existsSync()) return dir;
var relDir = Directory(path.join(libDir.path, arg));
if (relDir.existsSync()) return relDir;
return dir;
}