| // 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. | |
| final rulesUri = Uri.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]. | |
| /// | |
| /// 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(rulesUri)).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; | |
| } |