blob: 54a99adacd7a35ea83a8fb6bd14644acfea6c6d1 [file] [log] [blame]
// Copyright (c) 2023, 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:convert';
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart' as yaml;
import 'package:http/http.dart' as http;
/// Source of truth for linter rules.
const rulesUrl =
'https://raw.githubusercontent.com/dart-lang/site-www/main/src/_data/linter_rules.json';
/// Local cache of linter rules from [rulesUrl].
///
/// Relative to package root.
const rulesCacheFilePath = 'tool/rules.json';
/// Generated rules documentation markdown file.
///
/// Relative to package root.
const rulesMarkdownFilePath = 'rules.md';
/// Fetches the [rulesUrl] JSON description of all lints, saves a cached
/// summary of the relevant fields in [rulesCacheFilePath], and
/// updates [rulesMarkdownFilePath] to
///
/// Passing any command line argument disables generating documentation,
/// and makes this tool just verify that the doc is up-to-date with the
/// [rulesCacheFilePath]. (Which it always should be, since the two
/// are saved at the same time.)
void main(List<String> args) async {
final verifyOnly = args.isNotEmpty;
// Read lint rules.
final rulesJson = await _fetchRulesJson(verifyOnly: verifyOnly);
// Read existing generated Markdown documentation.
final rulesMarkdownFile = _packageRelativeFile(rulesMarkdownFilePath);
final rulesMarkdownContent = rulesMarkdownFile.readAsStringSync();
if (verifyOnly) {
print('Validating that ${rulesMarkdownFile.path} is up-to-date ...');
} else {
print('Regenerating ${rulesMarkdownFile.path} ...');
}
// Generate new documentation.
var newRulesMarkdownContent = _updateMarkdown(
rulesMarkdownContent,
rulesJson,
);
// If no documentation change, all is up-to-date.
if (newRulesMarkdownContent == rulesMarkdownContent) {
print('${rulesMarkdownFile.path} is up-to-date.');
return;
}
/// Documentation has changed.
if (verifyOnly) {
print(
'${rulesMarkdownFile.path} is not up-to-date (lint tables need to be '
'regenerated).',
);
print('');
print("Run 'dart tool/gen_docs.dart' to re-generate.");
exit(1);
} else {
// Save [rulesMarkdownFilePath].
rulesMarkdownFile.writeAsStringSync(newRulesMarkdownContent);
print('Wrote ${rulesMarkdownFile.path}.');
}
}
/// Fetches or load the JSON lint rules.
///
/// If [verifyOnly] is `false`, fetches JSON from [rulesUrl],
/// extracts the needed information, and writes a summary to
/// [rulesCacheFilePath].
///
/// If [verifyOnly] is `true`, only reads the cached data back from
/// [rulesCacheFilePath].
Future<Map<String, Map<String, String>>> _fetchRulesJson({
required bool verifyOnly,
}) async {
final rulesJsonFile = _packageRelativeFile(rulesCacheFilePath);
if (verifyOnly) {
final rulesJsonText = rulesJsonFile.readAsStringSync();
return _readJson(rulesJsonText);
}
final rulesJsonText = (await http.get(Uri.parse(rulesUrl))).body;
final rulesJson = _readJson(rulesJsonText);
// Re-save [rulesJsonFile] file.
var newRulesJson = [...rulesJson.values];
rulesJsonFile.writeAsStringSync(
JsonEncoder.withIndent(' ').convert(newRulesJson),
);
return rulesJson;
}
/// Extracts relevant information from a list of JSON objects.
///
/// For each JSON object, includes only the relevant (string-typed) properties,
/// then creates a map indexed by the `'name'` property of the objects.
Map<String, Map<String, String>> _readJson(String rulesJsonText) {
/// Relevant keys in the JSON information about lints.
const relevantKeys = {'name', 'description', 'fixStatus'};
final rulesJson = jsonDecode(rulesJsonText) as List<dynamic>;
return {
for (Map<String, Object?> rule in rulesJson)
rule['name'] as String: {
for (var key in relevantKeys) key: rule[key] as String,
},
};
}
/// Inserts new Markdown content for both rule sets into [content].
///
/// For both "core" and "recommended" rule sets,
/// replaces the table between the two `<!-- core -->` and the two
/// `<!-- recommended -->` markers with a new table generated from
/// [rulesJson], based on the list of rules in `lib/core.yaml` and
/// `lib/recommended.yaml`.
String _updateMarkdown(
String content,
Map<String, Map<String, String>> rulesJson,
) {
for (var ruleSetName in ['core', 'recommended']) {
var ruleFile = _packageRelativeFile(p.join('lib', '$ruleSetName.yaml'));
var ruleSet = _parseRules(ruleFile);
final rangeDelimiter = '<!-- $ruleSetName -->\n';
var rangeStart = content.indexOf(rangeDelimiter) + rangeDelimiter.length;
var rangeEnd = content.indexOf(rangeDelimiter, rangeStart);
if (rangeEnd < 0) {
stderr.writeln('Missing "$rangeDelimiter" in $rulesMarkdownFilePath.');
continue;
}
content = content.replaceRange(
rangeStart,
rangeEnd,
_createRuleTable(ruleSet, rulesJson),
);
}
return content;
}
/// Parses analysis options YAML file, and extracts linter rules.
List<String> _parseRules(File yamlFile) {
var yamlData = yaml.loadYaml(yamlFile.readAsStringSync()) as Map;
var linterEntry = yamlData['linter'] as Map;
return List<String>.from(linterEntry['rules'] as List);
}
/// Creates markdown source for a table of lint rules.
String _createRuleTable(
List<String> rules,
Map<String, Map<String, String>> lintMeta,
) {
rules.sort();
final lines = [
'| Lint Rules | Description | [Fix][] |',
'| :--------- | :---------- | ------- |',
for (var rule in rules) _createRuleTableRow(rule, lintMeta),
];
return '${lines.join('\n')}\n';
}
/// Creates a line containing the markdown table row for a single lint rule.
///
/// Used by [_createRuleTable] for each row in the generated table.
/// The row should have the same number of entires as the table format,
/// and should be on a single line with no newline at the end.
String _createRuleTableRow(
String rule,
Map<String, Map<String, String>> lintMeta,
) {
final ruleMeta = lintMeta[rule];
if (ruleMeta == null) {
stderr.writeln("WARNING: Missing rule information for rule: $rule");
}
final description = (ruleMeta?['description'] ?? '')
.replaceAll('\n', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
final hasFix = ruleMeta?['fixStatus'] == 'hasFix';
final fixDesc = hasFix ? '✅' : '';
return '| [`$rule`](https://dart.dev/lints/$rule) | '
'$description | $fixDesc |';
}
/// A path relative to the root of this package.
///
/// Works independently of the current working directory.
/// Is based on the location of this script, through [Platform.script].
File _packageRelativeFile(String packagePath) =>
File(p.join(_packageRoot, packagePath));
/// Cached package root used by [_packageRelative].
final String _packageRoot = _relativePackageRoot();
/// A path to the package root from the current directory.
///
/// If the current directory is inside the package, the returned path is
/// a relative path of a number of `..` segments.
/// If the current directory is outside of the package, the returned path
/// may be absolute.
String _relativePackageRoot() {
var rootPath = p.dirname(p.dirname(Platform.script.path));
if (p.isRelative(rootPath)) return rootPath;
var baseDir = p.current;
if (rootPath == baseDir) return '';
if (baseDir.startsWith(rootPath)) {
var backSteps = <String>[];
do {
backSteps.add('..');
baseDir = p.dirname(baseDir);
} while (baseDir != rootPath);
return p.joinAll(backSteps);
}
return rootPath;
}