[doc checker] Add support for repo-absolute paths

Markdown interprets /foo as relative to the repo root. This change makes
it so that such links are verified accordingly.

This change also makes all node labels source-absolute (e.g.,
//docs/foo), which will clarify the source of an error should one
present.

Tested locally; this change fixes a large number of false negatives.

Bug: INTK-932 #comment

Change-Id: I14cc9f836352f2d1fdde56dd7ac4412056e260a4
diff --git a/tools/doc_checker/bin/main.dart b/tools/doc_checker/bin/main.dart
index 2b2b11d..fabc53d 100644
--- a/tools/doc_checker/bin/main.dart
+++ b/tools/doc_checker/bin/main.dart
@@ -14,14 +14,23 @@
 import 'package:doc_checker/projects.dart';
 
 const String _optionHelp = 'help';
-const String _optionRootDir = 'root-dir';
+const String _optionRootDir = 'root';
+const String _optionProject = 'project';
 const String _optionDotFile = 'dot-file';
 
+// The fuchsia Gerrit host.
+const String _fuchsiaHost = 'fuchsia.googlesource.com';
+
+// Different ways of pointing to the master branch of a project in a Gerrit
+// link.
+const List<String> _masterSynonyms = ['master', 'refs/heads/master', 'HEAD'];
+
+// Documentation subdirectory to inspect.
+const String _docsDir = 'docs';
+
 void reportError(Error error) {
   String errorToString(ErrorType type) {
     switch (type) {
-      case ErrorType.convertPathToHttp:
-        return 'Convert path to http';
       case ErrorType.unknownLocalFile:
         return 'Linking to unknown file';
       case ErrorType.convertHttpToPath:
@@ -44,7 +53,6 @@
 }
 
 enum ErrorType {
-  convertPathToHttp,
   unknownLocalFile,
   convertHttpToPath,
   brokenLink,
@@ -65,6 +73,22 @@
   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) {
+  final int index = uri.pathSegments.indexOf('+');
+  if (index == -1 || index == uri.pathSegments.length - 1) {
+    return false;
+  }
+  final String subPath = uri.pathSegments.sublist(index + 1).join('/');
+  for (String branch in _masterSynonyms) {
+    if (subPath.startsWith(branch)) {
+      return true;
+    }
+  }
+  return false;
+}
+
 Future<Null> main(List<String> args) async {
   final ArgParser parser = ArgParser()
     ..addFlag(
@@ -74,8 +98,13 @@
     )
     ..addOption(
       _optionRootDir,
-      help: 'Path to the directory to inspect',
-      defaultsTo: 'docs',
+      help: 'Path to the root of the checkout',
+      defaultsTo: '.',
+    )
+    ..addOption(
+      _optionProject,
+      help: 'Name of the project being inspected',
+      defaultsTo: 'fuchsia',
     )
     ..addOption(
       _optionDotFile,
@@ -89,7 +118,9 @@
     return;
   }
 
-  final String docsDir = path.canonicalize(options[_optionRootDir]);
+  final String rootDir = path.canonicalize(options[_optionRootDir]);
+  final String docsProject = options[_optionProject];
+  final String docsDir = path.canonicalize(path.join(rootDir, _docsDir));
 
   final List<String> docs = Directory(docsDir)
       .listSync(recursive: true)
@@ -104,10 +135,11 @@
   final String readme = path.join(docsDir, 'README.md');
   final Graph graph = Graph();
   final List<Error> errors = <Error>[];
-  final List<Link<String>> linksToVerify = [];
+  final List<Link<String>> inTreeLinks = [];
+  final List<Link<String>> outOfTreeLinks = [];
 
   for (String doc in docs) {
-    final String label = path.relative(doc, from: docsDir);
+    final String label = '//${path.relative(doc, from: rootDir)}';
     final String baseDir = path.dirname(doc);
     final Node node = graph.getNode(label);
     if (doc == readme) {
@@ -121,54 +153,60 @@
         errors.add(Error(ErrorType.invalidUri, label, link));
         continue;
       }
-      if (uri.hasScheme) {
-        if (uri.scheme == 'http' || uri.scheme == 'https') {
-          bool shouldTestLink = true;
-          if (uri.authority == 'fuchsia.googlesource.com' &&
-              uri.pathSegments.isNotEmpty) {
 
-            final uriRelPath = uri.pathSegments.sublist(1).join('/');
-            if (path.isWithin(options[_optionRootDir], uriRelPath)) {
-              shouldTestLink = false;
-              errors.add(Error(
-                  ErrorType.convertHttpToPath, label, uri.toString()));
-            } else if (!validProjects.contains(uri.pathSegments[0])) {
-              shouldTestLink = false;
-              errors.add(
-                  Error(ErrorType.obsoleteProject, label, uri.toString()));
-            }
-          }
-          if (shouldTestLink) {
-            linksToVerify.add(Link(uri, label));
-          }
+      if (uri.hasScheme) {
+        if (uri.scheme != 'http' && uri.scheme != 'https') {
+          continue;
+        }
+        final bool onFuchsiaHost  = uri.authority == _fuchsiaHost;
+        final String project = uri.pathSegments.isEmpty? '' :
+            uri.pathSegments[0];
+        if (onFuchsiaHost && onGerritMaster(uri) && project == docsProject) {
+          errors.add(Error(
+              ErrorType.convertHttpToPath, label, uri.toString()));
+        } else if (onFuchsiaHost && !validProjects.contains(project)) {
+          errors.add(
+              Error(ErrorType.obsoleteProject, label, uri.toString()));
+        } else {
+            outOfTreeLinks.add(Link(uri, label));
         }
         continue;
       }
+
       final List<String> parts = link.split('#');
       final String location = parts[0];
       if (location.isEmpty) {
         continue;
       }
-      final String absoluteLocation = path.canonicalize(location.startsWith('/')
-          ? path.join(docsDir, location.substring(1))
-          : path.join(baseDir, location));
-      if (path.isWithin(docsDir, absoluteLocation)) {
-        final String relativeLocation =
-            path.relative(absoluteLocation, from: docsDir);
-        if (docs.contains(absoluteLocation)) {
-          graph.addEdge(from: node, to: graph.getNode(relativeLocation));
-        } else {
-          errors.add(
-              Error(ErrorType.unknownLocalFile, label, relativeLocation));
-        }
-      } else {
-        errors.add(Error(ErrorType.convertPathToHttp, label, location));
+
+      final String rootRelPath = location.startsWith('/') ?
+          location.substring(1):
+          path.relative(path.join(baseDir, location), from: rootDir);
+      final String absPath = path.join(rootDir, rootRelPath);
+      final String linkLabel = '//$rootRelPath';
+
+      if (docs.contains(absPath)) {
+        graph.addEdge(from: node, to: graph.getNode(linkLabel));
       }
+      final Uri localUri = Uri.parse('file://$absPath');
+      inTreeLinks.add(Link(localUri, label));
     }
   }
 
-  // Verify http links.
-  await verifyLinks(linksToVerify, (Link<String> link, bool isValid) {
+  // Verify http links pointing inside the tree just by checking to see if the
+  // path exists, as HTTP calls would be unnecessarily expensive here.
+  await Future.wait(inTreeLinks.map((Link<String> link) async {
+    final File possibleFile = File.fromUri(link.uri);
+    final Directory possibleDir = Directory.fromUri(link.uri);
+    if (!possibleFile.existsSync() && !possibleDir.existsSync()) {
+      errors.add(
+          Error(ErrorType.brokenLink, link.payload, link.toString()));
+    }
+    return null;
+  }));
+
+  // Verify http links pointing outside the tree.
+  await verifyLinks(outOfTreeLinks, (Link<String> link, bool isValid) {
     if (!isValid) {
       errors.add(
           Error(ErrorType.brokenLink, link.payload, link.uri.toString()));
@@ -178,7 +216,7 @@
   // Verify singletons and orphans.
   final List<Node> unreachable = graph.removeSingletons()
     ..addAll(
-        graph.orphans..removeWhere((Node node) => node.label == 'navbar.md'));
+        graph.orphans..removeWhere((Node node) => node.label == '//docs/navbar.md'));
   for (Node node in unreachable) {
     errors.add(Error.forProject(ErrorType.unreachablePage, node.label));
   }