[doc_checker] Adding filename to errors & check images

Adds a check for image sources.

Also adds the name of the yaml file being checked
making the error message more actionable.

Change-Id: I14762199cf05637dc070ab2a9342871bc99020ff
diff --git a/tools/doc_checker/bin/main.dart b/tools/doc_checker/bin/main.dart
index e92a8f9..c55558b 100644
--- a/tools/doc_checker/bin/main.dart
+++ b/tools/doc_checker/bin/main.dart
@@ -8,6 +8,7 @@
 import 'package:args/args.dart';
 import 'package:path/path.dart' as path;
 
+import 'package:doc_checker/errors.dart';
 import 'package:doc_checker/graph.dart';
 import 'package:doc_checker/link_scraper.dart';
 import 'package:doc_checker/link_verifier.dart';
@@ -56,28 +57,6 @@
   print('${errorToString(error.type).padRight(25)}: ${error.content}$location');
 }
 
-enum ErrorType {
-  unknownLocalFile,
-  convertHttpToPath,
-  brokenLink,
-  unreachablePage,
-  obsoleteProject,
-  invalidMenu,
-  invalidUri,
-}
-
-class Error {
-  final ErrorType type;
-  final String location;
-  final String content;
-
-  Error(this.type, this.location, this.content);
-
-  Error.forProject(this.type, this.content) : location = null;
-
-  bool get hasLocation => location != null;
-}
-
 // Checks whether the URI points to the master branch of a Gerrit (i.e.,
 // googlesource.com) project.
 bool onGerritMaster(Uri uri) {
@@ -244,8 +223,10 @@
 
   YamlChecker checker = YamlChecker(rootDir, rootYaml, yamls, docs);
   await checker.check();
-  for (var msg in checker.errors) {
-    errors.add(Error(ErrorType.invalidMenu, null, msg));
+  List<Error> yamlErrors = checker.errors;
+
+  if (yamlErrors.isNotEmpty) {
+    errors.addAll(yamlErrors);
   }
 
   errors
diff --git a/tools/doc_checker/lib/errors.dart b/tools/doc_checker/lib/errors.dart
new file mode 100644
index 0000000..555bfdf
--- /dev/null
+++ b/tools/doc_checker/lib/errors.dart
@@ -0,0 +1,25 @@
+// 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.
+
+enum ErrorType {
+  unknownLocalFile,
+  convertHttpToPath,
+  brokenLink,
+  unreachablePage,
+  obsoleteProject,
+  invalidMenu,
+  invalidUri,
+}
+
+class Error {
+  final ErrorType type;
+  final String location;
+  final String content;
+
+  Error(this.type, this.location, this.content);
+
+  Error.forProject(this.type, this.content) : location = null;
+
+  bool get hasLocation => location != null;
+}
diff --git a/tools/doc_checker/lib/link_scraper.dart b/tools/doc_checker/lib/link_scraper.dart
index c491a50..191a6b7 100644
--- a/tools/doc_checker/lib/link_scraper.dart
+++ b/tools/doc_checker/lib/link_scraper.dart
@@ -28,6 +28,8 @@
 
 class _Visitor implements NodeVisitor {
   static const String _key = 'href';
+  static const String _imgSrc = 'src';
+  static const String _imgTag = 'img';
 
   final Set<String> links = <String>{};
 
@@ -35,6 +37,9 @@
   bool visitElementBefore(Element element) {
     if (element.attributes.containsKey(_key)) {
       links.add(element.attributes[_key]);
+    } else if (element.tag == _imgTag &&
+        element.attributes.containsKey(_imgSrc)) {
+      links.add(element.attributes[_imgSrc]);
     }
     return true;
   }
diff --git a/tools/doc_checker/lib/yaml_checker.dart b/tools/doc_checker/lib/yaml_checker.dart
index 7b6a178..80b0436 100644
--- a/tools/doc_checker/lib/yaml_checker.dart
+++ b/tools/doc_checker/lib/yaml_checker.dart
@@ -7,6 +7,8 @@
 
 import 'package:yaml/yaml.dart';
 
+import 'package:doc_checker/errors.dart';
+
 /// Loads yaml files to verify they are
 /// compatible with fuchsia.dev hosting.
 class YamlChecker {
@@ -16,7 +18,7 @@
   String _rootDir;
 
   // List of errors found.
-  List<String> errors = <String>[];
+  List<Error> errors = <Error>[];
 
   static const List<String> validStatusValues = <String>[
     'alpha',
@@ -65,12 +67,14 @@
     if (_rootYaml != null) {
       File f = File(_rootYaml);
       f.readAsString().then((String data) {
-        parse(loadYamlDocuments(data));
+        parse(loadYamlDocuments(data), _rootYaml);
         for (String s in _yamlSet) {
-          errors.add('unreferenced yaml $s');
+          errors
+              .add(Error(ErrorType.invalidMenu, null, 'unreferenced yaml $s'));
         }
         for (String s in _mdSet) {
-          errors.add('File $s not referenced in any yaml file');
+          errors.add(Error(ErrorType.invalidMenu, null,
+              'File $s not referenced in any yaml file'));
         }
         completer.complete(errors.isEmpty);
       });
@@ -82,14 +86,14 @@
   }
 
   /// Parses the yaml structure.
-  void parse(List<YamlDocument> doc) {
+  void parse(List<YamlDocument> doc, String filename) {
     for (YamlDocument d in doc) {
-      validateTopLevel(d.contents.value);
+      validateTopLevel(d.contents.value, filename);
     }
   }
 
   /// Validates the top level of the menu.
-  void validateTopLevel(YamlMap map) {
+  void validateTopLevel(YamlMap map, String filename) {
     for (String key in map.keys) {
       switch (key) {
         case 'guides':
@@ -98,12 +102,13 @@
         case 'reference':
         case 'toc':
           {
-            validateTocElement(map[key]);
+            validateTocElement(map[key], filename);
           }
           break;
         default:
           {
-            errors.add('Unknown top level key: $key');
+            errors.add(Error(ErrorType.invalidMenu, filename,
+                'Unknown top level key: $key'));
             break;
           }
       }
@@ -111,28 +116,30 @@
   }
 
   /// Validates the toc element in the menu.
-  void validateTocElement(YamlNode val) {
+  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);
+          validateContentMap(node, filename);
         } else {
-          errors.add('Unexpected node of type: ${node.runtimeType} $node');
+          errors.add(Error(ErrorType.invalidMenu, filename,
+              'Unexpected node of type: ${node.runtimeType} $node'));
         }
       }
     } else {
-      errors.add('Expected a list, got ${val.runtimeType}: $val');
+      errors.add(Error(ErrorType.invalidMenu, filename,
+          'Expected a list, got ${val.runtimeType}: $val'));
     }
   }
 
   /// Validates the content of toc.
-  void validateContentMap(YamlMap map) {
+  void validateContentMap(YamlMap map, String filename) {
     for (String key in map.keys) {
       switch (key) {
         case 'alternate_paths':
           {
-            validatePathList(map[key]);
+            validatePathList(map[key], filename);
           }
           break;
         case 'break':
@@ -140,43 +147,46 @@
           {
             // only include if break : true.
             if (map[key] != true) {
-              errors.add('$key should only be included when set to true');
+              errors.add(Error(ErrorType.invalidMenu, filename,
+                  '$key should only be included when set to true'));
             }
           }
           break;
         case 'section':
         case 'contents':
           {
-            validateTocElement(map[key]);
+            validateTocElement(map[key], filename);
           }
           break;
         case 'include':
           {
-            processIncludedFile(map[key]);
+            processIncludedFile(map[key], filename);
           }
           break;
         case 'path':
           {
             String menuPath = map[key];
-            if (validatePath(menuPath)) {
+            if (validatePath(menuPath, filename)) {
               // If the path is to a file, check that the file exists.
               // TODO(wilkinsonclay): check the URLs are valid.
               if (!menuPath.startsWith('https://') &&
                   !menuPath.startsWith('//')) {
-                checkFileExists(menuPath);
+                checkFileExists(menuPath, filename);
               }
             }
           }
           break;
         case 'path_attributes':
           {
-            errors.add('path_attributes not supported on fuchsia.dev');
+            errors.add(Error(ErrorType.invalidMenu, filename,
+                'path_attributes not supported on fuchsia.dev'));
           }
           break;
         case 'status':
           {
             if (!validStatusValues.contains(map[key])) {
-              errors.add('invalid status value of ${map[key]}');
+              errors.add(Error(ErrorType.invalidMenu, filename,
+                  'invalid status value of ${map[key]}'));
             }
           }
           break;
@@ -184,28 +194,28 @@
           {
             // make sure there is no section in this group.
             if (map.containsKey('section')) {
-              errors.add(
-                  'invalid use of \'step_group\'. Group cannot also contain \`section\`');
+              errors.add(Error(ErrorType.invalidMenu, filename,
+                  'invalid use of \'step_group\'. Group cannot also contain \`section\`'));
             }
             if (!(map[key] is String)) {
-              errors.add(
-                  'invalid value of \'step_group\'. Expected String got ${map[key].runtimeType} ${map[key]}');
+              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(
-                  'invalid use of \'style\'. Group cannot also contain `break` nor `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(
-                  'invalid value of `style`. Expected `accordion` or `divider`');
+              errors.add(Error(ErrorType.invalidMenu, filename,
+                  'invalid value of `style`. Expected `accordion` or `divider`'));
             }
             if (!map.containsKey('heading') && !map.containsKey('section')) {
-              errors.add(
-                  'invalid use of \'style\'. Group must also have `heading` or `section`.');
+              errors.add(Error(ErrorType.invalidMenu, filename,
+                  'invalid use of \'style\'. Group must also have `heading` or `section`.'));
             }
           }
           break;
@@ -214,36 +224,38 @@
         case 'title':
           {
             if (map[key].runtimeType != String) {
-              errors
-                  .add('Expected String for $key, got ${map[key].runtimeType}');
+              errors.add(Error(ErrorType.invalidMenu, filename,
+                  'Expected String for $key, got ${map[key].runtimeType}'));
             }
           }
           break;
         default:
-          errors.add('Unknown Content Key $key');
+          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) {
-    if (validatePath(menuPath)) {
+  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()));
+        parse(loadYamlDocuments(File(filePath).readAsStringSync()), menuPath);
         _yamlSet.remove(filePath);
       } on Exception catch (exception) {
-        errors.add(exception.toString());
+        errors.add(
+            Error(ErrorType.invalidMenu, parentFilename, exception.toString()));
       }
     }
   }
 
   /// Validates a list of paths are valid paths for the menu.
-  bool validatePathList(List<String> paths) {
+  bool validatePathList(List<String> paths, String filename) {
     for (String s in paths) {
-      if (!validatePath(s)) {
+      if (!validatePath(s, filename)) {
         return false;
       }
     }
@@ -256,14 +268,15 @@
   /// 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) {
+  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('Path needs to start with \'/docs\', got $menuPath');
+      errors.add(Error(ErrorType.invalidMenu, filename,
+          'Path needs to start with \'/docs\', got $menuPath'));
       return false;
     }
 
@@ -273,7 +286,7 @@
   /// 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) {
+  void checkFileExists(String menuPath, String filename) {
     String filePath = '$_rootDir$menuPath';
 
     if (File(filePath).existsSync()) {
@@ -284,7 +297,8 @@
       if (File(filePath).existsSync()) {
         _mdSet.remove(filePath);
       } else {
-        errors.add('Invalid menu path $menuPath not found');
+        errors.add(Error(ErrorType.invalidMenu, filename,
+            'Invalid menu path $menuPath not found'));
       }
     }
   }
diff --git a/tools/doc_checker/test/link_scraper_test.dart b/tools/doc_checker/test/link_scraper_test.dart
index bb431d2..a05afca 100644
--- a/tools/doc_checker/test/link_scraper_test.dart
+++ b/tools/doc_checker/test/link_scraper_test.dart
@@ -16,11 +16,13 @@
       final List<String> lines = [
         'this text has no links',
         'this one [does](link.md).',
-        'but not *this one*'
+        'but not *this one*',
+        'images are scraped ![text](image.png)'
       ];
       Iterable<String> links = LinkScraper().scrapeLines(lines);
-      expect(links, hasLength(1));
+      expect(links, hasLength(2));
       expect(links.first, equals('link.md'));
+      expect(links.last, equals('image.png'));
     });
   });
 }
diff --git a/tools/doc_checker/test/yaml_checker_test.dart b/tools/doc_checker/test/yaml_checker_test.dart
index f06dfe4..3cba4f2 100644
--- a/tools/doc_checker/test/yaml_checker_test.dart
+++ b/tools/doc_checker/test/yaml_checker_test.dart
@@ -84,8 +84,8 @@
       bool ret = await checker.check();
 
       expect(ret, equals(false));
-      expect(checker.errors[0], equals('Unknown Content Key tile'));
-      expect(checker.errors[1], equals('Unknown Content Key filepath'));
+      expect(checker.errors[0].content, equals('Unknown Content Key tile'));
+      expect(checker.errors[1].content, equals('Unknown Content Key filepath'));
       expect(checker.errors.length, equals(2));
     });
 
@@ -115,7 +115,7 @@
           file.absolute.path, [file.absolute.path], []);
       bool ret = await checker.check();
       expect(ret, equals(false));
-      expect(checker.errors[0],
+      expect(checker.errors[0].content,
           equals('Path needs to start with \'/docs\', got /src/ref/1.cc'));
       expect(checker.errors.length, equals(1));
     });
@@ -168,7 +168,7 @@
           file.absolute.path, [file.absolute.path], []);
       bool ret = await checker.check();
       expect(ret, equals(false));
-      expect(checker.errors[0],
+      expect(checker.errors[0].content,
           equals('Path needs to start with \'/docs\', got /src/sample/1.c'));
     });
 
@@ -187,10 +187,9 @@
       expect(ret, equals(false));
       expect(checker.errors.length, equals(1));
       expect(
-          checker.errors[0],
+          checker.errors[0].content,
           startsWith(
               'FileSystemException: Cannot open file, path = \'${Directory.systemTemp.path}/docs/_included_notfound.yaml\''));
-
     });
 
     // Test when there is a directory with the same name and there is a README.md in that