diff --git a/lib/src/barback.dart b/lib/src/barback.dart index 21ca99a58cbcbb4614bd8f6ab2d2231c278be0cd..b539d6b3b59d9180e92388558331fe3420502df6 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 6315802760a5dffec1730ff3b15c360d1f80476b..4577174f8359a7da8ad97cc2fa2d08032c674e8b 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 0000000000000000000000000000000000000000..dbfd0158444879959a6edec151f5e22e5dc2c5a2 --- /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 876d7b5bf71fc4349245a319d2dfd27a6b23c505..c5d236d0c368ebdb3fbb43ad03e594031cab618a 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 66b3621e0072c0d767677fd492b7742563c585f6..3b17112331a72e4b2498f84e5d15ac59bd040368 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 0000000000000000000000000000000000000000..4754940464ac04f005040c4a58daa63f36ccb1ee --- /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 3075ebf5679ac8a602de3a2e2e4e823788e32388..dabf48099b10bd3c30f851daf1f5e816e9e6c99e 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 b51220010b3311ceba1faca9445163ebd3712dbf..12aa379870fc974457ed91c02ddccc2d3a698bf3 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 ffa5b46666b01d933845617b5d0aa92666a5f1df..f885eaaa0e9ac8c1975f4305074e2249f2c576d2 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 0000000000000000000000000000000000000000..c72799e8289f22080f048b21eb837d17ddcea733 --- /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 75b834e9ded73c74f5bca5fd1946a91cc8e5caa6..a381e089cc597ee32411a95ca6fa3e35832ad691 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].