Further tweaks on the API. (dart-lang/package_config#57) * Allow nested packages.
diff --git a/pkgs/package_config/lib/package_config.dart b/pkgs/package_config/lib/package_config.dart index 4d6f346..77e8d61 100644 --- a/pkgs/package_config/lib/package_config.dart +++ b/pkgs/package_config/lib/package_config.dart
@@ -23,10 +23,16 @@ /// It is considered a `package_config.json` file if its first character /// is a `{`. /// -/// If the file is a `.packages` file, also checks if there is a -/// `.dart_tool/package_config.json` file next to the original file, +/// If the file is a `.packages` file and [preferNewest] is true, the default, +/// also checks if there is a `.dart_tool/package_config.json` file next to the original file, /// and if so, loads that instead. -Future<PackageConfig> loadPackageConfig(File file) => readAnyConfigFile(file); +/// If [preferNewest] is set to false, a directly specified `.packages` file +/// is loaded even if there is an available `package_config.json` file. +/// The caller can determine this from the [PackageConfig.version] +/// being 1 and look for a `package_config.json` file themselves. +Future<PackageConfig> loadPackageConfig(File file, + {bool preferNewest = true}) => + readAnyConfigFile(file, preferNewest); /// Reads a specific package configuration URI. /// @@ -36,19 +42,24 @@ /// It is considered a `package_config.json` file if its first /// non-whitespace character is a `{`. /// -/// If the file is a `.packages` file, first checks if there is a -/// `.dart_tool/package_config.json` file next to the original file, -/// and if so, loads that instead. +/// If [preferNewest] is true, the default, and the file is a `.packages` file, +/// first checks if there is a `.dart_tool/package_config.json` file +/// next to the original file, and if so, loads that instead. /// The [file] *must not* be a `package:` URI. +/// If [preferNewest] is set to false, a directly specified `.packages` file +/// is loaded even if there is an available `package_config.json` file. +/// The caller can determine this from the [PackageConfig.version] +/// being 1 and look for a `package_config.json` file themselves. /// /// If [loader] is provided, URIs are loaded using that function. /// The future returned by the loader must complete with a [Uint8List] -/// containing the entire file content, +/// containing the entire file content encoded as UTF-8, /// or with `null` if the file does not exist. /// The loader may throw at its own discretion, for situations where /// it determines that an error might be need user attention, /// but it is always allowed to return `null`. /// This function makes no attempt to catch such errors. +/// As such, it may throw any error that [loader] throws. /// /// If no [loader] is supplied, a default loader is used which /// only accepts `file:`, `http:` and `https:` URIs, @@ -58,8 +69,9 @@ /// As such, it does not distinguish between a file not existing, /// and it being temporarily locked or unreachable. Future<PackageConfig> loadPackageConfigUri(Uri file, - {Future<Uint8List /*?*/ > loader(Uri uri) /*?*/}) => - readAnyConfigFileUri(file, loader); + {Future<Uint8List /*?*/ > loader(Uri uri) /*?*/, + bool preferNewest = true}) => + readAnyConfigFileUri(file, loader, preferNewest); /// Finds a package configuration relative to [directory]. /// @@ -123,14 +135,6 @@ /// If the `.dart_tool/` directory does not exist, it is created. /// If it cannot be created, this operation fails. /// -/// If [extraData] contains any entries, they are added to the JSON -/// written to the `package_config.json` file. Entries with the names -/// `"configVersion"` or `"packages"` are ignored, all other entries -/// are added verbatim. -/// This is intended for, e.g., the -/// `"generator"`, `"generated"` and `"generatorVersion"` -/// properties. -/// /// Also writes a `.packages` file in [directory]. /// This will stop happening eventually as the `.packages` file becomes /// discontinued.
diff --git a/pkgs/package_config/lib/src/discovery.dart b/pkgs/package_config/lib/src/discovery.dart index 6b0fc8f..5ad6ac5 100644 --- a/pkgs/package_config/lib/src/discovery.dart +++ b/pkgs/package_config/lib/src/discovery.dart
@@ -5,7 +5,7 @@ import "dart:io"; import 'dart:typed_data'; -import "package:path/path.dart" as path; +import "package:path/path.dart" as p; import "errors.dart"; import "package_config_impl.dart"; @@ -110,23 +110,13 @@ Future<File> /*?*/ checkForPackageConfigJsonFile(Directory directory) async { assert(directory.isAbsolute); - var file = - File(path.join(directory.path, ".dart_tool", "package_config.json")); + var file = File(p.join(directory.path, ".dart_tool", "package_config.json")); if (await file.exists()) return file; return null; } Future<File /*?*/ > checkForDotPackagesFile(Directory directory) async { - var file = File(path.join(directory.path, ".packages")); + var file = File(p.join(directory.path, ".packages")); if (await file.exists()) return file; return null; } - -Future<Uint8List /*?*/ > _loadFile(File file) async { - Uint8List bytes; - try { - return await file.readAsBytes(); - } catch (_) { - return null; - } -}
diff --git a/pkgs/package_config/lib/src/package_config_impl.dart b/pkgs/package_config/lib/src/package_config_impl.dart index 0bbe18f..6257961 100644 --- a/pkgs/package_config/lib/src/package_config_impl.dart +++ b/pkgs/package_config/lib/src/package_config_impl.dart
@@ -4,22 +4,26 @@ import 'errors.dart'; import "package_config.dart"; -export "package_config.dart"; import "util.dart"; +export "package_config.dart"; + class SimplePackageConfig implements PackageConfig { final int version; final Map<String, Package> _packages; + final PackageTree _packageTree; final dynamic extraData; - SimplePackageConfig(int version, Iterable<Package> packages, [this.extraData]) - : version = _validateVersion(version), - _packages = _validatePackages(packages); + SimplePackageConfig(int version, Iterable<Package> packages, + [dynamic extraData]) + : this._(_validateVersion(version), packages, + [...packages]..sort(_compareRoot), extraData); - SimplePackageConfig._( - int version, Iterable<SimplePackage> packages, this.extraData) - : version = _validateVersion(version), - _packages = {for (var package in packages) package.name: package}; + /// Expects a list of [packages] sorted on root path. + SimplePackageConfig._(this.version, Iterable<Package> originalPackages, + List<Package> packages, this.extraData) + : _packageTree = _validatePackages(originalPackages, packages), + _packages = {for (var p in packages) p.name: p}; /// Creates empty configuration. /// @@ -27,6 +31,7 @@ /// found, but code expects a non-null configuration. const SimplePackageConfig.empty() : version = 1, + _packageTree = const EmptyPackageTree(), _packages = const <String, Package>{}, extraData = null; @@ -38,18 +43,28 @@ return version; } - static Map<String, Package> _validatePackages(Iterable<Package> packages) { + static PackageTree _validatePackages( + Iterable<Package> originalPackages, List<Package> packages) { + // Assumes packages are sorted. Map<String, Package> result = {}; - for (var package in packages) { - if (package is! SimplePackage) { + var tree = MutablePackageTree(); + SimplePackage package; + for (var originalPackage in packages) { + if (originalPackage is! SimplePackage) { // SimplePackage validates these properties. try { - _validatePackageData(package.name, package.root, - package.packageUriRoot, package.languageVersion); + package = SimplePackage( + originalPackage.name, + originalPackage.root, + originalPackage.packageUriRoot, + originalPackage.languageVersion, + originalPackage.extraData); } catch (e) { throw PackageConfigArgumentError( packages, "packages", "Package ${package.name}: ${e.message}"); } + } else { + package = originalPackage; } var name = package.name; if (result.containsKey(name)) { @@ -57,33 +72,31 @@ name, "packages", "Duplicate package name"); } result[name] = package; - } - - // Check that no root URI is a prefix of another. - if (result.length > 1) { - // Uris cache their toString, so this is not as bad as it looks. - var rootUris = [...result.values] - ..sort((a, b) => a.root.toString().compareTo(b.root.toString())); - var prev = rootUris[0]; - var prevRoot = prev.root.toString(); - for (int i = 1; i < rootUris.length; i++) { - var next = rootUris[i]; - var nextRoot = next.root.toString(); - // If one string is a prefix of another, - // the former sorts just before the latter. - if (nextRoot.startsWith(prevRoot)) { + try { + tree.add(0, package); + } on ConflictException catch (e) { + // There is a conflict with an existing package. + var existingPackage = e.existingPackage; + if (e.isRootConflict) { throw PackageConfigArgumentError( - packages, + originalPackages, "packages", - "Package ${next.name} root overlaps " - "package ${prev.name} root.\n" - "${prev.name} root: $prevRoot\n" - "${next.name} root: $nextRoot\n"); + "Packages ${package.name} and ${existingPackage.name}" + "have the same root directory: ${package.root}.\n"); } - prev = next; + assert(e.isPackageRootConflict); + // Or package is inside the package URI root of the existing package. + throw 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"); } } - return result; + return tree; } Iterable<Package> get packages => _packages.values; @@ -96,14 +109,7 @@ /// That is, the [Package.rootUri] directory is a parent directory /// of the [file]'s location. /// Returns `null` if the file does not belong to any package. - Package /*?*/ packageOf(Uri file) { - String path = file.toString(); - for (var package in _packages.values) { - var rootPath = package.root.toString(); - if (path.startsWith(rootPath)) return package; - } - return null; - } + Package /*?*/ packageOf(Uri file) => _packageTree.packageOf(file); Uri /*?*/ resolve(Uri packageUri) { String packageName = checkValidPackageUri(packageUri, "packageUri"); @@ -120,12 +126,15 @@ throw PackageConfigArgumentError(nonPackageUri, "nonPackageUri", "Must not have query or fragment part"); } - for (var package in _packages.values) { - var root = package.packageUriRoot; - if (isUriPrefix(root, nonPackageUri)) { - var rest = nonPackageUri.toString().substring(root.toString().length); - return Uri(scheme: "package", path: "${package.name}/$rest"); - } + // Find package that file belongs to. + var package = _packageTree.packageOf(nonPackageUri); + if (package == null) return null; + // Check if it is inside the package URI root. + var path = nonPackageUri.toString(); + var root = package.packageUriRoot.toString(); + if (_beginsWith(package.root.toString().length, root, path)) { + var rest = path.substring(root.length); + return Uri(scheme: "package", path: "${package.name}/$rest"); } return null; } @@ -142,6 +151,9 @@ SimplePackage._(this.name, this.root, this.packageUriRoot, this.languageVersion, this.extraData); + /// Creates a [SimplePackage] with the provided content. + /// + /// The provided arguments must be valid. factory SimplePackage(String name, Uri root, Uri packageUriRoot, String /*?*/ languageVersion, dynamic extraData) { _validatePackageData(name, root, packageUriRoot, languageVersion); @@ -179,3 +191,144 @@ languageVersion, "languageVersion", "Invalid language version format"); } } + +abstract class PackageTree { + SimplePackage /*?*/ packageOf(Uri file); +} + +/// Packages of a package configuration ordered by root path. +/// +/// 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. +/// +/// The package tree contains an ordered mapping of unrelated packages +/// (represented by their name) to their immediately nested packages' names. +class MutablePackageTree implements PackageTree { + final List<SimplePackage> packages = []; + Map<String, MutablePackageTree /*?*/ > /*?*/ _packageChildren; + + /// Tries to (add) `package` to the tree. + /// + /// Throws [ConflictException] if the added package conflicts with an + /// existing package. + /// It conflicts if it has the same root path, or if the new package + /// contains the existing package's package root. + void add(int start, SimplePackage package) { + var path = package.root.toString(); + for (var childPackage in packages) { + var childPath = childPackage.root.toString(); + assert(childPath.length > start); + assert(path.startsWith(childPath.substring(0, start))); + if (_beginsWith(start, childPath, path)) { + var childPathLength = childPath.length; + if (path.length == childPathLength) { + throw ConflictException.root(package, childPackage); + } + var childPackageRoot = childPackage.packageUriRoot.toString(); + if (_beginsWith(childPathLength, childPackageRoot, path)) { + throw ConflictException.packageRoot(package, childPackage); + } + _treeOf(childPackage).add(childPathLength, package); + return; + } + } + packages.add(package); + } + + SimplePackage /*?*/ packageOf(Uri file) { + return findPackageOf(0, file.toString()); + } + + /// 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; + } + // Otherwise add [package] as child of [childPackage]. + // TODO(lrn): When NNBD comes, convert to: + // return _packageChildren?[childPackage.name] + // ?.packageOf(childPathLength, path) ?? childPackage; + if (_packageChildren == null) return childPackage; + var childTree = _packageChildren[childPackage.name]; + if (childTree == null) return childPackage; + return childTree.findPackageOf(childPathLength, path) ?? childPackage; + } + } + 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(); + } +} + +class EmptyPackageTree implements PackageTree { + const EmptyPackageTree(); + + SimplePackage packageOf(Uri file) => null; +} + +/// Checks whether [longerPath] begins with [parentPath]. +/// +/// Skips checking the [start] first characters which are assumed to +/// already have been matched. +bool _beginsWith(int start, String parentPath, String longerPath) { + if (longerPath.length < parentPath.length) return false; + for (int i = start; i < parentPath.length; i++) { + if (longerPath.codeUnitAt(i) != parentPath.codeUnitAt(i)) return false; + } + return true; +} + +/// Conflict between packages added to the same configuration. +/// +/// The [package] conflicts with [existingPackage] if it has +/// the same root path ([isRootConflict]) or the package URI root path +/// of [existingPackage] is inside the root path of [package] +/// ([isPackageRootConflict]). +class ConflictException { + /// The existing package that [package] conflicts with. + final SimplePackage existingPackage; + + /// The package that could not be added without a conflict. + final SimplePackage package; + + /// Whether the conflict is with the package URI root of [existingPackage]. + final bool isPackageRootConflict; + + /// 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; +} + +/// Used for sorting packages by root path. +int _compareRoot(Package p1, Package p2) => + p1.root.toString().compareTo(p2.root.toString());
diff --git a/pkgs/package_config/lib/src/package_config_json.dart b/pkgs/package_config/lib/src/package_config_json.dart index 8a6014c..8108102 100644 --- a/pkgs/package_config/lib/src/package_config_json.dart +++ b/pkgs/package_config/lib/src/package_config_json.dart
@@ -7,7 +7,7 @@ import "dart:typed_data"; import 'package:charcode/ascii.dart'; -import "package:path/path.dart" as path; +import "package:path/path.dart" as p; import "discovery.dart" show packageConfigJsonPath; import "errors.dart"; @@ -38,36 +38,42 @@ /// Detects whether the [file] is a version one `.packages` file or /// a version two `package_config.json` file. /// -/// If the [file] is a `.packages` file, first checks whether there is an -/// adjacent `.dart_tool/package_config.json` file, and if so, -/// reads that instead. +/// If the [file] is a `.packages` file and [preferNewest] is true, +/// first checks whether there is an adjacent `.dart_tool/package_config.json` +/// file, and if so, reads that instead. +/// If [preferNewset] is false, the specified file is loaded even if it is +/// a `.packages` file and there is an available `package_config.json` file. /// /// The file must exist and be a normal file. -Future<PackageConfig> readAnyConfigFile(File file) async { +Future<PackageConfig> readAnyConfigFile(File file, bool preferNewest) async { var bytes = await file.readAsBytes(); int firstChar = firstNonWhitespaceChar(bytes); if (firstChar != $lbrace) { // Definitely not a JSON object, probably a .packages. - var alternateFile = File(path.join( - path.dirname(file.path), ".dart_tool", "package_config.json")); - if (!alternateFile.existsSync()) { - return packages_file.parse(bytes, file.uri); + if (preferNewest) { + var alternateFile = File( + p.join(p.dirname(file.path), ".dart_tool", "package_config.json")); + if (alternateFile.existsSync()) { + return parsePackageConfigBytes( + await alternateFile.readAsBytes(), alternateFile.uri); + } } - file = alternateFile; - bytes = await alternateFile.readAsBytes(); + return packages_file.parse(bytes, file.uri); } return parsePackageConfigBytes(bytes, file.uri); } /// Like [readAnyConfigFile] but uses a URI and an optional loader. -Future<PackageConfig> readAnyConfigFileUri( - Uri file, Future<Uint8List /*?*/ > loader(Uri uri) /*?*/) async { +Future<PackageConfig> readAnyConfigFileUri(Uri file, + Future<Uint8List /*?*/ > loader(Uri uri) /*?*/, bool preferNewest) async { if (file.isScheme("package")) { throw PackageConfigArgumentError( file, "file", "Must not be a package: URI"); } if (loader == null) { - if (file.isScheme("file")) return readAnyConfigFile(File.fromUri(file)); + if (file.isScheme("file")) { + return readAnyConfigFile(File.fromUri(file), preferNewest); + } loader = defaultLoader; } var bytes = await loader(file); @@ -78,13 +84,15 @@ int firstChar = firstNonWhitespaceChar(bytes); if (firstChar != $lbrace) { // Definitely not a JSON object, probably a .packages. - var alternateFile = file.resolveUri(packageConfigJsonPath); - var alternateBytes = await loader(alternateFile); - if (alternateBytes == null) { - return packages_file.parse(bytes, file); + if (preferNewest) { + // Check if there is a package_config.json file. + var alternateFile = file.resolveUri(packageConfigJsonPath); + var alternateBytes = await loader(alternateFile); + if (alternateBytes != null) { + return parsePackageConfigBytes(alternateBytes, alternateFile); + } } - bytes = alternateBytes; - file = alternateFile; + return packages_file.parse(bytes, file); } return parsePackageConfigBytes(bytes, file); } @@ -242,8 +250,8 @@ Future<void> writePackageConfigJson( PackageConfig config, Directory targetDirectory) async { // Write .dart_tool/package_config.json first. - var file = File( - path.join(targetDirectory.path, ".dart_tool", "package_config.json")); + var file = + File(p.join(targetDirectory.path, ".dart_tool", "package_config.json")); var baseUri = file.uri; var extraData = config.extraData; var data = <String, dynamic>{ @@ -275,7 +283,7 @@ "${generated != null ? " on $generated" : ""}."; } } - file = File(path.join(targetDirectory.path, ".packages")); + file = File(p.join(targetDirectory.path, ".packages")); baseUri = file.uri; var buffer = StringBuffer(); packages_file.write(buffer, config, baseUri: baseUri, comment: comment); @@ -313,7 +321,7 @@ /// Checks that the object is a valid JSON-like data structure. bool _validateJson(dynamic object) { - if (object == null || object == true || object == false) return true; + if (object == null || true == object || false == object) return true; if (object is num || object is String) return true; if (object is List<dynamic>) { for (var element in object) if (!_validateJson(element)) return false;
diff --git a/pkgs/package_config/pubspec.yaml b/pkgs/package_config/pubspec.yaml index 0cd7ddc..781dc32 100644 --- a/pkgs/package_config/pubspec.yaml +++ b/pkgs/package_config/pubspec.yaml
@@ -5,7 +5,7 @@ homepage: https://github.com/dart-lang/package_config environment: - sdk: '>=2.5.0-dev <3.0.0' + sdk: '>=2.7.0 <3.0.0' dependencies: charcode: ^1.1.0
diff --git a/pkgs/package_config/test/discovery_test.dart b/pkgs/package_config/test/discovery_test.dart index 5db24a1..4dd1504 100644 --- a/pkgs/package_config/test/discovery_test.dart +++ b/pkgs/package_config/test/discovery_test.dart
@@ -170,6 +170,13 @@ expect(config.version, 2); validatePackagesFile(config, directory); }); + fileTest("prefer .packages", files, (Directory directory) async { + File file = dirFile(directory, ".packages"); + PackageConfig config = + await loadPackageConfig(file, preferNewest: false); + expect(config.version, 1); + validatePackagesFile(config, directory); + }); }); fileTest("package_config.json non-default name", {
diff --git a/pkgs/package_config/test/discovery_uri_test.dart b/pkgs/package_config/test/discovery_uri_test.dart index 414a43a..9558f58 100644 --- a/pkgs/package_config/test/discovery_uri_test.dart +++ b/pkgs/package_config/test/discovery_uri_test.dart
@@ -4,7 +4,6 @@ library package_config.discovery_test; -import "dart:io"; import "package:test/test.dart"; import "package:package_config_2/package_config.dart";
diff --git a/pkgs/package_config/test/parse_test.dart b/pkgs/package_config/test/parse_test.dart index 235f493..a07ef1d 100644 --- a/pkgs/package_config/test/parse_test.dart +++ b/pkgs/package_config/test/parse_test.dart
@@ -2,7 +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:io"; import "dart:convert"; import "package:test/test.dart"; @@ -199,9 +198,43 @@ expect(config.packages.first.name, "foo"); }); + test("nested packages", () { + var configBytes = utf8.encode(json.encode({ + "configVersion": 2, + "packages": [ + {"name": "foo", "rootUri": "/foo/", "packageUri": "lib/"}, + {"name": "bar", "rootUri": "/foo/bar/", "packageUri": "lib/"}, + {"name": "baz", "rootUri": "/foo/bar/baz/", "packageUri": "lib/"}, + {"name": "qux", "rootUri": "/foo/qux/", "packageUri": "lib/"}, + ] + })); + var config = parsePackageConfigBytes( + configBytes, Uri.parse("file:///tmp/.dart_tool/file.dart")); + expect(config.version, 2); + expect(config.packageOf(Uri.parse("file:///foo/lala/lala.dart")).name, + "foo"); + expect( + config.packageOf(Uri.parse("file:///foo/bar/lala.dart")).name, "bar"); + expect(config.packageOf(Uri.parse("file:///foo/bar/baz/lala.dart")).name, + "baz"); + expect( + config.packageOf(Uri.parse("file:///foo/qux/lala.dart")).name, "qux"); + expect(config.toPackageUri(Uri.parse("file:///foo/lib/diz")), + Uri.parse("package:foo/diz")); + expect(config.toPackageUri(Uri.parse("file:///foo/bar/lib/diz")), + Uri.parse("package:bar/diz")); + expect(config.toPackageUri(Uri.parse("file:///foo/bar/baz/lib/diz")), + Uri.parse("package:baz/diz")); + expect(config.toPackageUri(Uri.parse("file:///foo/qux/lib/diz")), + Uri.parse("package:qux/diz")); + }); + group("invalid", () { testThrows(String name, String source) { test(name, () { + if (name == "inside lib") { + print(name); + } expect( () => parsePackageConfigBytes(utf8.encode(source), Uri.parse("file:///tmp/.dart_tool/file.dart")), @@ -304,9 +337,15 @@ testThrows("same roots", '{$cfg,"packages":[{$name,$root},{"name":"bar",$root}]}'); testThrows( - "overlapping roots", - '{$cfg,"packages":[{$name,$root},' - '{"name":"bar","rootUri":"/foo/sub/"}]}'); + // The roots of foo and bar are the same. + "same roots", + '{$cfg,"packages":[{$name,$root},{"name":"bar",$root}]}'); + testThrows( + // The root of bar is inside the package root of foo. + "inside lib", + '{$cfg,"packages":[' + '{"name":"foo","rootUri":"/foo/","packageUri":"lib/"},' + '{"name":"bar","rootUri":"/foo/lib/qux/"}]}'); }); }); }