blob: 0ce6dc7c9472c2edecb92d452e5075027f37e6e4 [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. 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:async';
import 'dart:io';
import 'package:path/path.dart' as path;
import 'package:yaml/yaml.dart';
import 'package:doc_checker/errors.dart';
import 'package:doc_checker/link_verifier.dart';
/// Verify yaml files.
/// _toc.yaml files are verified for fuchsia.dev hosting compliance,
/// while other yaml files are just checked for regular yaml syntax.
class YamlChecker {
Set<String> _tocYamlSet;
Set<String> _otherYamlSet;
Set<String> _mdSet;
String _rootYaml;
String _rootDir;
// List of errors found.
final List<Error> errors = <Error>[];
final List<Link<String>> outOfTreeLinks = <Link<String>>[];
static const List<String> validStatusValues = <String>[
'alpha',
'beta',
'deprecated',
'experimental',
'external',
'limited',
'new'
];
/// Creates a new instance of YamlChecker.
///
/// [rootDir] is the parent directory of 'docs'. This is used
/// to locate the files on the filesystem.
/// [rootYaml] is the fuchsia.dev compliant yaml file to start the
/// checking from.
/// [yamls] is the list of .yaml files to check.
/// [mdFiles] is the list of .md files to make sure they are
/// referenced in the yaml files.
YamlChecker(String rootDir, String rootYaml, List<String> yamls,
List<String> mdFiles) {
_rootYaml = rootYaml;
_rootDir = rootDir;
if (rootYaml != null && !_isFuchsiaDevYaml(rootYaml)) {
throw AssertionError(
'If specified, root YAML file needs to be named "_toc.yaml": $rootYaml');
}
_tocYamlSet = <String>{}
..addAll(yamls.where(_isFuchsiaDevYaml))
..remove(_rootYaml);
_otherYamlSet = <String>{}
..addAll(yamls.where((yaml) => !_isFuchsiaDevYaml(yaml)))
..remove(_rootYaml);
_mdSet = <String>{}
..addAll(filterHidden(mdFiles))
// Remove navbar.md since it is only used on fuchsia.googlesource.com.
..remove('$_rootDir/docs/navbar.md')
// Remove docs/gen/build_arguments.md since it is generated, it is
// accessed as a source file and not published.
..remove('$_rootDir/docs/gen/build_arguments.md');
}
static bool _isFuchsiaDevYaml(String filename) {
return path.basename(filename) == '_toc.yaml';
}
/// Filters out paths that are hidden names according to
/// https://developers.google.com/devsite/reference/filenames?hl=en#hidden_files_single_underscore.
/// This function expects the input paths to already be canonicalized.
List<String> filterHidden(List<String> mdFiles) {
return mdFiles
.where((doc) => !path
.split(path.relative(doc, from: _rootDir))
.any((component) => component.startsWith('_')))
.toList();
}
/// Checks the validity of the yaml files.
/// Returns true if no errors are found.
/// Errors can be retrieved via the [errors]
/// property.
Future<bool> check() {
var checks = <Future>[];
if (_rootYaml != null) {
File f = File(_rootYaml);
checks.add(f.readAsString().then((String data) {
parse(loadYamlDocuments(data), _rootYaml);
for (String s in _tocYamlSet) {
errors
.add(Error(ErrorType.invalidMenu, null, 'unreferenced yaml $s'));
}
for (String s in _mdSet) {
errors.add(Error(ErrorType.unreachablePage, null,
'File $s not referenced in any yaml file'));
}
}));
}
for (String s in _otherYamlSet) {
File f = File(s);
checks.add(f.readAsString().then((String data) {
try {
loadYamlDocuments(data);
} on YamlException catch (e) {
errors.add(Error(ErrorType.unparseableYaml, null, '$s: $e'));
}
}));
}
return Future.wait(checks).then((value) => errors.isEmpty);
}
/// Parses the yaml structure.
void parse(List<YamlDocument> doc, String filename) {
for (YamlDocument d in doc) {
validateTopLevel(d.contents.value, filename);
}
}
/// Validates the top level of the menu.
void validateTopLevel(YamlMap map, String filename) {
for (String key in map.keys) {
switch (key) {
case 'guides':
case 'samples':
case 'support':
case 'reference':
case 'toc':
{
validateTocElement(map[key], filename);
}
break;
default:
{
errors.add(Error(ErrorType.invalidMenu, filename,
'Unknown top level key: $key'));
break;
}
}
}
}
/// Validates the toc element in the menu.
void validateTocElement(YamlNode val, String filename) {
// valid entries are documented at
if (val is YamlList) {
for (YamlNode node in val) {
if (node.runtimeType == YamlMap) {
validateContentMap(node, filename);
} else {
errors.add(Error(ErrorType.invalidMenu, filename,
'Unexpected node of type: ${node.runtimeType} $node'));
}
}
} else {
errors.add(Error(ErrorType.invalidMenu, filename,
'Expected a list, got ${val.runtimeType}: $val'));
}
}
/// Validates the content of toc.
void validateContentMap(YamlMap map, String filename) {
for (String key in map.keys) {
switch (key) {
case 'alternate_paths':
{
validatePathList(map[key], filename);
}
break;
case 'break':
case 'skip_translation':
{
// only include if break : true.
if (map[key] != true) {
errors.add(Error(ErrorType.invalidMenu, filename,
'$key should only be included when set to true'));
}
}
break;
case 'section':
case 'contents':
{
validateTocElement(map[key], filename);
}
break;
case 'include':
{
processIncludedFile(map[key], filename);
}
break;
case 'path':
{
String menuPath = map[key];
if (validatePath(menuPath, filename)) {
// If the path is to a file, check that the file exists.
if (!menuPath.startsWith('https://') &&
!menuPath.startsWith('//')) {
checkFileExists(menuPath, filename);
} else {
Uri uri;
try {
if (menuPath.startsWith('//')) {
uri = Uri.parse('https:$menuPath');
} else {
uri = Uri.parse(menuPath);
}
} on FormatException {
errors.add(Error(ErrorType.invalidUri, filename, menuPath));
continue;
}
outOfTreeLinks.add(Link(uri, filename));
}
}
}
break;
case 'path_attributes':
{
errors.add(Error(ErrorType.invalidMenu, filename,
'path_attributes not supported on fuchsia.dev'));
}
break;
case 'status':
{
if (!validStatusValues.contains(map[key])) {
errors.add(Error(ErrorType.invalidMenu, filename,
'invalid status value of ${map[key]}'));
}
}
break;
case 'step_group':
{
// make sure there is no section in this group.
if (map.containsKey('section')) {
errors.add(Error(ErrorType.invalidMenu, filename,
'invalid use of \'step_group\'. Group cannot also contain \`section\`'));
}
if (!(map[key] is String)) {
errors.add(Error(ErrorType.invalidMenu, filename,
'invalid value of \'step_group\'. Expected String got ${map[key].runtimeType} ${map[key]}'));
}
}
break;
case 'style':
{
if (map.containsKey('break') || map.containsKey('include')) {
errors.add(Error(ErrorType.invalidMenu, filename,
'invalid use of \'style\'. Group cannot also contain `break` nor `include`'));
}
if (map[key] != 'accordion' && map[key] != 'divider') {
errors.add(Error(ErrorType.invalidMenu, filename,
'invalid value of `style`. Expected `accordion` or `divider`'));
}
if (!map.containsKey('heading') && !map.containsKey('section')) {
errors.add(Error(ErrorType.invalidMenu, filename,
'invalid use of \'style\'. Group must also have `heading` or `section`.'));
}
}
break;
case 'heading':
case 'name':
case 'title':
{
if (map[key].runtimeType != String) {
errors.add(Error(ErrorType.invalidMenu, filename,
'Expected String for $key, got ${map[key].runtimeType}'));
}
}
break;
default:
errors.add(Error(
ErrorType.invalidMenu, filename, 'Unknown Content Key $key'));
}
}
}
/// Handles an included yaml file. It is validated completely then returns.
void processIncludedFile(String menuPath, String parentFilename) {
if (validatePath(menuPath, parentFilename)) {
String filePath = '$_rootDir$menuPath';
// parse in a try/catch to handle any syntax errors reading the included file.
try {
parse(loadYamlDocuments(File(filePath).readAsStringSync()), menuPath);
_tocYamlSet.remove(filePath);
} on Exception catch (exception) {
errors.add(
Error(ErrorType.invalidMenu, parentFilename, exception.toString()));
}
}
}
/// Validates a list of paths are valid paths for the menu.
bool validatePathList(List<String> paths, String filename) {
for (String s in paths) {
if (!validatePath(s, filename)) {
return false;
}
}
return true;
}
/// Validates the path is valid for the menu.
///
/// Valid paths are /docs/* and
/// http URLs. Exceptions are made for
/// CONTRIBUTING.md and CODE_OF_CONDUCT.md which
/// are in the root of the project.
bool validatePath(String menuPath, String filename) {
if (!menuPath.startsWith('/docs') &&
menuPath != '/CONTRIBUTING.md' &&
menuPath != '/CODE_OF_CONDUCT.md' &&
!menuPath.startsWith('http://') &&
!menuPath.startsWith('https://') &&
!menuPath.startsWith('//')) {
errors.add(Error(ErrorType.invalidMenu, filename,
'Path needs to start with \'/docs\', got $menuPath'));
return false;
}
return true;
}
/// Check that the file pointed to by the path exists.
/// if it does, remove the file from the mdSet to keep track of
/// which files are referenced.
void checkFileExists(String menuPath, String filename) {
String filePath = '$_rootDir$menuPath';
if (File(filePath).existsSync()) {
_mdSet.remove(filePath);
} else {
// could be a directory, so add README.md.
filePath = '$filePath/README.md';
if (File(filePath).existsSync()) {
_mdSet.remove(filePath);
} else {
errors.add(Error(ErrorType.invalidMenu, filename,
'Invalid menu path $menuPath not found'));
}
}
}
}