blob: c23b102eaaf7ed7a00456ab4ec8891541f61699b [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.
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/dart/error/hint_codes.dart';
import 'package:analyzer/src/util/comment.dart';
import 'package:path/path.dart' as path;
void main(List<String> args) async {
final libDir = Directory('sdk/lib');
if (!libDir.existsSync()) {
print('Please run this tool from the root of the sdk repo.');
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((arg) => Directory(arg)).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));
final directives = Set.unmodifiable(codeFenceSuffix.trim().split(' '));
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');
samples.add(
CodeSample(
lines.map((e) => ' ${cleanDocLine(e)}').join('\n'),
coreLibName: coreLibName,
directives: directives,
lineStartOffset: commentLineStart +
text.substring(0, offset - 1).split('\n').length -
1,
),
);
offset = text.indexOf(sampleStart, offset);
}
}
Future validateCodeSample(CodeSample sample) async {
var text = sample.text;
final lines = sample.text.split('\n').map((l) => l.trim()).toList();
final hasImports = text.contains("import '") || text.contains('import "');
// One of 'none', 'main', or 'expression'.
String? template;
if (sample.hasTemplateDirective) {
template = sample.templateDirective;
} else {
// If there's no explicit template, auto-detect one.
if (lines.any((line) =>
line.startsWith('class ') ||
line.startsWith('enum ') ||
line.startsWith('extension '))) {
template = 'none';
} else if (lines
.any((line) => line.startsWith('main(') || line.contains(' main('))) {
template = 'none';
} else if (lines.length == 1 && !lines.first.trim().endsWith(';')) {
template = 'expression';
} else {
template = 'main';
}
}
final assumptions = sampleAssumptions ?? '';
if (!hasImports) {
if (template == 'none') {
// just use the sample text as is
} else if (template == 'main') {
text = "${assumptions}main() async {\n${text.trimRight()}\n}\n";
} else if (template == 'expression') {
text = "${assumptions}main() async {\n${text.trimRight()}\n;\n}\n";
} else {
throw 'unexpected template directive: $template';
}
for (final directive
in sample.directives.where((str) => str.startsWith('import:'))) {
final libName = directive.substring('import:'.length);
text = "import 'dart:$libName';\n$text";
}
if (sample.coreLibName != 'internal') {
text = "import 'dart:${sample.coreLibName}';\n$text";
}
}
final result = await analysisHelper.resolveFile(text);
if (result is ResolvedUnitResult) {
// Filter out unused imports, since we speculatively add imports to some
// samples.
var errors = result.errors.where(
(e) => e.errorCode != HintCode.UNUSED_IMPORT,
);
// Also, don't worry about 'unused_local_variable' and related; this may
// be intentional in samples.
errors = errors.where(
(e) =>
e.errorCode != HintCode.UNUSED_LOCAL_VARIABLE &&
e.errorCode != HintCode.UNUSED_ELEMENT,
);
// Remove warnings about deprecated member use from the same library.
errors = errors.where(
(e) =>
e.errorCode != HintCode.DEPRECATED_MEMBER_USE_FROM_SAME_PACKAGE &&
e.errorCode !=
HintCode.DEPRECATED_MEMBER_USE_FROM_SAME_PACKAGE_WITH_MESSAGE,
);
if (errors.isNotEmpty) {
print('$filePath:${sample.lineStartOffset}: ${errors.length} errors');
hadErrors = true;
errors = errors.toList()
..sort(
(a, b) => a.offset - b.offset,
);
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.text
.split('\n')
.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 {
final String coreLibName;
final Set<String> directives;
final String text;
final int lineStartOffset;
CodeSample(
this.text, {
required this.coreLibName,
this.directives = const {},
required this.lineStartOffset,
});
bool get hasTemplateDirective => templateDirective != null;
String? get templateDirective {
const prefix = 'template:';
String? match = directives.cast<String?>().firstWhere(
(directive) => directive!.startsWith(prefix),
orElse: () => null);
return match == null ? match : match.substring(prefix.length);
}
}
/// 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);
}
}