Reactivate coverage tracking for dartdoc via coveralls (#1869)

* First try at enabling coveralls again for dartdoc

* dartfmt

* syntax error in travis.sh

* add quotes

* Fix coverage runner

* coveralls installation problem fix?

* coveralls is named coveralls

* Use relative package paths and otherwise fix up the format run

* Set up DART_VERSION correctly

* Work around dart-lang/coverage#239

* Don't try to run coverage on sdk-analyzer passes

* Add workarounds for bugs

* Drop almost everything from travis config to try to speed this up

* use packages flag instead of package-root

* Revert test code / travis changes

* dartfmt

* fix merge problem: flutter goes first
diff --git a/.gitignore b/.gitignore
index 9a076ca..a144cd7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
 .settings/
 build/
 doc/
+lcov.info
 packages
 pub.dartlang.org/
 testing/test_package/doc
diff --git a/README.md b/README.md
index f422274..7225a02 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
 # dartdoc
 
 [![Build Status](https://travis-ci.org/dart-lang/dartdoc.svg?branch=master)](https://travis-ci.org/dart-lang/dartdoc)
+[![Coverage Status](https://coveralls.io/repos/github/dart-lang/dartdoc/badge.svg?branch=master)](https://coveralls.io/github/dart-lang/dartdoc?branch=master)
+
 
 Use `dartdoc` to generate HTML documentaton for your Dart package.
 
diff --git a/bin/dartdoc.dart b/bin/dartdoc.dart
index 2b70ffa..3626038 100644
--- a/bin/dartdoc.dart
+++ b/bin/dartdoc.dart
@@ -38,7 +38,7 @@
 
 /// Analyzes Dart files and generates a representation of included libraries,
 /// classes, and members. Uses the current directory to look for libraries.
-void main(List<String> arguments) async {
+Future<void> main(List<String> arguments) async {
   DartdocOptionSet optionSet =
       await DartdocOptionSet.fromOptionGenerators('dartdoc', [
     createDartdocOptions,
@@ -52,17 +52,27 @@
   } on FormatException catch (e) {
     stderr.writeln(' fatal error: ${e.message}');
     stderr.writeln('');
-    _printUsageAndExit(optionSet.argParser, exitCode: 64);
+    _printUsage(optionSet.argParser);
+    // Do not use exit() as this bypasses --pause-isolates-on-exit
+    // TODO(jcollins-g): use exit once dart-lang/sdk#31747 is fixed.
+    exitCode = 64;
+    return;
   } on DartdocOptionError catch (e) {
     stderr.writeln(' fatal error: ${e.message}');
     stderr.writeln('');
-    _printUsageAndExit(optionSet.argParser, exitCode: 64);
+    _printUsage(optionSet.argParser);
+    exitCode = 64;
+    return;
   }
   if (optionSet['help'].valueAt(Directory.current)) {
-    _printHelpAndExit(optionSet.argParser);
+    _printHelp(optionSet.argParser);
+    exitCode = 0;
+    return;
   }
   if (optionSet['version'].valueAt(Directory.current)) {
-    _printVersionAndExit(optionSet.argParser);
+    _printVersion(optionSet.argParser);
+    exitCode = 0;
+    return;
   }
 
   DartdocProgramOptionContext config =
@@ -88,34 +98,37 @@
     }, onError: (e, Chain chain) {
       if (e is DartdocFailure) {
         stderr.writeln('\nGeneration failed: ${e}.');
-        exit(1);
+        exitCode = 1;
+        return;
       } else {
         stderr.writeln('\nGeneration failed: ${e}\n${chain.terse}');
-        exit(255);
+        exitCode = 255;
+        return;
       }
     }, when: config.asyncStackTraces);
   } finally {
     // Clear out any cached tool snapshots and temporary directories.
+    // ignore: unawaited_futures
     SnapshotCache.instance.dispose();
+    // ignore: unawaited_futures
     ToolTempFileTracker.instance.dispose();
   }
+  exitCode = 0;
+  return;
 }
 
 /// Print help if we are passed the help option.
-void _printHelpAndExit(ArgParser parser, {int exitCode: 0}) {
+void _printHelp(ArgParser parser) {
   print('Generate HTML documentation for Dart libraries.\n');
-  _printUsageAndExit(parser, exitCode: exitCode);
 }
 
 /// Print usage information on invalid command lines.
-void _printUsageAndExit(ArgParser parser, {int exitCode: 0}) {
+void _printUsage(ArgParser parser) {
   print('Usage: dartdoc [OPTIONS]\n');
   print(parser.usage);
-  exit(exitCode);
 }
 
 /// Print version information.
-void _printVersionAndExit(ArgParser parser) {
+void _printVersion(ArgParser parser) {
   print('dartdoc version: ${dartdocVersion}');
-  exit(exitCode);
 }
diff --git a/lib/src/io_utils.dart b/lib/src/io_utils.dart
index 79fc42a..92ce509 100644
--- a/lib/src/io_utils.dart
+++ b/lib/src/io_utils.dart
@@ -9,6 +9,7 @@
 import 'dart:convert';
 import 'dart:io';
 
+import 'package:dartdoc/src/tuple.dart';
 import 'package:path/path.dart' as pathLib;
 
 /// Return a resolved path including the home directory in place of tilde
@@ -116,6 +117,88 @@
   Future<void> wait() async => await _waitUntil(0);
 }
 
+/// Keeps track of coverage data automatically for any processes run by this
+/// [CoverageSubprocessLauncher].  Requires that these be dart processes.
+class CoverageSubprocessLauncher extends SubprocessLauncher {
+  CoverageSubprocessLauncher(String context, [Map<String, String> environment])
+      : super(context, environment);
+
+  static int nextObservatoryPort = 9292;
+
+  /// Set this to true to enable coverage runs.
+  static bool coverageEnabled = false;
+
+  /// A list of all coverage results picked up by all launchers.
+  static List<Tuple2<String, Future<Iterable<Map>>>> coverageResults = [];
+
+  static Directory _tempDir;
+  static Directory get tempDir =>
+      _tempDir ??= Directory.systemTemp.createTempSync('dartdoc_coverage_data');
+
+  int _observatoryPort;
+  // TODO(jcollins-g): use ephemeral ports
+  int get observatoryPort => _observatoryPort ??= nextObservatoryPort++;
+
+  String _outCoverageFilename;
+  String get outCoverageFilename => _outCoverageFilename ??=
+      pathLib.join(tempDir.path, 'dart-cov-0-${observatoryPort}.json');
+
+  /// Call once all coverage runs have been generated by calling runStreamed
+  /// on all [CoverageSubprocessLaunchers].
+  static Future<void> generateCoverageToFile(File outputFile) async {
+    if (!coverageEnabled) return Future.value(null);
+    var currentCoverageResults = coverageResults;
+    coverageResults = [];
+    var launcher = SubprocessLauncher('format_coverage');
+
+    /// Wait for all coverage runs to finish.
+    await Future.wait(currentCoverageResults.map((t) => t.item2));
+
+    return launcher.runStreamed(Platform.executable, [
+      'tool/format_coverage.dart', // TODO(jcollins-g): use pub after dart-lang/coverage#240 is landed
+      '--lcov',
+      '-v',
+      '-b', '.',
+      '--packages=.packages',
+      '--sdk-root=${pathLib.canonicalize(pathLib.join(pathLib.dirname(Platform.executable), '..'))}',
+      '--out=${pathLib.canonicalize(outputFile.path)}',
+      '--report-on=bin,lib',
+      '-i', tempDir.path,
+    ]);
+  }
+
+  @override
+  Future<Iterable<Map>> runStreamed(String executable, List<String> arguments,
+      {String workingDirectory}) {
+    assert(executable == Platform.executable,
+        'Must use dart executable for tracking coverage');
+
+    if (coverageEnabled) {
+      arguments = [
+        '--enable-vm-service=${observatoryPort}',
+        '--pause-isolates-on-exit'
+      ]..addAll(arguments);
+    }
+
+    Future<Iterable<Map>> results = super
+        .runStreamed(executable, arguments, workingDirectory: workingDirectory);
+
+    if (coverageEnabled) {
+      coverageResults.add(new Tuple2(
+          outCoverageFilename,
+          super.runStreamed('pub', [
+            'run',
+            'coverage:collect_coverage',
+            '--wait-paused',
+            '--resume-isolates',
+            '--port=${observatoryPort}',
+            '--out=${outCoverageFilename}',
+          ])));
+    }
+    return results;
+  }
+}
+
 class SubprocessLauncher {
   final String context;
   final Map<String, String> environment;
diff --git a/pubspec.yaml b/pubspec.yaml
index 54aa004..c04c02a 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -34,6 +34,7 @@
   build: ^1.0.1
   build_runner: ^1.0.0
   build_version: ^1.0.0
+  coverage: any
   dhttpd: ^3.0.0
   glob: ^1.1.5
   grinder: ^0.8.2
diff --git a/tool/format_coverage.dart b/tool/format_coverage.dart
new file mode 100644
index 0000000..4e65129
--- /dev/null
+++ b/tool/format_coverage.dart
@@ -0,0 +1,232 @@
+// Copyright (c) 2013, 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:async';
+import 'dart:io';
+
+import 'package:args/args.dart';
+import 'package:coverage/coverage.dart';
+import 'package:path/path.dart' as p;
+
+/// [Environment] stores gathered arguments information.
+class Environment {
+  String sdkRoot;
+  String pkgRoot;
+  String packagesPath;
+  String baseDirectory;
+  String input;
+  IOSink output;
+  List<String> reportOn;
+  String bazelWorkspace;
+  bool bazel;
+  int workers;
+  bool prettyPrint;
+  bool lcov;
+  bool expectMarkers;
+  bool verbose;
+}
+
+Future<Null> main(List<String> arguments) async {
+  final env = parseArgs(arguments);
+
+  List<File> files = filesToProcess(env.input);
+  if (env.verbose) {
+    print('Environment:');
+    print('  # files: ${files.length}');
+    print('  # workers: ${env.workers}');
+    print('  sdk-root: ${env.sdkRoot}');
+    print('  package-root: ${env.pkgRoot}');
+    print('  package-spec: ${env.packagesPath}');
+    print('  report-on: ${env.reportOn}');
+  }
+
+  var clock = new Stopwatch()..start();
+  var hitmap = await parseCoverage(files, env.workers);
+
+  // All workers are done. Process the data.
+  if (env.verbose) {
+    print('Done creating global hitmap. Took ${clock.elapsedMilliseconds} ms.');
+  }
+
+  String output;
+  var resolver = env.bazel
+      ? new BazelResolver(workspacePath: env.bazelWorkspace)
+      : new Resolver(
+          packagesPath: env.packagesPath,
+          packageRoot: env.pkgRoot,
+          sdkRoot: env.sdkRoot);
+  var loader = new Loader();
+  if (env.prettyPrint) {
+    output =
+        await new PrettyPrintFormatter(resolver, loader, reportOn: env.reportOn)
+            .format(hitmap);
+  } else {
+    assert(env.lcov);
+    output = await new LcovFormatter(resolver,
+            reportOn: env.reportOn, basePath: env.baseDirectory)
+        .format(hitmap);
+  }
+
+  env.output.write(output);
+  await env.output.flush();
+  if (env.verbose) {
+    print('Done flushing output. Took ${clock.elapsedMilliseconds} ms.');
+  }
+
+  if (env.verbose) {
+    if (resolver.failed.length > 0) {
+      print('Failed to resolve:');
+      for (String error in resolver.failed.toSet()) {
+        print('  $error');
+      }
+    }
+    if (loader.failed.length > 0) {
+      print('Failed to load:');
+      for (String error in loader.failed.toSet()) {
+        print('  $error');
+      }
+    }
+  }
+  await env.output.close();
+}
+
+/// Checks the validity of the provided arguments. Does not initialize actual
+/// processing.
+Environment parseArgs(List<String> arguments) {
+  final env = new Environment();
+  var parser = new ArgParser();
+
+  parser.addOption('sdk-root', abbr: 's', help: 'path to the SDK root');
+  parser.addOption('package-root', abbr: 'p', help: 'path to the package root');
+  parser.addOption('packages', help: 'path to the package spec file');
+  parser.addOption('in', abbr: 'i', help: 'input(s): may be file or directory');
+  parser.addOption('out',
+      abbr: 'o', defaultsTo: 'stdout', help: 'output: may be file or stdout');
+  parser.addMultiOption('report-on',
+      help: 'which directories or files to report coverage on');
+  parser.addOption('workers',
+      abbr: 'j', defaultsTo: '1', help: 'number of workers');
+  parser.addOption('bazel-workspace',
+      defaultsTo: '', help: 'Bazel workspace directory');
+  parser.addOption('base-directory',
+      abbr: 'b',
+      help: 'the base directory relative to which source paths are output');
+  parser.addFlag('bazel',
+      defaultsTo: false, help: 'use Bazel-style path resolution');
+  parser.addFlag('pretty-print',
+      abbr: 'r',
+      negatable: false,
+      help: 'convert coverage data to pretty print format');
+  parser.addFlag('lcov',
+      abbr: 'l',
+      negatable: false,
+      help: 'convert coverage data to lcov format');
+  parser.addFlag('verbose',
+      abbr: 'v', negatable: false, help: 'verbose output');
+  parser.addFlag('help', abbr: 'h', negatable: false, help: 'show this help');
+
+  var args = parser.parse(arguments);
+
+  void printUsage() {
+    print('Usage: dart format_coverage.dart [OPTION...]\n');
+    print(parser.usage);
+  }
+
+  void fail(String msg) {
+    print('\n$msg\n');
+    printUsage();
+    exit(1);
+  }
+
+  if (args['help']) {
+    printUsage();
+    exit(0);
+  }
+
+  env.sdkRoot = args['sdk-root'];
+  if (env.sdkRoot != null) {
+    env.sdkRoot = p.normalize(p.join(p.absolute(env.sdkRoot), 'lib'));
+    if (!FileSystemEntity.isDirectorySync(env.sdkRoot)) {
+      fail('Provided SDK root "${args["sdk-root"]}" is not a valid SDK '
+          'top-level directory');
+    }
+  }
+
+  if (args['package-root'] != null && args['packages'] != null) {
+    fail('Only one of --package-root or --packages may be specified.');
+  }
+
+  env.packagesPath = args['packages'];
+  if (env.packagesPath != null) {
+    if (!FileSystemEntity.isFileSync(env.packagesPath)) {
+      fail('Package spec "${args["packages"]}" not found, or not a file.');
+    }
+  }
+
+  env.pkgRoot = args['package-root'];
+  if (env.pkgRoot != null) {
+    env.pkgRoot = p.absolute(p.normalize(args['package-root']));
+    if (!FileSystemEntity.isDirectorySync(env.pkgRoot)) {
+      fail('Package root "${args["package-root"]}" is not a directory.');
+    }
+  }
+
+  if (args['in'] == null) fail('No input files given.');
+  env.input = p.absolute(p.normalize(args['in']));
+  if (!FileSystemEntity.isDirectorySync(env.input) &&
+      !FileSystemEntity.isFileSync(env.input)) {
+    fail('Provided input "${args["in"]}" is neither a directory nor a file.');
+  }
+
+  if (args['out'] == 'stdout') {
+    env.output = stdout;
+  } else {
+    var outpath = p.absolute(p.normalize(args['out']));
+    var outfile = new File(outpath)..createSync(recursive: true);
+    env.output = outfile.openWrite();
+  }
+
+  env.reportOn = args['report-on'].isNotEmpty ? args['report-on'] : null;
+
+  env.bazel = args['bazel'];
+  env.bazelWorkspace = args['bazel-workspace'];
+  if (env.bazelWorkspace.isNotEmpty && !env.bazel) {
+    stderr.writeln('warning: ignoring --bazel-workspace: --bazel not set');
+  }
+
+  if (args['base-directory'] != null) {
+    env.baseDirectory = p.absolute(args['base-directory']);
+  }
+
+  env.lcov = args['lcov'];
+  if (args['pretty-print'] && env.lcov) {
+    fail('Choose one of pretty-print or lcov output');
+  }
+  // Use pretty-print either explicitly or by default.
+  env.prettyPrint = !env.lcov;
+
+  try {
+    env.workers = int.parse('${args["workers"]}');
+  } catch (e) {
+    fail('Invalid worker count: $e');
+  }
+
+  env.verbose = args['verbose'];
+  return env;
+}
+
+/// Given an absolute path absPath, this function returns a [List] of files
+/// are contained by it if it is a directory, or a [List] containing the file if
+/// it is a file.
+List<File> filesToProcess(String absPath) {
+  var filePattern = new RegExp(r'^dart-cov-\d+-\d+.json$');
+  if (FileSystemEntity.isDirectorySync(absPath)) {
+    return new Directory(absPath)
+        .listSync(recursive: true)
+        .whereType<File>()
+        .where((e) => filePattern.hasMatch(p.basename(e.path)))
+        .toList();
+  }
+  return <File>[new File(absPath)];
+}
diff --git a/tool/grind.dart b/tool/grind.dart
index 614bc94..9f2546e 100644
--- a/tool/grind.dart
+++ b/tool/grind.dart
@@ -852,18 +852,20 @@
 Future<void> testDart2() async {
   List<String> parameters = ['--enable-asserts'];
 
+  CoverageSubprocessLauncher.coverageEnabled =
+      Platform.environment.containsKey('COVERAGE_TOKEN');
   for (File dartFile in testFiles) {
-    await testFutures.addFutureFromClosure(() =>
-        new SubprocessLauncher('dart2-${pathLib.basename(dartFile.path)}')
-            .runStreamed(
-                Platform.resolvedExecutable,
-                <String>[]
-                  ..addAll(parameters)
-                  ..add(dartFile.path)));
+    await testFutures.addFutureFromClosure(() => new CoverageSubprocessLauncher(
+            'dart2-${pathLib.basename(dartFile.path)}')
+        .runStreamed(
+            Platform.resolvedExecutable,
+            <String>[]
+              ..addAll(parameters)
+              ..add(dartFile.path)));
   }
 
   for (File dartFile in binFiles) {
-    await testFutures.addFutureFromClosure(() => new SubprocessLauncher(
+    await testFutures.addFutureFromClosure(() => new CoverageSubprocessLauncher(
             'dart2-bin-${pathLib.basename(dartFile.path)}-help')
         .runStreamed(
             Platform.resolvedExecutable,
@@ -872,6 +874,9 @@
               ..add(dartFile.path)
               ..add('--help')));
   }
+
+  return await CoverageSubprocessLauncher.generateCoverageToFile(
+      new File('lcov.info'));
 }
 
 @Task('Generate docs for dartdoc')
diff --git a/tool/install_travis.sh b/tool/install_travis.sh
index c3bdc15..91ff42c 100755
--- a/tool/install_travis.sh
+++ b/tool/install_travis.sh
@@ -8,7 +8,10 @@
 set -x
 
 if uname | grep -q Linux ; then
+  sudo apt-get update
   sudo apt-get install -y gdb
+  sudo gem install coveralls-lcov
+  coveralls-lcov --help
 fi
 
 exit 0
diff --git a/tool/travis.sh b/tool/travis.sh
index 4bdc78b..c26fcef 100755
--- a/tool/travis.sh
+++ b/tool/travis.sh
@@ -9,6 +9,7 @@
 
 # add globally activated packages to the path
 export PATH="$PATH":"~/.pub-cache/bin"
+DART_VERSION=`dart --version 2>&1 | awk '{print $4}'`
 
 if [ "$DARTDOC_BOT" = "sdk-docs" ]; then
   # Build the SDK docs
@@ -22,7 +23,6 @@
   pub run grinder validate-flutter-docs
 elif [ "$DARTDOC_BOT" = "packages" ]; then
   echo "Running packages dartdoc bot"
-  DART_VERSION=`dart --version 2>&1 | awk '{print $4}'`
   if [ ${DART_VERSION} != 2.0.0 ] ; then
     PACKAGE_NAME=angular PACKAGE_VERSION=">=5.1.0" DARTDOC_PARAMS="--include=angular,angular.security" pub run grinder build-pub-package
   else
@@ -34,8 +34,13 @@
   PACKAGE_NAME=shelf_exception_handler PACKAGE_VERSION=">=0.2.0" pub run grinder build-pub-package
 elif [ "$DARTDOC_BOT" = "sdk-analyzer" ]; then
   echo "Running main dartdoc bot against the SDK analyzer"
+  unset COVERAGE_TOKEN
   DARTDOC_GRIND_STEP=buildbot-no-publish pub run grinder test-with-analyzer-sdk
 else
   echo "Running main dartdoc bot"
   pub run grinder buildbot
+  if [ -n "$COVERAGE_TOKEN" ] && [ "${DART_VERSION}" != "2.1.0" ] && uname | grep -q Linux ; then
+    # Only attempt to upload coverage data for dev builds.
+    coveralls-lcov --repo-token="${COVERAGE_TOKEN}" lcov.info
+  fi
 fi