diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index cca8715b461..58561a98230 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -764,7 +764,8 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS // `BuildPlan`. if let pluginConfiguration: PluginConfiguration, !self.config.shouldSkipBuilding(for: .target) { let pluginsPerModule = graph.pluginsPerModule( - satisfying: self.config.buildEnvironment(for: .host) + satisfyingHost: self.config.buildEnvironment(for: .host), + targetEnvironment: self.config.buildEnvironment(for: .target) ) pluginTools = try await buildPluginTools( diff --git a/Sources/Build/BuildPlan/BuildPlan.swift b/Sources/Build/BuildPlan/BuildPlan.swift index 1960c4ed374..314d44b8c21 100644 --- a/Sources/Build/BuildPlan/BuildPlan.swift +++ b/Sources/Build/BuildPlan/BuildPlan.swift @@ -325,7 +325,8 @@ public class BuildPlan: SPMBuildCore.BuildPlan { for: module, destination: destination, configuration: pluginConfiguration, - buildParameters: toolsBuildParameters, + hostBuildParameters: toolsBuildParameters, + targetBuildEnvironment: buildParameters.buildEnvironment, modulesGraph: graph, tools: pluginTools, additionalFileRules: additionalFileRules, @@ -741,7 +742,8 @@ extension BuildPlan { for module: ResolvedModule, destination: BuildParameters.Destination, configuration: PluginConfiguration, - buildParameters: BuildParameters, + hostBuildParameters: BuildParameters, + targetBuildEnvironment: BuildEnvironment, modulesGraph: ModulesGraph, tools: [ResolvedModule.ID: [String: PluginTool]], additionalFileRules: [FileRuleDescription], @@ -757,10 +759,16 @@ extension BuildPlan { throw InternalError("could not determine package for module \(self)") } + let enabledTraits = modulesGraph.enabledTraitsMap[package.identity] + // Apply each build tool plugin used by the target in order, // creating a list of results (one for each plugin usage). var buildToolPluginResults: [BuildToolPluginInvocationResult] = [] - for plugin in module.pluginDependencies(satisfying: buildParameters.buildEnvironment) { + for plugin in module.pluginDependencies( + satisfying: hostBuildParameters.buildEnvironment, + targetEnvironment: targetBuildEnvironment, + enabledTraits: enabledTraits + ) { let pluginModule = plugin.underlying as! PluginModule // Determine the tools to which this plugin has access, and create a name-to-path mapping from tool @@ -801,14 +809,14 @@ extension BuildPlan { target: module, toolsVersion: package.manifest.toolsVersion, additionalFileRules: additionalFileRules, - buildParameters: buildParameters, + buildParameters: hostBuildParameters, buildToolPluginInvocationResults: buildToolPluginResults, prebuildCommandResults: [], observabilityScope: observability.topScope ) pluginDerivedSources = Sources( paths: pluginGeneratedFiles.sources.map(\.self), - root: buildParameters.dataPath + root: hostBuildParameters.dataPath ) pluginDerivedResources = pluginGeneratedFiles.resources.values.map(\.self) } else { @@ -824,18 +832,18 @@ extension BuildPlan { pluginGeneratedSources: pluginDerivedSources.paths, pluginGeneratedResources: pluginDerivedResources.map(\.path) ), - buildEnvironment: buildParameters.buildEnvironment, - workers: buildParameters.workers, + buildEnvironment: hostBuildParameters.buildEnvironment, + workers: hostBuildParameters.workers, scriptRunner: configuration.scriptRunner, workingDirectory: package.path, outputDirectory: pluginOutputDir, - toolSearchDirectories: [buildParameters.toolchain.swiftCompilerPath.parentDirectory], + toolSearchDirectories: [hostBuildParameters.toolchain.swiftCompilerPath.parentDirectory], accessibleTools: accessibleTools, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories, allowNetworkConnections: [], pkgConfigDirectories: pkgConfigDirectories, - sdkRootPath: buildParameters.toolchain.sdkRootPath, + sdkRootPath: hostBuildParameters.toolchain.sdkRootPath, fileSystem: fileSystem, modulesGraph: modulesGraph, observabilityScope: observabilityScope diff --git a/Sources/Commands/PackageCommands/BuildServer.swift b/Sources/Commands/PackageCommands/BuildServer.swift index 0ca7a0aa324..c99faf2772a 100644 --- a/Sources/Commands/PackageCommands/BuildServer.swift +++ b/Sources/Commands/PackageCommands/BuildServer.swift @@ -51,7 +51,13 @@ struct BuildServer: AsyncSwiftCommand { sendMirrorFile: nil ) - let buildSystem = try await swiftCommandState.createBuildSystem(explicitBuildSystem: .swiftbuild) + // BSP / SourceKit-LSP feeds workspaces that can declare multiple ConfiguredTargets + // across platforms — this is the only context where one PIF generation can drive a + // multi-CT build. + let buildSystem = try await swiftCommandState.createBuildSystem( + explicitBuildSystem: .swiftbuild, + configuredTargetMode: .multiple + ) guard let swiftBuildSystem = buildSystem as? SwiftBuildSystem else { throw ArgumentParser.ValidationError("Failed to initialize the '--build-system swiftbuild' backend; expected a 'SwiftBuildSystem' but got '\(buildSystem)'") } diff --git a/Sources/CoreCommands/BuildSystemSupport.swift b/Sources/CoreCommands/BuildSystemSupport.swift index e3ddf294d40..fa19cf48a22 100644 --- a/Sources/CoreCommands/BuildSystemSupport.swift +++ b/Sources/CoreCommands/BuildSystemSupport.swift @@ -37,7 +37,8 @@ private struct NativeBuildSystemFactory: BuildSystemFactory { outputStream: OutputByteStream?, logLevel: Diagnostic.Severity?, observabilityScope: ObservabilityScope?, - delegate: BuildSystemDelegate? + delegate: BuildSystemDelegate?, + configuredTargetMode: PIFConfiguredTargetMode ) async throws -> any BuildSystem { _ = try await swiftCommandState.getRootPackageInformation(enableAllTraits) let testEntryPointPath = productsBuildParameters?.testProductStyle.explicitlySpecifiedEntryPointPath @@ -89,7 +90,8 @@ private struct XcodeBuildSystemFactory: BuildSystemFactory { outputStream: OutputByteStream?, logLevel: Diagnostic.Severity?, observabilityScope: ObservabilityScope?, - delegate: BuildSystemDelegate? + delegate: BuildSystemDelegate?, + configuredTargetMode: PIFConfiguredTargetMode ) throws -> any BuildSystem { return try XcodeBuildSystem( buildParameters: productsBuildParameters ?? self.swiftCommandState.productsBuildParameters, @@ -121,7 +123,8 @@ private struct SwiftBuildSystemFactory: BuildSystemFactory { outputStream: OutputByteStream?, logLevel: Diagnostic.Severity?, observabilityScope: ObservabilityScope?, - delegate: BuildSystemDelegate? + delegate: BuildSystemDelegate?, + configuredTargetMode: PIFConfiguredTargetMode ) throws -> any BuildSystem { return try SwiftBuildSystem( buildParameters: productsBuildParameters ?? self.swiftCommandState.productsBuildParameters, @@ -145,6 +148,7 @@ private struct SwiftBuildSystemFactory: BuildSystemFactory { ), delegate: delegate, scratchDirectory: self.swiftCommandState.scratchDirectory, + configuredTargetMode: configuredTargetMode, ) } } diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index 1fa671f69d9..b3e3e9d346b 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -972,7 +972,8 @@ public final class SwiftCommandState { outputStream: OutputByteStream? = .none, logLevel: Basics.Diagnostic.Severity? = nil, observabilityScope: ObservabilityScope? = .none, - delegate: BuildSystemDelegate? = nil + delegate: BuildSystemDelegate? = nil, + configuredTargetMode: PIFConfiguredTargetMode = .single ) async throws -> BuildSystem { if self.options.build.useIntegratedSwiftDriver && self.options.build.buildSystem == .native { @@ -995,7 +996,8 @@ public final class SwiftCommandState { outputStream: outputStream, logLevel: logLevel ?? self.logLevel, observabilityScope: observabilityScope, - delegate: delegate + delegate: delegate, + configuredTargetMode: configuredTargetMode ) // register the build system with the cancellation handler diff --git a/Sources/PackageGraph/ModulesGraph+Loading.swift b/Sources/PackageGraph/ModulesGraph+Loading.swift index 558d06673d3..ecffdfb741f 100644 --- a/Sources/PackageGraph/ModulesGraph+Loading.swift +++ b/Sources/PackageGraph/ModulesGraph+Loading.swift @@ -223,7 +223,8 @@ extension ModulesGraph { rootDependencies: resolvedPackages.filter { rootDependencies.contains($0.manifest) }, packages: resolvedPackages, dependencies: requiredDependencies, - binaryArtifacts: binaryArtifacts + binaryArtifacts: binaryArtifacts, + enabledTraitsMap: enabledTraitsMap ) } } @@ -237,7 +238,7 @@ private func checkAllDependenciesAreUsed( for package in rootPackages { // List all dependency products dependent on by the package modules. let productDependencies = IdentifiableSet(package.modules.flatMap { module in - module.dependencies.compactMap { moduleDependency in + let dependencyProducts = module.dependencies.compactMap { moduleDependency in switch moduleDependency { case .product(let product, _): product @@ -245,11 +246,20 @@ private func checkAllDependenciesAreUsed( nil } } + let pluginProducts = module.pluginUsages.compactMap { pluginUsage in + switch pluginUsage { + case .product(let product, _): + product + case .module: + nil + } + } + return dependencyProducts + pluginProducts }) // List all dependencies of modules that are guarded by a trait. let traitGuardedProductDependencies = Set(package.underlying.modules.flatMap { module in - module.dependencies.compactMap { moduleDependency in + let dependencyProducts = module.dependencies.compactMap { moduleDependency in switch moduleDependency { case .product(let product, let conditions): if conditions.contains(where: { $0.traitCondition != nil }) { @@ -261,6 +271,18 @@ private func checkAllDependenciesAreUsed( return nil } } + let pluginProducts = module.pluginUsages.compactMap { pluginUsage in + switch pluginUsage { + case .product(let product, let condition): + if let condition, !condition.traits.isEmpty { + return product.name + } + return nil + case .module: + return nil + } + } + return dependencyProducts + pluginProducts }) for dependencyId in package.dependencies { @@ -587,8 +609,8 @@ private func createResolvedPackages( } packageBuilder.modules = moduleBuilders - // Establish dependencies between the modules. A module can only depend on another module present in the same - // package. + // Establish dependencies and local plugin usages between the modules. + // A module can only depend on another module present in the same package. let modulesMap = moduleBuilders.spm_createDictionary { ($0.module, $0) } for moduleBuilder in moduleBuilders { moduleBuilder.dependencies += try moduleBuilder.module.dependencies.compactMap { dependency in @@ -607,6 +629,17 @@ private func createResolvedPackages( return nil } } + moduleBuilder.pluginUsages += try moduleBuilder.module.pluginUsages.compactMap { usage in + switch usage { + case .module(let moduleDependency, let condition): + guard let moduleBuilder = modulesMap[moduleDependency] else { + throw InternalError("unknown plugin target \(moduleDependency.name)") + } + return .module(moduleBuilder, condition: condition) + case .product: + return nil + } + } moduleBuilder.defaultLocalization = packageBuilder.defaultLocalization moduleBuilder.supportedPlatforms = packageBuilder.supportedPlatforms } @@ -783,6 +816,26 @@ private func createResolvedPackages( moduleBuilder.dependencies.append(.product(product, conditions: conditions)) } + + // Establish product plugin usages. + for case .product(let productRef, let condition) in moduleBuilder.module.pluginUsages { + let product = lookupByProductIDs ? productDependencyMap[productRef.identity] : + productDependencyMap[productRef.name] + guard let product else { + if !observabilityScope.errorsReportedInAnyScope { + let error = prepareProductDependencyNotFoundError( + packageBuilder: packageBuilder, + moduleBuilder: moduleBuilder, + dependency: productRef, + lookupByProductIDs: lookupByProductIDs + ) + packageObservabilityScope.emit(error) + } + continue + } + + moduleBuilder.pluginUsages.append(.product(product, condition: condition)) + } } } @@ -1491,6 +1544,11 @@ private final class ResolvedModuleBuilder: ResolvedBuilder { case product(_ product: ResolvedProductBuilder, conditions: [PackageCondition]) } + enum PluginUsage { + case module(_ module: ResolvedModuleBuilder, condition: Module.PluginUsageCondition?) + case product(_ product: ResolvedProductBuilder, condition: Module.PluginUsageCondition?) + } + /// The reference to its package. let packageIdentity: PackageIdentity @@ -1500,6 +1558,9 @@ private final class ResolvedModuleBuilder: ResolvedBuilder { /// The module dependencies of this module. var dependencies: [Dependency] = [] + /// The plugin usages of this module. + var pluginUsages: [PluginUsage] = [] + /// All the modules this module depends on ignoring the conditions var allModuleDependencies: [ResolvedModuleBuilder] { dependencies.flatMap { @@ -1575,10 +1636,21 @@ private final class ResolvedModuleBuilder: ResolvedBuilder { } } + let pluginUsages = try self.pluginUsages.map { usage -> ResolvedModule.PluginUsage in + switch usage { + case .module(let moduleBuilder, let condition): + return try .module(moduleBuilder.construct(), condition: condition) + case .product(let productBuilder, let condition): + let product = try productBuilder.construct() + return .product(product, condition: condition) + } + } + return ResolvedModule( packageIdentity: self.packageIdentity, underlying: self.module, dependencies: dependencies, + pluginUsages: pluginUsages, defaultLocalization: self.defaultLocalization, supportedPlatforms: self.supportedPlatforms, // maintain existing functionality and default to .all diff --git a/Sources/PackageGraph/ModulesGraph.swift b/Sources/PackageGraph/ModulesGraph.swift index 5df55390e6f..3de23d4f6ce 100644 --- a/Sources/PackageGraph/ModulesGraph.swift +++ b/Sources/PackageGraph/ModulesGraph.swift @@ -197,18 +197,23 @@ public struct ModulesGraph { /// Any binary artifacts referenced by the graph. public let binaryArtifacts: [PackageIdentity: [String: BinaryArtifact]] + /// The enabled traits map for all packages in the graph. + public let enabledTraitsMap: EnabledTraitsMap + /// Construct a package graph directly. public init( rootPackages: [ResolvedPackage], rootDependencies: [ResolvedPackage] = [], packages: IdentifiableSet, dependencies requiredDependencies: [PackageReference], - binaryArtifacts: [PackageIdentity: [String: BinaryArtifact]] + binaryArtifacts: [PackageIdentity: [String: BinaryArtifact]], + enabledTraitsMap: EnabledTraitsMap = .init() ) throws { let rootPackages = IdentifiableSet(rootPackages) self.requiredDependencies = requiredDependencies self.inputPackages = rootPackages + rootDependencies self.binaryArtifacts = binaryArtifacts + self.enabledTraitsMap = enabledTraitsMap self.packages = packages var allModules = IdentifiableSet() diff --git a/Sources/PackageGraph/Resolution/ResolvedModule.swift b/Sources/PackageGraph/Resolution/ResolvedModule.swift index 7361db07914..84c4d615ea4 100644 --- a/Sources/PackageGraph/Resolution/ResolvedModule.swift +++ b/Sources/PackageGraph/Resolution/ResolvedModule.swift @@ -74,6 +74,63 @@ public struct ResolvedModule { } } + /// Represents a plugin usage of a resolved module. + public enum PluginUsage: Hashable { + + /// A plugin defined as a module in the same package, with an optional condition. + case module(_ module: ResolvedModule, condition: Module.PluginUsageCondition?) + + /// A plugin defined as a product in a package dependency, with an optional condition. + case product(_ product: ResolvedProduct, condition: Module.PluginUsageCondition?) + + /// The condition under which the plugin is applied, if any. + public var condition: Module.PluginUsageCondition? { + switch self { + case .module(_, let condition), .product(_, let condition): + condition + } + } + + /// The name of the plugin module or product. + public var name: String { + switch self { + case .module(let module, _): + module.name + case .product(let product, _): + product.name + } + } + + /// The build-tool plugin module(s) referenced by this usage. A direct `.module` + /// usage yields at most one module (the empty array if the referenced module isn't + /// a build-tool plugin); a `.product` usage yields every plugin module reachable + /// through the product. Used everywhere a caller needs to enumerate plugins from + /// a `PluginUsage` without re-implementing the dispatch. + public var buildToolPluginModules: [ResolvedModule] { + switch self { + case .module(let module, _): + if (module.underlying as? PluginModule)?.capability == .buildTool { + return [module] + } + return [] + case .product(let product, _): + return product.modules.filter { $0.underlying is PluginModule } + } + } + + /// Returns true if the condition is satisfied by the given build environments and enabled traits. + public func satisfies(hostEnvironment: BuildEnvironment, targetEnvironment: BuildEnvironment, enabledTraits: EnabledTraits) -> Bool { + guard let condition else { + return true + } + return condition.satisfies( + hostEnvironment: hostEnvironment, + targetEnvironment: targetEnvironment, + enabledTraits: enabledTraits + ) + } + } + /// The name of this module. public var name: String { self.underlying.name @@ -107,10 +164,18 @@ public struct ResolvedModule { } /// Collect all of the plugins that the current target depends on. - package func pluginDependencies(satisfying environment: BuildEnvironment) -> [ResolvedModule] { + package func pluginDependencies( + satisfying hostEnvironment: BuildEnvironment, + targetEnvironment: BuildEnvironment, + enabledTraits: EnabledTraits + ) -> [ResolvedModule] { var plugins = IdentifiableSet() - for dependency in self.dependencies(satisfying: environment) { - switch dependency { + for usage in self.pluginUsages where usage.satisfies( + hostEnvironment: hostEnvironment, + targetEnvironment: targetEnvironment, + enabledTraits: enabledTraits + ) { + switch usage { case .module(let module, _): if let plugin = module.underlying as? PluginModule { assert(plugin.capability == .buildTool) @@ -160,6 +225,9 @@ public struct ResolvedModule { /// The dependencies of this module. public internal(set) var dependencies: [Dependency] + /// The plugin usages of this module. + public let pluginUsages: [PluginUsage] + /// The default localization for resources. public let defaultLocalization: String? @@ -199,6 +267,7 @@ public struct ResolvedModule { packageIdentity: PackageIdentity, underlying: Module, dependencies: [ResolvedModule.Dependency], + pluginUsages: [ResolvedModule.PluginUsage] = [], defaultLocalization: String? = nil, supportedPlatforms: [SupportedPlatform], platformConstraint: PlatformConstraint, @@ -208,6 +277,7 @@ public struct ResolvedModule { self.packageIdentity = packageIdentity self.underlying = underlying self.dependencies = dependencies + self.pluginUsages = pluginUsages self.defaultLocalization = defaultLocalization self.supportedPlatforms = supportedPlatforms self.platformConstraint = platformConstraint diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index e677ae24e1b..cf46496d41b 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -547,6 +547,17 @@ extension PackageConditionDescription { } } + +extension PluginUsageConditionDescription { + init(_ condition: Serialization.PluginUsageCondition) { + self.init( + hostPlatformNames: condition.hostPlatforms?.map { $0.name } ?? [], + targetPlatformNames: condition.targetPlatforms?.map { $0.name } ?? [], + traits: condition.traits.map { Set($0) } ?? EnabledTraits.defaults.names + ) + } +} + extension TargetDescription.TargetKind { init(_ type: Serialization.TargetType) { switch type { @@ -623,8 +634,8 @@ extension TargetDescription.PluginNetworkPermissionScope { extension TargetDescription.PluginUsage { init(_ usage: Serialization.PluginUsage) { switch usage { - case .plugin(let name, let package): - self = .plugin(name: name, package: package) + case .plugin(let name, let package, let condition): + self = .plugin(name: name, package: package, condition: condition.map { .init($0) }) } } } diff --git a/Sources/PackageLoading/PackageBuilder.swift b/Sources/PackageLoading/PackageBuilder.swift index 2d2fdc77f01..ba5d9de66a3 100644 --- a/Sources/PackageLoading/PackageBuilder.swift +++ b/Sources/PackageLoading/PackageBuilder.swift @@ -711,9 +711,9 @@ public final class PackageBuilder { if let pluginUsages = target.pluginUsages { successors += pluginUsages.compactMap { switch $0 { - case .plugin(_, .some(_)): + case .plugin(_, .some(_), _): nil - case .plugin(let name, nil): + case .plugin(let name, nil, _): if let potentialModule = potentialModuleMap[name] { potentialModule } else if let targetName = pluginTargetName(for: name), @@ -785,18 +785,19 @@ public final class PackageBuilder { } } ?? [] - // Get dependencies from the plugin usages of this target. + // Get plugin usages from the plugin usages of this target. let pluginUsages: [Module.PluginUsage] = manifestTarget?.pluginUsages.map { $0.compactMap { usage in switch usage { - case .plugin(let name, let package): + case .plugin(let name, let package, let condition): + let condition = self.buildPluginUsageCondition(from: condition) if let package { - return .product(Module.ProductReference(name: name, package: package), conditions: []) + return .product(Module.ProductReference(name: name, package: package), condition: condition) } else { if let target = targets[name] { - return .module(target, conditions: []) + return .module(target, condition: condition) } else if let targetName = pluginTargetName(for: name), let target = targets[targetName] { - return .module(target, conditions: []) + return .module(target, condition: condition) } else { self.observabilityScope.emit(.pluginNotFound(name: name)) return nil @@ -807,10 +808,19 @@ public final class PackageBuilder { } ?? [] // Create the target, adding the inferred dependencies from plugin usages to the declared dependencies. + let pluginDependencies: [Module.Dependency] = pluginUsages.map { usage in + switch usage { + case .module(let module, _): + return .module(module, conditions: []) + case .product(let product, _): + return .product(product, conditions: []) + } + } let target = try createTarget( potentialModule: potentialModule, manifestTarget: manifestTarget, - dependencies: dependencies + pluginUsages + dependencies: dependencies + pluginDependencies, + pluginUsages: pluginUsages ) // Add the created target to the map or print no sources warning. if let createdTarget = target { @@ -860,7 +870,8 @@ public final class PackageBuilder { private func createTarget( potentialModule: PotentialModule, manifestTarget: TargetDescription?, - dependencies: [Module.Dependency] + dependencies: [Module.Dependency], + pluginUsages: [Module.PluginUsage] ) throws -> Module? { guard let manifestTarget else { return nil } @@ -1032,6 +1043,7 @@ public final class PackageBuilder { declaredSwiftVersions: self.declaredSwiftVersions(), buildSettings: buildSettings, buildSettingsDescription: manifestTarget.settings, + pluginUsages: pluginUsages, // unsafe flags check disabled in 6.2 usesUnsafeFlags: manifest.toolsVersion >= .v6_2 ? false : manifestTarget.usesUnsafeFlags, implicit: false @@ -1079,6 +1091,7 @@ public final class PackageBuilder { dependencies: dependencies, buildSettings: buildSettings, buildSettingsDescription: manifestTarget.settings, + pluginUsages: pluginUsages, // unsafe flags check disabled in 6.2 usesUnsafeFlags: manifest.toolsVersion >= .v6_2 ? false : manifestTarget.usesUnsafeFlags, implicit: false @@ -1387,6 +1400,30 @@ public final class PackageBuilder { return conditions } + + func buildPluginUsageCondition(from condition: PluginUsageConditionDescription?) -> Module.PluginUsageCondition? { + guard let condition else { + return nil + } + + let hostPlatforms = condition.hostPlatformNames.map { + if let platform = platformRegistry.platformByName[$0] { + platform + } else { + PackageModel.Platform.custom(name: $0, oldestSupportedVersion: .unknown) + } + } + let targetPlatforms = condition.targetPlatformNames.map { + if let platform = platformRegistry.platformByName[$0] { + platform + } else { + PackageModel.Platform.custom(name: $0, oldestSupportedVersion: .unknown) + } + } + + return .init(hostPlatforms: hostPlatforms, targetPlatforms: targetPlatforms, traits: condition.traits) + } + private func declaredSwiftVersions() throws -> [SwiftLanguageVersion] { if let versions = self.declaredSwiftVersionsCache { return versions diff --git a/Sources/PackageModel/Manifest/Manifest.swift b/Sources/PackageModel/Manifest/Manifest.swift index de069dc15ef..5a366a82dfd 100644 --- a/Sources/PackageModel/Manifest/Manifest.swift +++ b/Sources/PackageModel/Manifest/Manifest.swift @@ -349,7 +349,7 @@ public final class Manifest: Sendable { let plugins: [String] = target.pluginUsages?.compactMap { pluginUsage in switch pluginUsage { - case .plugin(name: let name, package: nil): + case .plugin(name: let name, package: nil, condition: _): if targetsByName.keys.contains(name) { name } else if let targetName = productsByName[name]?.targets.first { @@ -447,7 +447,7 @@ public final class Manifest: Sendable { referencedBy pluginUsage: TargetDescription.PluginUsage ) -> PackageDependency? { switch pluginUsage { - case .plugin(_, .some(let package)): + case .plugin(_, .some(let package), _): self.packageDependency(referencedBy: package) default: nil @@ -546,7 +546,7 @@ public final class Manifest: Sendable { availablePackages: Set ) { switch requiredPlugIn { - case .plugin(let name, let package): + case .plugin(let name, let package, _): if let package { if !self.register( product: name, diff --git a/Sources/PackageModel/Manifest/PackageConditionDescription.swift b/Sources/PackageModel/Manifest/PackageConditionDescription.swift index c25dbd024c4..bca610abd75 100644 --- a/Sources/PackageModel/Manifest/PackageConditionDescription.swift +++ b/Sources/PackageModel/Manifest/PackageConditionDescription.swift @@ -31,6 +31,32 @@ public struct PackageConditionDescription: Codable, Hashable, Sendable { } } + +/// Represents a plug-in usage condition in a manifest. +public struct PluginUsageConditionDescription: Codable, Hashable, Sendable { + public let hostPlatformNames: [String] + public let targetPlatformNames: [String] + public let traits: Set + + public init( + hostPlatformNames: [String] = [], + targetPlatformNames: [String] = [], + traits: Set = EnabledTraits.defaults.names + ) { + precondition(!(hostPlatformNames.isEmpty && targetPlatformNames.isEmpty && traits.isEmpty)) + self.hostPlatformNames = hostPlatformNames + self.targetPlatformNames = targetPlatformNames + self.traits = traits + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(hostPlatformNames, forKey: .hostPlatformNames) + try container.encode(targetPlatformNames, forKey: .targetPlatformNames) + try container.encode(traits.sorted(), forKey: .traits) + } +} + /// One of possible conditions used in package manifests to restrict modules from being built for certain platforms or /// build configurations. public enum PackageCondition: Hashable, Sendable { diff --git a/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index f75de84fd13..d61ffb7f9db 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -191,7 +191,32 @@ public struct TargetDescription: Hashable, Encodable, Sendable { /// Represents a target's usage of a plugin target or product. public enum PluginUsage: Hashable, Sendable { - case plugin(name: String, package: String?) + /// A plugin in the same or another package, with an optional condition. + case plugin(name: String, package: String?, condition: PluginUsageConditionDescription? = nil) + + /// The condition under which the plugin is applied, if any. + public var condition: PluginUsageConditionDescription? { + switch self { + case .plugin(_, _, let condition): + condition + } + } + + /// The name of the plugin target. + public var name: String { + switch self { + case .plugin(let name, _, _): + name + } + } + + /// The name of the package that defines the plugin, or nil if it is in the same package. + public var package: String? { + switch self { + case .plugin(_, let package, _): + package + } + } } public init( @@ -564,10 +589,11 @@ extension TargetDescription.PluginUsage: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case let .plugin(name, package): + case let .plugin(name, package, condition): var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .plugin) try unkeyedContainer.encode(name) try unkeyedContainer.encode(package) + try unkeyedContainer.encode(condition) } } @@ -581,7 +607,8 @@ extension TargetDescription.PluginUsage: Codable { var unkeyedValues = try values.nestedUnkeyedContainer(forKey: key) let name = try unkeyedValues.decode(String.self) let package = try unkeyedValues.decodeIfPresent(String.self) - self = .plugin(name: name, package: package) + let condition = try unkeyedValues.decodeIfPresent(PluginUsageConditionDescription.self) + self = .plugin(name: name, package: package, condition: condition) } } } diff --git a/Sources/PackageModel/Module/ClangModule.swift b/Sources/PackageModel/Module/ClangModule.swift index 5105b7bb563..166998b29d5 100644 --- a/Sources/PackageModel/Module/ClangModule.swift +++ b/Sources/PackageModel/Module/ClangModule.swift @@ -61,6 +61,7 @@ public final class ClangModule: Module { dependencies: [Module.Dependency] = [], buildSettings: BuildSettings.AssignmentTable = .init(), buildSettingsDescription: [TargetBuildSettingDescription.Setting] = [], + pluginUsages: [PluginUsage] = [], usesUnsafeFlags: Bool, implicit: Bool ) throws { @@ -86,7 +87,7 @@ public final class ClangModule: Module { packageAccess: false, buildSettings: buildSettings, buildSettingsDescription: buildSettingsDescription, - pluginUsages: [], + pluginUsages: pluginUsages, usesUnsafeFlags: usesUnsafeFlags, implicit: implicit ) diff --git a/Sources/PackageModel/Module/Module.swift b/Sources/PackageModel/Module/Module.swift index 55961954abe..9968ce20411 100644 --- a/Sources/PackageModel/Module/Module.swift +++ b/Sources/PackageModel/Module/Module.swift @@ -115,11 +115,107 @@ public class Module { } } - /// A usage of a plugin module or product. Implemented as a dependency - /// for now and added to the `dependencies` array, since they currently - /// have exactly the same characteristics and to avoid duplicating the - /// implementation for now. - public typealias PluginUsage = Dependency + /// A condition that limits the application of a plugin usage. + public struct PluginUsageCondition: Hashable, Sendable { + + /// The host platforms on which the plugin may run. + public let hostPlatforms: [Platform] + + /// The target platforms for which the plugin may produce output. + public let targetPlatforms: [Platform] + + /// The traits that must be enabled for the plugin to apply. + public let traits: Set + + public init(hostPlatforms: [Platform] = [], targetPlatforms: [Platform] = [], traits: Set = EnabledTraits.defaults.names) { + precondition(!hostPlatforms.isEmpty || !targetPlatforms.isEmpty || !traits.isEmpty) + self.hostPlatforms = hostPlatforms + self.targetPlatforms = targetPlatforms + self.traits = traits + } + + /// Returns true if the host axis is satisfied by the given host build environment. + public func hostAxisSatisfied(hostEnv: BuildEnvironment) -> Bool { + return hostPlatforms.isEmpty || hostPlatforms.contains(hostEnv.platform) + } + + /// Returns true if the target axis is satisfied by the given target build environment. + public func targetAxisSatisfied(targetEnv: BuildEnvironment) -> Bool { + return targetPlatforms.isEmpty || targetPlatforms.contains(targetEnv.platform) + } + + /// Returns true if the trait axis is satisfied by the given enabled-trait set. + /// + /// After trait resolution, `enabledTraits.names` contains the resolved trait names + /// (e.g. `{"Logging"}`) without the `"default"` sentinel. We re-insert it when the + /// build has defaults enabled so that a condition requiring `["default"]` matches. + public func traitsAxisSatisfied(enabledTraits: EnabledTraits) -> Bool { + if traits.isEmpty { + return true + } + let effectiveNames: Set = enabledTraits.areDefaultsEnabled + ? enabledTraits.names.union(EnabledTraits.defaults.names) + : enabledTraits.names + return !traits.intersection(effectiveNames).isEmpty + } + + /// Returns true if the condition is satisfied by the given build environments and enabled traits. + /// + /// Each axis uses OR semantics internally (the condition passes if the actual value matches + /// ANY of the listed values). Across axes, AND semantics apply (all specified axes must pass). + /// For traits: the condition is satisfied if at least one listed trait is enabled. + public func satisfies(hostEnvironment: BuildEnvironment, targetEnvironment: BuildEnvironment, enabledTraits: EnabledTraits) -> Bool { + return hostAxisSatisfied(hostEnv: hostEnvironment) + && targetAxisSatisfied(targetEnv: targetEnvironment) + && traitsAxisSatisfied(enabledTraits: enabledTraits) + } + } + + /// A usage of a plugin module or product. + public enum PluginUsage { + + /// A plugin defined as a module in the same package, with an optional condition. + case module(_ module: Module, condition: PluginUsageCondition?) + + /// A plugin defined as a product in a package dependency, with an optional condition. + case product(_ product: ProductReference, condition: PluginUsageCondition?) + + /// The plugin module, if this is a module usage. + public var module: Module? { + if case .module(let module, _) = self { + return module + } else { + return nil + } + } + + /// The product reference, if this is a product usage. + public var product: ProductReference? { + if case .product(let product, _) = self { + return product + } else { + return nil + } + } + + /// The condition under which the plugin is applied, if any. + public var condition: PluginUsageCondition? { + switch self { + case .module(_, let condition), .product(_, let condition): + return condition + } + } + + /// The name of the plugin module or product. + public var name: String { + switch self { + case .module(let module, _): + return module.name + case .product(let product, _): + return product.name + } + } + } /// The name of the module. /// diff --git a/Sources/PackageModel/ToolsVersion.swift b/Sources/PackageModel/ToolsVersion.swift index 55cc6f5b5f9..57351b90f3b 100644 --- a/Sources/PackageModel/ToolsVersion.swift +++ b/Sources/PackageModel/ToolsVersion.swift @@ -36,6 +36,7 @@ public struct ToolsVersion: Equatable, Hashable, Codable, Sendable { public static let v6_2 = ToolsVersion(version: "6.2.0") public static let v6_3 = ToolsVersion(version: "6.3.0") public static let v6_4 = ToolsVersion(version: "6.4.0") + public static let v6_5 = ToolsVersion(version: "6.5.0") public static let vNext = ToolsVersion(version: "999.0.0") /// The current tools version in use. diff --git a/Sources/Runtimes/PackageDescription/PackageDescription.docc/Curation/PluginUsage.md b/Sources/Runtimes/PackageDescription/PackageDescription.docc/Curation/PluginUsage.md index 74c7fc3d114..c176d714700 100644 --- a/Sources/Runtimes/PackageDescription/PackageDescription.docc/Curation/PluginUsage.md +++ b/Sources/Runtimes/PackageDescription/PackageDescription.docc/Curation/PluginUsage.md @@ -6,3 +6,5 @@ - ``plugin(name:)`` - ``plugin(name:package:)`` +- ``plugin(name:condition:)`` +- ``plugin(name:package:condition:)`` diff --git a/Sources/Runtimes/PackageDescription/PackageDescription.docc/Curation/PluginUsageCondition.md b/Sources/Runtimes/PackageDescription/PackageDescription.docc/Curation/PluginUsageCondition.md new file mode 100644 index 00000000000..11f2c2093d6 --- /dev/null +++ b/Sources/Runtimes/PackageDescription/PackageDescription.docc/Curation/PluginUsageCondition.md @@ -0,0 +1,7 @@ +# ``PackageDescription/PluginUsageCondition`` + +## Topics + +### Creating a Plugin Usage Condition + +- ``when(hostPlatforms:targetPlatforms:traits:)`` diff --git a/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift b/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift index d37021c7b04..f66e53ebafd 100644 --- a/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift @@ -206,8 +206,14 @@ enum Serialization { case unixDomainSocket } + struct PluginUsageCondition: Codable { + let hostPlatforms: [Platform]? + let targetPlatforms: [Platform]? + let traits: [String]? + } + enum PluginUsage: Codable { - case plugin(name: String, package: String?) + case plugin(name: String, package: String?, condition: PluginUsageCondition?) } struct Target: Codable { diff --git a/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift b/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift index 5d4a0336362..adb7d47809f 100644 --- a/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift @@ -288,10 +288,23 @@ extension Serialization.PluginNetworkPermissionScope { } } + +@available(_PackageDescription, introduced: 6.5) +extension Serialization.PluginUsageCondition { + init(_ condition: PackageDescription.PluginUsageCondition) { + self.hostPlatforms = condition.hostPlatforms?.map { .init($0) } + self.targetPlatforms = condition.targetPlatforms?.map { .init($0) } + self.traits = condition.traits?.sorted() + } +} + extension Serialization.PluginUsage { init(_ usage: PackageDescription.Target.PluginUsage) { switch usage { - case .plugin(let name, let package): self = .plugin(name: name, package: package) + case .plugin(let name, let package): + self = .plugin(name: name, package: package, condition: nil) + case .pluginWithCondition(let name, let package, let condition): + self = .plugin(name: name, package: package, condition: condition.map { .init($0) }) } } } diff --git a/Sources/Runtimes/PackageDescription/Target.swift b/Sources/Runtimes/PackageDescription/Target.swift index 9c7183a82f4..b612e479cab 100644 --- a/Sources/Runtimes/PackageDescription/Target.swift +++ b/Sources/Runtimes/PackageDescription/Target.swift @@ -227,6 +227,14 @@ public final class Target { /// - name: The name of the plug-in target. /// - package: The name of the package that defines the plug-in target. case plugin(name: String, package: String?) + + /// Specifies the use of a plug-in product in a package dependency. + /// + /// - Parameters: + /// - name: The name of the plug-in target. + /// - package: The name of the package that defines the plug-in target. + /// - condition: The condition under which the plug-in is applied. + case pluginWithCondition(name: String, package: String?, condition: PluginUsageCondition?) } /// Construct a target. @@ -1411,6 +1419,44 @@ public struct TargetDependencyCondition: Sendable { } } + +/// A condition that limits the application of a target's plug-in usage. +@available(_PackageDescription, introduced: 6.5) +public struct PluginUsageCondition: Sendable { + let hostPlatforms: [Platform]? + let targetPlatforms: [Platform]? + let traits: Set? + + private init(hostPlatforms: [Platform]?, targetPlatforms: [Platform]?, traits: Set?) { + self.hostPlatforms = hostPlatforms + self.targetPlatforms = targetPlatforms + self.traits = traits + } + + /// Creates a plug-in usage condition. + /// + /// - Parameter hostPlatforms: The applicable host platforms for this plug-in usage condition. + /// - Parameter targetPlatforms: The applicable target platforms for this plug-in usage condition. + /// - Parameter traits: The applicable traits for this plug-in usage condition. + public static func when( + hostPlatforms: [Platform]? = nil, + targetPlatforms: [Platform]? = nil, + traits: Set? = nil + ) -> PluginUsageCondition? { + let hostPlatforms = hostPlatforms.flatMap { !$0.isEmpty ? $0 : nil } + let targetPlatforms = targetPlatforms.flatMap { !$0.isEmpty ? $0 : nil } + let traits = traits.flatMap { !$0.isEmpty ? $0 : nil } + if hostPlatforms == nil, targetPlatforms == nil, traits == nil { + return .none + } + return PluginUsageCondition( + hostPlatforms: hostPlatforms, + targetPlatforms: targetPlatforms, + traits: traits + ) + } +} + extension Target.PluginCapability { /// The plug-in is a build tool. @@ -1533,6 +1579,35 @@ extension Target.PluginUsage { public static func plugin(name: String) -> Target.PluginUsage { return .plugin(name: name, package: nil) } + + /// Specifies use of a plugin target in the same or another package. + /// + /// - Parameters: + /// - name: The name of the plugin target. + /// - condition: The condition under which the plugin is applied. + /// - Returns: A `PluginUsage` instance. + @available(_PackageDescription, introduced: 6.5) + public static func plugin(name: String, condition: PluginUsageCondition?) -> Target.PluginUsage { + guard let condition else { + return .plugin(name: name, package: nil) + } + return .pluginWithCondition(name: name, package: nil, condition: condition) + } + + /// Specifies use of a plugin target in the same or another package. + /// + /// - Parameters: + /// - name: The name of the plugin target. + /// - package: The name of the package containing the plugin target, or nil if it is in the same package. + /// - condition: The condition under which the plugin is applied. + /// - Returns: A `PluginUsage` instance. + @available(_PackageDescription, introduced: 6.5) + public static func plugin(name: String, package: String?, condition: PluginUsageCondition?) -> Target.PluginUsage { + guard let condition else { + return .plugin(name: name, package: package) + } + return .pluginWithCondition(name: name, package: package, condition: condition) + } } @@ -1561,4 +1636,3 @@ extension Target.PluginUsage: ExpressibleByStringLiteral { self = .plugin(name: value, package: nil) } } - diff --git a/Sources/SPMBuildCore/BuildSystem/BuildSystem.swift b/Sources/SPMBuildCore/BuildSystem/BuildSystem.swift index f9ac8e11d88..52f0aef5232 100644 --- a/Sources/SPMBuildCore/BuildSystem/BuildSystem.swift +++ b/Sources/SPMBuildCore/BuildSystem/BuildSystem.swift @@ -247,7 +247,8 @@ public protocol BuildSystemFactory { outputStream: OutputByteStream?, logLevel: Diagnostic.Severity?, observabilityScope: ObservabilityScope?, - delegate: BuildSystemDelegate? + delegate: BuildSystemDelegate?, + configuredTargetMode: PIFConfiguredTargetMode ) async throws -> any BuildSystem } @@ -284,7 +285,8 @@ public struct BuildSystemProvider { outputStream: OutputByteStream? = .none, logLevel: Diagnostic.Severity? = .none, observabilityScope: ObservabilityScope? = .none, - delegate: BuildSystemDelegate? = nil + delegate: BuildSystemDelegate? = nil, + configuredTargetMode: PIFConfiguredTargetMode ) async throws -> any BuildSystem { guard let buildSystemFactory = self.providers[kind] else { throw Errors.buildSystemProviderNotRegistered(kind: kind) @@ -299,7 +301,8 @@ public struct BuildSystemProvider { outputStream: outputStream, logLevel: logLevel, observabilityScope: observabilityScope, - delegate: delegate + delegate: delegate, + configuredTargetMode: configuredTargetMode ) } } diff --git a/Sources/SPMBuildCore/BuildSystem/PIFConfiguredTargetMode.swift b/Sources/SPMBuildCore/BuildSystem/PIFConfiguredTargetMode.swift new file mode 100644 index 00000000000..bc5dbe2fc59 --- /dev/null +++ b/Sources/SPMBuildCore/BuildSystem/PIFConfiguredTargetMode.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2026 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 +// +//===----------------------------------------------------------------------===// + +/// Whether one PIF generation can feed multiple `ConfiguredTarget`s with different platforms. +/// +/// `.single` callers (`swift build`, `swift package dump-pif`, library users) materialize +/// exactly one configured target — the triple is fixed, no fan-out is possible. `.multiple` +/// callers (BSP / SourceKit-LSP, Xcode-driven workspace ingestion) can drive a graph +/// resolved once into multiple configured targets at different platforms; the canonical +/// case is an iOS app embedding a watchOS extension that links a SwiftPM package. +/// +/// `PIFBuilder` uses this to gate the per-platform plugin invocation fan-out: under +/// `.single`, a plugin usage with `condition: .when(targetPlatforms: [...])` produces at +/// most one invocation pinned to the build platform (and is dropped if that platform +/// isn't in the list); under `.multiple`, the same usage fans out to one invocation per +/// listed platform. Plugin usages without an explicit `targetPlatforms` axis stay +/// untagged in either mode. +public enum PIFConfiguredTargetMode: Sendable, Hashable { + case single + case multiple +} diff --git a/Sources/SPMBuildCore/CMakeLists.txt b/Sources/SPMBuildCore/CMakeLists.txt index 478a124b8e6..602c74a0c94 100644 --- a/Sources/SPMBuildCore/CMakeLists.txt +++ b/Sources/SPMBuildCore/CMakeLists.txt @@ -19,6 +19,7 @@ add_library(SPMBuildCore BuildSystem/BuildSystemCommand.swift BuildSystem/BuildSystemDelegate.swift BuildSystem/DiagnosticsCapturingBuildSystemDelegate.swift + BuildSystem/PIFConfiguredTargetMode.swift BuiltTestProduct.swift Diagnostics+Extensions.swift Plugins/DefaultPluginScriptRunner.swift diff --git a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift index d02b9c71bbb..f082255b7b7 100644 --- a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift +++ b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift @@ -597,12 +597,16 @@ fileprivate extension PluginToHostMessage { extension ModulesGraph { public func pluginsPerModule( - satisfying buildEnvironment: BuildEnvironment + satisfyingHost hostEnvironment: BuildEnvironment, + targetEnvironment: BuildEnvironment ) -> [ResolvedModule.ID: [ResolvedModule]] { var pluginsPerModule = [ResolvedModule.ID: [ResolvedModule]]() for module in self.allModules.sorted(by: { $0.name < $1.name }) { + let enabledTraits = self.enabledTraitsMap[module.packageIdentity] let pluginDependencies = module.pluginDependencies( - satisfying: buildEnvironment + satisfying: hostEnvironment, + targetEnvironment: targetEnvironment, + enabledTraits: enabledTraits ) if !pluginDependencies.isEmpty { pluginsPerModule[module.id] = pluginDependencies @@ -611,6 +615,82 @@ extension ModulesGraph { return pluginsPerModule } + /// Per-(module, plugin, platform) plugin entries. + /// + /// Like `pluginsPerModule`, but produces one entry per `(module, plugin, platform?)` + /// tuple. A `nil` platform means "match every platform" — used when the plugin usage + /// has no `targetPlatforms` axis (or no condition at all). When `condition.targetPlatforms` + /// is non-empty, the plugin fans out across exactly those platforms. + /// + /// Host and trait axes are evaluated once per `(module, plugin)` pair before fan-out; + /// the trait axis uses the module's package's `enabledTraitsMap` entry. + /// + /// Single-CT callers pass `forceSinglePlatform: targetEnv.platform`. That collapses each + /// fanned-out entry to either the build platform (if the explicit `targetPlatforms` set + /// permits it) or drops it entirely (proposal: "plugin not invoked when condition not + /// met"). Untagged entries (no `targetPlatforms` axis) stay untagged. + /// + /// Entries are de-duplicated per `(plugin, platform)` so that a plugin referenced via + /// both `.module` and `.product` usages on the same module produces a single entry. + package func pluginsPerModulePerPlatform( + satisfyingHost hostEnvironment: BuildEnvironment, + forceSinglePlatform: PackageModel.Platform? = nil + ) -> [ResolvedModule.ID: [(plugin: ResolvedModule, platform: PackageModel.Platform?)]] { + var pluginsPerModulePerPlatform: [ResolvedModule.ID: [(ResolvedModule, PackageModel.Platform?)]] = [:] + for module in self.allModules.sorted(by: { $0.name < $1.name }) { + let enabledTraits = self.enabledTraitsMap[module.packageIdentity] + var entries: [(ResolvedModule, PackageModel.Platform?)] = [] + // Per-module dedupe: ensures a plugin reachable via two usages (e.g. + // `.module(p, ...)` and `.product(prod, ...)` where `prod.modules` contains `p`) + // is only emitted once per platform tag. + struct EntryKey: Hashable { + let pluginID: ResolvedModule.ID + let platform: PackageModel.Platform? + } + var seenPairs = Set() + + for usage in module.pluginUsages { + // Trait + host axes are singletons per PIF generation; evaluate once per + // usage before the per-platform fan-out. A nil condition matches every + // host/trait set (`?? true`). + guard usage.condition?.traitsAxisSatisfied(enabledTraits: enabledTraits) ?? true, + usage.condition?.hostAxisSatisfied(hostEnv: hostEnvironment) ?? true + else { continue } + + let conditionPlatforms = Set(usage.condition?.targetPlatforms ?? []) + // Resolve the platform tags this usage will produce: + // - Empty `targetPlatforms` axis → one untagged entry (`nil`), commands + // carry empty `platformFilters` and apply to every CT. + // - Non-empty `targetPlatforms` → one entry per listed platform. + // - Under `forceSinglePlatform`: untagged entries collapse to the pinned + // platform (still emitted once); explicit entries are dropped unless the + // pinned platform is in the list. + let resolvedPlatforms: [PackageModel.Platform?] + if conditionPlatforms.isEmpty { + resolvedPlatforms = [forceSinglePlatform] + } else if let pinned = forceSinglePlatform { + resolvedPlatforms = conditionPlatforms.contains(pinned) ? [pinned] : [] + } else { + // Sort by name for deterministic ordering across runs and CI. + resolvedPlatforms = conditionPlatforms.sorted(by: { $0.name < $1.name }) + } + + for plugin in usage.buildToolPluginModules { + for platform in resolvedPlatforms { + let key = EntryKey(pluginID: plugin.id, platform: platform) + if seenPairs.insert(key).inserted { + entries.append((plugin, platform)) + } + } + } + } + if !entries.isEmpty { + pluginsPerModulePerPlatform[module.id] = entries + } + } + return pluginsPerModulePerPlatform + } + public static func computePluginGeneratedFiles( target: ResolvedModule, toolsVersion: ToolsVersion, diff --git a/Sources/SwiftBuildSupport/PIFBuilder.swift b/Sources/SwiftBuildSupport/PIFBuilder.swift index 83ee8c29e64..40bb9d922f7 100644 --- a/Sources/SwiftBuildSupport/PIFBuilder.swift +++ b/Sources/SwiftBuildSupport/PIFBuilder.swift @@ -87,7 +87,11 @@ package struct PIFBuilderParameters { /// reported by the build system for the host `BuildParameters`. let hostBuildProductsPath: AbsolutePath - package init(isPackageAccessModifierSupported: Bool, enableTestability: Bool, shouldCreateDylibForDynamicProducts: Bool, materializeStaticArchiveProductsForRootPackages: Bool, createDynamicVariantsForLibraryProducts: Bool, toolchainLibDir: AbsolutePath, pkgConfigDirectories: [AbsolutePath], supportedSwiftVersions: [SwiftLanguageVersion], pluginScriptRunner: PluginScriptRunner, disableSandbox: Bool, pluginWorkingDirectory: AbsolutePath, additionalFileRules: [FileRuleDescription], addLocalRpaths: PackagePIFBuilder.AddLocalRpaths, hostBuildProductsPath: AbsolutePath) { + /// Whether this PIF generation can drive multiple configured targets at different + /// platforms. See ``PIFConfiguredTargetMode``. + let configuredTargetMode: PIFConfiguredTargetMode + + package init(isPackageAccessModifierSupported: Bool, enableTestability: Bool, shouldCreateDylibForDynamicProducts: Bool, materializeStaticArchiveProductsForRootPackages: Bool, createDynamicVariantsForLibraryProducts: Bool, toolchainLibDir: AbsolutePath, pkgConfigDirectories: [AbsolutePath], supportedSwiftVersions: [SwiftLanguageVersion], pluginScriptRunner: PluginScriptRunner, disableSandbox: Bool, pluginWorkingDirectory: AbsolutePath, additionalFileRules: [FileRuleDescription], addLocalRPaths: Bool, hostBuildProductsPath: AbsolutePath, configuredTargetMode: PIFConfiguredTargetMode) { self.isPackageAccessModifierSupported = isPackageAccessModifierSupported self.enableTestability = enableTestability self.shouldCreateDylibForDynamicProducts = shouldCreateDylibForDynamicProducts @@ -102,6 +106,7 @@ package struct PIFBuilderParameters { self.additionalFileRules = additionalFileRules self.addLocalRpaths = addLocalRpaths self.hostBuildProductsPath = hostBuildProductsPath + self.configuredTargetMode = configuredTargetMode } } @@ -161,7 +166,8 @@ public final class PIFBuilder { prettyPrint: Bool = true, preservePIFModelStructure: Bool = false, printPIFManifestGraphviz: Bool = false, - buildParameters: BuildParameters + targetBuildParameters: BuildParameters, + hostBuildParameters: BuildParameters ) async throws -> PIFGenerationResult { let encoder = prettyPrint ? JSONEncoder.makeWithDefaults() : JSONEncoder() @@ -169,7 +175,7 @@ public final class PIFBuilder { encoder.userInfo[.encodeForSwiftBuild] = true } - let (topLevelObject, modulesAndProducts) = try await self.constructPIF(buildParameters: buildParameters) + let (topLevelObject, modulesAndProducts) = try await self.constructPIF(targetBuildParameters: targetBuildParameters, hostBuildParameters: hostBuildParameters) // Sign the PIF objects before encoding it for Swift Build. try PIF.sign(workspace: topLevelObject.workspace) @@ -195,7 +201,7 @@ public final class PIFBuilder { /// Compute the available build tools, and their destination build path for host for each plugin. private func availableBuildPluginTools( graph: ModulesGraph, - buildParameters: BuildParameters, + hostBuildParameters: BuildParameters, pluginsPerModule: [ResolvedModule.ID: [ResolvedModule]], hostTriple: Basics.Triple ) async throws -> [ResolvedModule.ID: [String: PluginTool]] { @@ -221,7 +227,7 @@ public final class PIFBuilder { // names to the corresponding paths. Built tools are assumed to be in the build tools directory. let accessibleTools = try await plugin.preparePluginTools( fileSystem: fileSystem, - environment: buildParameters.buildEnvironment, + environment: hostBuildParameters.buildEnvironment, for: hostTriple ) { name, path in // `name` is the product name (for .product dependencies) or target name (for @@ -244,18 +250,32 @@ public final class PIFBuilder { /// Constructs all `PackagePIFBuilder` objects used by the `constructPIF` function. /// In particular, this is useful for unit testing the complex `PIFBuilder` class. func makePIFBuilders( - buildParameters: BuildParameters + targetBuildParameters: BuildParameters, + hostBuildParameters: BuildParameters ) async throws -> [(ResolvedPackage, PackagePIFBuilder, any PackagePIFBuilder.BuildDelegate)] { let pluginScriptRunner = self.parameters.pluginScriptRunner let outputDir = self.parameters.pluginWorkingDirectory.appending("outputs") - - let pluginsPerModule = graph.pluginsPerModule( - satisfying: buildParameters.buildEnvironment // .buildEnvironment(for: .host) + let isMultiCT = self.parameters.configuredTargetMode == .multiple + + // Per-(module, plugin, platform?) entries. A `nil` platform means "match every + // platform" — the plugin usage has no `targetPlatforms` axis. Under .single, the + // fan-out collapses each tagged entry to either the build platform (if it's in the + // explicit `targetPlatforms` list) or drops it; untagged entries stay untagged. + let pluginsPerModulePerPlatform = graph.pluginsPerModulePerPlatform( + satisfyingHost: hostBuildParameters.buildEnvironment, + forceSinglePlatform: isMultiCT ? nil : targetBuildParameters.buildEnvironment.platform ) + // For tool preparation we still need a per-(module, plugin) view (the host-side + // tooling is platform-invariant). Collapse the per-platform entries. + let pluginsPerModule: [ResolvedModule.ID: [ResolvedModule]] + = pluginsPerModulePerPlatform.mapValues { entries in + Array(IdentifiableSet(entries.map(\.plugin))) + } + let availablePluginTools = try await availableBuildPluginTools( graph: graph, - buildParameters: buildParameters, + hostBuildParameters: hostBuildParameters, pluginsPerModule: pluginsPerModule, hostTriple: try pluginScriptRunner.hostTriple ) @@ -269,13 +289,24 @@ public final class PIFBuilder { var buildToolPluginResultsByTargetName: [String: [PackagePIFBuilder.BuildToolPluginInvocationResult]] = [:] for module in package.modules { - // Apply each build tool plugin used by the target in order, - // creating a list of results (one for each plugin usage). - var buildToolPluginResults: [BuildToolPluginInvocationResult] = [] + // Per-(module, plugin, platform?) entries from the graph walk above. A + // `nil` platform = "match every platform" (no `targetPlatforms` axis). + let entries = pluginsPerModulePerPlatform[module.id] ?? [] + + // Plugin invocation results paired with the platform tag that drove + // each invocation. The tag is needed for two things: + // 1. The next iteration's plugin asks for a view of "previously + // generated sources" — only same-tag and untagged priors are + // visible, so a watchOS plugin doesn't see iOS-only outputs. + // 2. Tagging emitted commands and prebuild outputs so SwiftBuild's + // `CustomTaskProducer` and `BuildFile.platformFilters` route them + // to the matching `ConfiguredTarget`. + var taggedResults: [(result: BuildToolPluginInvocationResult, platform: PackageModel.Platform?)] = [] + // Build commands and prebuild outputs accumulate as parallel arrays. var buildCommands: [PackagePIFBuilder.CustomBuildCommand] = [] - var prebuildCommands: [BuildToolPluginInvocationResult.PrebuildCommand] = [] + var buildCommandPlatforms: [PackageModel.Platform?] = [] - for plugin in module.pluginDependencies(satisfying: buildParameters.buildEnvironment) { + for (plugin, platform) in entries { let pluginModule = plugin.underlying as! PluginModule // Determine the tools to which this plugin has access, and create a name-to-path mapping from tool @@ -284,15 +315,21 @@ public final class PIFBuilder { throw InternalError("No tools found for plugin \(plugin.name)") } - // Assign a plugin working directory based on the package, target, and plugin. - let pluginOutputDir = outputDir.appending( - components: [ - package.identity.description, - module.name, - buildParameters.destination == .host ? "tools" : "destination", - plugin.name, - ] - ) + // Output directory layout. Tagged entries append the platform name + // so prebuild outputs from different platforms can't clobber each other. + // Untagged entries + // preserve today's layout `[package, module, tools|destination, plugin]` + // so existing tests and tooling that read these paths keep working. + var pluginOutputDirComponents = [ + package.identity.description, + module.name, + targetBuildParameters.destination == .host ? "tools" : "destination", + plugin.name, + ] + if let platform { + pluginOutputDirComponents.append(platform.name) + } + let pluginOutputDir = outputDir.appending(components: pluginOutputDirComponents) // Determine the set of directories under which plugins are allowed to write. // We always include just the output directory, and for now there is no possibility @@ -304,27 +341,38 @@ public final class PIFBuilder { // to the temporary directory). let readOnlyDirectories = [package.path] - // In tools version 6.0 and newer, we vend the list of files generated by previous plugins. + // In tools version 6.0 and newer, we vend the list of files generated + // by previous plugin invocations to this plugin invocation. Restrict + // the view so a watchOS-tagged plugin doesn't see iOS-only generated + // sources from an earlier iOS-tagged plugin: only same-tag and + // untagged priors are visible (untagged outputs reach every CT, so + // they're in scope regardless of this plugin's tag). let pluginDerivedSources: Sources let pluginDerivedResources: [Resource] if package.manifest.toolsVersion >= .v6_0 { + let priorSamePlatform = taggedResults + .filter { $0.platform == nil || $0.platform == platform } + .map(\.result) + // Set up dummy observability because we don't want to emit diagnostics for this before the actual // build. let observability = ObservabilitySystem { _, _ in } + // Compute the generated files based on all results we have computed so far. let pluginGeneratedFiles = ModulesGraph.computePluginGeneratedFiles( target: module, toolsVersion: package.manifest.toolsVersion, additionalFileRules: self.parameters.additionalFileRules, - buildParameters: buildParameters, - buildToolPluginInvocationResults: buildToolPluginResults, + buildParameters: targetBuildParameters, + buildToolPluginInvocationResults: priorSamePlatform, prebuildCommandResults: [], observabilityScope: observability.topScope ) + pluginDerivedSources = Sources( - paths: pluginGeneratedFiles.sources.map(\.self), - root: buildParameters.dataPath - ) + paths: pluginGeneratedFiles.sources.map(\.self), + root: targetBuildParameters.dataPath + ) pluginDerivedResources = pluginGeneratedFiles.resources.values.map(\.self) } else { pluginDerivedSources = .init(paths: [], root: package.path) @@ -333,54 +381,49 @@ public final class PIFBuilder { let result = try await pluginModule.invoke( module: plugin, - action: .createBuildToolCommands( + action: PluginAction.createBuildToolCommands( package: package, target: module, pluginGeneratedSources: pluginDerivedSources.paths, pluginGeneratedResources: pluginDerivedResources.map(\.path) ), - buildEnvironment: buildParameters.buildEnvironment, - workers: buildParameters.workers, + buildEnvironment: hostBuildParameters.buildEnvironment, + workers: hostBuildParameters.workers, scriptRunner: pluginScriptRunner, workingDirectory: package.path, outputDirectory: pluginOutputDir, - toolSearchDirectories: [buildParameters.toolchain.swiftCompilerPath.parentDirectory], + toolSearchDirectories: [hostBuildParameters.toolchain.swiftCompilerPath.parentDirectory], accessibleTools: accessibleTools, writableDirectories: writableDirectories, readOnlyDirectories: readOnlyDirectories, allowNetworkConnections: [], pkgConfigDirectories: self.parameters.pkgConfigDirectories, - sdkRootPath: buildParameters.toolchain.sdkRootPath, + sdkRootPath: targetBuildParameters.toolchain.sdkRootPath, fileSystem: fileSystem, modulesGraph: self.graph, observabilityScope: observabilityScope ) - buildToolPluginResults.append(result) - let diagnosticsEmitter = observabilityScope.makeDiagnosticsEmitter { var metadata = ObservabilityMetadata() metadata.moduleName = module.name metadata.pluginName = result.plugin.name return metadata } - for line in result.textOutput.split(whereSeparator: { $0.isNewline }) { diagnosticsEmitter.emit(info: line) } - for diag in result.diagnostics { diagnosticsEmitter.emit(diag) } - guard result.succeeded else { observabilityScope.emit(error: "build planning stopped due to build-tool plugin failures") throw Diagnostics.fatalError } - prebuildCommands.append(contentsOf: result.prebuildCommands) + taggedResults.append((result, platform)) - buildCommands.append(contentsOf: result.buildCommands.map( { buildCommand in + for buildCommand in result.buildCommands { var newEnv: Environment = buildCommand.configuration.environment // FIXME: This is largely a workaround for improper rpath setup on Linux. It should be @@ -389,7 +432,7 @@ public final class PIFBuilder { // plugins do not inadvertently use the toolchain stdlib instead of the OS stdlib // when built with a Swift.org toolchain. #if !os(macOS) - let runtimeLibPaths = buildParameters.toolchain.runtimeLibraryPaths + let runtimeLibPaths = targetBuildParameters.toolchain.runtimeLibraryPaths // Add paths to swift standard runtime libraries to the library path so that they can be found at runtime for libPath in runtimeLibPaths { @@ -404,7 +447,7 @@ public final class PIFBuilder { let writableDirectories: [AbsolutePath] = [pluginOutputDir] - return PackagePIFBuilder.CustomBuildCommand( + buildCommands.append(PackagePIFBuilder.CustomBuildCommand( displayName: buildCommand.configuration.displayName, executable: buildCommand.configuration.executable.pathString, arguments: buildCommand.configuration.arguments, @@ -416,20 +459,26 @@ public final class PIFBuilder { sandboxProfile: self.parameters.disableSandbox ? nil : - .init( - strictness: .writableTemporaryDirectory, - writableDirectories: writableDirectories, - readOnlyDirectories: buildCommand.inputFiles - ) - ) - })) + .init( + strictness: .writableTemporaryDirectory, + writableDirectories: writableDirectories, + readOnlyDirectories: buildCommand.inputFiles + ) + )) + buildCommandPlatforms.append(platform) + } } - // Run the prebuild commands generated from the plugin invocation now for this module. This will - // also give use the derived source code files needed for PIF generation. + let buildToolPluginInvocationResults = taggedResults.map(\.result) + + // Run the prebuild commands generated from the plugin invocation now for this module. + // This will also give use the derived source code files needed for PIF generation. + // Per-platform output dirs (when `isMultiCT`) prevent cross-platform + // on-disk collisions; the per-output platform tag attached below ensures + // the resulting derived sources only flow into the matching CT. let runResults = try Self.runPluginCommands( using: self.pluginConfiguration, - for: buildToolPluginResults, + for: buildToolPluginInvocationResults, fileSystem: fileSystem, observabilityScope: observabilityScope ) @@ -437,20 +486,30 @@ public final class PIFBuilder { self.diagnoseUnhandledFiles( package: package, module: module, - buildToolPluginInvocationResults: buildToolPluginResults + buildToolPluginInvocationResults: buildToolPluginInvocationResults ) + // Tag prebuild-command outputs with the source platform, parallel to the + // path list. `runResults[i]` came from `taggedResults[i]`. + var prebuildOutputPaths: [AbsolutePath] = [] + var prebuildOutputPlatforms: [PackageModel.Platform?] = [] + for (index, runResult) in runResults.enumerated() { + let platform = taggedResults[index].platform + for path in runResult.derivedFiles { + prebuildOutputPaths.append(path) + prebuildOutputPlatforms.append(platform) + } + } + let result = PackagePIFBuilder.BuildToolPluginInvocationResult( - prebuildCommandOutputPaths: runResults.flatMap( { $0.derivedFiles }), - buildCommands: buildCommands + prebuildCommandOutputPaths: prebuildOutputPaths, + buildCommands: buildCommands, + prebuildCommandOutputPlatforms: prebuildOutputPlatforms, + buildCommandPlatforms: buildCommandPlatforms ) // Add a BuildToolPluginInvocationResult to the mapping. - if var existingResults = buildToolPluginResultsByTargetName[module.name] { - existingResults.append(result) - } else { - buildToolPluginResultsByTargetName[module.name] = [result] - } + buildToolPluginResultsByTargetName[module.name, default: []].append(result) } let packagePIFBuilderDelegate = PackagePIFBuilderDelegate( @@ -480,7 +539,8 @@ public final class PIFBuilder { /// Constructs a `PIF.TopLevelObject` representing the package graph. package func constructPIF( - buildParameters: BuildParameters + targetBuildParameters: BuildParameters, + hostBuildParameters: BuildParameters ) async throws -> (PIF.TopLevelObject, [PackagePIFBuilder.ModuleOrProduct]) { return try await memoize(to: &self.cachedPIF) { let rootPackages = self.graph.rootPackages @@ -488,7 +548,7 @@ public final class PIFBuilder { throw PIFGenerationError.rootPackageNotFound } - let packagesAndPIFBuilders = try await makePIFBuilders(buildParameters: buildParameters) + let packagesAndPIFBuilders = try await makePIFBuilders(targetBuildParameters: targetBuildParameters, hostBuildParameters: hostBuildParameters) var modulesAndProducts: [PackagePIFBuilder.ModuleOrProduct] = [] let packagesAndPIFProjects = try packagesAndPIFBuilders.map { (package, pifBuilder, _) in @@ -504,7 +564,7 @@ public final class PIFBuilder { packagesAndProjects: packagesAndPIFProjects, observabilityScope: observabilityScope, modulesGraph: graph, - buildParameters: buildParameters + targetBuildParameters: targetBuildParameters ) ) @@ -580,7 +640,8 @@ public final class PIFBuilder { // Convenience method for generating PIF. public static func generatePIF( - buildParameters: BuildParameters, + targetBuildParameters: BuildParameters, + hostBuildParameters: BuildParameters, packageGraph: ModulesGraph, fileSystem: FileSystem, observabilityScope: ObservabilityScope, @@ -593,10 +654,11 @@ public final class PIFBuilder { addLocalRpaths: PackagePIFBuilder.AddLocalRpaths, materializeStaticArchiveProductsForRootPackages: Bool, createDynamicVariantsForLibraryProducts: Bool, - hostBuildProductsPath: AbsolutePath + hostBuildProductsPath: AbsolutePath, + configuredTargetMode: PIFConfiguredTargetMode ) async throws -> PIFGenerationResult { let parameters = PIFBuilderParameters( - buildParameters, + targetBuildParameters, supportedSwiftVersions: [], pluginScriptRunner: pluginScriptRunner, disableSandbox: disableSandbox, @@ -605,7 +667,8 @@ public final class PIFBuilder { addLocalRpaths: addLocalRpaths, materializeStaticArchiveProductsForRootPackages: materializeStaticArchiveProductsForRootPackages, createDynamicVariantsForLibraryProducts: createDynamicVariantsForLibraryProducts, - hostBuildProductsPath: hostBuildProductsPath + hostBuildProductsPath: hostBuildProductsPath, + configuredTargetMode: configuredTargetMode ) let builder = Self( graph: packageGraph, @@ -613,7 +676,7 @@ public final class PIFBuilder { fileSystem: fileSystem, observabilityScope: observabilityScope ) - return try await builder.generatePIF(preservePIFModelStructure: preservePIFModelStructure, buildParameters: buildParameters) + return try await builder.generatePIF(preservePIFModelStructure: preservePIFModelStructure, targetBuildParameters: targetBuildParameters, hostBuildParameters: hostBuildParameters) } private func diagnoseUnhandledFiles( @@ -762,7 +825,7 @@ fileprivate func buildAggregatePIFProject( packagesAndProjects: [(package: ResolvedPackage, project: ProjectModel.Project)], observabilityScope: ObservabilityScope, modulesGraph: ModulesGraph, - buildParameters: BuildParameters + targetBuildParameters: BuildParameters ) throws -> ProjectModel.Project { precondition(!packagesAndProjects.isEmpty) @@ -825,7 +888,7 @@ fileprivate func buildAggregatePIFProject( } if let resolvedModule = modulesGraph.module(for: target.name) { - guard modulesGraph.isInRootPackages(resolvedModule, satisfying: buildParameters.buildEnvironment) else { + guard modulesGraph.isInRootPackages(resolvedModule, satisfying: targetBuildParameters.buildEnvironment) else { // Disconnected target, possibly due to platform when condition that isn't satisfied continue } @@ -916,7 +979,8 @@ extension PIFBuilderParameters { addLocalRpaths: PackagePIFBuilder.AddLocalRpaths, materializeStaticArchiveProductsForRootPackages: Bool, createDynamicVariantsForLibraryProducts: Bool, - hostBuildProductsPath: AbsolutePath + hostBuildProductsPath: AbsolutePath, + configuredTargetMode: PIFConfiguredTargetMode ) { self.init( isPackageAccessModifierSupported: buildParameters.driverParameters.isPackageAccessModifierSupported, @@ -931,8 +995,9 @@ extension PIFBuilderParameters { disableSandbox: disableSandbox, pluginWorkingDirectory: pluginWorkingDirectory, additionalFileRules: additionalFileRules, - addLocalRpaths: addLocalRpaths, - hostBuildProductsPath: hostBuildProductsPath + addLocalRPaths: addLocalRpaths, + hostBuildProductsPath: hostBuildProductsPath, + configuredTargetMode: configuredTargetMode ) } } diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift index b905b781f6f..ba308e297da 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift @@ -317,7 +317,28 @@ extension Sequence { } return Set(pifPlatforms.flatMap { $0.toPlatformFilter() }) } +} + +extension PackageModel.Platform { + /// Maps a single `Platform` to the `Set` SwiftBuild's + /// `CustomTaskProducer` expects. Goes through `BuildSettings.Platform.toPlatformFilter()`, + /// which is the same path `BuildFile.platformFilters` and `TargetDependency.platformFilters` + /// use today; this inherits the iOS-device+simulator pair, macCatalyst → (ios, maccatalyst), + /// Linux gnu env, etc. + func toPlatformFilter(toolsVersion: ToolsVersion) -> Set { + var pifPlatforms: [ProjectModel.BuildSettings.Platform] = [] + if let pifPlatform = try? ProjectModel.BuildSettings.Platform(from: self) { + pifPlatforms.append(pifPlatform) + } + // Treat catalyst like macOS for backwards compatibility with older tools versions. + if pifPlatforms.contains(.macOS), toolsVersion < ToolsVersion.v5_5 { + pifPlatforms.append(.macCatalyst) + } + return Set(pifPlatforms.flatMap { $0.toPlatformFilter() }) + } +} +extension Sequence { var splitIntoConcreteConditions: ( [PackageModel.Platform?], [PackageModel.BuildConfiguration], diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder+Plugins.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder+Plugins.swift index b8a6ddb922b..496f72dd401 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder+Plugins.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder+Plugins.swift @@ -20,6 +20,8 @@ import struct Basics.SourceControlURL import enum SwiftBuild.ProjectModel +import struct PackageModel.Platform + extension PackagePIFBuilder { /// Contains all of the information resulting from applying a build tool plugin to a package target thats affect how /// a target is built. @@ -31,9 +33,21 @@ extension PackagePIFBuilder { /// Absolute paths of output files of any prebuild commands. public let prebuildCommandOutputPaths: [AbsolutePath] + /// Per-output platform tag, parallel to `prebuildCommandOutputPaths`. `nil` means + /// the output is not platform-attributed (preserved today's "match every platform" + /// behavior). Used downstream by `PackagePIFProjectBuilder` to gate which + /// configured targets see each derived source file. + public let prebuildCommandOutputPlatforms: [PackageModel.Platform?] + /// Build commands to incorporate into the dependency graph. public let buildCommands: [CustomBuildCommand] + /// Per-command platform tag, parallel to `buildCommands`. `nil` means the command + /// is not platform-attributed. Used downstream by `PackagePIFProjectBuilder` to + /// attach `CustomTask.platformFilters` for SwiftBuild's per-`ConfiguredTarget` + /// dispatch. + public let buildCommandPlatforms: [PackageModel.Platform?] + /// Absolute paths of all derived source files that should be compiled as sources of the target. /// This includes the outputs of any prebuild commands as well as all the outputs referenced in all the build /// commands. @@ -43,10 +57,20 @@ extension PackagePIFBuilder { public init( prebuildCommandOutputPaths: [AbsolutePath], - buildCommands: [CustomBuildCommand] + buildCommands: [CustomBuildCommand], + prebuildCommandOutputPlatforms: [PackageModel.Platform?]? = nil, + buildCommandPlatforms: [PackageModel.Platform?]? = nil ) { self.prebuildCommandOutputPaths = prebuildCommandOutputPaths self.buildCommands = buildCommands + self.prebuildCommandOutputPlatforms = prebuildCommandOutputPlatforms + ?? Array(repeating: nil, count: prebuildCommandOutputPaths.count) + self.buildCommandPlatforms = buildCommandPlatforms + ?? Array(repeating: nil, count: buildCommands.count) + precondition(self.prebuildCommandOutputPlatforms.count == prebuildCommandOutputPaths.count, + "prebuildCommandOutputPlatforms must be parallel to prebuildCommandOutputPaths") + precondition(self.buildCommandPlatforms.count == buildCommands.count, + "buildCommandPlatforms must be parallel to buildCommands") } } diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift index 0496993df0c..38cb15ef2c5 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift @@ -726,15 +726,21 @@ public final class PackagePIFBuilder { struct Resource { let path: String let rule: PackageModel.Resource.Rule + /// Platform filters applied to this resource's `BuildFile`. Empty = match every CT. + /// Plugin-emitted resources tagged with `condition: .when(targetPlatforms: [...])` + /// carry a non-empty set so they only land in the matching configured target. + let platformFilters: Set - init(path: String, rule: PackageModel.Resource.Rule) { + init(path: String, rule: PackageModel.Resource.Rule, platformFilters: Set = []) { self.path = path self.rule = rule + self.platformFilters = platformFilters } init(_ resource: PackageModel.Resource) { self.path = resource.path.pathString self.rule = resource.rule + self.platformFilters = [] } } } diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift index b78d36efe71..a9b05315d74 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift @@ -323,6 +323,11 @@ extension PackagePIFProjectBuilder { addBuildToolPluginCommands: false ) + // Add any additional source files emitted by custom build commands. Tagged + // generated sources only flow into the matching configured target; untagged + // sources keep today's "match every CT" behavior via empty platform filters. + let generatedSourceFilters = platformFiltersByGeneratedSourcePath(forModule: sourceModule.name) + // Either create or reuse the resource bundle. var resourceBundleName = inputResourceBundleName let shouldGenerateBundleAccessor: Bool @@ -331,10 +336,13 @@ extension PackagePIFProjectBuilder { // FIXME: We are not handling resource rules here, but the same is true for non-generated resources. // (Today, everything gets essentially treated as `.processResource` even if it may have been declared as // `.copy` in the manifest.) + let generatedResources = generatedFiles.resources.keys.map { path in + (path: path.pathString, platformFilters: generatedSourceFilters[path] ?? []) + } let (result, resourceBundle) = try addResourceBundle( for: sourceModule, targetKeyPath: sourceModuleTargetKeyPath, - generatedResourceFiles: generatedFiles.resources.keys.map(\.pathString) + generatedResourceFiles: generatedResources ) if let resourceBundle { self.builtModulesAndProducts.append(resourceBundle) } @@ -683,8 +691,9 @@ extension PackagePIFProjectBuilder { let sourceFileRef = self.project.mainGroup[keyPath: targetSourceFileGroupKeyPath].addFileReference { id in FileReference(id: id, path: path.pathString, pathBase: .absolute) } + let filters = generatedSourceFilters[path] ?? [] self.project[keyPath: sourceModuleTargetKeyPath].addSourceFile { id in - BuildFile(id: id, fileRef: sourceFileRef) + BuildFile(id: id, fileRef: sourceFileRef, platformFilters: filters) } log(.debug, indent: 2, "Added generated source file '\(path)'") } diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift index 96de60beece..2d13a403ac5 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift @@ -251,7 +251,11 @@ extension PackagePIFProjectBuilder { let doccCatalogs = mainModule.underlying.doccCatalogPaths - // Add any additional source files emitted by custom build commands. + // Add any additional source files emitted by custom build commands. Tagged + // generated sources only flow into the matching configured target; untagged + // sources keep today's "match every CT" behavior via empty platform filters. + let generatedSourceFilters = platformFiltersByGeneratedSourcePath(forModule: mainModule.name) + for path in generatedFiles.sources { let sourceFileRef = self.project.mainGroup[keyPath: mainTargetSourceFileGroupKeyPath] .addFileReference { id in @@ -261,15 +265,18 @@ extension PackagePIFProjectBuilder { pathBase: .absolute ) } + let filters = generatedSourceFilters[path] ?? [] self.project[keyPath: mainModuleTargetKeyPath].addSourceFile { id in - BuildFile(id: id, fileRef: sourceFileRef) + BuildFile(id: id, fileRef: sourceFileRef, platformFilters: filters) } log(.debug, indent: 2, "Added generated source file '\(path)'") } // Add any additional resource files emitted by synthesized build commands - let generatedResourceFiles: [String] = { - var generatedResourceFiles = generatedFiles.resources.keys.map(\.pathString) + let generatedResourceFiles: [(path: String, platformFilters: Set)] = { + var generatedResourceFiles = generatedFiles.resources.keys.map { path in + (path: path.pathString, platformFilters: generatedSourceFilters[path] ?? []) + } generatedResourceFiles.append( contentsOf: addBuildToolCommands( from: synthesizedResourceGeneratingPluginInvocationResults, diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder.swift index 9133d82d853..e0ee36ff163 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder.swift @@ -167,7 +167,7 @@ struct PackagePIFProjectBuilder { mutating func addResourceBundle( for module: PackageGraph.ResolvedModule, targetKeyPath: WritableKeyPath, - generatedResourceFiles: [String] + generatedResourceFiles: [(path: String, platformFilters: Set)] ) throws -> (PackagePIFBuilder.EmbedResourcesResult, PackagePIFBuilder.ModuleOrProduct?) { if module.resources.isEmpty && generatedResourceFiles.isEmpty { return (PackagePIFBuilder.EmbedResourcesResult( @@ -262,7 +262,7 @@ struct PackagePIFProjectBuilder { for module: PackageGraph.ResolvedModule, sourceModuleTargetKeyPath: WritableKeyPath, resourceBundleTargetKeyPath: WritableKeyPath?, - generatedResourceFiles: [String] + generatedResourceFiles: [(path: String, platformFilters: Set)] ) -> PackagePIFBuilder.EmbedResourcesResult { if module.resources.isEmpty && generatedResourceFiles.isEmpty { return PackagePIFBuilder.EmbedResourcesResult( @@ -275,9 +275,12 @@ struct PackagePIFProjectBuilder { let targetForResourcesKeyPath: WritableKeyPath = resourceBundleTargetKeyPath ?? sourceModuleTargetKeyPath - // Generated resources get a default treatment for rule and localization. - let generatedResources = generatedResourceFiles.compactMap { - PackagePIFBuilder.Resource(path: $0, rule: .process(localization: nil)) + // Generated resources get a default treatment for rule and localization. The + // platform filter from the originating plugin invocation flows through to every + // BuildFile we create for this resource, so a watchOS-tagged plugin's resource + // only lands in the watchOS configured target. + let generatedResources = generatedResourceFiles.map { + PackagePIFBuilder.Resource(path: $0.path, rule: .process(localization: nil), platformFilters: $0.platformFilters) } let resources = module.resources.map { PackagePIFBuilder.Resource($0) } + generatedResources @@ -286,6 +289,7 @@ struct PackagePIFProjectBuilder { for resource in resources { let resourcePath = resource.path + let filters = resource.platformFilters // Add a file reference for the resource. We use an absolute path, as for all the other files, // but we should be able to optimize this later by making it group-relative. let ref = self.project.mainGroup.addFileReference { id in @@ -300,7 +304,7 @@ struct PackagePIFProjectBuilder { if isCoreDataFile { self.project[keyPath: sourceModuleTargetKeyPath].addSourceFile { id in - BuildFile(id: id, fileRef: ref) + BuildFile(id: id, fileRef: ref, platformFilters: filters) } self.log(.debug, indent: 2, "Added core data resource as source file '\(resourcePath)'") } @@ -311,7 +315,7 @@ struct PackagePIFProjectBuilder { if isCoreMLFile { self.project[keyPath: sourceModuleTargetKeyPath].addSourceFile { id in - BuildFile(id: id, fileRef: ref, generatedCodeVisibility: .public) + BuildFile(id: id, fileRef: ref, generatedCodeVisibility: .public, platformFilters: filters) } self.log(.debug, indent: 2, "Added coreml resource as source file '\(resourcePath)'") } @@ -321,7 +325,7 @@ struct PackagePIFProjectBuilder { if isMetalFile, case .process = resource.rule { self.project[keyPath: targetForResourcesKeyPath].addSourceFile { id in - BuildFile(id: id, fileRef: ref) + BuildFile(id: id, fileRef: ref, platformFilters: filters) } } else { let swiftBuildResourceRule: BuildFile.ResourceRule @@ -337,7 +341,7 @@ struct PackagePIFProjectBuilder { BuildFile( id: id, fileRef: ref, - platformFilters: [], + platformFilters: filters, resourceRule: swiftBuildResourceRule ) } @@ -347,7 +351,7 @@ struct PackagePIFProjectBuilder { let isAssetCatalog = resourcePath.pathExtension == "xcassets" if isAssetCatalog { self.project[keyPath: sourceModuleTargetKeyPath].addSourceFile { id in - BuildFile(id: id, fileRef: ref) + BuildFile(id: id, fileRef: ref, platformFilters: filters) } self.log(.debug, indent: 2, "Added asset catalog as source file '\(resourcePath)'") } @@ -355,7 +359,7 @@ struct PackagePIFProjectBuilder { // String Catalogs can also generate symbols. if SwiftBuild.SwiftBuildFileType.xcstrings.fileTypes.contains(resourcePath.pathExtension) { self.project[keyPath: sourceModuleTargetKeyPath].addSourceFile { id in - BuildFile(id: id, fileRef: ref) + BuildFile(id: id, fileRef: ref, platformFilters: filters) } self.log(.debug, indent: 2, "Added string catalog as source file '\(resourcePath)'") } @@ -412,12 +416,16 @@ struct PackagePIFProjectBuilder { return generatedFiles } + let toolsVersion = self.package.manifest.toolsVersion for pluginResult in pluginResults { // Process the results of applying any build tool plugins on the target. - // If we've been asked to add build tool commands for the result, we do so now. + // If we've been asked to add build tool commands for the result, we do so now, + // tagging each command with its source platform so SwiftBuild's + // `CustomTaskProducer` only emits it for matching configured targets. if addBuildToolPluginCommands { - for command in pluginResult.buildCommands { - self.addBuildToolCommand(command, to: targetKeyPath) + for (index, command) in pluginResult.buildCommands.enumerated() { + let filters = pluginResult.buildCommandPlatforms[index]?.toPlatformFilter(toolsVersion: toolsVersion) ?? [] + self.addBuildToolCommand(command, to: targetKeyPath, platformFilters: filters) } } @@ -426,7 +434,7 @@ struct PackagePIFProjectBuilder { let files = self.process( pluginGeneratedFilePaths: command.absoluteOutputPaths, forModule: module, - toolsVersion: self.package.manifest.toolsVersion + toolsVersion: toolsVersion ) generatedFiles.add(files) @@ -443,7 +451,7 @@ struct PackagePIFProjectBuilder { let files = self.process( pluginGeneratedFilePaths: pluginResult.prebuildCommandOutputPaths, forModule: module, - toolsVersion: self.package.manifest.toolsVersion) + toolsVersion: toolsVersion) generatedFiles.add(files) } @@ -463,14 +471,16 @@ struct PackagePIFProjectBuilder { return } + let toolsVersion = self.package.manifest.toolsVersion for pluginResult in pluginResults { - for command in pluginResult.buildCommands { + for (index, command) in pluginResult.buildCommands.enumerated() { let producesResources = Set(command.outputPaths).intersection(resourceFilePaths).hasContent + let filters = pluginResult.buildCommandPlatforms[index]?.toPlatformFilter(toolsVersion: toolsVersion) ?? [] if producesResources { - self.addBuildToolCommand(command, to: resourceBundleTargetKeyPath) + self.addBuildToolCommand(command, to: resourceBundleTargetKeyPath, platformFilters: filters) } else { - self.addBuildToolCommand(command, to: sourceModuleTargetKeyPath) + self.addBuildToolCommand(command, to: sourceModuleTargetKeyPath, platformFilters: filters) } } } @@ -478,30 +488,83 @@ struct PackagePIFProjectBuilder { /// Adds build rules to `pifTarget` for any build tool commands from invocation results. /// Returns the absolute paths of any generated source files that should be added to the sources build phase of the - /// PIF target. + /// PIF target, paired with the platform filter the originating plugin invocation + /// produced (empty filter = match every platform). mutating func addBuildToolCommands( from pluginInvocationResults: [PackagePIFBuilder.BuildToolPluginInvocationResult], targetKeyPath: WritableKeyPath, addBuildToolPluginCommands: Bool - ) -> [String] { - var generatedSourceFileAbsPaths: [String] = [] + ) -> [(path: String, platformFilters: Set)] { + var generatedSourceFileAbsPaths: [(path: String, platformFilters: Set)] = [] + let toolsVersion = self.package.manifest.toolsVersion for result in pluginInvocationResults { - // Create build rules for all the commands in the result. - if addBuildToolPluginCommands { - for command in result.buildCommands { - self.addBuildToolCommand(command, to: targetKeyPath) + for (index, command) in result.buildCommands.enumerated() { + let filters = result.buildCommandPlatforms[index]?.toPlatformFilter(toolsVersion: toolsVersion) ?? [] + if addBuildToolPluginCommands { + self.addBuildToolCommand(command, to: targetKeyPath, platformFilters: filters) } + for path in command.absoluteOutputPaths { + generatedSourceFileAbsPaths.append((path.pathString, filters)) + } + } + for (index, path) in result.prebuildCommandOutputPaths.enumerated() { + let filters = result.prebuildCommandOutputPlatforms[index]?.toPlatformFilter(toolsVersion: toolsVersion) ?? [] + generatedSourceFileAbsPaths.append((path.pathString, filters)) } - // Add the paths of the generated source files, so that they can be added to the Sources build phase. - generatedSourceFileAbsPaths.append(contentsOf: result.allDerivedOutputPaths.map(\.pathString)) } return generatedSourceFileAbsPaths } + /// Builds a side-table mapping each derived-source path emitted by build-tool plugins + /// for `module` to the `Set` it should be tagged with. + /// + /// Generated sources are added to the target's `SourcesBuildPhase` as `BuildFile`s; + /// without per-source platform filters, every configured target compiles every derived + /// source, which is wrong under multi-CT (an iOS-only plugin's outputs would land in + /// the watchOS configured target and vice versa). This helper lets call sites that add + /// generated sources look up the right filter set per path. + /// + /// Paths produced under non-multi-CT drivers (or by legacy callers that don't tag) map + /// to `[]` (no entry); the `BuildFile` then has empty `platformFilters`, which matches + /// every platform — preserving today's behavior. + func platformFiltersByGeneratedSourcePath(forModule moduleName: String) + -> [AbsolutePath: Set] + { + guard let pluginResults = pifBuilder.buildToolPluginResultsByTargetName[moduleName] else { + return [:] + } + let toolsVersion = self.package.manifest.toolsVersion + var map: [AbsolutePath: Set] = [:] + for result in pluginResults { + for (index, path) in result.prebuildCommandOutputPaths.enumerated() { + if let platform = result.prebuildCommandOutputPlatforms[index] { + map[path] = platform.toPlatformFilter(toolsVersion: toolsVersion) + } + } + for (index, command) in result.buildCommands.enumerated() { + guard let platform = result.buildCommandPlatforms[index] else { continue } + let filters = platform.toPlatformFilter(toolsVersion: toolsVersion) + for path in command.absoluteOutputPaths { + map[path] = filters + } + } + } + return map + } + /// Adds a single plugin-created build command to a PIF target. + /// + /// - Parameters: + /// - command: The plugin-emitted build command. + /// - targetKeyPath: The PIF target receiving the `CustomTask`. + /// - platformFilters: Filters used by `swift-build`'s `CustomTaskProducer` to gate the + /// task per-`ConfiguredTarget`. Empty (default) matches every platform — preserves + /// legacy behavior. Non-empty is set by the per-platform fan-out under multi-CT + /// drivers so a watchOS-tagged command only runs in the watchOS CT, etc. mutating func addBuildToolCommand( _ command: PackagePIFBuilder.CustomBuildCommand, - to targetKeyPath: WritableKeyPath + to targetKeyPath: WritableKeyPath, + platformFilters: Set = [] ) { var commandLine = [command.executable] + command.arguments if let sandbox = command.sandboxProfile, !pifBuilder.delegate.isPluginExecutionSandboxingDisabled { @@ -517,7 +580,8 @@ struct PackagePIFProjectBuilder { inputFilePaths: [command.executable] + command.inputPaths.map(\.pathString), outputFilePaths: command.outputPaths, enableSandboxing: false, - preparesForIndexing: true + preparesForIndexing: true, + platformFilters: platformFilters ) ) } diff --git a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift index ae43743553f..40178e40ef5 100644 --- a/Sources/SwiftBuildSupport/SwiftBuildSystem.swift +++ b/Sources/SwiftBuildSupport/SwiftBuildSystem.swift @@ -266,6 +266,11 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { /// Additional rules for different file types generated from plugins. private let additionalFileRules: [FileRuleDescription] + /// Whether this build system can drive multiple configured targets at different + /// platforms in one PIF generation. Threaded into ``PIFBuilderParameters`` to gate + /// the per-platform plugin-invocation fan-out. See ``PIFConfiguredTargetMode``. + package let configuredTargetMode: PIFConfiguredTargetMode + public var builtTestProducts: [BuiltTestProduct] { get async { do { @@ -348,6 +353,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { pluginConfiguration: PluginConfiguration, delegate: BuildSystemDelegate?, scratchDirectory: Basics.AbsolutePath, // currently used to create the symbolic links + configuredTargetMode: PIFConfiguredTargetMode, ) throws { self.buildParameters = buildParameters self.hostBuildParameters = hostBuildParameters @@ -361,6 +367,7 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { self.pluginConfiguration = pluginConfiguration self.delegate = delegate self.scratchDirectory = scratchDirectory + self.configuredTargetMode = configuredTargetMode } private func createREPLArguments( @@ -1357,7 +1364,8 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { addLocalRpaths: self.buildParameters.linkingParameters.shouldDisableLocalRpath ? .never : .always, materializeStaticArchiveProductsForRootPackages: materializeStaticArchiveProductsForRootPackages, createDynamicVariantsForLibraryProducts: false, - hostBuildProductsPath: try await self.buildProductsPath(for: self.hostBuildParameters) + hostBuildProductsPath: try await self.buildProductsPath(for: self.hostBuildParameters), + configuredTargetMode: self.configuredTargetMode ), fileSystem: self.fileSystem, observabilityScope: self.observabilityScope, @@ -1373,7 +1381,8 @@ public final class SwiftBuildSystem: SPMBuildCore.BuildSystem { return try await pifBuilder.generatePIF( preservePIFModelStructure: preserveStructure, printPIFManifestGraphviz: buildParameters.printPIFManifestGraphviz, - buildParameters: buildParameters + targetBuildParameters: buildParameters, + hostBuildParameters: hostBuildParameters ) } diff --git a/Sources/swift-bootstrap/main.swift b/Sources/swift-bootstrap/main.swift index f6d1235ce8e..52a1675d3dd 100644 --- a/Sources/swift-bootstrap/main.swift +++ b/Sources/swift-bootstrap/main.swift @@ -452,6 +452,7 @@ struct SwiftBootstrapBuildTool: AsyncParsableCommand { ), delegate: nil, scratchDirectory: scratchDirectory, + configuredTargetMode: .single, ) } } diff --git a/Tests/BuildTests/PluginInvocationTests.swift b/Tests/BuildTests/PluginInvocationTests.swift index c6df85264b9..2130e880542 100644 --- a/Tests/BuildTests/PluginInvocationTests.swift +++ b/Tests/BuildTests/PluginInvocationTests.swift @@ -652,6 +652,637 @@ final class PluginInvocationTests: XCTestCase { } } + func testConditionalPluginUsageHostPlatform() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Plugins/FooPlugin/source.swift", + "/Foo/Sources/Foo/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + let buildParameters = mockBuildParameters(destination: .host, buildSystemKind: .native) + let hostPlatformName = buildParameters.buildEnvironment.platform.name + + let graph = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [ + Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + products: [ + ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin( + name: "FooPlugin", + package: nil, + condition: .init(hostPlatformNames: [hostPlatformName]) + ) + ] + ), + TargetDescription(name: "FooPlugin", type: .plugin, pluginCapability: .buildTool), + ] + ) + ], + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + let enabled = graph.pluginsPerModule( + satisfyingHost: buildParameters.buildEnvironment, + targetEnvironment: buildParameters.buildEnvironment + ) + XCTAssertEqual(enabled.count, 1) + + let disabled = graph.pluginsPerModule( + satisfyingHost: .init(platform: .linux), + targetEnvironment: buildParameters.buildEnvironment + ) + XCTAssertTrue(disabled.isEmpty) + } + + func testConditionalPluginUsageTargetPlatform() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Plugins/FooPlugin/source.swift", + "/Foo/Sources/Foo/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + let buildParameters = mockBuildParameters(destination: .host, buildSystemKind: .native) + + let graph = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [ + Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + products: [ + ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin( + name: "FooPlugin", + package: nil, + condition: .init(targetPlatformNames: ["linux"]) + ) + ] + ), + TargetDescription(name: "FooPlugin", type: .plugin, pluginCapability: .buildTool), + ] + ) + ], + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + let enabled = graph.pluginsPerModule( + satisfyingHost: buildParameters.buildEnvironment, + targetEnvironment: .init(platform: .linux) + ) + XCTAssertEqual(enabled.count, 1) + + let disabled = graph.pluginsPerModule( + satisfyingHost: buildParameters.buildEnvironment, + targetEnvironment: .init(platform: .macOS) + ) + XCTAssertTrue(disabled.isEmpty) + } + + func testConditionalPluginUsageTraits() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Plugins/FooPlugin/source.swift", + "/Foo/Sources/Foo/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + let manifest = Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + products: [ + try ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + try TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin( + name: "FooPlugin", + package: nil, + condition: .init(traits: ["Lint"]) + ) + ] + ), + try TargetDescription(name: "FooPlugin", type: .plugin, pluginCapability: .buildTool), + ], + traits: [TraitDescription(name: "Lint")] + ) + + let graphWithoutTrait = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [manifest], + observabilityScope: observability.topScope + ) + XCTAssertTrue(graphWithoutTrait.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).isEmpty) + + var enabledTraitsMap = EnabledTraitsMap() + enabledTraitsMap[manifest.packageIdentity] = EnabledTraits(["Lint"], setBy: .traitConfiguration) + let graphWithTrait = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [manifest], + observabilityScope: observability.topScope, + enabledTraitsMap: enabledTraitsMap + ) + XCTAssertEqual( + graphWithTrait.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).count, + 1 + ) + } + + func testConditionalPluginUsageProductPlugin() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Sources/Foo/source.swift", + "/Bar/Plugins/BarPlugin/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + + let graph = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [ + Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + dependencies: [ + .fileSystem( + identity: .plain("bar"), + nameForTargetDependencyResolutionOnly: "Bar", + path: "/Bar", + productFilter: .everything + ) + ], + products: [ + ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin( + name: "BarPlugin", + package: "Bar", + condition: .init(hostPlatformNames: ["macos"]) + ) + ] + ) + ] + ), + Manifest.createFileSystemManifest( + displayName: "Bar", + path: "/Bar", + products: [ + ProductDescription(name: "BarPlugin", type: .plugin, targets: ["BarPlugin"]) + ], + targets: [ + TargetDescription(name: "BarPlugin", type: .plugin, pluginCapability: .buildTool), + ] + ), + ], + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + XCTAssertEqual( + graph.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).count, + 1 + ) + XCTAssertTrue( + graph.pluginsPerModule( + satisfyingHost: .init(platform: .linux), + targetEnvironment: .init(platform: .macOS) + ).isEmpty + ) + } + + func testConditionalPluginUsageCombinedConditions() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Plugins/FooPlugin/source.swift", + "/Foo/Sources/Foo/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + + let graph = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [ + Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + products: [ + ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin( + name: "FooPlugin", + package: nil, + condition: .init(hostPlatformNames: ["macos"], targetPlatformNames: ["linux"]) + ) + ] + ), + TargetDescription(name: "FooPlugin", type: .plugin, pluginCapability: .buildTool), + ] + ) + ], + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + + // Both match → enabled + XCTAssertEqual( + graph.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .linux) + ).count, + 1 + ) + + // Host matches, target doesn't → disabled + XCTAssertTrue( + graph.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).isEmpty + ) + + // Target matches, host doesn't → disabled + XCTAssertTrue( + graph.pluginsPerModule( + satisfyingHost: .init(platform: .linux), + targetEnvironment: .init(platform: .linux) + ).isEmpty + ) + } + + func testConditionalPluginUsageTraitsAnySemantic() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Plugins/FooPlugin/source.swift", + "/Foo/Sources/Foo/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + let manifest = Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + products: [ + try ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + try TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin( + name: "FooPlugin", + package: nil, + condition: .init(traits: ["Lint", "Dev"]) + ) + ] + ), + try TargetDescription(name: "FooPlugin", type: .plugin, pluginCapability: .buildTool), + ], + traits: [TraitDescription(name: "Lint"), TraitDescription(name: "Dev"), TraitDescription(name: "Other")] + ) + + // Only "Lint" enabled → should be enabled (ANY semantics) + var enabledTraitsMap = EnabledTraitsMap() + enabledTraitsMap[manifest.packageIdentity] = EnabledTraits(["Lint"], setBy: .traitConfiguration) + let graphWithLint = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [manifest], + observabilityScope: observability.topScope, + enabledTraitsMap: enabledTraitsMap + ) + XCTAssertEqual( + graphWithLint.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).count, + 1 + ) + + // Only "Dev" enabled → should be enabled (ANY semantics) + enabledTraitsMap[manifest.packageIdentity] = EnabledTraits(["Dev"], setBy: .traitConfiguration) + let graphWithDev = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [manifest], + observabilityScope: observability.topScope, + enabledTraitsMap: enabledTraitsMap + ) + XCTAssertEqual( + graphWithDev.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).count, + 1 + ) + + // Both enabled → should be enabled + enabledTraitsMap[manifest.packageIdentity] = EnabledTraits(["Lint", "Dev"], setBy: .traitConfiguration) + let graphWithBoth = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [manifest], + observabilityScope: observability.topScope, + enabledTraitsMap: enabledTraitsMap + ) + XCTAssertEqual( + graphWithBoth.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).count, + 1 + ) + + // Unrelated trait enabled → should be disabled + var otherTraitsMap = EnabledTraitsMap() + otherTraitsMap[manifest.packageIdentity] = EnabledTraits(["Other"], setBy: .traitConfiguration) + let graphWithOther = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [manifest], + observabilityScope: observability.topScope, + enabledTraitsMap: otherTraitsMap + ) + XCTAssertTrue( + graphWithOther.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).isEmpty + ) + + // No traits enabled → should be disabled + let graphWithNone = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [manifest], + observabilityScope: observability.topScope + ) + XCTAssertTrue( + graphWithNone.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).isEmpty + ) + } + + func testPluginInBothDependenciesAndPluginUsages() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Plugins/FooPlugin/source.swift", + "/Foo/Sources/Foo/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + + let graph = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [ + Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + products: [ + ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin(name: "FooPlugin", package: nil) + ] + ), + TargetDescription(name: "FooPlugin", type: .plugin, pluginCapability: .buildTool), + ] + ) + ], + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + let fooModule = graph.allModules.first { $0.name == "Foo" }! + + // Plugin should be in dependencies (for build ordering) + let dependencyNames = fooModule.dependencies.map(\.name) + XCTAssertTrue(dependencyNames.contains("FooPlugin")) + + // Plugin should also be in pluginUsages (for condition-gated invocation) + let pluginNames = fooModule.pluginUsages.map(\.name) + XCTAssertTrue(pluginNames.contains("FooPlugin")) + } + + func testMultiplePluginsDifferentConditions() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Plugins/PluginA/source.swift", + "/Foo/Plugins/PluginB/source.swift", + "/Foo/Plugins/PluginC/source.swift", + "/Foo/Sources/Foo/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + + let graph = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [ + Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + products: [ + ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin(name: "PluginA", package: nil, condition: .init(hostPlatformNames: ["macos"])), + .plugin(name: "PluginB", package: nil, condition: .init(targetPlatformNames: ["linux"])), + .plugin(name: "PluginC", package: nil), + ] + ), + TargetDescription(name: "PluginA", type: .plugin, pluginCapability: .buildTool), + TargetDescription(name: "PluginB", type: .plugin, pluginCapability: .buildTool), + TargetDescription(name: "PluginC", type: .plugin, pluginCapability: .buildTool), + ] + ) + ], + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + + // Host=macOS, target=linux → all 3 + let allEnabled = graph.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .linux) + ) + XCTAssertEqual(allEnabled.values.flatMap { $0 }.count, 3) + + // Host=macOS, target=macOS → PluginA + PluginC + let macTarget = graph.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ) + let macTargetNames = Set(macTarget.values.flatMap { $0 }.map(\.name)) + XCTAssertEqual(macTargetNames, ["PluginA", "PluginC"]) + + // Host=linux, target=linux → PluginB + PluginC + let linuxHost = graph.pluginsPerModule( + satisfyingHost: .init(platform: .linux), + targetEnvironment: .init(platform: .linux) + ) + let linuxHostNames = Set(linuxHost.values.flatMap { $0 }.map(\.name)) + XCTAssertEqual(linuxHostNames, ["PluginB", "PluginC"]) + } + + func testConditionalPluginUsageMultiplePlatforms() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Plugins/FooPlugin/source.swift", + "/Foo/Sources/Foo/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + + let graph = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [ + Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + products: [ + ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin( + name: "FooPlugin", + package: nil, + condition: .init(hostPlatformNames: ["macos", "linux"]) + ) + ] + ), + TargetDescription(name: "FooPlugin", type: .plugin, pluginCapability: .buildTool), + ] + ) + ], + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + + // macOS matches → enabled + XCTAssertEqual( + graph.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).count, + 1 + ) + + // linux matches → enabled + XCTAssertEqual( + graph.pluginsPerModule( + satisfyingHost: .init(platform: .linux), + targetEnvironment: .init(platform: .macOS) + ).count, + 1 + ) + + // windows doesn't match → disabled + XCTAssertTrue( + graph.pluginsPerModule( + satisfyingHost: .init(platform: .windows), + targetEnvironment: .init(platform: .macOS) + ).isEmpty + ) + } + + func testConditionalPluginStillResolves() throws { + let fileSystem = InMemoryFileSystem(emptyFiles: + "/Foo/Sources/Foo/source.swift", + "/Bar/Plugins/BarPlugin/source.swift" + ) + let observability = ObservabilitySystem.makeForTesting() + + let graph = try loadModulesGraph( + fileSystem: fileSystem, + manifests: [ + Manifest.createRootManifest( + displayName: "Foo", + path: "/Foo", + dependencies: [ + .fileSystem( + identity: .plain("bar"), + nameForTargetDependencyResolutionOnly: "Bar", + path: "/Bar", + productFilter: .everything + ) + ], + products: [ + ProductDescription(name: "Foo", type: .library(.automatic), targets: ["Foo"]) + ], + targets: [ + TargetDescription( + name: "Foo", + type: .regular, + pluginUsages: [ + .plugin( + name: "BarPlugin", + package: "Bar", + condition: .init(hostPlatformNames: ["windows"]) + ) + ] + ) + ] + ), + Manifest.createFileSystemManifest( + displayName: "Bar", + path: "/Bar", + products: [ + ProductDescription(name: "BarPlugin", type: .plugin, targets: ["BarPlugin"]) + ], + targets: [ + TargetDescription(name: "BarPlugin", type: .plugin, pluginCapability: .buildTool), + ] + ), + ], + observabilityScope: observability.topScope + ) + + // Plugin condition is false (host is not windows), but package should still resolve + XCTAssertNoDiagnostics(observability.diagnostics) + XCTAssertTrue(graph.packages.contains { $0.manifest.displayName == "Bar" }) + + // Plugin should NOT be returned for non-matching host + XCTAssertTrue( + graph.pluginsPerModule( + satisfyingHost: .init(platform: .macOS), + targetEnvironment: .init(platform: .macOS) + ).isEmpty + ) + } + func testUnsupportedDependencyProduct() async throws { // Only run the test if the environment in which we're running actually supports Swift concurrency (which the plugin APIs require). try XCTSkipIf(!UserToolchain.default.supportsSwiftConcurrency(), "skipping because test environment doesn't support concurrency") @@ -1546,7 +2177,8 @@ final class PluginInvocationTests: XCTestCase { observabilityScope: ObservabilityScope ) async throws -> [ResolvedModule.ID: (target: ResolvedModule, results: [BuildToolPluginInvocationResult])] { let pluginsPerModule = graph.pluginsPerModule( - satisfying: buildParameters.buildEnvironment + satisfyingHost: buildParameters.buildEnvironment, + targetEnvironment: buildParameters.buildEnvironment ) let plugins = pluginsPerModule.values.reduce(into: IdentifiableSet()) { result, plugins in @@ -1571,7 +2203,8 @@ final class PluginInvocationTests: XCTestCase { for: module, destination: .target, configuration: pluginConfiguration, - buildParameters: buildParameters, + hostBuildParameters: buildParameters, + targetBuildEnvironment: buildParameters.buildEnvironment, modulesGraph: graph, tools: mockPluginTools( plugins: plugins, diff --git a/Tests/PackageLoadingTests/PD_6_5_LoadingTests.swift b/Tests/PackageLoadingTests/PD_6_5_LoadingTests.swift new file mode 100644 index 00000000000..20a039edd88 --- /dev/null +++ b/Tests/PackageLoadingTests/PD_6_5_LoadingTests.swift @@ -0,0 +1,192 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2026 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 Basics +import PackageLoading +import PackageModel +import _InternalTestSupport +import XCTest + +final class PackageDescription6_5LoadingTests: PackageDescriptionLoadingTests { + override var toolsVersion: ToolsVersion { + .v6_5 + } + + func testConditionalPluginUsage() async throws { + let content = #""" + import PackageDescription + let package = Package( + name: "Foo", + traits: [ + .trait(name: "Lint") + ], + targets: [ + .target( + name: "Foo", + plugins: [ + .plugin( + name: "MyPlugin", + condition: .when( + hostPlatforms: [.macOS], + targetPlatforms: [.linux], + traits: ["Lint"] + ) + ) + ] + ), + .plugin( + name: "MyPlugin", + capability: .buildTool() + ) + ] + ) + """# + + let observability = ObservabilitySystem.makeForTesting() + let (manifest, validationDiagnostics) = try await loadAndValidateManifest( + content, + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + XCTAssertNoDiagnostics(validationDiagnostics) + + guard case .plugin(name: let name, package: let package, condition: let condition)? = manifest.targets[0].pluginUsages?.first else { + return XCTFail("expected conditional plugin usage") + } + XCTAssertEqual(name, "MyPlugin") + XCTAssertNil(package) + XCTAssertEqual( + condition, + .init( + hostPlatformNames: ["macos"], + targetPlatformNames: ["linux"], + traits: ["Lint"] + ) + ) + } + + func testConditionalPluginUsageNilConditionFallback() async throws { + let content = #""" + import PackageDescription + let package = Package( + name: "Foo", + targets: [ + .target( + name: "Foo", + plugins: [ + .plugin(name: "MyPlugin") + ] + ), + .plugin( + name: "MyPlugin", + capability: .buildTool() + ) + ] + ) + """# + + let observability = ObservabilitySystem.makeForTesting() + let (manifest, validationDiagnostics) = try await loadAndValidateManifest( + content, + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + XCTAssertNoDiagnostics(validationDiagnostics) + + guard case .plugin(name: let name, package: _, condition: let condition)? = manifest.targets[0].pluginUsages?.first else { + return XCTFail("expected plugin usage") + } + XCTAssertEqual(name, "MyPlugin") + XCTAssertNil(condition) + } + + func testConditionalPluginUsageWithPackage() async throws { + let content = #""" + import PackageDescription + let package = Package( + name: "Foo", + dependencies: [ + .package(url: "https://github.com/example/Bar.git", from: "1.0.0") + ], + targets: [ + .target( + name: "Foo", + plugins: [ + .plugin( + name: "BarPlugin", + package: "Bar", + condition: .when(hostPlatforms: [.macOS]) + ) + ] + ) + ] + ) + """# + + let observability = ObservabilitySystem.makeForTesting() + let (manifest, validationDiagnostics) = try await loadAndValidateManifest( + content, + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + XCTAssertNoDiagnostics(validationDiagnostics) + + guard case .plugin(name: let name, package: let package, condition: let condition)? = manifest.targets[0].pluginUsages?.first else { + return XCTFail("expected conditional plugin usage") + } + XCTAssertEqual(name, "BarPlugin") + XCTAssertEqual(package, "Bar") + XCTAssertEqual(condition, .init(hostPlatformNames: ["macos"])) + } + + func testConditionalPluginUsageEmptyConditionReturnsNil() async throws { + let content = #""" + import PackageDescription + let package = Package( + name: "Foo", + targets: [ + .target( + name: "Foo", + plugins: [ + .plugin( + name: "MyPlugin", + condition: .when(hostPlatforms: [], targetPlatforms: []) + ) + ] + ), + .plugin( + name: "MyPlugin", + capability: .buildTool() + ) + ] + ) + """# + + let observability = ObservabilitySystem.makeForTesting() + let (manifest, validationDiagnostics) = try await loadAndValidateManifest( + content, + observabilityScope: observability.topScope + ) + + XCTAssertNoDiagnostics(observability.diagnostics) + XCTAssertNoDiagnostics(validationDiagnostics) + + guard case .plugin(name: _, package: _, condition: let condition)? = manifest.targets[0].pluginUsages?.first else { + return XCTFail("expected plugin usage") + } + // Empty platforms should normalize to nil condition + XCTAssertNil(condition) + } +} diff --git a/Tests/SwiftBuildSupportTests/CGenPIFTests.swift b/Tests/SwiftBuildSupportTests/CGenPIFTests.swift index 785155bef04..29f403a21e6 100644 --- a/Tests/SwiftBuildSupportTests/CGenPIFTests.swift +++ b/Tests/SwiftBuildSupportTests/CGenPIFTests.swift @@ -201,10 +201,8 @@ import SwiftBuild ) return try await pifBuilder.constructPIF( - buildParameters: mockBuildParameters( - destination: .host, - buildSystemKind: .swiftbuild, - ) + targetBuildParameters: mockBuildParameters(destination: .target, buildSystemKind: .swiftbuild), + hostBuildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) ).0 } diff --git a/Tests/SwiftBuildSupportTests/PIFBuilderTests.swift b/Tests/SwiftBuildSupportTests/PIFBuilderTests.swift index 0ddeb9fb40b..3d652c4b4b9 100644 --- a/Tests/SwiftBuildSupportTests/PIFBuilderTests.swift +++ b/Tests/SwiftBuildSupportTests/PIFBuilderTests.swift @@ -51,8 +51,9 @@ extension PIFBuilderParameters { disableSandbox: false, pluginWorkingDirectory: temporaryDirectory.appending(component: "plugin-working-dir"), additionalFileRules: [], - addLocalRpaths: addLocalRpaths, - hostBuildProductsPath: hostBuildProductsPath ?? temporaryDirectory.appending(component: "host-build-products") + addLocalRPaths: addLocalRpaths, + hostBuildProductsPath: hostBuildProductsPath ?? temporaryDirectory.appending(component: "host-build-products"), + configuredTargetMode: .single ) } } @@ -68,7 +69,7 @@ fileprivate func withGeneratedPIF( let buildParameters = if let buildParameters { buildParameters } else { - mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) + mockBuildParameters(destination: .target, buildSystemKind: .swiftbuild) } try await fixture(name: fixtureName) { fixturePath in let observabilitySystem: TestingObservability = ObservabilitySystem.makeForTesting(verbose: false) @@ -98,8 +99,10 @@ fileprivate func withGeneratedPIF( fileSystem: localFileSystem, observabilityScope: observabilitySystem.topScope ) + let hostBuildParameters = mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) let (pif, _) = try await builder.constructPIF( - buildParameters: buildParameters + targetBuildParameters: buildParameters, + hostBuildParameters: hostBuildParameters ) try await doIt(pif, observabilitySystem) } @@ -390,7 +393,8 @@ struct PIFBuilderTests { // Act let (pif, _) = try await pifBuilder.constructPIF( - buildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) + targetBuildParameters: mockBuildParameters(destination: .target, buildSystemKind: .swiftbuild), + hostBuildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) ) // Assert @@ -950,7 +954,8 @@ struct PIFBuilderTests { observabilityScope: observability.topScope ) let (pif, _) = try await pifBuilder.constructPIF( - buildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) + targetBuildParameters: mockBuildParameters(destination: .target, buildSystemKind: .swiftbuild), + hostBuildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) ) let remoteProject = try pif.workspace.project(named: "remote-pkg") @@ -1088,7 +1093,8 @@ struct PIFBuilderTests { ) let (pif, _) = try await pifBuilder.constructPIF( - buildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) + targetBuildParameters: mockBuildParameters(destination: .target, buildSystemKind: .swiftbuild), + hostBuildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) ) let project = try pif.workspace.project(named: "Root") @@ -1181,7 +1187,8 @@ struct PIFBuilderTests { ) let (pif, _) = try await pifBuilder.constructPIF( - buildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) + targetBuildParameters: mockBuildParameters(destination: .target, buildSystemKind: .swiftbuild), + hostBuildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) ) let project = try pif.workspace.project(named: "Pkg") diff --git a/Tests/SwiftBuildSupportTests/PluginPlatformFanOutTests.swift b/Tests/SwiftBuildSupportTests/PluginPlatformFanOutTests.swift new file mode 100644 index 00000000000..b988e4cb25e --- /dev/null +++ b/Tests/SwiftBuildSupportTests/PluginPlatformFanOutTests.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2026 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 Basics +import PackageGraph +import PackageModel +import SPMBuildCore +import SwiftBuildSupport +import Testing +import _InternalTestSupport + +@testable import SPMBuildCore + +@_spi(DontAdoptOutsideOfSwiftPMExposedForBenchmarksAndTestsOnly) @testable import PackageGraph +@_spi(SwiftPMInternal) @testable import PackageModel + +@Suite struct PluginUsageConditionAxisTests { + @Test func hostAxisEmptyMatchesAnything() { + let condition = Module.PluginUsageCondition(hostPlatforms: [], targetPlatforms: [.iOS], traits: []) + let env = BuildEnvironment(platform: .linux, configuration: .debug) + #expect(condition.hostAxisSatisfied(hostEnv: env)) + } + + @Test func hostAxisMatch() { + let condition = Module.PluginUsageCondition(hostPlatforms: [.macOS], targetPlatforms: [], traits: []) + let macOSEnv = BuildEnvironment(platform: .macOS, configuration: .debug) + let linuxEnv = BuildEnvironment(platform: .linux, configuration: .debug) + #expect(condition.hostAxisSatisfied(hostEnv: macOSEnv)) + #expect(!condition.hostAxisSatisfied(hostEnv: linuxEnv)) + } + + @Test func targetAxisEmptyMatchesAnything() { + let condition = Module.PluginUsageCondition(hostPlatforms: [.macOS], targetPlatforms: [], traits: []) + let env = BuildEnvironment(platform: .linux, configuration: .debug) + #expect(condition.targetAxisSatisfied(targetEnv: env)) + } + + @Test func targetAxisMatch() { + let condition = Module.PluginUsageCondition(hostPlatforms: [], targetPlatforms: [.iOS, .watchOS], traits: []) + #expect(condition.targetAxisSatisfied(targetEnv: BuildEnvironment(platform: .iOS, configuration: .debug))) + #expect(condition.targetAxisSatisfied(targetEnv: BuildEnvironment(platform: .watchOS, configuration: .debug))) + #expect(!condition.targetAxisSatisfied(targetEnv: BuildEnvironment(platform: .macOS, configuration: .debug))) + } + + @Test func traitsAxisEmptyMatchesAnything() { + let condition = Module.PluginUsageCondition(hostPlatforms: [.macOS], targetPlatforms: [], traits: []) + let traits = EnabledTraits.defaults + #expect(condition.traitsAxisSatisfied(enabledTraits: traits)) + } + + @Test func traitsAxisDefaultSentinelHandling() { + // Condition requires the "default" sentinel; runtime traits have it implicitly. + let condition = Module.PluginUsageCondition( + hostPlatforms: [], targetPlatforms: [], traits: ["default"] + ) + let traits = EnabledTraits.defaults + #expect(condition.traitsAxisSatisfied(enabledTraits: traits)) + } + + @Test func traitsAxisIntersection() { + let condition = Module.PluginUsageCondition( + hostPlatforms: [], targetPlatforms: [], traits: ["Logging"] + ) + // The runtime trait set must contain "Logging" for the condition to match. + let withLogging = EnabledTraits([ + EnabledTrait(name: "Logging", setBy: .traitConfiguration), + ]) + let withoutLogging = EnabledTraits([ + EnabledTrait(name: "Metrics", setBy: .traitConfiguration), + ]) + #expect(condition.traitsAxisSatisfied(enabledTraits: withLogging)) + #expect(!condition.traitsAxisSatisfied(enabledTraits: withoutLogging)) + } + + @Test func satisfiesIsAndOfThreeAxes() { + let condition = Module.PluginUsageCondition( + hostPlatforms: [.macOS], targetPlatforms: [.iOS], traits: ["default"] + ) + let host = BuildEnvironment(platform: .macOS, configuration: .debug) + let target = BuildEnvironment(platform: .iOS, configuration: .debug) + + // All three axes pass. + #expect(condition.satisfies( + hostEnvironment: host, + targetEnvironment: target, + enabledTraits: .defaults + )) + // Host axis fails. + #expect(!condition.satisfies( + hostEnvironment: BuildEnvironment(platform: .linux, configuration: .debug), + targetEnvironment: target, + enabledTraits: .defaults + )) + // Target axis fails. + #expect(!condition.satisfies( + hostEnvironment: host, + targetEnvironment: BuildEnvironment(platform: .watchOS, configuration: .debug), + enabledTraits: .defaults + )) + } +} + +@Suite struct PIFConfiguredTargetModeTests { + @Test func twoCases() { + // The enum should have exactly two cases; this guard fires if a future case is + // added without updating the rest of the per-platform fan-out logic. + let modes: [PIFConfiguredTargetMode] = [.single, .multiple] + #expect(modes.count == 2) + } + + @Test func singleAndMultipleAreDistinct() { + #expect(PIFConfiguredTargetMode.single != PIFConfiguredTargetMode.multiple) + } +} diff --git a/Tests/SwiftBuildSupportTests/PrebuiltsPIFTests.swift b/Tests/SwiftBuildSupportTests/PrebuiltsPIFTests.swift index b4cfcdeee8e..b90be3dd209 100644 --- a/Tests/SwiftBuildSupportTests/PrebuiltsPIFTests.swift +++ b/Tests/SwiftBuildSupportTests/PrebuiltsPIFTests.swift @@ -176,7 +176,8 @@ struct PrebuiltsPIFTests { observabilityScope: observability.topScope ) let (pif, _) = try await pifBuilder.constructPIF( - buildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) + targetBuildParameters: mockBuildParameters(destination: .target, buildSystemKind: .swiftbuild), + hostBuildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) ) let hostTargets = Set([ @@ -432,7 +433,8 @@ struct PrebuiltsPIFTests { observabilityScope: observability.topScope ) let (pif, _) = try await pifBuilder.constructPIF( - buildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) + targetBuildParameters: mockBuildParameters(destination: .target, buildSystemKind: .swiftbuild), + hostBuildParameters: mockBuildParameters(destination: .host, buildSystemKind: .swiftbuild) ) let targets = pif.workspace.projects.flatMap({ $0.underlying.targets }) diff --git a/Tests/SwiftBuildSupportTests/SwiftBuildSystemTests.swift b/Tests/SwiftBuildSupportTests/SwiftBuildSystemTests.swift index 9950f21d925..e436e23acc6 100644 --- a/Tests/SwiftBuildSupportTests/SwiftBuildSystemTests.swift +++ b/Tests/SwiftBuildSupportTests/SwiftBuildSystemTests.swift @@ -80,6 +80,7 @@ func withInstantiatedSwiftBuildSystem( ), delegate: nil, scratchDirectory: tmpDir.appending("scratchDirectory"), + configuredTargetMode: .single, ) try await SwiftBuildSupport.withService(