Initial approach to discovering package resolution for directory tree.

R=brianwilkerson@google.com, pquitslund@google.com

Review URL: https://codereview.chromium.org//1169513002.
diff --git a/lib/discovery_analysis.dart b/lib/discovery_analysis.dart
new file mode 100644
index 0000000..f3bcac6
--- /dev/null
+++ b/lib/discovery_analysis.dart
@@ -0,0 +1,167 @@
+// Copyright (c) 2015, 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.
+
+/// Analyse a directory structure and find packages resolvers for each
+/// sub-directory.
+///
+/// The resolvers are generally the same that would be found by using
+/// the `discovery.dart` library on each sub-directory in turn,
+/// but more efficiently and with some heuristics for directories that
+/// wouldn't otherwise have a package resolution strategy, or that are
+/// determined to be "package directories" themselves.
+library package_config.discovery_analysis;
+
+import "dart:io" show File, Directory;
+import "dart:collection" show HashMap;
+
+import "package:path/path.dart" as path;
+
+import "packages.dart";
+import "discovery.dart";
+import "packages_file.dart" as pkgfile;
+import "src/packages_impl.dart";
+import "src/packages_io_impl.dart";
+
+/// Associates a [Packages] package resolution strategy with a directory.
+///
+/// The package resolution applies to the directory and any sub-directory
+/// that doesn't have its own overriding child [PackageContext].
+abstract class PackageContext {
+  /// The directory that introduced the [packages] resolver.
+  Directory get directory;
+
+  /// A [Packages] resolver that applies to the directory.
+  ///
+  /// Introduced either by a `.packages` file or a `packages/` directory.
+  Packages get packages;
+
+  /// Child contexts that apply to sub-directories of [directory].
+  List<PackageContext> get children;
+
+  /// Look up the [PackageContext] that applies to a specific directory.
+  ///
+  /// The directory must be inside [directory].
+  PackageContext operator[](Directory directory);
+
+  /// A map from directory to package resolver.
+  ///
+  /// Has an entry for this package context and for each child context
+  /// contained in this one.
+  Map<Directory, Packages> asMap();
+
+  /// Analyze [directory] and sub-directories for package resolution strategies.
+  ///
+  /// Returns a mapping from sub-directories to [Packages] objects.
+  ///
+  /// The analysis assumes that there are no `.packages` files in a parent
+  /// directory of `directory`. If there is, its corresponding `Packages` object
+  /// should be provided as `root`.
+  static PackageContext findAll(Directory directory,
+                                {Packages root: Packages.noPackages}) {
+    if (!directory.existsSync()) {
+      throw new ArgumentError("Directory not found: $directory");
+    }
+    List contexts = [];
+    void findRoots(Directory directory) {
+      Packages packages;
+      List oldContexts;
+      File packagesFile = new File(path.join(directory.path, ".packages"));
+      if (packagesFile.existsSync()) {
+        packages = _loadPackagesFile(packagesFile);
+        oldContexts = contexts;
+        contexts = [];
+      } else {
+        Directory packagesDir =
+            new Directory(path.join(directory.path, "packages"));
+        if (packagesDir.existsSync()) {
+          packages = new FilePackagesDirectoryPackages(packagesDir);
+          oldContexts = contexts;
+          contexts = [];
+        }
+      }
+      for (var entry in directory.listSync()) {
+        if (entry is Directory) {
+          if (packages == null || !entry.path.endsWith("/packages")) {
+            findRoots(entry);
+          }
+        }
+      }
+      if (packages != null) {
+        oldContexts.add(new _PackageContext(directory, packages, contexts));
+        contexts = oldContexts;
+      }
+    }
+    findRoots(directory);
+    // If the root is not itself context root, add a the wrapper context.
+    if (contexts.length == 1 &&
+        contexts[0].directory == directory) {
+      return contexts[0];
+    }
+    return new _PackageContext(directory, root, contexts);
+  }
+}
+
+class _PackageContext implements PackageContext {
+  final Directory directory;
+  final Packages packages;
+  final List<PackageContext> children;
+  _PackageContext(this.directory, this.packages, List<PackageContext> children)
+      : children = new List<PackageContext>.unmodifiable(children);
+
+  Map<Directory, Packages> asMap() {
+    var result = new HashMap<Directory, Packages>();
+    recurse(_PackageContext current) {
+      result[current.directory] = current.packages;
+      for (var child in current.children) {
+        recurse(child);
+      }
+    }
+    recurse(this);
+    return result;
+  }
+
+  PackageContext operator[](Directory directory) {
+    String path = directory.path;
+    if (!path.startsWith(this.directory.path)) {
+      throw new ArgumentError("Not inside $path: $directory");
+    }
+    _PackageContext current = this;
+    // The current path is know to agree with directory until deltaIndex.
+    int deltaIndex = current.directory.path.length;
+    List children = current.children;
+    int i = 0;
+    while (i < children.length) {
+      // TODO(lrn): Sort children and use binary search.
+      _PackageContext child = children[i];
+      String childPath = child.directory.path;
+      if (_stringsAgree(path, childPath, deltaIndex, childPath.length)) {
+        deltaIndex = childPath.length;
+        if (deltaIndex == path.length) {
+          return child;
+        }
+        current = child;
+        children = current.children;
+        i = 0;
+        continue;
+      }
+      i++;
+    }
+    return current;
+  }
+
+  static bool _stringsAgree(String a, String b, int start, int end) {
+    if (a.length < end || b.length < end) return false;
+    for (int i = start; i < end; i++) {
+      if (a.codeUnitAt(i) != b.codeUnitAt(i)) return false;
+    }
+    return true;
+  }
+}
+
+Packages _loadPackagesFile(File file) {
+  var uri = new Uri.file(file.path);
+  var bytes = file.readAsBytesSync();
+  var map = pkgfile.parse(bytes, uri);
+  return new MapPackages(map);
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 5eca330..0f2735b 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: package_config
-version: 0.0.3+1
+version: 0.0.4
 description: Support for working with Package Resolution config files.
 author: Dart Team <misc@dartlang.org>
 homepage: https://github.com/dart-lang/package_config
diff --git a/test/discovery_analysis_test.dart b/test/discovery_analysis_test.dart
new file mode 100644
index 0000000..ad007c0
--- /dev/null
+++ b/test/discovery_analysis_test.dart
@@ -0,0 +1,122 @@
+// Copyright (c) 2015, 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:package_config/discovery_analysis.dart";
+import "package:package_config/packages.dart";
+import "package:path/path.dart" as path;
+import "package:test/test.dart";
+
+main() {
+  fileTest("basic",
+           {".packages": packagesFile,
+            "foo": {".packages": packagesFile},
+            "bar": {"packages": {"foo": {}, "bar":{}, "baz": {}}},
+            "baz": {}},
+           (Directory directory) {
+    var dirUri = new Uri.directory(directory.path);
+    PackageContext ctx = PackageContext.findAll(directory);
+    PackagesContext root = ctx[directory];
+    expect(root, same(ctx));
+    validatePackagesFile(root.packages, dirUri);
+    var fooDir = sub(directory, "foo");
+    PackagesContext foo = ctx[fooDir];
+    expect(identical(root, foo), isFalse);
+    validatePackagesFile(foo.packages, dirUri.resolve("foo/"));
+    var barDir = sub(directory, "bar");
+    PackagesContext bar = ctx[sub(directory, "bar")];
+    validatePackagesDir(bar.packages, dirUri.resolve("bar/"));
+    PackagesContext barbar = ctx[sub(barDir, "bar")];
+    expect(barbar, same(bar));  // inherited.
+    PackagesContext baz = ctx[sub(directory, "baz")];
+    expect(baz, same(root));  // inherited.
+
+    var map = ctx.asMap();
+    expect(map.keys.map((dir) => dir.path),
+           unorderedEquals([directory.path, fooDir.path, barDir.path]));
+  });
+}
+
+Directory sub(Directory parent, String dirName) {
+  return new Directory(path.join(parent.path, dirName));
+}
+
+const packagesFile = """
+# A comment
+foo=file:///dart/packages/foo/
+bar=http://example.com/dart/packages/bar/
+baz=packages/baz/
+""";
+
+void validatePackagesFile(Packages resolver, Uri location) {
+  expect(resolver, isNotNull);
+  expect(resolver.resolve(pkg("foo", "bar/baz")),
+         equals(Uri.parse("file:///dart/packages/foo/bar/baz")));
+  expect(resolver.resolve(pkg("bar", "baz/qux")),
+         equals(Uri.parse("http://example.com/dart/packages/bar/baz/qux")));
+  expect(resolver.resolve(pkg("baz", "qux/foo")),
+         equals(location.resolve("packages/baz/qux/foo")));
+  expect(resolver.packages, unorderedEquals(["foo", "bar", "baz"]));
+}
+
+void validatePackagesDir(Packages resolver, Uri location) {
+  // Expect three packages: foo, bar and baz
+  expect(resolver, isNotNull);
+  expect(resolver.resolve(pkg("foo", "bar/baz")),
+         equals(location.resolve("packages/foo/bar/baz")));
+  expect(resolver.resolve(pkg("bar", "baz/qux")),
+         equals(location.resolve("packages/bar/baz/qux")));
+  expect(resolver.resolve(pkg("baz", "qux/foo")),
+         equals(location.resolve("packages/baz/qux/foo")));
+  if (location.scheme == "file") {
+    expect(resolver.packages, unorderedEquals(["foo", "bar", "baz"]));
+  } else {
+    expect(() => resolver.packages, throws);
+  }
+}
+
+Uri pkg(String packageName, String packagePath) {
+  var path;
+  if (packagePath.startsWith('/')) {
+    path = "$packageName$packagePath";
+  } else {
+    path = "$packageName/$packagePath";
+  }
+  return new Uri(scheme: "package", path: path);
+}
+
+/// Create a directory structure from [description] and run [fileTest].
+///
+/// Description is a map, each key is a file entry. If the value is a map,
+/// it's a sub-dir, otherwise it's a file and the value is the content
+/// as a string.
+void fileTest(String name,
+              Map description,
+              Future fileTest(Uri directory)) {
+  group("file-test", () {
+    Directory tempDir = Directory.systemTemp.createTempSync("file-test");
+    setUp(() {
+      _createFiles(tempDir, description);
+    });
+    tearDown(() {
+      tempDir.deleteSync(recursive: true);
+    });
+    test(name, () => fileTest(tempDir));
+  });
+}
+
+void _createFiles(Directory target, Map description) {
+  description.forEach((name, content) {
+    if (content is Map) {
+      Directory subDir = new Directory(path.join(target.path, name));
+      subDir.createSync();
+      _createFiles(subDir, content);
+    } else {
+      File file = new File(path.join(target.path, name));
+      file.writeAsStringSync(content, flush: true);
+    }
+  });
+}