Skip to content

Commit

Permalink
[Package/ModuleGraph] Allow cyclic package dependencies if they don't…
Browse files Browse the repository at this point in the history
… introduce a cycle in a build graph (swiftlang#7530)

It should be possible for packages to depend on each other if such
dependence doesn't introduce cycles in the build graph.

- Introduced a new DFS method to walk over graphs that breaks cycles.
- Replaces use of `findCycle` + `topologicalSort` with `DFS` while
building manifest and package graphs. This allows cycles in dependencies
to be modeled correctly.
- Removes some of the redundant data transformations from modules graph.
- Modifies `ResolvedPackage` to carry identities of its dependencies
instead of resolved dependencies themselves. This helps to simplify
logic in `createResolvedPackages`.
- Adds detection of target cycles across packages.

Makes it possible for package A to depend on package B and B to depend
on A if their targets don't form a cycle.

(cherry picked from commit 8b12909)
  • Loading branch information
xedin committed May 8, 2024
1 parent 1889cd1 commit 6246c3a
Show file tree
Hide file tree
Showing 21 changed files with 279 additions and 202 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ Note: This is in reverse chronological order, so newer entries are added to the
Swift 6.0
-----------

* [#7530]

Makes it possible for packages to depend on each other if such dependency doesn't form any target-level cycles. For example,
package `A` can depend on `B` and `B` on `A` unless targets in `B` depend on products of `A` that depend on some of the same
targets from `B` and vice versa.

* [#7507]

`swift experimental-sdk` command is deprecated with `swift sdk` command replacing it. `--experimental-swift-sdk` and
Expand Down
1 change: 1 addition & 0 deletions Sources/Basics/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ add_library(Basics
FileSystem/TemporaryFile.swift
FileSystem/TSCAdapters.swift
FileSystem/VFSOverlay.swift
Graph/GraphAlgorithms.swift
SourceControlURL.swift
HTTPClient/HTTPClient.swift
HTTPClient/HTTPClientConfiguration.swift
Expand Down
57 changes: 57 additions & 0 deletions Sources/Basics/Graph/GraphAlgorithms.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2015-2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import struct OrderedCollections.OrderedSet

/// Implements a pre-order depth-first search.
///
/// The cycles are handled by skipping cycle points but it should be possible to
/// to extend this in the future to provide a callback for every cycle.
///
/// - Parameters:
/// - nodes: The list of input nodes to sort.
/// - successors: A closure for fetching the successors of a particular node.
/// - onUnique: A callback to indicate the the given node is being processed for the first time.
/// - onDuplicate: A callback to indicate that the node was already processed at least once.
///
/// - Complexity: O(v + e) where (v, e) are the number of vertices and edges
/// reachable from the input nodes via the relation.
public func depthFirstSearch<T: Hashable>(
_ nodes: [T],
successors: (T) throws -> [T],
onUnique: (T) -> Void,
onDuplicate: (T, T) -> Void
) rethrows {
var stack = OrderedSet<T>()
var visited = Set<T>()

for node in nodes {
precondition(stack.isEmpty)
stack.append(node)

while !stack.isEmpty {
let curr = stack.removeLast()

let visitResult = visited.insert(curr)
if visitResult.inserted {
onUnique(curr)
} else {
onDuplicate(visitResult.memberAfterInsert, curr)
continue
}

for succ in try successors(curr) {
stack.append(succ)
}
}
}
}
1 change: 1 addition & 0 deletions Sources/Commands/PackageCommands/CompletionCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ extension SwiftPackageCommand {
// command's result output goes on stdout
// ie "swift package list-dependencies" should output to stdout
ShowDependencies.dumpDependenciesOf(
graph: graph,
rootPackage: graph.rootPackages[graph.rootPackages.startIndex],
mode: .flatlist,
on: TSCBasic.stdoutStream
Expand Down
16 changes: 12 additions & 4 deletions Sources/Commands/PackageCommands/PluginCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ struct PluginCommand: SwiftCommand {
pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories,
sdkRootPath: buildParameters.toolchain.sdkRootPath,
fileSystem: swiftCommandState.fileSystem,
modulesGraph: packageGraph,
observabilityScope: swiftCommandState.observabilityScope,
callbackQueue: delegateQueue,
delegate: pluginDelegate,
Expand All @@ -375,10 +376,17 @@ struct PluginCommand: SwiftCommand {

static func availableCommandPlugins(in graph: ModulesGraph, limitedTo packageIdentity: String?) -> [PluginTarget] {
// All targets from plugin products of direct dependencies are "available".
let directDependencyPackages = graph.rootPackages.flatMap { $0.dependencies }.filter { $0.matching(identity: packageIdentity) }
let directDependencyPackages = graph.rootPackages.flatMap {
$0.dependencies
}.filter {
$0.matching(identity: packageIdentity)
}.compactMap {
graph.package(for: $0)
}

let directDependencyPluginTargets = directDependencyPackages.flatMap { $0.products.filter { $0.type == .plugin } }.flatMap { $0.targets }
// As well as any plugin targets in root packages.
let rootPackageTargets = graph.rootPackages.filter { $0.matching(identity: packageIdentity) }.flatMap { $0.targets }
let rootPackageTargets = graph.rootPackages.filter { $0.identity.matching(identity: packageIdentity) }.flatMap { $0.targets }
return (directDependencyPluginTargets + rootPackageTargets).compactMap { $0.underlying as? PluginTarget }.filter {
switch $0.capability {
case .buildTool: return false
Expand Down Expand Up @@ -462,10 +470,10 @@ extension SandboxNetworkPermission {
}
}

extension ResolvedPackage {
extension PackageIdentity {
fileprivate func matching(identity: String?) -> Bool {
if let identity {
return self.identity == .plain(identity)
return self == .plain(identity)
} else {
return true
}
Expand Down
10 changes: 8 additions & 2 deletions Sources/Commands/PackageCommands/ShowDependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,19 @@ extension SwiftPackageCommand {
// ie "swift package show-dependencies" should output to stdout
let stream: OutputByteStream = try outputPath.map { try LocalFileOutputByteStream($0) } ?? TSCBasic.stdoutStream
Self.dumpDependenciesOf(
graph: graph,
rootPackage: graph.rootPackages[graph.rootPackages.startIndex],
mode: format,
on: stream
)
}

static func dumpDependenciesOf(rootPackage: ResolvedPackage, mode: ShowDependenciesMode, on stream: OutputByteStream) {
static func dumpDependenciesOf(
graph: ModulesGraph,
rootPackage: ResolvedPackage,
mode: ShowDependenciesMode,
on stream: OutputByteStream
) {
let dumper: DependenciesDumper
switch mode {
case .text:
Expand All @@ -61,7 +67,7 @@ extension SwiftPackageCommand {
case .flatlist:
dumper = FlatListDumper()
}
dumper.dump(dependenciesOf: rootPackage, on: stream)
dumper.dump(graph: graph, dependenciesOf: rootPackage, on: stream)
stream.flush()
}

Expand Down
22 changes: 11 additions & 11 deletions Sources/Commands/Utilities/DependenciesSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ import enum TSCBasic.JSON
import protocol TSCBasic.OutputByteStream

protocol DependenciesDumper {
func dump(dependenciesOf: ResolvedPackage, on: OutputByteStream)
func dump(graph: ModulesGraph, dependenciesOf: ResolvedPackage, on: OutputByteStream)
}

final class PlainTextDumper: DependenciesDumper {
func dump(dependenciesOf rootpkg: ResolvedPackage, on stream: OutputByteStream) {
func dump(graph: ModulesGraph, dependenciesOf rootpkg: ResolvedPackage, on stream: OutputByteStream) {
func recursiveWalk(packages: [ResolvedPackage], prefix: String = "") {
var hanger = prefix + "├── "

Expand All @@ -39,38 +39,38 @@ final class PlainTextDumper: DependenciesDumper {
var childPrefix = hanger
let startIndex = childPrefix.index(childPrefix.endIndex, offsetBy: -4)
childPrefix.replaceSubrange(startIndex..<childPrefix.endIndex, with: replacement)
recursiveWalk(packages: package.dependencies, prefix: childPrefix)
recursiveWalk(packages: graph.directDependencies(for: package), prefix: childPrefix)
}
}
}

if !rootpkg.dependencies.isEmpty {
stream.send(".\n")
recursiveWalk(packages: rootpkg.dependencies)
recursiveWalk(packages: graph.directDependencies(for: rootpkg))
} else {
stream.send("No external dependencies found\n")
}
}
}

final class FlatListDumper: DependenciesDumper {
func dump(dependenciesOf rootpkg: ResolvedPackage, on stream: OutputByteStream) {
func dump(graph: ModulesGraph, dependenciesOf rootpkg: ResolvedPackage, on stream: OutputByteStream) {
func recursiveWalk(packages: [ResolvedPackage]) {
for package in packages {
stream.send(package.identity.description).send("\n")
if !package.dependencies.isEmpty {
recursiveWalk(packages: package.dependencies)
recursiveWalk(packages: graph.directDependencies(for: package))
}
}
}
if !rootpkg.dependencies.isEmpty {
recursiveWalk(packages: rootpkg.dependencies)
recursiveWalk(packages: graph.directDependencies(for: rootpkg))
}
}
}

final class DotDumper: DependenciesDumper {
func dump(dependenciesOf rootpkg: ResolvedPackage, on stream: OutputByteStream) {
func dump(graph: ModulesGraph, dependenciesOf rootpkg: ResolvedPackage, on stream: OutputByteStream) {
var nodesAlreadyPrinted: Set<String> = []
func printNode(_ package: ResolvedPackage) {
let url = package.manifest.packageLocation
Expand All @@ -87,7 +87,7 @@ final class DotDumper: DependenciesDumper {
var dependenciesAlreadyPrinted: Set<DependencyURLs> = []
func recursiveWalk(rootpkg: ResolvedPackage) {
printNode(rootpkg)
for dependency in rootpkg.dependencies {
for dependency in graph.directDependencies(for: rootpkg) {
let rootURL = rootpkg.manifest.packageLocation
let dependencyURL = dependency.manifest.packageLocation
let urlPair = DependencyURLs(root: rootURL, dependency: dependencyURL)
Expand Down Expand Up @@ -120,15 +120,15 @@ final class DotDumper: DependenciesDumper {
}

final class JSONDumper: DependenciesDumper {
func dump(dependenciesOf rootpkg: ResolvedPackage, on stream: OutputByteStream) {
func dump(graph: ModulesGraph, dependenciesOf rootpkg: ResolvedPackage, on stream: OutputByteStream) {
func convert(_ package: ResolvedPackage) -> JSON {
return .orderedDictionary([
"identity": .string(package.identity.description),
"name": .string(package.manifest.displayName), // TODO: remove?
"url": .string(package.manifest.packageLocation),
"version": .string(package.manifest.version?.description ?? "unspecified"),
"path": .string(package.path.pathString),
"dependencies": .array(package.dependencies.map(convert)),
"dependencies": .array(package.dependencies.compactMap { graph.packages[$0] }.map(convert)),
])
}

Expand Down
Loading

0 comments on commit 6246c3a

Please sign in to comment.