Merge pull request #117 from jensjoha/faster_read_and_lookup
Faster read; faster lookup; more tests
diff --git a/lib/src/package_config_impl.dart b/lib/src/package_config_impl.dart
index 4167d35..aef8176 100644
--- a/lib/src/package_config_impl.dart
+++ b/lib/src/package_config_impl.dart
@@ -8,6 +8,8 @@
export 'package_config.dart';
+const bool _disallowPackagesInsidePackageUriRoot = false;
+
// Implementations of the main data types exposed by the API of this package.
class SimplePackageConfig implements PackageConfig {
@@ -54,12 +56,12 @@
static PackageTree _validatePackages(Iterable<Package> originalPackages,
List<Package> packages, void Function(Object error) onError) {
var packageNames = <String>{};
- var tree = MutablePackageTree();
+ var tree = TriePackageTree();
for (var originalPackage in packages) {
- SimplePackage? package;
+ SimplePackage? newPackage;
if (originalPackage is! SimplePackage) {
// SimplePackage validates these properties.
- package = SimplePackage.validate(
+ newPackage = SimplePackage.validate(
originalPackage.name,
originalPackage.root,
originalPackage.packageUriRoot,
@@ -68,43 +70,58 @@
originalPackage.relativeRoot, (error) {
if (error is PackageConfigArgumentError) {
onError(PackageConfigArgumentError(packages, 'packages',
- 'Package ${package!.name}: ${error.message}'));
+ 'Package ${newPackage!.name}: ${error.message}'));
} else {
onError(error);
}
});
- if (package == null) continue;
+ if (newPackage == null) continue;
} else {
- package = originalPackage;
+ newPackage = originalPackage;
}
- var name = package.name;
+ var name = newPackage.name;
if (packageNames.contains(name)) {
onError(PackageConfigArgumentError(
name, 'packages', "Duplicate package name '$name'"));
continue;
}
packageNames.add(name);
- tree.add(0, package, (error) {
+ tree.add(newPackage, (error) {
if (error is ConflictException) {
// There is a conflict with an existing package.
var existingPackage = error.existingPackage;
- if (error.isRootConflict) {
- onError(PackageConfigArgumentError(
- originalPackages,
- 'packages',
- 'Packages ${package!.name} and ${existingPackage.name} '
- 'have the same root directory: ${package.root}.\n'));
- } else {
- assert(error.isPackageRootConflict);
- // Package is inside the package URI root of the existing package.
- onError(PackageConfigArgumentError(
- originalPackages,
- 'packages',
- 'Package ${package!.name} is inside the package URI root of '
- 'package ${existingPackage.name}.\n'
- '${existingPackage.name} URI root: '
- '${existingPackage.packageUriRoot}\n'
- '${package.name} root: ${package.root}\n'));
+ switch (error.conflictType) {
+ case ConflictType.sameRoots:
+ onError(PackageConfigArgumentError(
+ originalPackages,
+ 'packages',
+ 'Packages ${newPackage!.name} and ${existingPackage.name} '
+ 'have the same root directory: ${newPackage.root}.\n'));
+ break;
+ case ConflictType.interleaving:
+ // The new package is inside the package URI root of the existing
+ // package.
+ onError(PackageConfigArgumentError(
+ originalPackages,
+ 'packages',
+ 'Package ${newPackage!.name} is inside the root of '
+ 'package ${existingPackage.name}, and the package root '
+ 'of ${existingPackage.name} is inside the root of '
+ '${newPackage.name}.\n'
+ '${existingPackage.name} package root: '
+ '${existingPackage.packageUriRoot}\n'
+ '${newPackage.name} root: ${newPackage.root}\n'));
+ break;
+ case ConflictType.insidePackageRoot:
+ onError(PackageConfigArgumentError(
+ originalPackages,
+ 'packages',
+ 'Package ${newPackage!.name} is inside the package root of '
+ 'package ${existingPackage.name}.\n'
+ '${existingPackage.name} package root: '
+ '${existingPackage.packageUriRoot}\n'
+ '${newPackage.name} root: ${newPackage.root}\n'));
+ break;
}
} else {
// Any other error.
@@ -367,6 +384,13 @@
SimplePackage? packageOf(Uri file);
}
+class _PackageTrieNode {
+ SimplePackage? package;
+
+ /// Indexed by path segment.
+ Map<String, _PackageTrieNode> map = {};
+}
+
/// Packages of a package configuration ordered by root path.
///
/// A package has a root path and a package root path, where the latter
@@ -375,122 +399,127 @@
/// A package is said to be inside another package if the root path URI of
/// the latter is a prefix of the root path URI of the former.
///
-/// No two packages of a package may have the same root path, so this
-/// path prefix ordering defines a tree-like partial ordering on packages
-/// of a configuration.
-///
+/// No two packages of a package may have the same root path.
/// The package root path of a package must not be inside another package's
/// root path.
-/// Entire other packages are allowed inside a package's root or
-/// package root path.
-///
-/// The package tree contains an ordered mapping of unrelated packages
-/// (represented by their name) to their immediately nested packages' names.
-class MutablePackageTree implements PackageTree {
- /// A list of packages that are not nested inside each other.
- final List<SimplePackage> packages = [];
+/// Entire other packages are allowed inside a package's root.
+class TriePackageTree implements PackageTree {
+ /// Indexed by URI scheme.
+ final Map<String, _PackageTrieNode> _map = {};
- /// The tree of the immediately nested packages inside each package.
- ///
- /// Indexed by [Package.name].
- /// If a package has no nested packages (which is most often the case),
- /// there is no tree object associated with it.
- Map<String, MutablePackageTree>? _packageChildren;
+ /// A list of all packages.
+ final List<SimplePackage> _packages = [];
@override
Iterable<Package> get allPackages sync* {
- for (var package in packages) {
+ for (var package in _packages) {
yield package;
}
- var children = _packageChildren;
- if (children != null) {
- for (var tree in children.values) {
- yield* tree.allPackages;
- }
- }
}
- /// Tries to (add) `package` to the tree.
+ bool _checkConflict(_PackageTrieNode node, SimplePackage newPackage,
+ void Function(Object error) onError) {
+ var existingPackage = node.package;
+ if (existingPackage != null) {
+ // Trying to add package that is inside the existing package.
+ // 1) If it's an exact match it's not allowed (i.e. the roots can't be
+ // the same).
+ if (newPackage.root.path.length == existingPackage.root.path.length) {
+ onError(ConflictException(
+ newPackage, existingPackage, ConflictType.sameRoots));
+ return true;
+ }
+ // 2) The existing package has a packageUriRoot thats inside the
+ // root of the new package.
+ if (_beginsWith(0, newPackage.root.toString(),
+ existingPackage.packageUriRoot.toString())) {
+ onError(ConflictException(
+ newPackage, existingPackage, ConflictType.interleaving));
+ return true;
+ }
+
+ // For internal reasons we allow this (for now). One should still never do
+ // it thouh.
+ // 3) The new package is inside the packageUriRoot of existing package.
+ if (_disallowPackagesInsidePackageUriRoot) {
+ if (_beginsWith(0, existingPackage.packageUriRoot.toString(),
+ newPackage.root.toString())) {
+ onError(ConflictException(
+ newPackage, existingPackage, ConflictType.insidePackageRoot));
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /// Tries to add `newPackage` to the tree.
///
/// Reports a [ConflictException] if the added package conflicts with an
/// existing package.
- /// It conflicts if its root or package root is the same as another
- /// package's root or package root, or is between the two.
+ /// It conflicts if its root or package root is the same as an existing
+ /// package's root or package root, is between the two, or if it's inside the
+ /// package root of an existing package.
///
- /// If a conflict is detected between [package] and a previous package,
+ /// If a conflict is detected between [newPackage] and a previous package,
/// then [onError] is called with a [ConflictException] object
- /// and the [package] is not added to the tree.
+ /// and the [newPackage] is not added to the tree.
///
/// The packages are added in order of their root path.
- /// It is never necessary to insert a node between two existing levels.
- void add(
- int start, SimplePackage package, void Function(Object error) onError) {
- var path = package.root.toString();
- for (var treePackage in packages) {
- // Check is package is inside treePackage.
- var treePackagePath = treePackage.root.toString();
- assert(treePackagePath.length > start);
- assert(path.startsWith(treePackagePath.substring(0, start)));
- if (_beginsWith(start, treePackagePath, path)) {
- // Package *is* inside treePackage.
- var treePackagePathLength = treePackagePath.length;
- if (path.length == treePackagePathLength) {
- // Has same root. Do not add package.
- onError(ConflictException.root(package, treePackage));
- return;
- }
- var treePackageUriRoot = treePackage.packageUriRoot.toString();
- if (_beginsWith(treePackagePathLength, path, treePackageUriRoot)) {
- // The treePackage's package root is inside package, which is inside
- // the treePackage. This is not allowed.
- onError(ConflictException.packageRoot(package, treePackage));
- return;
- }
- _treeOf(treePackage).add(treePackagePathLength, package, onError);
- return;
- }
+ void add(SimplePackage newPackage, void Function(Object error) onError) {
+ var root = newPackage.root;
+ var node = _map[root.scheme] ??= _PackageTrieNode();
+ if (_checkConflict(node, newPackage, onError)) return;
+ var segments = root.pathSegments;
+ // Notice that we're skipping the last segment as it's always the empty
+ // string because roots are directories.
+ for (var i = 0; i < segments.length - 1; i++) {
+ var path = segments[i];
+ node = node.map[path] ??= _PackageTrieNode();
+ if (_checkConflict(node, newPackage, onError)) return;
}
- packages.add(package);
+ node.package = newPackage;
+ _packages.add(newPackage);
+ }
+
+ bool _isMatch(
+ String path, _PackageTrieNode node, List<SimplePackage> potential) {
+ var currentPackage = node.package;
+ if (currentPackage != null) {
+ var currentPackageRootLength = currentPackage.root.toString().length;
+ if (path.length == currentPackageRootLength) return true;
+ var currentPackageUriRoot = currentPackage.packageUriRoot.toString();
+ // Is [file] inside the package root of [currentPackage]?
+ if (currentPackageUriRoot.length == currentPackageRootLength ||
+ _beginsWith(currentPackageRootLength, currentPackageUriRoot, path)) {
+ return true;
+ }
+ potential.add(currentPackage);
+ }
+ return false;
}
@override
SimplePackage? packageOf(Uri file) {
- return findPackageOf(0, file.toString());
- }
+ var currentTrieNode = _map[file.scheme];
+ if (currentTrieNode == null) return null;
+ var path = file.toString();
+ var potential = <SimplePackage>[];
+ if (_isMatch(path, currentTrieNode, potential)) {
+ return currentTrieNode.package;
+ }
+ var segments = file.pathSegments;
- /// Finds package containing [path] in this tree.
- ///
- /// Returns `null` if no such package is found.
- ///
- /// Assumes the first [start] characters of path agrees with all
- /// the packages at this level of the tree.
- SimplePackage? findPackageOf(int start, String path) {
- for (var childPackage in packages) {
- var childPath = childPackage.root.toString();
- if (_beginsWith(start, childPath, path)) {
- // The [package] is inside [childPackage].
- var childPathLength = childPath.length;
- if (path.length == childPathLength) return childPackage;
- var uriRoot = childPackage.packageUriRoot.toString();
- // Is [package] is inside the URI root of [childPackage].
- if (uriRoot.length == childPathLength ||
- _beginsWith(childPathLength, uriRoot, path)) {
- return childPackage;
- }
- return _packageChildren?[childPackage.name]
- ?.findPackageOf(childPathLength, path) ??
- childPackage;
+ for (var i = 0; i < segments.length - 1; i++) {
+ var segment = segments[i];
+ currentTrieNode = currentTrieNode!.map[segment];
+ if (currentTrieNode == null) break;
+ if (_isMatch(path, currentTrieNode, potential)) {
+ return currentTrieNode.package;
}
}
- return null;
- }
-
- /// Returns the [PackageTree] of the children of [package].
- ///
- /// Ensures that the object is allocated if necessary.
- MutablePackageTree _treeOf(SimplePackage package) {
- var children = _packageChildren ??= {};
- return children[package.name] ??= MutablePackageTree();
+ if (potential.isEmpty) return null;
+ return potential.last;
}
}
@@ -516,6 +545,8 @@
return true;
}
+enum ConflictType { sameRoots, interleaving, insidePackageRoot }
+
/// Conflict between packages added to the same configuration.
///
/// The [package] conflicts with [existingPackage] if it has
@@ -530,18 +561,10 @@
final SimplePackage package;
/// Whether the conflict is with the package URI root of [existingPackage].
- final bool isPackageRootConflict;
+ final ConflictType conflictType;
/// Creates a root conflict between [package] and [existingPackage].
- ConflictException.root(this.package, this.existingPackage)
- : isPackageRootConflict = false;
-
- /// Creates a package root conflict between [package] and [existingPackage].
- ConflictException.packageRoot(this.package, this.existingPackage)
- : isPackageRootConflict = true;
-
- /// WHether the conflict is with the root URI of [existingPackage].
- bool get isRootConflict => !isPackageRootConflict;
+ ConflictException(this.package, this.existingPackage, this.conflictType);
}
/// Used for sorting packages by root path.
diff --git a/test/bench.dart b/test/bench.dart
new file mode 100644
index 0000000..7466431
--- /dev/null
+++ b/test/bench.dart
@@ -0,0 +1,71 @@
+// Copyright (c) 2021, 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:convert';
+import 'dart:typed_data';
+
+import 'package:package_config/src/package_config_json.dart';
+
+void throwError(Object error) => throw error;
+
+void bench(final int size, final bool doPrint) {
+ var sb = StringBuffer();
+ sb.writeln('{');
+ sb.writeln('"configVersion": 2,');
+ sb.writeln('"packages": [');
+ for (var i = 0; i < size; i++) {
+ if (i != 0) {
+ sb.writeln(',');
+ }
+ sb.writeln('{');
+ sb.writeln(' "name": "p_$i",');
+ sb.writeln(' "rootUri": "file:///p_$i/",');
+ sb.writeln(' "packageUri": "lib/",');
+ sb.writeln(' "languageVersion": "2.5",');
+ sb.writeln(' "nonstandard": true');
+ sb.writeln('}');
+ }
+ sb.writeln('],');
+ sb.writeln('"generator": "pub",');
+ sb.writeln('"other": [42]');
+ sb.writeln('}');
+ var stopwatch = Stopwatch()..start();
+ var config = parsePackageConfigBytes(
+ // ignore: unnecessary_cast
+ utf8.encode(sb.toString()) as Uint8List,
+ Uri.parse('file:///tmp/.dart_tool/file.dart'),
+ throwError);
+ final int read = stopwatch.elapsedMilliseconds;
+
+ stopwatch.reset();
+ for (var i = 0; i < size; i++) {
+ if (config.packageOf(Uri.parse('file:///p_$i/lib/src/foo.dart'))!.name !=
+ 'p_$i') {
+ throw "Unexpected result!";
+ }
+ }
+ final int lookup = stopwatch.elapsedMilliseconds;
+
+ if (doPrint) {
+ print('Read file with $size packages in $read ms, '
+ 'looked up all packages in $lookup ms');
+ }
+}
+
+void main(List<String> args) {
+ if (args.length != 1 && args.length != 2) {
+ throw "Expects arguments: <size> <warmup iterations>?";
+ }
+ final size = int.parse(args[0]);
+ if (args.length > 1) {
+ final warmups = int.parse(args[1]);
+ print("Performing $warmups warmup iterations.");
+ for (var i = 0; i < warmups; i++) {
+ bench(10, false);
+ }
+ }
+
+ // Benchmark.
+ bench(size, true);
+}
diff --git a/test/parse_test.dart b/test/parse_test.dart
index d5c2e72..174a099 100644
--- a/test/parse_test.dart
+++ b/test/parse_test.dart
@@ -272,6 +272,36 @@
Uri.parse('package:qux/diz'));
});
+ test('nested packages 2', () {
+ var configBytes = utf8.encode(json.encode({
+ 'configVersion': 2,
+ 'packages': [
+ {'name': 'foo', 'rootUri': '/', 'packageUri': 'lib/'},
+ {'name': 'bar', 'rootUri': '/bar/', 'packageUri': 'lib/'},
+ {'name': 'baz', 'rootUri': '/bar/baz/', 'packageUri': 'lib/'},
+ {'name': 'qux', 'rootUri': '/qux/', 'packageUri': 'lib/'},
+ ]
+ }));
+ // ignore: unnecessary_cast
+ var config = parsePackageConfigBytes(configBytes as Uint8List,
+ Uri.parse('file:///tmp/.dart_tool/file.dart'), throwError);
+ expect(config.version, 2);
+ expect(
+ config.packageOf(Uri.parse('file:///lala/lala.dart'))!.name, 'foo');
+ expect(config.packageOf(Uri.parse('file:///bar/lala.dart'))!.name, 'bar');
+ expect(config.packageOf(Uri.parse('file:///bar/baz/lala.dart'))!.name,
+ 'baz');
+ expect(config.packageOf(Uri.parse('file:///qux/lala.dart'))!.name, 'qux');
+ expect(config.toPackageUri(Uri.parse('file:///lib/diz')),
+ Uri.parse('package:foo/diz'));
+ expect(config.toPackageUri(Uri.parse('file:///bar/lib/diz')),
+ Uri.parse('package:bar/diz'));
+ expect(config.toPackageUri(Uri.parse('file:///bar/baz/lib/diz')),
+ Uri.parse('package:baz/diz'));
+ expect(config.toPackageUri(Uri.parse('file:///qux/lib/diz')),
+ Uri.parse('package:qux/diz'));
+ });
+
group('invalid', () {
void testThrows(String name, String source) {
test(name, () {
@@ -283,6 +313,21 @@
});
}
+ void testThrowsContains(
+ String name, String source, String containsString) {
+ test(name, () {
+ dynamic exception;
+ try {
+ parsePackageConfigBytes(utf8.encode(source) as Uint8List,
+ Uri.parse('file:///tmp/.dart_tool/file.dart'), throwError);
+ } catch (e) {
+ exception = e;
+ }
+ if (exception == null) fail("Didn't get exception");
+ expect('$exception', contains(containsString));
+ });
+ }
+
testThrows('comment', '# comment\n {$cfg,$pkgs}');
testThrows('.packages file', 'foo:/foo\n');
testThrows('no configVersion', '{$pkgs}');
@@ -375,19 +420,43 @@
});
testThrows('duplicate package name',
'{$cfg,"packages":[{$name,$root},{$name,"rootUri":"/other/"}]}');
- testThrows('same roots',
- '{$cfg,"packages":[{$name,$root},{"name":"bar",$root}]}');
- testThrows(
+ testThrowsContains(
// The roots of foo and bar are the same.
'same roots',
- '{$cfg,"packages":[{$name,$root},{"name":"bar",$root}]}');
- testThrows(
+ '{$cfg,"packages":[{$name,$root},{"name":"bar",$root}]}',
+ 'the same root directory');
+ testThrowsContains(
+ // The roots of foo and bar are the same.
+ 'same roots 2',
+ '{$cfg,"packages":[{$name,"rootUri":"/"},{"name":"bar","rootUri":"/"}]}',
+ 'the same root directory');
+ testThrowsContains(
// The root of bar is inside the root of foo,
// but the package root of foo is inside the root of bar.
'between root and lib',
'{$cfg,"packages":['
'{"name":"foo","rootUri":"/foo/","packageUri":"bar/lib/"},'
- '{"name":"bar","rootUri":"/foo/bar/"},"packageUri":"baz/lib"]}');
+ '{"name":"bar","rootUri":"/foo/bar/","packageUri":"baz/lib"}]}',
+ 'package root of foo is inside the root of bar');
+
+ // This shouldn't be allowed, but for internal reasons it is.
+ test("package inside package root", () {
+ var config = parsePackageConfigBytes(
+ utf8.encode(
+ '{$cfg,"packages":['
+ '{"name":"foo","rootUri":"/foo/","packageUri":"lib/"},'
+ '{"name":"bar","rootUri":"/foo/lib/bar/","packageUri":"lib"}]}',
+ ) as Uint8List,
+ Uri.parse('file:///tmp/.dart_tool/file.dart'),
+ throwError);
+ expect(
+ config
+ .packageOf(Uri.parse('file:///foo/lib/bar/lib/lala.dart'))!
+ .name,
+ 'foo'); // why not bar?
+ expect(config.toPackageUri(Uri.parse('file:///foo/lib/bar/lib/diz')),
+ Uri.parse('package:foo/bar/lib/diz')); // why not package:bar/diz?
+ });
});
});