fb19233ff8
Change the `verify_docs` package's use of `DiagnosticCode.name` to `DiagnosticCode.lowerCaseName`. There is no functional change since all diagnostic codes generated by the analyzer are already in lower case. This helps pave the way for eventually deprecating and removing `DiagnosticCode.name`, which will help ensure that analyzer clients treat diagnostic codes in a case-insensitive fashion. Change-Id: I6a6a69645300e89b1bed342804d5e89ae9cf556b Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/468141 Reviewed-by: Nate Bosch <nbosch@google.com> Commit-Queue: Paul Berry <paulberry@google.com>
563 lines
17 KiB
Dart
Executable File
563 lines
17 KiB
Dart
Executable File
// 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:_fe_analyzer_shared/src/sdk/allowed_experiments.dart'; // ignore: implementation_imports
|
|
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/analysis/experiments.dart'; // ignore: implementation_imports
|
|
import 'package:analyzer/src/diagnostic/diagnostic.dart' // ignore: implementation_imports
|
|
as diag;
|
|
import 'package:analyzer/src/util/comment.dart'; // ignore: implementation_imports
|
|
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;
|
|
}
|
|
|
|
final Future<AllowedExperiments> allowedExperiments = () async {
|
|
final allowedExperimentsFile = File(
|
|
'sdk/lib/_internal/allowed_experiments.json',
|
|
);
|
|
final contents = await allowedExperimentsFile.readAsString();
|
|
return parseAllowedExperiments(contents);
|
|
}();
|
|
|
|
Future<bool> verifyFile(
|
|
AnalysisHelper analysisHelper,
|
|
String coreLibName,
|
|
File file,
|
|
Directory parent,
|
|
) async {
|
|
final text = file.readAsStringSync();
|
|
var parseResult = parseString(
|
|
content: text,
|
|
featureSet: FeatureSet.fromEnableFlags2(
|
|
sdkLanguageVersion: ExperimentStatus.currentVersion,
|
|
flags: (await allowedExperiments).sdkDefaultExperiments,
|
|
),
|
|
path: file.path,
|
|
throwIfDiagnostics: false,
|
|
);
|
|
|
|
// Throw if there are syntactic errors.
|
|
var syntacticErrors = parseResult.errors.where((error) {
|
|
return error.diagnosticCode.type == DiagnosticType.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) {
|
|
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 matching 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'final class\b|class\b|mixin\b|enum\b|extension\b|typedef\b|.*\bmain\('
|
|
r')',
|
|
);
|
|
|
|
Future<void> 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.isNotEmpty);
|
|
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 diagnostics =
|
|
SplayTreeSet<Diagnostic>.from(result.diagnostics, (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.
|
|
diagnostics.removeWhere((e) => e.diagnosticCode == diag.unusedImport);
|
|
|
|
// Also, don't worry about 'unused_local_variable' and related; this may
|
|
// be intentional in samples.
|
|
diagnostics.removeWhere(
|
|
(e) =>
|
|
e.diagnosticCode == diag.unusedLocalVariable ||
|
|
e.diagnosticCode == diag.unusedElement,
|
|
);
|
|
|
|
// Handle edge case around dart:_http
|
|
diagnostics.removeWhere((e) {
|
|
if (e.message.contains("'dart:_http'")) {
|
|
return e.diagnosticCode == diag.unnecessaryImport ||
|
|
e.diagnosticCode == diag.importInternalLibrary;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (diagnostics.isNotEmpty) {
|
|
print(
|
|
'$filePath:${sample.lineStartOffset}: ${diagnostics.length} errors');
|
|
|
|
hadErrors = true;
|
|
|
|
for (final diagnostic in diagnostics) {
|
|
final location = result.lineInfo.getLocation(diagnostic.offset);
|
|
print(
|
|
' ${_severity(diagnostic.severity)}: ${diagnostic.message} '
|
|
'[$location] (${diagnostic.diagnosticCode.lowerCaseName})',
|
|
);
|
|
}
|
|
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';
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
late final String separator = resourceProvider.pathContext.separator;
|
|
late final pathRoot = Directory('sdk${separator}lib$separator').absolute.path;
|
|
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$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) {
|
|
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;
|
|
}
|