From e70e72b4b97a7015a1ce4c416f28f6a393c5ce30 Mon Sep 17 00:00:00 2001
From: Jacob MacDonald <jakemac@google.com>
Date: Fri, 28 Apr 2017 07:36:38 -0700
Subject: [PATCH] Add a module computer class which can compute modules for a
 package (#1574)

* add `_ModuleComputer` class and top level `computeModules` function.
  * uses tarjans algorithm to create strongly connected components first
  * then groups modules based on the entry points that import them
* move InMemoryModuleConfigManager to util.dart
* add equalsModule as a proper matcher, and makeAssets test utility
* add isPart function to lib/src/dart.dart and add proper support for part files
---
 lib/src/barback.dart                          |  49 +++
 lib/src/barback/dartdevc/module.dart          |   5 +
 lib/src/barback/dartdevc/module_computer.dart | 406 ++++++++++++++++++
 lib/src/dart.dart                             |   4 +
 lib/src/io.dart                               |  18 +
 .../dartdevc/module_computer_test.dart        | 406 ++++++++++++++++++
 test/barback/dartdevc/module_reader_test.dart |  30 +-
 test/barback/dartdevc/module_test.dart        |   4 +-
 test/barback/dartdevc/util.dart               |  87 +++-
 test/barback_test.dart                        |  61 +++
 test/io_test.dart                             |  17 +
 11 files changed, 1046 insertions(+), 41 deletions(-)
 create mode 100644 lib/src/barback/dartdevc/module_computer.dart
 create mode 100644 test/barback/dartdevc/module_computer_test.dart
 create mode 100644 test/barback_test.dart

diff --git a/lib/src/barback.dart b/lib/src/barback.dart
index 21ca99a5..b539d6b3 100644
--- a/lib/src/barback.dart
+++ b/lib/src/barback.dart
@@ -6,6 +6,8 @@ import 'package:barback/barback.dart';
 import 'package:path/path.dart' as p;
 import 'package:pub_semver/pub_semver.dart';
 
+import 'io.dart';
+
 /// The currently supported versions of packages that this version of pub works
 /// with.
 ///
@@ -87,3 +89,50 @@ AssetId packagesUrlToId(Uri url) {
   var assetPath = p.url.join("lib", p.url.joinAll(parts.skip(index + 2)));
   return new AssetId(package, assetPath);
 }
+
+/// Convert [importUri] found in [source] to an [AssetId], handling both
+/// `package:` imports and relative imports.
+///
+/// Returns [null] for `dart:` uris since they cannot be referenced properly
+/// by an `AssetId`.
+///
+/// Throws an [ArgumentError] if an [AssetId] can otherwise not be created. This
+/// might happen if:
+///
+/// * [importUri] is absolute but has a scheme other than `dart:` or `package:`.
+/// * [importUri] is a relative path that reaches outside of the current top
+///   level directory of a package (relative import from `web` to `lib` for
+///   instance).
+AssetId importUriToAssetId(AssetId source, String importUri) {
+  var parsedUri = Uri.parse(importUri);
+  if (parsedUri.isAbsolute) {
+    switch (parsedUri.scheme) {
+      case 'package':
+        var parts = parsedUri.pathSegments;
+        var packagePath = p.url.joinAll(['lib']..addAll(parts.skip(1)));
+        if (!p.isWithin('lib', packagePath)) {
+          throw new ArgumentError(
+              'Unable to create AssetId for import `$importUri` in `$source` '
+              'because it reaches outside the `lib` directory.');
+        }
+        return new AssetId(parts.first, packagePath);
+      case 'dart':
+        return null;
+      default:
+        throw new ArgumentError(
+            'Unable to resolve import. Only package: paths and relative '
+            'paths are supported, got `$importUri`.');
+    }
+  } else {
+    // Relative path.
+    var targetPath =
+        p.url.normalize(p.url.join(p.url.dirname(source.path), parsedUri.path));
+    var dir = topLevelDir(source.path);
+    if (!p.isWithin(dir, targetPath)) {
+      throw new ArgumentError(
+          'Unable to create AssetId for relative import `$importUri` in '
+          '`$source`  because it reaches outside the `$dir` directory.');
+    }
+    return new AssetId(source.package, targetPath);
+  }
+}
diff --git a/lib/src/barback/dartdevc/module.dart b/lib/src/barback/dartdevc/module.dart
index 63158027..4577174f 100644
--- a/lib/src/barback/dartdevc/module.dart
+++ b/lib/src/barback/dartdevc/module.dart
@@ -43,6 +43,11 @@ class Module {
         assetIds.map((id) => id.serialize()).toList(),
         directDependencies.map((d) => d.serialize()).toList(),
       ];
+
+  String toString() => '''
+$id
+assetIds: $assetIds
+directDependencies: $directDependencies''';
 }
 
 /// Serializable identifier of a [Module].
diff --git a/lib/src/barback/dartdevc/module_computer.dart b/lib/src/barback/dartdevc/module_computer.dart
new file mode 100644
index 00000000..dbfd0158
--- /dev/null
+++ b/lib/src/barback/dartdevc/module_computer.dart
@@ -0,0 +1,406 @@
+// Copyright (c) 2017, 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:collection';
+import 'dart:math';
+
+import 'package:analyzer/analyzer.dart';
+import 'package:barback/barback.dart';
+import 'package:path/path.dart' as p;
+
+import 'module.dart';
+import '../../barback.dart';
+import '../../io.dart';
+import '../../dart.dart' show isEntrypoint, isPart;
+
+/// There are two "types" of modules, `public` and `private`.
+///
+/// The `public` mode requires that all files are under `lib`.
+///
+/// The `private` mode requires that no files are under `lib`. All files must
+/// still live under some shared top level directory.
+enum ModuleMode {
+  public,
+  private,
+}
+
+/// Computes the [Module]s for [srcAssets], or throws an [ArgumentError] if the
+/// configuration is invalid.
+///
+/// All entrypoints are guaranteed their own [Module], unless they are in a
+/// strongly connected component with another entrypoint in which case a
+/// single [Module] is created for the strongly connected component.
+///
+/// Note that only entrypoints are guaranteed to exist in any [Module], if
+/// an asset exists in [assetIds] but is not reachable from any entrypoint
+/// then it will not be contained in any [Module].
+///
+/// An entrypoint is defined as follows:
+///
+///   * In [ModuleMode.public], any asset under "lib" but not "lib/src".
+///
+///   * In [ModuleMode.private], any asset for which [isEntrypoint] returns
+///     `true` (for the parsed ast).
+///
+/// It is guaranteed that no asset will be added to more than one [Module].
+Future<List<Module>> computeModules(
+    ModuleMode mode, Iterable<Asset> srcAssets) async {
+  var dir = topLevelDir(srcAssets.first.id.path);
+
+  // Validate `srcAssets`, must be non-empty and all under the same dir.
+  if (srcAssets.isEmpty)
+    throw new ArgumentError('Got unexpected empty `srcs`.');
+  if (!srcAssets.every((src) => topLevelDir(src.id.path) == dir)) {
+    throw new ArgumentError(
+        'All srcs must live in the same top level directory.');
+  }
+
+  // Validate that the `mode` and `srcAssets` agree.
+  switch (mode) {
+    case ModuleMode.public:
+      if (dir != 'lib') {
+        throw new ArgumentError(
+            'In `ModuleMode.public` all sources must be under `lib`, but the '
+            'given `srcs` are under `$dir`.');
+      }
+      break;
+    case ModuleMode.private:
+      if (dir == 'lib') {
+        throw new ArgumentError(
+            'In `ModuleMode.private` no sources may be under `lib`, but the '
+            'given `srcs` are.');
+      }
+      break;
+  }
+
+  // The set of entry points from `srcAssets` based on `mode`.
+  var entryIds = new Set<AssetId>();
+  // All the `srcAssets` that are part files.
+  var partIds = new Set<AssetId>();
+  // Invalid assets that should be removed from `srcAssets` after this loop.
+  var idsToRemove = <AssetId>[];
+  var parsedAssetsById = <AssetId, CompilationUnit>{};
+  for (var asset in srcAssets) {
+    var id = asset.id;
+    var content = await asset.readAsString();
+    var parsed = parseCompilationUnit(content,
+        name: id.path, parseFunctionBodies: false, suppressErrors: true);
+    parsedAssetsById[id] = parsed;
+
+    // Skip any files which contain a `dart:_` import.
+    if (parsed.directives.any((d) =>
+        d is UriBasedDirective && d.uri.stringValue.startsWith('dart:_'))) {
+      idsToRemove.add(asset.id);
+      continue;
+    }
+
+    // Short-circuit for part files.
+    if (isPart(parsed)) {
+      partIds.add(asset.id);
+      continue;
+    }
+
+    switch (mode) {
+      case ModuleMode.public:
+        if (!id.path.startsWith('lib/src/')) entryIds.add(id);
+        break;
+      case ModuleMode.private:
+        if (isEntrypoint(parsed)) entryIds.add(id);
+        break;
+    }
+  }
+
+  srcAssets = srcAssets.where((asset) => !idsToRemove.contains(asset.id));
+
+  // Build the `_AssetNode`s for each asset, skipping part files.
+  var nodesById = <AssetId, _AssetNode>{};
+  var srcAssetIds = srcAssets.map((asset) => asset.id).toSet();
+  var nonPartAssets = srcAssets.where((asset) => !partIds.contains(asset.id));
+  for (var asset in nonPartAssets) {
+    var node = new _AssetNode.forParsedUnit(
+        asset.id, parsedAssetsById[asset.id], srcAssetIds);
+    nodesById[asset.id] = node;
+  }
+
+  return new _ModuleComputer(entryIds, mode, nodesById)._computeModules();
+}
+
+/// An [AssetId] and all of its internal/external deps based on it's
+/// [Directive]s.
+///
+/// Used to compute strongly connected components in the import graph for all
+/// "internal" deps. Any "external" deps are ignored during that computation
+/// since they are not allowed to be in a strongly connected component with
+/// internal deps.
+///
+/// External deps are used to compute the dependent modules of each module once
+/// the modules are decided.
+///
+/// Part files are also tracked here but ignored during computation of strongly
+/// connected components, as they must always be a part of this assets module.
+class _AssetNode {
+  final AssetId id;
+
+  /// The other internal sources that this file import or exports.
+  ///
+  /// These may be merged into the same [Module] as this node, and are used when
+  /// computing strongly connected components.
+  final Set<AssetId> internalDeps;
+
+  /// Part files included by this asset.
+  ///
+  /// These should always be a part of the same connected component.
+  final Set<AssetId> parts;
+
+  /// The deps of this source that are from an external package.
+  ///
+  /// These are not used in computing strongly connected components (they are
+  /// not allowed to be in a strongly connected component with any of our
+  /// internal srcs).
+  final Set<AssetId> externalDeps;
+
+  /// Order in which this node was discovered.
+  int discoveryIndex;
+
+  /// Lowest discoveryIndex for any node this is connected to.
+  int lowestLinkedDiscoveryIndex;
+
+  _AssetNode(this.id, this.internalDeps, this.parts, this.externalDeps);
+
+  /// Creates an [_AssetNode] for [id] given a parsed [CompilationUnit] and some
+  /// [internalSrcs] which represent other assets that may become part of the
+  /// same module.
+  factory _AssetNode.forParsedUnit(
+      AssetId id, CompilationUnit parsed, Set<AssetId> internalSrcs) {
+    var externalDeps = new Set<AssetId>();
+    var internalDeps = new Set<AssetId>();
+    var parts = new Set<AssetId>();
+    for (var directive in parsed.directives) {
+      if (directive is! UriBasedDirective) continue;
+      var linkedId = importUriToAssetId(
+          id, (directive as UriBasedDirective).uri.stringValue);
+      if (linkedId == null) continue;
+      if (directive is PartDirective) {
+        if (!internalSrcs.contains(linkedId)) {
+          throw new StateError(
+              'Referenced part file $linkedId from $id which is not in the '
+              'same package');
+        }
+        parts.add(linkedId);
+      } else if (internalSrcs.contains(linkedId)) {
+        internalDeps.add(linkedId);
+      } else {
+        externalDeps.add(linkedId);
+      }
+    }
+    return new _AssetNode(id, internalDeps, parts, externalDeps);
+  }
+}
+
+/// Computes the ideal set of [Module]s for a group of [_AssetNode]s.
+class _ModuleComputer {
+  final Set<AssetId> entrypoints;
+  final ModuleMode mode;
+  final Map<AssetId, _AssetNode> nodesById;
+
+  _ModuleComputer(this.entrypoints, this.mode, this.nodesById);
+
+  /// Does the actual computation of [Module]s.
+  ///
+  /// See [computeModules] top level function for more information.
+  Future<List<Module>> _computeModules() async {
+    var connectedComponents = _stronglyConnectedComponents();
+    var modulesById = _createModulesFromComponents(connectedComponents);
+    return _mergeModules(modulesById);
+  }
+
+  /// Creates simple modules based strictly off of [connectedComponents].
+  ///
+  /// This creates more modules than we want, but we collapse them later on.
+  Map<ModuleId, Module> _createModulesFromComponents(
+      Iterable<Set<_AssetNode>> connectedComponents) {
+    var modules = <ModuleId, Module>{};
+    for (var componentNodes in connectedComponents) {
+      // Name components based on first alphabetically sorted node, preferring
+      // public srcs (not under lib/src).
+      var sortedNodes = componentNodes.toList()
+        ..sort((a, b) => a.id.path.compareTo(b.id.path));
+      var primaryNode = sortedNodes.firstWhere(
+          (node) => !node.id.path.startsWith('lib/src/'),
+          orElse: () => sortedNodes.first);
+      var moduleName =
+          p.url.split(p.withoutExtension(primaryNode.id.path)).join('__');
+      var id = new ModuleId(primaryNode.id.package, moduleName);
+      // Expand to include all the part files of each node, these aren't
+      // included as individual `_AssetNodes`s in `connectedComponents`.
+      var allAssetIds = componentNodes
+          .expand((node) => [node.id]..addAll(node.parts))
+          .toSet();
+      var allDepIds = new Set<AssetId>();
+      for (var node in componentNodes) {
+        allDepIds.addAll(node.externalDeps);
+        for (var id in node.internalDeps) {
+          if (allAssetIds.contains(id)) continue;
+          allDepIds.add(id);
+        }
+      }
+      var module = new Module(id, allAssetIds, allDepIds);
+      modules[module.id] = module;
+    }
+    return modules;
+  }
+
+  /// Filters [modules] to just those that contain entrypoint assets.
+  Set<ModuleId> _entryPointModules(Iterable<Module> modules) {
+    var entrypointModules = new Set<ModuleId>();
+    for (var module in modules) {
+      if (module.assetIds.intersection(entrypoints).isNotEmpty) {
+        entrypointModules.add(module.id);
+      }
+    }
+    return entrypointModules;
+  }
+
+  /// Creates a map of modules to the entrypoint modules that transitively
+  /// depend on those modules.
+  Map<ModuleId, Set<ModuleId>> _findReverseEntrypointDeps(
+      Set<ModuleId> entrypointModules, Map<ModuleId, Module> modulesById) {
+    var reverseDeps = <ModuleId, Set<ModuleId>>{};
+    var assetsToModules = <AssetId, Module>{};
+    for (var module in modulesById.values) {
+      for (var assetId in module.assetIds) {
+        assetsToModules[assetId] = module;
+      }
+    }
+    for (var id in entrypointModules) {
+      for (var moduleDep
+          in _localTransitiveDeps(modulesById[id], assetsToModules)) {
+        reverseDeps.putIfAbsent(moduleDep, () => new Set<ModuleId>()).add(id);
+      }
+    }
+    return reverseDeps;
+  }
+
+  /// Gets the local (same top level dir of the same package) transitive deps of
+  /// [module] using [assetsToModules].
+  Set<ModuleId> _localTransitiveDeps(
+      Module module, Map<AssetId, Module> assetsToModules) {
+    var localTransitiveDeps = new Set<ModuleId>();
+    var nextIds = module.directDependencies;
+    var seenIds = new Set<AssetId>();
+    while (nextIds.isNotEmpty) {
+      var ids = nextIds;
+      seenIds.addAll(ids);
+      nextIds = new Set<AssetId>();
+      for (var id in ids) {
+        var module = assetsToModules[id];
+        if (module == null) continue; // Skip non-local modules
+        if (localTransitiveDeps.add(module.id)) {
+          nextIds.addAll(module.directDependencies.difference(seenIds));
+        }
+      }
+    }
+    return localTransitiveDeps;
+  }
+
+  /// Merges [originalModulesById] into a minimum set of [Module]s using the
+  /// following rules:
+  ///
+  ///   * If it is an entrypoint module, skip it.
+  ///   * Else merge it into a module whose name is a combination of all the
+  ///     entrypoints that import it (create that module if it doesn't exist).
+  List<Module> _mergeModules(Map<ModuleId, Module> originalModulesById) {
+    var modulesById = new Map<ModuleId, Module>.from(originalModulesById);
+
+    // Maps modules to entrypoint modules that transitively depend on them.
+    var entrypointModuleIds = _entryPointModules(modulesById.values);
+    var modulesToEntryPoints =
+        _findReverseEntrypointDeps(entrypointModuleIds, modulesById);
+
+    for (var moduleId in modulesById.keys.toList()) {
+      // Skip entrypoint modules.
+      if (entrypointModuleIds.any((id) => id == moduleId)) continue;
+
+      // The entry points that transitively import this module.
+      var entrypointIds = modulesToEntryPoints[moduleId];
+      if (entrypointIds == null || entrypointIds.isEmpty) {
+        throw new StateError(
+            'Internal error, found a module that is not depended on by any '
+            'entrypoints. Please file an issue at '
+            'https://github.com/dart-lang/pub/issues/new');
+      }
+
+      // Create a new module based off the name of all entrypoints or merge into
+      // an existing one by that name.
+      var moduleNames = entrypointIds.map((id) => id.name).toList()..sort();
+      var newModuleId =
+          new ModuleId(entrypointIds.first.package, moduleNames.join('\$'));
+      var newModule = modulesById.putIfAbsent(
+          newModuleId,
+          () =>
+              new Module(newModuleId, new Set<AssetId>(), new Set<AssetId>()));
+
+      var oldModule = modulesById.remove(moduleId);
+      // Add all the original assets and deps to the new module.
+      newModule.assetIds.addAll(oldModule.assetIds);
+      newModule.directDependencies.addAll(oldModule.directDependencies);
+      // Clean up deps to remove assetIds, they may have been merged in.
+      newModule.directDependencies.removeAll(newModule.assetIds);
+    }
+
+    return modulesById.values.toList();
+  }
+
+  /// Computes the strongly connected components reachable from [entrypoints].
+  List<Set<_AssetNode>> _stronglyConnectedComponents() {
+    var currentDiscoveryIndex = 0;
+    // [LinkedHashSet] maintains insertion order which is important!
+    var nodeStack = new LinkedHashSet<_AssetNode>();
+    var connectedComponents = <Set<_AssetNode>>[];
+
+    void stronglyConnect(_AssetNode node) {
+      node.discoveryIndex = currentDiscoveryIndex;
+      node.lowestLinkedDiscoveryIndex = currentDiscoveryIndex;
+      currentDiscoveryIndex++;
+      nodeStack.add(node);
+
+      for (var dep in node.internalDeps) {
+        var depNode = nodesById[dep];
+        if (depNode.discoveryIndex == null) {
+          stronglyConnect(depNode);
+          node.lowestLinkedDiscoveryIndex = min(node.lowestLinkedDiscoveryIndex,
+              depNode.lowestLinkedDiscoveryIndex);
+        } else if (nodeStack.contains(depNode)) {
+          node.lowestLinkedDiscoveryIndex = min(node.lowestLinkedDiscoveryIndex,
+              depNode.lowestLinkedDiscoveryIndex);
+        }
+      }
+
+      if (node.discoveryIndex == node.lowestLinkedDiscoveryIndex) {
+        var component = new Set<_AssetNode>();
+
+        // Pops the last node off of `nodeStack`, adds it to `component`, and
+        // returns it.
+        _AssetNode _popAndAddNode() {
+          var last = nodeStack.last;
+          nodeStack.remove(last);
+          component.add(last);
+          return last;
+        }
+
+        while (_popAndAddNode() != node) {}
+
+        connectedComponents.add(component);
+      }
+    }
+
+    for (var node in entrypoints.map((e) => nodesById[e])) {
+      if (node.discoveryIndex != null) continue;
+      stronglyConnect(node);
+    }
+
+    return connectedComponents;
+  }
+}
diff --git a/lib/src/dart.dart b/lib/src/dart.dart
index 876d7b5b..c5d236d0 100644
--- a/lib/src/dart.dart
+++ b/lib/src/dart.dart
@@ -128,6 +128,10 @@ bool isEntrypoint(CompilationUnit dart) {
   });
 }
 
+/// Returns whether [dart] contains a [PartOfDirective].
+bool isPart(CompilationUnit dart) =>
+    dart.directives.any((directive) => directive is PartOfDirective);
+
 /// Efficiently parses the import and export directives in [contents].
 ///
 /// If [name] is passed, it's used as the filename for error reporting.
diff --git a/lib/src/io.dart b/lib/src/io.dart
index 66b3621e..3b171123 100644
--- a/lib/src/io.dart
+++ b/lib/src/io.dart
@@ -1132,3 +1132,21 @@ class PubProcessResult {
 
   bool get success => exitCode == exit_codes.SUCCESS;
 }
+
+/// Returns the top level directory in [uri].
+///
+/// Throws an [ArgumentError] if [uri] is just a filename with no directory.
+String topLevelDir(String uri) {
+  var parts = path.url.split(path.normalize(uri));
+  String error;
+  if (parts.length == 1) {
+    error = 'The uri `$uri` does not contain a directory.';
+  } else if (parts.first == '..') {
+    error = 'The uri `$uri` reaches outside the root directory.';
+  }
+  if (error != null) {
+    throw new ArgumentError(
+        'Cannot compute top level dir for path `$uri`. $error');
+  }
+  return parts.first;
+}
diff --git a/test/barback/dartdevc/module_computer_test.dart b/test/barback/dartdevc/module_computer_test.dart
new file mode 100644
index 00000000..47549404
--- /dev/null
+++ b/test/barback/dartdevc/module_computer_test.dart
@@ -0,0 +1,406 @@
+// Copyright (c) 2017, 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 'package:test/test.dart';
+
+import 'package:pub/src/barback/dartdevc/module_computer.dart';
+
+import 'util.dart';
+
+main() {
+  group('computeModules', () {
+    group('ModuleMode.public', () {
+      test('no strongly connected components, one shared lib', () async {
+        var assets = makeAssets({
+          'myapp|lib/a.dart': '''
+            import 'b.dart';
+            import 'src/c.dart';
+          ''',
+          'myapp|lib/b.dart': '''
+            import 'src/c.dart';
+          ''',
+          'myapp|lib/src/c.dart': '''
+            import 'd.dart';
+          ''',
+          'myapp|lib/src/d.dart': '',
+        });
+
+        var expectedModules = [
+          equalsModule(makeModule(package: 'myapp', name: 'lib__a', srcs: [
+            'myapp|lib/a.dart'
+          ], directDependencies: [
+            'myapp|lib/b.dart',
+            'myapp|lib/src/c.dart'
+          ])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'lib__b',
+              srcs: ['myapp|lib/b.dart'],
+              directDependencies: ['myapp|lib/src/c.dart'])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'lib__a\$lib__b',
+              srcs: ['myapp|lib/src/c.dart', 'myapp|lib/src/d.dart'])),
+        ];
+
+        var modules = await computeModules(ModuleMode.public, assets.values);
+
+        expect(modules, unorderedMatches(expectedModules));
+      });
+
+      test('single strongly connected component', () async {
+        var assets = makeAssets({
+          'myapp|lib/a.dart': '''
+            import 'b.dart';
+            import 'src/c.dart';
+          ''',
+          'myapp|lib/b.dart': '''
+            import 'src/c.dart';
+          ''',
+          'myapp|lib/src/c.dart': '''
+            import 'package:myapp/a.dart';
+          ''',
+        });
+
+        var expectedModules = [
+          equalsModule(makeModule(package: 'myapp', name: 'lib__a', srcs: [
+            'myapp|lib/a.dart',
+            'myapp|lib/b.dart',
+            'myapp|lib/src/c.dart'
+          ])),
+        ];
+
+        var modules = await computeModules(ModuleMode.public, assets.values);
+        expect(modules, unorderedMatches(expectedModules));
+      });
+
+      test('multiple strongly connected components', () async {
+        var assets = makeAssets({
+          'myapp|lib/a.dart': '''
+            import 'src/c.dart';
+            import 'src/e.dart';
+          ''',
+          'myapp|lib/b.dart': '''
+            import 'src/c.dart';
+            import 'src/d.dart';
+            import 'src/e.dart';
+          ''',
+          'myapp|lib/src/c.dart': '''
+            import 'package:myapp/a.dart';
+            import 'g.dart';
+          ''',
+          'myapp|lib/src/d.dart': '''
+            import 'e.dart';
+            import 'g.dart';
+          ''',
+          'myapp|lib/src/e.dart': '''
+            import 'f.dart';
+          ''',
+          'myapp|lib/src/f.dart': '''
+            import 'e.dart';
+          ''',
+          'myapp|lib/src/g.dart': '',
+        });
+
+        var expectedModules = [
+          equalsModule(makeModule(package: 'myapp', name: 'lib__a', srcs: [
+            'myapp|lib/a.dart',
+            'myapp|lib/src/c.dart'
+          ], directDependencies: [
+            'myapp|lib/src/e.dart',
+            'myapp|lib/src/g.dart'
+          ])),
+          equalsModule(makeModule(package: 'myapp', name: 'lib__b', srcs: [
+            'myapp|lib/b.dart',
+            'myapp|lib/src/d.dart'
+          ], directDependencies: [
+            'myapp|lib/src/c.dart',
+            'myapp|lib/src/e.dart',
+            'myapp|lib/src/g.dart'
+          ])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'lib__a\$lib__b',
+              srcs: [
+                'myapp|lib/src/e.dart',
+                'myapp|lib/src/f.dart',
+                'myapp|lib/src/g.dart'
+              ])),
+        ];
+
+        var modules = await computeModules(ModuleMode.public, assets.values);
+
+        expect(modules, unorderedMatches(expectedModules));
+      });
+
+      test('ignores non-reachable assets in lib/src/ and external assets',
+          () async {
+        var assets = makeAssets({
+          'myapp|lib/a.dart': '''
+            import 'package:b/b.dart';
+          ''',
+          // Not imported by any public entry point, should be ignored.
+          'myapp|lib/src/c.dart': '''
+          ''',
+        });
+
+        var expectedModules = [
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'lib__a',
+              srcs: ['myapp|lib/a.dart'],
+              directDependencies: ['b|lib/b.dart'])),
+        ];
+
+        var modules = await computeModules(ModuleMode.public, assets.values);
+
+        expect(modules, unorderedMatches(expectedModules));
+      });
+
+      test(
+          'components can be merged into entrypoints, but other entrypoints are '
+          'left alone', () async {
+        var assets = makeAssets({
+          'myapp|lib/a.dart': '''
+            import 'b.dart';
+            import 'src/c.dart';
+          ''',
+          'myapp|lib/b.dart': '',
+          'myapp|lib/src/c.dart': '''
+            import 'd.dart';
+          ''',
+          'myapp|lib/src/d.dart': '',
+        });
+
+        var expectedModules = [
+          equalsModule(makeModule(package: 'myapp', name: 'lib__a', srcs: [
+            'myapp|lib/a.dart',
+            'myapp|lib/src/c.dart',
+            'myapp|lib/src/d.dart'
+          ], directDependencies: [
+            'myapp|lib/b.dart'
+          ])),
+          equalsModule(makeModule(
+              package: 'myapp', name: 'lib__b', srcs: ['myapp|lib/b.dart'])),
+        ];
+
+        var modules = await computeModules(ModuleMode.public, assets.values);
+
+        expect(modules, unorderedMatches(expectedModules));
+      });
+
+      test('multiple shared libs', () async {
+        var assets = makeAssets({
+          'myapp|lib/a.dart': '''
+            import 'src/d.dart';
+            import 'src/e.dart';
+            import 'src/f.dart';
+          ''',
+          'myapp|lib/b.dart': '''
+            import 'src/d.dart';
+            import 'src/e.dart';
+          ''',
+          'myapp|lib/c.dart': '''
+            import 'src/d.dart';
+            import 'src/f.dart';
+          ''',
+          'myapp|lib/src/d.dart': '''
+          ''',
+          'myapp|lib/src/e.dart': '''
+            import 'd.dart';
+          ''',
+          'myapp|lib/src/f.dart': '''
+            import 'd.dart';
+          ''',
+        });
+
+        var expectedModules = [
+          equalsModule(makeModule(package: 'myapp', name: 'lib__a', srcs: [
+            'myapp|lib/a.dart'
+          ], directDependencies: [
+            'myapp|lib/src/d.dart',
+            'myapp|lib/src/e.dart',
+            'myapp|lib/src/f.dart'
+          ])),
+          equalsModule(makeModule(package: 'myapp', name: 'lib__b', srcs: [
+            'myapp|lib/b.dart'
+          ], directDependencies: [
+            'myapp|lib/src/d.dart',
+            'myapp|lib/src/e.dart',
+          ])),
+          equalsModule(makeModule(package: 'myapp', name: 'lib__c', srcs: [
+            'myapp|lib/c.dart'
+          ], directDependencies: [
+            'myapp|lib/src/d.dart',
+            'myapp|lib/src/f.dart'
+          ])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'lib__a\$lib__b',
+              srcs: ['myapp|lib/src/e.dart'],
+              directDependencies: ['myapp|lib/src/d.dart'])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'lib__a\$lib__c',
+              srcs: ['myapp|lib/src/f.dart'],
+              directDependencies: ['myapp|lib/src/d.dart'])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'lib__a\$lib__b\$lib__c',
+              srcs: ['myapp|lib/src/d.dart'])),
+        ];
+
+        var modules = await computeModules(ModuleMode.public, assets.values);
+
+        expect(modules, unorderedMatches(expectedModules));
+      });
+
+      test('part files are merged into the parent libraries component',
+          () async {
+        var assets = makeAssets({
+          'myapp|lib/a.dart': '''
+            library a;
+
+            part 'a.part.dart';
+            part 'src/a.part.dart';
+          ''',
+          'myapp|lib/a.part.dart': '''
+            part of a;
+          ''',
+          'myapp|lib/src/a.part.dart': '''
+            part of a;
+          ''',
+        });
+
+        var expectedModules = [
+          equalsModule(makeModule(package: 'myapp', name: 'lib__a', srcs: [
+            'myapp|lib/a.dart',
+            'myapp|lib/a.part.dart',
+            'myapp|lib/src/a.part.dart'
+          ])),
+        ];
+
+        var modules = await computeModules(ModuleMode.public, assets.values);
+
+        expect(modules, unorderedMatches(expectedModules));
+      });
+
+      test('throws if given non-lib srcs', () async {
+        var assets = makeAssets({
+          'myapp|web/a.dart': '',
+        });
+        expect(computeModules(ModuleMode.public, assets.values),
+            throwsArgumentError);
+      });
+    });
+
+    group("ModuleMode.private", () {
+      test('shared lib, only files with a `main` are entry points', () async {
+        var assets = makeAssets({
+          'myapp|web/a.dart': '''
+            import 'b.dart';
+            import 'c.dart';
+
+            void main() {}
+          ''',
+          'myapp|web/b.dart': '''
+            import 'c.dart';
+
+            void main() {}
+          ''',
+          'myapp|web/c.dart': '''
+            import 'd.dart';
+          ''',
+          'myapp|web/d.dart': '',
+        });
+
+        var expectedModules = [
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'web__a',
+              srcs: ['myapp|web/a.dart'],
+              directDependencies: ['myapp|web/b.dart', 'myapp|web/c.dart'])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'web__b',
+              srcs: ['myapp|web/b.dart'],
+              directDependencies: ['myapp|web/c.dart'])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'web__a\$web__b',
+              srcs: ['myapp|web/c.dart', 'myapp|web/d.dart'])),
+        ];
+
+        var modules = await computeModules(ModuleMode.private, assets.values);
+
+        expect(modules, unorderedMatches(expectedModules));
+      });
+
+      test('strongly connected component under web', () async {
+        var assets = makeAssets({
+          'myapp|web/a.dart': '''
+            import 'b.dart';
+
+            void main() {}
+          ''',
+          'myapp|web/b.dart': '''
+            import 'a.dart';
+            import 'c.dart';
+
+            void main() {}
+          ''',
+          'myapp|web/c.dart': '''
+            import 'd.dart';
+          ''',
+          'myapp|web/d.dart': '''
+            import 'c.dart';
+          ''',
+          'myapp|web/e.dart': '''
+            import 'd.dart';
+
+            void main() {}
+          ''',
+        });
+
+        var expectedModules = [
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'web__a',
+              srcs: ['myapp|web/a.dart', 'myapp|web/b.dart'],
+              directDependencies: ['myapp|web/c.dart'])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'web__e',
+              srcs: ['myapp|web/e.dart'],
+              directDependencies: ['myapp|web/d.dart'])),
+          equalsModule(makeModule(
+              package: 'myapp',
+              name: 'web__a\$web__e',
+              srcs: ['myapp|web/c.dart', 'myapp|web/d.dart'])),
+        ];
+
+        var modules = await computeModules(ModuleMode.private, assets.values);
+
+        expect(modules, unorderedMatches(expectedModules));
+      });
+
+      test('throws if given lib srcs', () async {
+        var assets = makeAssets({
+          'myapp|lib/a.dart': '',
+        });
+        expect(computeModules(ModuleMode.private, assets.values),
+            throwsArgumentError);
+      });
+
+      test('throws if given srcs in different top level dirs', () async {
+        var assets = makeAssets({
+          'myapp|web/a.dart': '',
+          'myapp|example/b.dart': '',
+        });
+        expect(computeModules(ModuleMode.private, assets.values),
+            throwsArgumentError);
+      });
+    });
+  });
+}
diff --git a/test/barback/dartdevc/module_reader_test.dart b/test/barback/dartdevc/module_reader_test.dart
index 3075ebf5..dabf4809 100644
--- a/test/barback/dartdevc/module_reader_test.dart
+++ b/test/barback/dartdevc/module_reader_test.dart
@@ -2,8 +2,6 @@
 // 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 'package:barback/barback.dart';
 import 'package:test/test.dart';
 
@@ -33,13 +31,13 @@ void main() {
       test('readModules', () async {
         var modules = await moduleReader.readModules(originalModuleConfig);
         expect(modules.length, 1);
-        expectModulesEqual(modules.first, originalModule);
+        expect(modules.first, equalsModule(originalModule));
       });
 
       test('moduleFor', () async {
         for (var assetId in originalModule.assetIds) {
           var module = await moduleReader.moduleFor(assetId);
-          expectModulesEqual(originalModule, module);
+          expect(module, equalsModule(originalModule));
         }
       });
 
@@ -112,7 +110,7 @@ void main() {
         for (var config in expectedModules.keys) {
           var modules = await moduleReader.readModules(config);
           for (int i = 0; i < modules.length; i++) {
-            expectModulesEqual(modules[i], expectedModules[config][i]);
+            expect(modules[i], equalsModule(expectedModules[config][i]));
           }
         }
       });
@@ -122,7 +120,7 @@ void main() {
         for (var expected in allModules) {
           for (var assetId in expected.assetIds) {
             var actual = await moduleReader.moduleFor(assetId);
-            expectModulesEqual(expected, actual);
+            expect(expected, equalsModule(actual));
           }
         }
       });
@@ -162,23 +160,3 @@ void main() {
     });
   });
 }
-
-/// Manages an in memory view of a set of module configs, mimics on disk module
-/// config files.
-class InMemoryModuleConfigManager {
-  final _moduleConfigs = <AssetId, String>{};
-
-  /// Adds a module config file containing serialized [modules] to
-  /// [_moduleConfigs].
-  ///
-  /// Returns the [AssetId] for the config that was created.
-  AssetId addConfig(Iterable<Module> modules, {AssetId configId}) {
-    var package = modules.first.id.package;
-    assert(modules.every((m) => m.id.package == package));
-    configId ??= new AssetId(package, 'lib/$moduleConfigName');
-    _moduleConfigs[configId] = JSON.encode(modules);
-    return configId;
-  }
-
-  String readAsString(AssetId id) => _moduleConfigs[id];
-}
diff --git a/test/barback/dartdevc/module_test.dart b/test/barback/dartdevc/module_test.dart
index b5122001..12aa3798 100644
--- a/test/barback/dartdevc/module_test.dart
+++ b/test/barback/dartdevc/module_test.dart
@@ -24,7 +24,7 @@ void main() {
     test('can go to and from json', () {
       var module = makeModule();
       var newModule = new Module.fromJson(JSON.decode(JSON.encode(module)));
-      expectModulesEqual(module, newModule);
+      expect(module, equalsModule(newModule));
     });
 
     test('can be serialized in a list', () {
@@ -34,7 +34,7 @@ void main() {
           JSON.decode(serialized).map((s) => new Module.fromJson(s)).toList();
       expect(modules.length, equals(newModules.length));
       for (int i = 0; i < modules.length; i++) {
-        expectModulesEqual(modules[i], newModules[i]);
+        expect(modules[i], equalsModule(newModules[i]));
       }
     });
   });
diff --git a/test/barback/dartdevc/util.dart b/test/barback/dartdevc/util.dart
index ffa5b466..f885eaaa 100644
--- a/test/barback/dartdevc/util.dart
+++ b/test/barback/dartdevc/util.dart
@@ -2,14 +2,30 @@
 // 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 'package:barback/barback.dart';
 import 'package:test/test.dart';
 
 import 'package:pub/src/barback/dartdevc/module.dart';
+import 'package:pub/src/barback/dartdevc/module_reader.dart';
 
 // Keep incrementing ids so we don't accidentally create duplicates.
 int _next = 0;
 
+/// Makes a bunch of [Asset]s by parsing the keys of [assets] as an [AssetId]
+/// and the values as the contents.
+///
+/// Returns a [Map<AssetId, Asset>] of the created [Asset]s.
+Map<AssetId, Asset> makeAssets(Map<String, String> assetDescriptors) {
+  var assets = <AssetId, Asset>{};
+  assetDescriptors.forEach((serializedId, content) {
+    var id = new AssetId.parse(serializedId);
+    assets[id] = new Asset.fromString(id, content);
+  });
+  return assets;
+}
+
 AssetId makeAssetId({String package, String topLevelDir}) {
   _next++;
   package ??= 'pkg_$_next';
@@ -21,28 +37,73 @@ Set<AssetId> makeAssetIds({String package, String topLevelDir}) =>
     new Set<AssetId>.from(new List.generate(
         10, (_) => makeAssetId(package: package, topLevelDir: topLevelDir)));
 
-ModuleId makeModuleId({String package}) {
-  _next++;
-  package ??= 'pkg_$_next';
-  return new ModuleId(package, 'name_$_next');
+ModuleId makeModuleId({String name, String package}) {
+  package ??= 'pkg_${_next++}';
+  name ??= 'name_${_next++}';
+  return new ModuleId(package, name);
 }
 
 Set<ModuleId> makeModuleIds({String package}) => new Set<ModuleId>.from(
     new List.generate(10, (_) => makeModuleId(package: package)));
 
 Module makeModule(
-    {String package, Set<AssetId> directDependencies, String topLevelDir}) {
-  var id = makeModuleId(package: package);
-  var assetIds = makeAssetIds(package: id.package, topLevelDir: topLevelDir);
-  directDependencies ??= new Set<AssetId>();
-  return new Module(id, assetIds, directDependencies);
+    {String name,
+    String package,
+    Iterable<dynamic> directDependencies = const [],
+    Iterable<dynamic> srcs,
+    String topLevelDir}) {
+  assert(srcs == null || topLevelDir == null);
+  AssetId toAssetId(dynamic id) {
+    if (id is AssetId) return id;
+    assert(id is String);
+    return new AssetId.parse(id);
+  }
+
+  var id = makeModuleId(package: package, name: name);
+  srcs ??= makeAssetIds(package: id.package, topLevelDir: topLevelDir);
+  var assetIds = srcs.map(toAssetId).toSet();
+  directDependencies ??= new Set();
+  var realDeps = directDependencies.map(toAssetId).toSet();
+  return new Module(id, assetIds, realDeps);
 }
 
 List<Module> makeModules({String package}) =>
     new List.generate(10, (_) => makeModule(package: package));
 
-void expectModulesEqual(Module expected, Module actual) {
-  expect(expected.id, equals(actual.id));
-  expect(expected.assetIds, equals(actual.assetIds));
-  expect(expected.directDependencies, equals(actual.directDependencies));
+Matcher equalsModule(Module expected) => new _EqualsModule(expected);
+
+class _EqualsModule extends Matcher {
+  Module _expected;
+
+  _EqualsModule(this._expected);
+
+  bool matches(item, _) =>
+      item is Module &&
+      item.id == _expected.id &&
+      unorderedEquals(_expected.assetIds).matches(item.assetIds, _) &&
+      unorderedEquals(_expected.directDependencies)
+          .matches(item.directDependencies, _);
+
+  Description describe(Description description) =>
+      description.addDescriptionOf(_expected);
+}
+
+/// Manages an in memory view of a set of module configs, mimics on disk module
+/// config files.
+class InMemoryModuleConfigManager {
+  final _moduleConfigs = <AssetId, String>{};
+
+  /// Adds a module config file containing serialized [modules] to
+  /// [_moduleConfigs].
+  ///
+  /// Returns the [AssetId] for the config that was created.
+  AssetId addConfig(Iterable<Module> modules, {AssetId configId}) {
+    var package = modules.first.id.package;
+    assert(modules.every((m) => m.id.package == package));
+    configId ??= new AssetId(package, 'lib/$moduleConfigName');
+    _moduleConfigs[configId] = JSON.encode(modules);
+    return configId;
+  }
+
+  String readAsString(AssetId id) => _moduleConfigs[id];
 }
diff --git a/test/barback_test.dart b/test/barback_test.dart
new file mode 100644
index 00000000..c72799e8
--- /dev/null
+++ b/test/barback_test.dart
@@ -0,0 +1,61 @@
+// Copyright (c) 2017, 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 'package:barback/barback.dart';
+import 'package:pub/src/barback.dart';
+import 'package:test/test.dart';
+
+void main() {
+  group('importUriToId', () {
+    test('returns null for dart: imports', () {
+      expect(importUriToAssetId(new AssetId('a', 'lib/a.dart'), 'dart:async'),
+          isNull);
+    });
+
+    test('relative imports can be resolved', () {
+      expect(importUriToAssetId(new AssetId('a', 'web/a.dart'), 'b.dart'),
+          new AssetId('a', 'web/b.dart'));
+      expect(importUriToAssetId(new AssetId('a', 'lib/a.dart'), 'b.dart'),
+          new AssetId('a', 'lib/b.dart'));
+      expect(importUriToAssetId(new AssetId('a', 'lib/a/a.dart'), '../a.dart'),
+          new AssetId('a', 'lib/a.dart'));
+      expect(importUriToAssetId(new AssetId('a', 'lib/a.dart'), 'a/a.dart'),
+          new AssetId('a', 'lib/a/a.dart'));
+    });
+
+    test('throws for invalid relative imports', () {
+      expect(
+          () =>
+              importUriToAssetId(new AssetId('a', 'lib/a.dart'), '../foo.dart'),
+          throwsArgumentError,
+          reason: 'Relative imports can\'t reach outside lib.');
+
+      expect(
+          () => importUriToAssetId(
+              new AssetId('a', 'web/a.dart'), '../lib/foo.dart'),
+          throwsArgumentError,
+          reason: 'Relative imports can\'t reach from web to lib.');
+
+      expect(
+          () => importUriToAssetId(
+              new AssetId('a', 'lib/a.dart'), '../web/foo.dart'),
+          throwsArgumentError,
+          reason: 'Relative imports can\'t reach from lib to web.');
+    });
+
+    test('package: imports can be resolved', () {
+      expect(
+          importUriToAssetId(
+              new AssetId('a', 'lib/a.dart'), 'package:b/b.dart'),
+          new AssetId('b', 'lib/b.dart'));
+    });
+
+    test('Invalid package: imports throw', () {
+      expect(
+          () => importUriToAssetId(
+              new AssetId('a', 'lib/a.dart'), 'package:b/../b.dart'),
+          throwsArgumentError);
+    });
+  });
+}
diff --git a/test/io_test.dart b/test/io_test.dart
index 75b834e9..a381e089 100644
--- a/test/io_test.dart
+++ b/test/io_test.dart
@@ -344,6 +344,23 @@ void testExistencePredicate(String name, bool predicate(String path),
       });
     }
   });
+
+  group('topLevelDir', () {
+    test('returns the top level dir in a path', () {
+      expect(topLevelDir('foo/bar/baz.dart'), 'foo');
+      expect(topLevelDir('foo/../bar/baz.dart'), 'bar');
+      expect(topLevelDir('./foo/baz.dart'), 'foo');
+    });
+
+    test('throws for invalid paths', () {
+      expect(() => topLevelDir('foo/../../bar.dart'), throwsArgumentError,
+          reason: 'Paths reaching outside the root dir should throw.');
+      expect(() => topLevelDir('foo.dart'), throwsArgumentError,
+          reason: 'Paths to the root directory should throw.');
+      expect(() => topLevelDir('foo/../foo.dart'), throwsArgumentError,
+          reason: 'Normalized paths to the root directory should throw.');
+    });
+  });
 }
 
 /// Like [withTempDir], but canonicalizes the path before passing it to [fn].
-- 
GitLab