diff --git a/Package.swift b/Package.swift index aac15d8d..56fd0097 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,7 @@ let targets: [Target] = [ .product(name: "FileSystem", package: "FileSystem"), .product(name: "Mockable", package: "Mockable"), .product(name: "MachOKitC", package: "MachOKit"), + "XcodeGraph", ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency"), @@ -43,6 +44,7 @@ let targets: [Target] = [ ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency"), + .define("MOCKING", .when(configuration: .debug)), ] ), .testTarget( @@ -60,6 +62,7 @@ let targets: [Target] = [ ], swiftSettings: [ .enableExperimentalFeature("StrictConcurrency"), + .define("MOCKING", .when(configuration: .debug)), ] ), ] diff --git a/Sources/XcodeGraph/Models/Target.swift b/Sources/XcodeGraph/Models/Target.swift index 438f5166..11d6b416 100644 --- a/Sources/XcodeGraph/Models/Target.swift +++ b/Sources/XcodeGraph/Models/Target.swift @@ -62,6 +62,8 @@ public struct Target: Equatable, Hashable, Comparable, Codable, Sendable { public let onDemandResourcesTags: OnDemandResourcesTags? public let metadata: TargetMetadata public let type: TargetType + /// Package directories + public let packages: [AbsolutePath] // MARK: - Init @@ -94,7 +96,8 @@ public struct Target: Equatable, Hashable, Comparable, Codable, Sendable { mergeable: Bool = false, onDemandResourcesTags: OnDemandResourcesTags? = nil, metadata: TargetMetadata = .metadata(tags: []), - type: TargetType = .local + type: TargetType = .local, + packages: [AbsolutePath] = [] ) { self.name = name self.product = product @@ -125,6 +128,7 @@ public struct Target: Equatable, Hashable, Comparable, Codable, Sendable { self.onDemandResourcesTags = onDemandResourcesTags self.metadata = metadata self.type = type + self.packages = packages } /// Given a target name, it obtains the product name by turning "-" characters into "_" and "/" into "_" diff --git a/Sources/XcodeGraph/Models/Version.swift b/Sources/XcodeGraph/Models/Version.swift index 8d22d3f7..49d8f500 100644 --- a/Sources/XcodeGraph/Models/Version.swift +++ b/Sources/XcodeGraph/Models/Version.swift @@ -39,6 +39,11 @@ public struct Version: Hashable, Codable, Sendable { public var xcodeStringValue: String { "\(major)\(minor)\(patch)" } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(description) + } } extension Version: Comparable { diff --git a/Sources/XcodeGraph/PackageInfo.swift b/Sources/XcodeGraph/PackageInfo.swift new file mode 100644 index 00000000..237cf6c9 --- /dev/null +++ b/Sources/XcodeGraph/PackageInfo.swift @@ -0,0 +1,802 @@ +import Foundation + +/// The Swift Package Manager package information. +/// It decodes data encoded from Manifest.swift: https://github.com/apple/swift-package-manager/blob/06f9b30f4593940272f57f6284e5614d817d2f22/Sources/PackageModel/Manifest.swift#L372-L409 +/// Fields not needed by tuist are commented out and not decoded at all. +public struct PackageInfo: Equatable, Hashable { + /// The name of the package. + public let name: String + + /// The products declared in the manifest. + public let products: [Product] + + /// The targets declared in the manifest. + public let targets: [Target] + + /// The declared platforms in the manifest. + public let platforms: [Platform] + + /// The supported C language standard to use for compiling C sources in the package. + public let cLanguageStandard: String? + + /// The supported C++ language standard to use for compiling C++ sources in the package. + public let cxxLanguageStandard: String? + + /// The supported swift language standard to use for compiling Swift sources in the package. + public let swiftLanguageVersions: [Version]? + + /// The tools version declared in the manifest. + public let toolsVersion: Version + + // Ignored fields + + // /// The declared package dependencies. + // let dependencies: [PackageDependencyDescription] + + // /// The pkg-config name of a system package. + // let pkgConfig: String? + + // /// The system package providers of a system package. + // let providers: [SystemPackageProviderDescription]? + + // /// Whether kind of package this manifest is from. + // let packageKind: PackageReference.Kind + + public init( + name: String, + products: [Product], + targets: [Target], + platforms: [Platform], + cLanguageStandard: String?, + cxxLanguageStandard: String?, + swiftLanguageVersions: [Version]?, + toolsVersion: Version + ) { + self.name = name + self.products = products + self.targets = targets + self.platforms = platforms + self.cLanguageStandard = cLanguageStandard + self.cxxLanguageStandard = cxxLanguageStandard + self.swiftLanguageVersions = swiftLanguageVersions + self.toolsVersion = toolsVersion + } +} + +// MARK: Platform + +extension PackageInfo { + public struct Platform: Codable, Hashable { + public let platformName: String + public let version: String + public let options: [String] + + public init( + platformName: String, + version: String, + options: [String] + ) { + self.platformName = platformName + self.version = version + self.options = options + } + + public var platform: PackagePlatform? { + PackagePlatform(rawValue: platformName) + } + } +} + +// MARK: PackageConditionDescription + +extension PackageInfo { + public struct PackageConditionDescription: Codable, Hashable { + public let platformNames: [String] + public let config: String? + + public init( + platformNames: [String], + config: String? + ) { + self.platformNames = platformNames + self.config = config + } + } +} + +extension PackageInfo { + /// A package dependency of a Swift package. + /// + /// A package dependency consists of a Git URL to the source of the package, + /// and a requirement for the version of the package. + /// + /// Swift Package Manager performs a process called _dependency resolution_ to determine + /// the exact version of the package dependencies that an app or other Swift + /// package can use. The `Package.resolved` file records the results of the + /// dependency resolution and lives in the top-level directory of a Swift + /// package. If you add the Swift package as a package dependency to an app + /// for an Apple platform, you can find the `Package.resolved` file inside + /// your `.xcodeproj` or `.xcworkspace`. + public struct Dependency: Codable, Hashable { + /// The type of dependency. + public enum Kind: Codable, Hashable { + /// A dependency located at the given path. + /// - Parameters: + /// - name: The name of the dependency. + /// - path: The path to the dependency. + case fileSystem(name: String?, path: String) + + /// A dependency based on a source control requirement. + /// - Parameters: + /// - name: The name of the dependency. + /// - location: The Git URL of the dependency. + /// - requirement: The version-based requirement for a package. + case sourceControl(name: String?, location: String) + + /// A dependency based on a registry requirement. + /// - Parameters: + /// - id: The package identifier of the dependency. + /// - requirement: The version based requirement for a package. + case registry(id: String) + } + + /// A description of the package dependency. + public let kind: Dependency.Kind + } +} + +// MARK: - Product + +extension PackageInfo { + public struct Product: Equatable, Codable, Hashable { + /// The name of the product. + public let name: String + + /// The type of product to create. + public let type: Product.ProductType + + /// The list of targets to combine to form the product. + /// + /// This is never empty, and is only the targets which are required to be in + /// the product, but not necessarily their transitive dependencies. + public let targets: [String] + + public init( + name: String, + type: Product.ProductType, + targets: [String] + ) { + self.name = name + self.type = type + self.targets = targets + } + } +} + +extension PackageInfo.Product { + public enum ProductType: Hashable { + /// The type of library. + public enum LibraryType: String, Codable { + /// Static library. + case `static` + + /// Dynamic library. + case dynamic + + /// The type of library is unspecified and should be decided by package manager. + case automatic + } + + /// A library product. + case library(LibraryType) + + /// An executable product. + case executable + + /// A plugin product. + case plugin + + /// A test product. + case test + } +} + +// MARK: - Target + +extension PackageInfo { + public struct Target: Codable, Hashable { + private enum CodingKeys: String, CodingKey { + case name, path, url, sources, packageAccess, resources, exclude, dependencies, publicHeadersPath, type, settings, + checksum + } + + /// The name of the target. + public let name: String + + /// The custom path of the target. + public let path: String? + + /// The url of the binary target artifact. + public let url: String? + + /// The custom sources of the target. + public let sources: [String]? + + /// The explicitly declared resources of the target. + public let resources: [Resource] + + /// The exclude patterns. + public let exclude: [String] + + /// The declared target dependencies. + public let dependencies: [Dependency] + + /// The custom headers path. + public let publicHeadersPath: String? + + /// The type of target. + public let type: TargetType + + /// The target-specific build settings declared in this target. + public let settings: [TargetBuildSettingDescription.Setting] + + /// The binary target checksum. + public let checksum: String? + + /// If true, access to package declarations from other targets in the package is allowed. + public let packageAccess: Bool + + public init( + name: String, + path: String?, + url: String?, + sources: [String]?, + resources: [Resource], + exclude: [String], + dependencies: [Dependency], + publicHeadersPath: String?, + type: TargetType, + settings: [TargetBuildSettingDescription.Setting], + checksum: String?, + packageAccess: Bool = false + ) { + self.name = name + self.path = path + self.url = url + self.sources = sources + self.packageAccess = packageAccess + self.resources = resources + self.exclude = exclude + self.dependencies = dependencies + self.publicHeadersPath = publicHeadersPath + self.type = type + self.settings = settings + self.checksum = checksum + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + path = try container.decodeIfPresent(String.self, forKey: .path) + url = try container.decodeIfPresent(String.self, forKey: .url) + sources = try container.decodeIfPresent([String].self, forKey: .sources) + packageAccess = try container.decodeIfPresent(Bool.self, forKey: .packageAccess) ?? false + resources = try container.decode([Resource].self, forKey: .resources) + exclude = try container.decode([String].self, forKey: .exclude) + dependencies = try container.decode([Dependency].self, forKey: .dependencies) + publicHeadersPath = try container.decodeIfPresent(String.self, forKey: .publicHeadersPath) + type = try container.decode(TargetType.self, forKey: .type) + settings = try container.decode([TargetBuildSettingDescription.Setting].self, forKey: .settings) + checksum = try container.decodeIfPresent(String.self, forKey: .checksum) + } + + #if DEBUG + public static func test( + name: String = "Library", + path: String? = nil, + url: String? = nil, + sources: [String]? = [], + resources: [Resource] = [], + exclude: [String] = [], + dependencies: [Dependency] = [], + publicHeadersPath: String? = nil, + type: TargetType = .regular, + settings: [TargetBuildSettingDescription.Setting] = [], + checksum: String? = nil, + packageAccess _: Bool = false + ) -> Self { + Self( + name: name, + path: path, + url: url, + sources: sources, + resources: resources, + exclude: exclude, + dependencies: dependencies, + publicHeadersPath: publicHeadersPath, + type: type, + settings: settings, + checksum: checksum + ) + } + #endif + } +} + +// MARK: Target.Dependency + +extension PackageInfo.Target { + /// A dependency of the target. + public enum Dependency: Hashable { + /// A dependency internal to the same package. + case target(name: String, condition: PackageInfo.PackageConditionDescription?) + + /// A product from an external package. + case product( + name: String, + package: String, + moduleAliases: [String: String]?, + condition: PackageInfo.PackageConditionDescription? + ) + + /// A dependency to be resolved by name. + case byName(name: String, condition: PackageInfo.PackageConditionDescription?) + } +} + +// MARK: Target.Resource + +extension PackageInfo.Target { + public struct Resource: Codable, Hashable { + public enum Rule: String, Codable, Hashable { + case process + case copy + + public init(from decoder: Decoder) throws { + // Xcode 14 format + enum RuleXcode14: Codable, Equatable { + case process(localization: String?) + case copy + } + + if let kind = try? RuleXcode14(from: decoder) { + switch kind { + case .process: + self = .process + case .copy: + self = .copy + } + } else if let singleValue = try? decoder.singleValueContainer().decode(String.self) { + switch singleValue { + case "process": + self = .process + case "copy": + self = .copy + default: + throw DecodingError + .dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Invalid value for Resource.Rule: \(singleValue)" + )) + } + } else { + throw DecodingError + .dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Invalid content for Resource decoder" + )) + } + } + } + + public enum Localization: String, Codable, Hashable { + case `default` + case base + } + + /// The rule for the resource. + public let rule: Rule + + /// The path of the resource. + public let path: String + + /// The explicit localization of the resource. + public let localization: Localization? + + public init(rule: Rule, path: String, localization: Localization? = nil) { + self.rule = rule + self.path = path + self.localization = localization + } + } +} + +// MARK: Target.TargetType + +extension PackageInfo.Target { + public enum TargetType: String, Hashable, Codable { + case regular + case executable + case test + case system + case binary + case plugin + case macro + } +} + +// MARK: Target.TargetBuildSettingDescription + +extension PackageInfo.Target { + /// A namespace for target-specific build settings. + public enum TargetBuildSettingDescription { + /// The tool for which a build setting is declared. + public enum Tool: String, Codable, Hashable, CaseIterable { + case c + case cxx + case swift + case linker + } + + /// The name of the build setting. + public enum SettingName: String, Codable, Hashable { + case swiftLanguageMode + case headerSearchPath + case define + case linkedLibrary + case linkedFramework + case unsafeFlags + case enableUpcomingFeature + case enableExperimentalFeature + } + + /// An individual build setting. + public struct Setting: Codable, Hashable { + /// The tool associated with this setting. + public let tool: Tool + + /// The name of the setting. + public let name: SettingName + + /// The condition at which the setting should be applied. + public let condition: PackageInfo.PackageConditionDescription? + + public var hasConditions: Bool { + condition != nil || condition?.platformNames.isEmpty == true + } + + /// The value of the setting. + /// + /// This is kind of like an "untyped" value since the length + /// of the array will depend on the setting type. + public let value: [String] + + public init( + tool: Tool, + name: SettingName, + condition: PackageInfo.PackageConditionDescription?, + value: [String] + ) { + self.tool = tool + self.name = name + self.condition = condition + self.value = value + } + + private enum CodingKeys: String, CodingKey { + case tool, name, condition, value, kind + } + + // Xcode 14 format + private enum Kind: Codable, Equatable { + case swiftLanguageMode(String) + case headerSearchPath(String) + case define(String) + case linkedLibrary(String) + case linkedFramework(String) + case unsafeFlags([String]) + case enableUpcomingFeature(String) + case enableExperimentalFeature(String) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + tool = try container.decode(Tool.self, forKey: .tool) + condition = try container.decodeIfPresent(PackageInfo.PackageConditionDescription.self, forKey: .condition) + if let kind = try? container.decode(Kind.self, forKey: .kind) { + switch kind { + case let .headerSearchPath(value): + name = .headerSearchPath + self.value = [value] + case let .define(value): + name = .define + self.value = [value] + case let .linkedLibrary(value): + name = .linkedLibrary + self.value = [value] + case let .linkedFramework(value): + name = .linkedFramework + self.value = [value] + case let .unsafeFlags(value): + name = .unsafeFlags + self.value = value + case let .enableUpcomingFeature(value): + name = .enableUpcomingFeature + self.value = [value] + case let .enableExperimentalFeature(value): + name = .enableExperimentalFeature + self.value = [value] + case let .swiftLanguageMode(value): + name = .swiftLanguageMode + self.value = [value] + } + } else { + name = try container.decode(SettingName.self, forKey: .name) + value = try container.decode([String].self, forKey: .value) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(tool, forKey: .tool) + try container.encodeIfPresent(condition, forKey: .condition) + switch name { + case .headerSearchPath: + try container.encode(Kind.headerSearchPath(value.first!), forKey: .kind) + case .define: + try container.encode(Kind.define(value.first!), forKey: .kind) + case .linkedLibrary: + try container.encode(Kind.linkedLibrary(value.first!), forKey: .kind) + case .linkedFramework: + try container.encode(Kind.linkedFramework(value.first!), forKey: .kind) + case .unsafeFlags: + try container.encode(Kind.unsafeFlags(value), forKey: .kind) + case .enableUpcomingFeature: + try container.encode(Kind.enableUpcomingFeature(value.first!), forKey: .kind) + case .enableExperimentalFeature: + try container.encode(Kind.enableExperimentalFeature(value.first!), forKey: .kind) + case .swiftLanguageMode: + try container.encode(Kind.swiftLanguageMode(value.first!), forKey: .kind) + } + } + } + } +} + +// MARK: Codable conformances + +extension PackageInfo: Codable { + private struct ToolsVersion: Codable { + // swiftlint:disable:next identifier_name + let _version: String + } + + private enum CodingKeys: String, CodingKey { + case name, products, targets, platforms, cLanguageStandard, cxxLanguageStandard, swiftLanguageVersions, toolsVersion + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + name = try values.decode(String.self, forKey: .name) + products = try values.decode([Product].self, forKey: .products) + targets = try values.decode([Target].self, forKey: .targets) + platforms = try values.decode([Platform].self, forKey: .platforms) + cLanguageStandard = try values.decodeIfPresent(String.self, forKey: .cLanguageStandard) + cxxLanguageStandard = try values.decodeIfPresent(String.self, forKey: .cxxLanguageStandard) + swiftLanguageVersions = try values + .decodeIfPresent([String].self, forKey: .swiftLanguageVersions)? + .compactMap { Version(string: $0) } + + let versionString = try values.decode(ToolsVersion.self, forKey: .toolsVersion)._version + guard let toolsVersion = Version( + string: versionString + ) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid Swift tools version string \(versionString)" + ) + ) + } + self.toolsVersion = toolsVersion + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(products, forKey: .products) + try container.encode(targets, forKey: .targets) + try container.encode(platforms, forKey: .platforms) + try container.encodeIfPresent(cLanguageStandard, forKey: .cLanguageStandard) + try container.encodeIfPresent(cxxLanguageStandard, forKey: .cxxLanguageStandard) + try container.encodeIfPresent(swiftLanguageVersions, forKey: .swiftLanguageVersions) + try container.encode(ToolsVersion(_version: toolsVersion.description), forKey: .toolsVersion) + } +} + +extension PackageInfo.Target.Dependency: Codable { + private enum CodingKeys: String, CodingKey { + case target, product, byName + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + guard let key = values.allKeys.first(where: values.contains) else { + throw DecodingError + .dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Did not find a matching key")) + } + + var unkeyedValues = try values.nestedUnkeyedContainer(forKey: key) + switch key { + case .target: + self = .target( + name: try unkeyedValues.decode(String.self), + condition: try unkeyedValues.decodeIfPresent(PackageInfo.PackageConditionDescription.self) + ) + case .product: + let first = try unkeyedValues.decode(String.self) + self = .product( + name: first, + package: try unkeyedValues.decodeIfPresent(String.self) ?? first, + moduleAliases: try unkeyedValues.decodeIfPresent([String: String].self), + condition: try unkeyedValues.decodeIfPresent(PackageInfo.PackageConditionDescription.self) + ) + case .byName: + self = .byName( + name: try unkeyedValues.decode(String.self), + condition: try unkeyedValues.decodeIfPresent(PackageInfo.PackageConditionDescription.self) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .byName(name: name, condition: condition): + var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .byName) + try unkeyedContainer.encode(name) + if let condition { + try unkeyedContainer.encode(condition) + } + case let .product(name: name, package: package, moduleAliases: moduleAliases, condition: condition): + var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .product) + try unkeyedContainer.encode(name) + try unkeyedContainer.encode(package) + try unkeyedContainer.encode(moduleAliases) + try unkeyedContainer.encode(condition) + case let .target(name: name, condition: condition): + var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .target) + try unkeyedContainer.encode(name) + if let condition { + try unkeyedContainer.encode(condition) + } + } + } +} + +extension PackageInfo.Product.ProductType: Codable { + private enum CodingKeys: String, CodingKey { + case library, executable, plugin, test + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + guard let key = values.allKeys.first(where: values.contains) else { + throw DecodingError + .dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Did not find a matching key")) + } + switch key { + case .library: + var unkeyedValues = try values.nestedUnkeyedContainer(forKey: key) + let libraryType = try unkeyedValues.decode(PackageInfo.Product.ProductType.LibraryType.self) + self = .library(libraryType) + case .test: + self = .test + case .executable: + self = .executable + case .plugin: + self = .plugin + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .executable: + try container.encode(CodingKeys.executable.rawValue, forKey: .executable) + case .plugin: + try container.encode(CodingKeys.plugin.rawValue, forKey: .plugin) + case .test: + try container.encode(CodingKeys.test.rawValue, forKey: .test) + case let .library(libraryType): + var nestedContainer = container.nestedUnkeyedContainer(forKey: .library) + try nestedContainer.encode(libraryType) + } + } +} + +extension PackageInfo.Target.TargetType { + /// Defines if target may have a public headers path + /// Based on preconditions in https://github.com/apple/swift-package-manager/blob/main/Sources/PackageDescription/Target.swift + public var supportsPublicHeaderPath: Bool { + switch self { + case .regular, .executable, .test: + return true + case .system, .binary, .plugin, .macro: + return false + } + } + + /// Defines if target may have source files + /// Based on preconditions in https://github.com/apple/swift-package-manager/blob/main/Sources/PackageDescription/Target.swift + public var supportsSources: Bool { + switch self { + case .regular, .executable, .test, .plugin, .macro: + return true + case .system, .binary: + return false + } + } + + /// Defines if target may have resource files + /// Based on preconditions in https://github.com/apple/swift-package-manager/blob/main/Sources/PackageDescription/Target.swift + public var supportsResources: Bool { + switch self { + case .regular, .executable, .test: + return true + case .system, .binary, .plugin, .macro: + return false + } + } + + /// Defines if target may have other dependencies + /// Based on preconditions in https://github.com/apple/swift-package-manager/blob/main/Sources/PackageDescription/Target.swift + public var supportsDependencies: Bool { + switch self { + case .regular, .executable, .test, .plugin, .macro: + return true + case .system, .binary: + return false + } + } + + /// Defines if target supports C, CXX, Swift or linker settings + /// Based on preconditions in https://github.com/apple/swift-package-manager/blob/main/Sources/PackageDescription/Target.swift + public var supportsCustomSettings: Bool { + switch self { + case .regular, .executable, .test: + return true + case .system, .binary, .plugin, .macro: + return false + } + } +} + +#if DEBUG + extension PackageInfo { + public static func test( + name: String = "Package", + products: [Product] = [], + targets: [Target] = [], + platforms: [Platform] = [], + cLanguageStandard: String? = nil, + cxxLanguageStandard: String? = nil, + swiftLanguageVersions: [Version]? = nil, + toolsVersion: Version = Version(5, 9, 0) + ) -> Self { + .init( + name: name, + products: products, + targets: targets, + platforms: platforms, + cLanguageStandard: cLanguageStandard, + cxxLanguageStandard: cxxLanguageStandard, + swiftLanguageVersions: swiftLanguageVersions, + toolsVersion: toolsVersion + ) + } + } +#endif diff --git a/Sources/XcodeGraphMapper/Mappers/Graph/XcodeGraphMapper.swift b/Sources/XcodeGraphMapper/Mappers/Graph/XcodeGraphMapper.swift index 09c6e1d2..ab39da09 100644 --- a/Sources/XcodeGraphMapper/Mappers/Graph/XcodeGraphMapper.swift +++ b/Sources/XcodeGraphMapper/Mappers/Graph/XcodeGraphMapper.swift @@ -49,11 +49,26 @@ enum XcodeMapperGraphType { /// ``` public struct XcodeGraphMapper: XcodeGraphMapping { private let fileSystem: FileSysteming + private let packageInfoLoader: PackageInfoLoading + private let packageMapper: PackageMapping + private let projectMapper: PBXProjectMapping // MARK: - Initialization - public init(fileSystem: FileSysteming = FileSystem()) { + public init() { + self.init(fileSystem: FileSystem()) + } + + init( + fileSystem: FileSysteming = FileSystem(), + packageInfoLoader: PackageInfoLoading = PackageInfoLoader(), + packageMapper: PackageMapping = PackageMapper(), + projectMapper: PBXProjectMapping = PBXProjectMapper() + ) { self.fileSystem = fileSystem + self.packageInfoLoader = packageInfoLoader + self.packageMapper = packageMapper + self.projectMapper = projectMapper } // MARK: - Public API @@ -96,15 +111,15 @@ public struct XcodeGraphMapper: XcodeGraphMapping { } private func detectGraphTypeInDirectory(at path: AbsolutePath) async throws -> XcodeMapperGraphType { - let patterns = ["**/*.xcworkspace", "**/*.xcodeproj"] - let contents = try fileSystem.glob(directory: path, include: patterns) + let patterns = ["*.xcworkspace", "*.xcodeproj"] + let contents = try await fileSystem.glob(directory: path, include: patterns).collect() - if let workspacePath = try await contents.first(where: { $0.extension?.lowercased() == "xcworkspace" }) { + if let workspacePath = contents.first(where: { $0.extension?.lowercased() == "xcworkspace" }) { let xcworkspace = try XCWorkspace(path: Path(workspacePath.pathString)) return .workspace(xcworkspace) } - if let projectPath = try await contents.first(where: { $0.extension?.lowercased() == "xcodeproj" }) { + if let projectPath = contents.first(where: { $0.extension?.lowercased() == "xcodeproj" }) { let xcodeProj = try XcodeProj(pathString: projectPath.pathString) return .project(xcodeProj) } @@ -117,8 +132,28 @@ public struct XcodeGraphMapper: XcodeGraphMapping { func buildGraph(from graphType: XcodeMapperGraphType) async throws -> Graph { let projectPaths = try await identifyProjectPaths(from: graphType) let workspace = assembleWorkspace(graphType: graphType, projectPaths: projectPaths) - let projects = try await loadProjects(projectPaths) + var projects = try await loadProjects(projectPaths) let packages = extractPackages(from: projects) + var packageInfos: [AbsolutePath: PackageInfo] = [:] + var packagesByName: [String: AbsolutePath] = [:] + for projectPackage in projects.values.flatMap(\.packages) { + switch projectPackage { + case .remote: + break + case let .local(path: packagePath): + guard packageInfos[packagePath] == nil else { break } + let packageInfo = try await packageInfoLoader.loadPackageInfo(at: packagePath) + packageInfos[packagePath] = packageInfo + packagesByName[packageInfo.name] = packagePath + } + } + for (path, packageInfo) in packageInfos { + projects[path] = try await packageMapper.map( + packageInfo, + packages: packagesByName, + at: path + ) + } let (dependencies, dependencyConditions) = try await resolveDependencies(for: projects) return assembleFinalGraph( @@ -181,7 +216,6 @@ public struct XcodeGraphMapper: XcodeGraphMapping { } for xcodeProject in xcodeProjects { - let projectMapper = PBXProjectMapper() let project = try await projectMapper.map( xcodeProj: xcodeProject, projectNativeTargets: projectNativeTargets diff --git a/Sources/XcodeGraphMapper/Mappers/Packages/PackageMapper.swift b/Sources/XcodeGraphMapper/Mappers/Packages/PackageMapper.swift new file mode 100644 index 00000000..9f881f7c --- /dev/null +++ b/Sources/XcodeGraphMapper/Mappers/Packages/PackageMapper.swift @@ -0,0 +1,196 @@ +import FileSystem +import Foundation +import Mockable +import Path +import XcodeGraph + +@Mockable +protocol PackageMapping { + func map( + _ packageInfo: PackageInfo, + packages: [String: AbsolutePath], + at path: AbsolutePath + ) async throws -> Project +} + +struct PackageMapper: PackageMapping { + private let fileSystem: FileSysteming + + init( + fileSystem: FileSysteming = FileSystem() + ) { + self.fileSystem = fileSystem + } + + func map( + _ packageInfo: PackageInfo, + packages: [String: AbsolutePath], + at path: AbsolutePath + ) async throws -> Project { + var targets: [Target] = [] + for target in packageInfo.targets { + targets.append( + try await mapTarget( + target, + packageInfo: packageInfo, + packages: packages, + path: path + ) + ) + } + return Project( + path: path, + sourceRootPath: path, + xcodeProjPath: path, + name: packageInfo.name, + organizationName: nil, + classPrefix: nil, + defaultKnownRegions: nil, + developmentRegion: nil, + options: Project.Options( + automaticSchemesOptions: .disabled, + disableBundleAccessors: true, + disableShowEnvironmentVarsInScriptPhases: true, + disableSynthesizedResourceAccessors: true, + textSettings: Project.Options.TextSettings( + usesTabs: nil, + indentWidth: nil, + tabWidth: nil, + wrapsLines: nil + ) + ), + settings: Settings(configurations: [:]), + filesGroup: .group(name: packageInfo.name), + targets: targets, + packages: [], + schemes: [], + ideTemplateMacros: nil, + additionalFiles: [], + resourceSynthesizers: [], + lastUpgradeCheck: nil, + type: .local + ) + } + + private func mapTarget( + _ target: PackageInfo.Target, + packageInfo: PackageInfo, + packages: [String: AbsolutePath], + path: AbsolutePath + ) async throws -> Target { + // Some of the products, such as "regular" are approximations until XcodeGraph supports these SwiftPM-specific products + let product: Product = switch target.type { + case .regular: + .staticFramework + case .executable: + .commandLineTool + case .macro: + .macro + case .plugin: + .commandLineTool + case .system: + .framework + case .binary: + .framework + case .test: + .unitTests + } + + let directory: AbsolutePath + switch target.type { + case .test: + directory = path.appending(components: "Tests", target.name) + default: + directory = path.appending(components: "Sources", target.name) + } + let sources: [SourceFile] = try await fileSystem.glob( + directory: directory, + include: [ + "**/*.{\(Target.validSourceExtensions.joined(separator: ","))}", + ] + ) + .collect() + .map { SourceFile(path: $0) } + + let dependencies: [TargetDependency] = target.dependencies.compactMap { dependency in + switch dependency { + case let .target(name: name, condition: condition): + return .target(name: name, status: .required, condition: mapCondition(condition)) + case let .byName(name: name, condition: condition): + if let target = packageInfo.targets.first(where: { $0.name == name }) { + return .target(name: target.name, status: .required, condition: mapCondition(condition)) + } else { + if let path = packages[name] { + return .project( + target: name, + path: path, + status: .required, + condition: mapCondition(condition) + ) + } else { + return .package( + product: name, + type: .runtime, + condition: mapCondition(condition) + ) + } + } + case let .product( + name: name, + package: package, + moduleAliases: _, + condition: condition + ): + if let path = packages[package] { + return .project( + target: name, + path: path, + status: .required, + condition: mapCondition(condition) + ) + } else { + return .package( + product: name, + type: .runtime, + condition: mapCondition(condition) + ) + } + } + } + + return Target( + name: target.name, + destinations: Destinations(Destination.allCases), + product: product, + productName: nil, + bundleId: "", + sources: sources, + filesGroup: .group(name: target.name), + dependencies: dependencies + ) + } + + private func mapCondition(_ condition: PackageInfo.PackageConditionDescription?) -> PlatformCondition? { + guard let condition else { return nil } + let filters: [PlatformFilter] = condition.platformNames.compactMap { name in + switch name { + case "ios": + return .ios + case "maccatalyst": + return .catalyst + case "macos": + return .macos + case "tvos": + return .tvos + case "watchos": + return .watchos + case "visionos": + return .visionos + default: + return nil + } + } + + return .when(Set(filters)) + } +} diff --git a/Sources/XcodeGraphMapper/Mappers/Packages/XCPackageMapper.swift b/Sources/XcodeGraphMapper/Mappers/Packages/XCPackageMapper.swift index a3ddc4de..999cff3b 100644 --- a/Sources/XcodeGraphMapper/Mappers/Packages/XCPackageMapper.swift +++ b/Sources/XcodeGraphMapper/Mappers/Packages/XCPackageMapper.swift @@ -16,7 +16,7 @@ enum PackageMappingError: Error, LocalizedError, Equatable { } /// A protocol defining how to map remote and local Swift package references into `Package` models. -protocol PackageMapping { +protocol XCPackageMapping { /// Maps a remote Swift package reference to a `Package`. /// /// - Parameter package: The remote package reference. @@ -35,7 +35,7 @@ protocol PackageMapping { } /// A mapper that converts remote and local Swift package references into `Package` domain models. -struct XCPackageMapper: PackageMapping { +struct XCPackageMapper: XCPackageMapping { func map(package: XCRemoteSwiftPackageReference) throws -> Package { guard let repositoryURL = package.repositoryURL else { let name = package.name ?? "Unknown Package" diff --git a/Sources/XcodeGraphMapper/Mappers/Project/PBXProjectMapper.swift b/Sources/XcodeGraphMapper/Mappers/Project/PBXProjectMapper.swift index fd02efa7..820c8913 100644 --- a/Sources/XcodeGraphMapper/Mappers/Project/PBXProjectMapper.swift +++ b/Sources/XcodeGraphMapper/Mappers/Project/PBXProjectMapper.swift @@ -1,4 +1,6 @@ +import FileSystem import Foundation +import Mockable import Path import PathKit import XcodeGraph @@ -8,6 +10,7 @@ import XcodeMetadata // swiftlint:disable function_body_length /// A protocol for mapping an Xcode project (`.xcodeproj`) into a `Project` domain model. +@Mockable protocol PBXProjectMapping { /// Maps the given `XcodeProj` into a `Project` model. /// @@ -29,6 +32,17 @@ protocol PBXProjectMapping { /// - Identifying and integrating user and shared schemes. /// - Providing resource synthesizers for code generation. struct PBXProjectMapper: PBXProjectMapping { + private let fileSystem: FileSysteming + private let targetMapper: PBXTargetMapping + + init( + fileSystem: FileSysteming = FileSystem(), + targetMapper: PBXTargetMapping = PBXTargetMapper() + ) { + self.fileSystem = fileSystem + self.targetMapper = targetMapper + } + /// Maps the given Xcode project into a `Project` model. /// /// - Parameter xcodeProj: The Xcode project reference containing `.pbxproj` data. @@ -49,15 +63,17 @@ struct PBXProjectMapper: PBXProjectMapping { configurationList: pbxProject.buildConfigurationList ) + let localPackagePaths = try await collectAllPackages(from: pbxProject.mainGroup, xcodeProj: xcodeProj) + // Map PBXTargets to domain Targets - let targetMapper = PBXTargetMapper() let targets = try await withThrowingTaskGroup(of: Target.self, returning: [Target].self) { taskGroup in for pbxTarget in pbxProject.targets { taskGroup.addTask { try await targetMapper.map( pbxTarget: pbxTarget, xcodeProj: xcodeProj, - projectNativeTargets: projectNativeTargets + projectNativeTargets: projectNativeTargets, + packages: localPackagePaths ) } } @@ -78,7 +94,7 @@ struct PBXProjectMapper: PBXProjectMapping { } let localPackages = try pbxProject.localPackages.compactMap { try packageMapper.map(package: $0, sourceDirectory: sourceDirectory) - } + } + localPackagePaths.map { .local(path: $0) } // Create a files group for the main group let filesGroup = ProjectGroup.group(name: pbxProject.mainGroup?.name ?? "Project") @@ -87,13 +103,13 @@ struct PBXProjectMapper: PBXProjectMapping { let schemeMapper = XCSchemeMapper() let graphType: XcodeMapperGraphType = .project(xcodeProj) var userSchemes: [Scheme] = [] - for scheme in try xcodeProj.userData.flatMap(\.schemes) { + for scheme in xcodeProj.userData.flatMap(\.schemes) { userSchemes.append( try await schemeMapper.map(scheme, shared: false, graphType: graphType) ) } var sharedSchemes: [Scheme] = [] - for scheme in try (xcodeProj.sharedData?.schemes ?? []) { + for scheme in xcodeProj.sharedData?.schemes ?? [] { sharedSchemes.append( try await schemeMapper.map(scheme, shared: true, graphType: graphType) ) @@ -151,6 +167,36 @@ struct PBXProjectMapper: PBXProjectMapping { ) } } + + private func collectAllPackages(from group: PBXGroup, xcodeProj: XcodeProj) async throws -> [AbsolutePath] { + var packages = Set() + for child in group.children { + if let file = child as? PBXFileReference, + let pathString = try file.fullPath(sourceRoot: xcodeProj.srcPathString) + { + let path = try AbsolutePath(validating: pathString) + if try await fileSystem.exists(path, isDirectory: true), + try await fileSystem.exists(path.appending(component: "Package.swift")) + { + packages.insert(path) + } + } else if let subgroup = child as? PBXGroup { + packages.formUnion(try await collectAllPackages(from: subgroup, xcodeProj: xcodeProj)) + } else if let subgroup = child as? PBXFileSystemSynchronizedRootGroup, let synchronizedGroupPath = subgroup.path { + let directory = xcodeProj.srcPath.appending(component: synchronizedGroupPath) + let groupPackages = try await fileSystem.glob( + directory: directory, + include: [ + "**/Package.swift", + ] + ) + .collect() + .map(\.parentDirectory) + packages.formUnion(groupPackages) + } + } + return Array(packages) + } } // MARK: - ResourceSynthesizer.Parser Helpers diff --git a/Sources/XcodeGraphMapper/Mappers/Schemes/XCSchemeMapper.swift b/Sources/XcodeGraphMapper/Mappers/Schemes/XCSchemeMapper.swift index fdef8357..b8fe6059 100644 --- a/Sources/XcodeGraphMapper/Mappers/Schemes/XCSchemeMapper.swift +++ b/Sources/XcodeGraphMapper/Mappers/Schemes/XCSchemeMapper.swift @@ -146,13 +146,20 @@ struct XCSchemeMapper: SchemeMapping { case .some(false): .none } + let containerPath = try containerPath( + from: $0.target.containerPath, + graphType: graphType + ) + let projectPath: AbsolutePath + if containerPath.extension == nil { + projectPath = containerPath + } else { + projectPath = containerPath.parentDirectory + } return TestableTarget( target: TargetReference( - projectPath: try containerPath( - from: $0.target.containerPath, - graphType: graphType - ).parentDirectory, + projectPath: projectPath, name: $0.target.name ), parallelization: parallelization diff --git a/Sources/XcodeGraphMapper/Mappers/Targets/PBXTargetMapper.swift b/Sources/XcodeGraphMapper/Mappers/Targets/PBXTargetMapper.swift index 88503947..bb5ce10c 100644 --- a/Sources/XcodeGraphMapper/Mappers/Targets/PBXTargetMapper.swift +++ b/Sources/XcodeGraphMapper/Mappers/Targets/PBXTargetMapper.swift @@ -1,5 +1,6 @@ import FileSystem import Foundation +import Mockable import Path import XcodeGraph import XcodeProj @@ -30,6 +31,7 @@ enum PBXTargetMappingError: LocalizedError, Equatable { /// Conforming types transform raw `PBXTarget` instances—including their build phases, /// settings, and dependencies—into fully realized `Target` models suitable for analysis, /// code generation, or tooling integration. +@Mockable protocol PBXTargetMapping { /// Maps a given `PBXTarget` into a `Target` model. /// @@ -48,7 +50,8 @@ protocol PBXTargetMapping { func map( pbxTarget: PBXTarget, xcodeProj: XcodeProj, - projectNativeTargets: [String: ProjectNativeTarget] + projectNativeTargets: [String: ProjectNativeTarget], + packages: [AbsolutePath] ) async throws -> Target } @@ -100,7 +103,8 @@ struct PBXTargetMapper: PBXTargetMapping { func map( pbxTarget: PBXTarget, xcodeProj: XcodeProj, - projectNativeTargets: [String: ProjectNativeTarget] + projectNativeTargets: [String: ProjectNativeTarget], + packages: [AbsolutePath] ) async throws -> Target { let platform = try pbxTarget.platform() let deploymentTargets = pbxTarget.deploymentTargets() @@ -119,7 +123,8 @@ struct PBXTargetMapper: PBXTargetMapping { } ?? [] sources = try await fileSystemSynchronizedGroupsSources( from: pbxTarget, - xcodeProj: xcodeProj + xcodeProj: xcodeProj, + packages: packages ) + sources var resources = try pbxTarget.resourcesBuildPhase().map { @@ -227,7 +232,8 @@ struct PBXTargetMapper: PBXTargetMapping { mergedBinaryType: mergedBinaryType, mergeable: mergeable, onDemandResourcesTags: onDemandResourcesTags, - metadata: metadata + metadata: metadata, + packages: packages ) } @@ -376,7 +382,8 @@ struct PBXTargetMapper: PBXTargetMapping { private func fileSystemSynchronizedGroupsSources( from pbxTarget: PBXTarget, - xcodeProj: XcodeProj + xcodeProj: XcodeProj, + packages: [AbsolutePath] ) async throws -> [SourceFile] { guard let fileSystemSynchronizedGroups = pbxTarget.fileSystemSynchronizedGroups else { return [] } var sources: [SourceFile] = [] @@ -398,6 +405,9 @@ struct PBXTargetMapper: PBXTargetMapping { ], membershipExceptions: membershipExceptions ) + .filter { path in + !packages.contains(where: { $0.isAncestor(of: path) }) + } .map { SourceFile( path: $0, diff --git a/Sources/XcodeGraphMapper/Mappers/Workspace/XCWorkspaceMapper.swift b/Sources/XcodeGraphMapper/Mappers/Workspace/XCWorkspaceMapper.swift index 6d892b36..ad8d4bf5 100644 --- a/Sources/XcodeGraphMapper/Mappers/Workspace/XCWorkspaceMapper.swift +++ b/Sources/XcodeGraphMapper/Mappers/Workspace/XCWorkspaceMapper.swift @@ -119,7 +119,7 @@ struct XCWorkspaceMapper: WorkspaceMapping { let schemePaths = try sharedDataPath.children().filter { $0.extension == "xcscheme" } var schemes: [Scheme] = [] - for schemePath in try schemePaths { + for schemePath in schemePaths { let xcscheme = try XCScheme(path: schemePath) schemes.append( try await schemeMapper.map( diff --git a/Sources/XcodeGraphMapper/Utilities/PackageInfoLoader.swift b/Sources/XcodeGraphMapper/Utilities/PackageInfoLoader.swift new file mode 100644 index 00000000..c9f65347 --- /dev/null +++ b/Sources/XcodeGraphMapper/Utilities/PackageInfoLoader.swift @@ -0,0 +1,38 @@ +import Command +import Foundation +import Mockable +import Path +import XcodeGraph + +@Mockable +protocol PackageInfoLoading { + func loadPackageInfo(at path: AbsolutePath) async throws -> PackageInfo +} + +struct PackageInfoLoader: PackageInfoLoading { + private let commandRunner: CommandRunning + private let decoder = JSONDecoder() + + init( + commandRunner: CommandRunning = CommandRunner() + ) { + self.commandRunner = commandRunner + } + + func loadPackageInfo(at path: AbsolutePath) async throws -> PackageInfo { + let output = try await commandRunner.run( + arguments: [ + "swift", + "package", + "--package-path", + path.pathString, + "dump-package", + ] + ) + .concatenatedString() + + let data = Data(output.utf8) + + return try decoder.decode(PackageInfo.self, from: data) + } +} diff --git a/Tests/XcodeGraphMapperTests/MapperTests/Graph/GraphMapperTests.swift b/Tests/XcodeGraphMapperTests/MapperTests/Graph/XcodeGraphMapperTests.swift similarity index 61% rename from Tests/XcodeGraphMapperTests/MapperTests/Graph/GraphMapperTests.swift rename to Tests/XcodeGraphMapperTests/MapperTests/Graph/XcodeGraphMapperTests.swift index 9fed9690..a3267821 100644 --- a/Tests/XcodeGraphMapperTests/MapperTests/Graph/GraphMapperTests.swift +++ b/Tests/XcodeGraphMapperTests/MapperTests/Graph/XcodeGraphMapperTests.swift @@ -1,4 +1,6 @@ +import FileSystem import Foundation +import Mockable import Path import Testing import XcodeGraph @@ -7,6 +9,8 @@ import XcodeProj @Suite struct XcodeGraphMapperTests { + private let fileSystem = FileSystem() + @Test("Maps a single project into a workspace graph") func testSingleProjectGraph() async throws { // Given @@ -213,4 +217,152 @@ struct XcodeGraphMapperTests { #expect(expectedDependency == [targetDep]) } + + @Test("Maps a project graph with local packages") + func testGraphWithLocalPackages() async throws { + // Given + let pbxProj = PBXProj() + let debug: XCBuildConfiguration = .testDebug().add(to: pbxProj) + let releaseConfig: XCBuildConfiguration = .testRelease().add(to: pbxProj) + let configurationList: XCConfigurationList = .test( + buildConfigurations: [debug, releaseConfig] + ) + .add(to: pbxProj) + + let xcodeProj = try await XcodeProj.test( + projectName: "ProjectWithPackages", + configurationList: configurationList, + pbxProj: pbxProj + ) + + let appTarget = try PBXNativeTarget.test( + name: "App", + buildConfigurationList: configurationList, + buildPhases: [], + productType: .application + ) + .add(to: pbxProj) + .add(to: pbxProj.rootObject) + + try xcodeProj.write(path: xcodeProj.path!) + let packageMapper = MockPackageMapping() + let packageInfoLoader = MockPackageInfoLoading() + let projectMapper = MockPBXProjectMapping() + given(projectMapper) + .map( + xcodeProj: .any, + projectNativeTargets: .any + ) + .willReturn( + .test( + packages: [ + .local(path: "/tmp/LibraryA"), + .local(path: "/tmp/LibraryB"), + ] + ) + ) + given(packageInfoLoader) + .loadPackageInfo(at: .value("/tmp/LibraryA")) + .willReturn( + .test( + name: "LibraryA" + ) + ) + given(packageInfoLoader) + .loadPackageInfo(at: .value("/tmp/LibraryB")) + .willReturn( + .test( + name: "LibraryB" + ) + ) + let libraryAProject: Project = .test( + targets: [ + .test( + name: "LibraryA", + dependencies: [ + .project( + target: "LibraryB", + path: "/tmp/LibraryB", + status: .required, + condition: nil + ), + ] + ), + .test( + name: "LibraryATests", + dependencies: [ + .target( + name: "LibraryA", + status: .required, + condition: nil + ), + ] + ), + ] + ) + let libraryBProject: Project = .test( + targets: [ + .test( + name: "LibraryB" + ), + ] + ) + given(packageMapper) + .map( + .any, + packages: .any, + at: .value("/tmp/LibraryA") + ) + .willReturn(libraryAProject) + given(packageMapper) + .map( + .any, + packages: .any, + at: .value("/tmp/LibraryB") + ) + .willReturn(libraryBProject) + let mapper = XcodeGraphMapper( + packageInfoLoader: packageInfoLoader, + packageMapper: packageMapper, + projectMapper: projectMapper + ) + + // When + let graph = try await mapper.buildGraph(from: .project(xcodeProj)) + + // Then + #expect(graph.projects["/tmp/LibraryA"] == libraryAProject) + #expect(graph.projects["/tmp/LibraryB"] == libraryBProject) + #expect( + graph.dependencies == [ + .target( + name: "LibraryATests", + path: "/tmp/LibraryA", + status: .required + ): [ + .target( + name: "LibraryA", + path: "/tmp/LibraryA", + status: .required + ), + ], + .target( + name: "LibraryA", + path: "/tmp/LibraryA", + status: .required + ): [ + .target( + name: "LibraryB", + path: "/tmp/LibraryB", + status: .required + ), + ], + ] + ) +// // Verify dependencies are mapped +// let targetDep = GraphDependency.target(name: "AFramework", path: xcodeProj.srcPath) +// let expectedDependency = try #require(graph.dependencies.first?.value) + +// #expect(expectedDependency == [targetDep]) + } } diff --git a/Tests/XcodeGraphMapperTests/MapperTests/Package/PackageMapperTests.swift b/Tests/XcodeGraphMapperTests/MapperTests/Package/PackageMapperTests.swift new file mode 100644 index 00000000..f1e21e7c --- /dev/null +++ b/Tests/XcodeGraphMapperTests/MapperTests/Package/PackageMapperTests.swift @@ -0,0 +1,176 @@ +import FileSystem +import Foundation +import Testing +import XcodeGraph + +@testable import XcodeGraphMapper + +struct PackageMapperTests: Sendable { + private let fileSystem = FileSystem() + + @Test + func test_map_package() async throws { + try await fileSystem.runInTemporaryDirectory(prefix: "PackageMapperTests") { path in + // Given + let subject = PackageMapper() + let sourcesLibraryAPath = path.appending(components: "Sources", "LibraryA") + try await fileSystem.makeDirectory(at: sourcesLibraryAPath) + try await fileSystem.touch(sourcesLibraryAPath.appending(component: "File.swift")) + let testsLibraryAPath = path.appending(components: "Tests", "LibraryATests") + try await fileSystem.makeDirectory(at: testsLibraryAPath) + try await fileSystem.touch(testsLibraryAPath.appending(component: "TestFile.swift")) + + // When + let got = try await subject.map( + .test( + name: "LibraryA", + targets: [ + .test( + name: "LibraryA", + dependencies: [ + .product( + name: "Alamofire", + package: "Alamofire", + moduleAliases: nil, + condition: nil + ), + .byName( + name: "LibraryB", + condition: nil + ), + .product( + name: "LibraryCProduct", + package: "LibraryC", + moduleAliases: nil, + condition: PackageInfo.PackageConditionDescription(platformNames: ["ios"], config: nil) + ), + .byName( + name: "LibraryAHelpers", + condition: nil + ), + ] + ), + .test( + name: "LibraryATests", + dependencies: [ + .byName( + name: "LibraryA", + condition: nil + ), + ], + type: .test + ), + .test( + name: "LibraryAHelpers" + ), + ] + ), + packages: [ + "LibraryB": path.appending(component: "LibraryB"), + "LibraryC": path.appending(component: "LibraryC"), + ], + at: path + ) + + // Then + #expect( + got == Project( + path: path, + sourceRootPath: path, + xcodeProjPath: path, + name: "LibraryA", + organizationName: nil, + classPrefix: nil, + defaultKnownRegions: nil, + developmentRegion: nil, + options: Project.Options( + automaticSchemesOptions: .disabled, + disableBundleAccessors: true, + disableShowEnvironmentVarsInScriptPhases: true, + disableSynthesizedResourceAccessors: true, + textSettings: Project.Options.TextSettings( + usesTabs: nil, + indentWidth: nil, + tabWidth: nil, + wrapsLines: nil + ) + ), + settings: Settings(configurations: [:]), + filesGroup: .group(name: "LibraryA"), + targets: [ + Target( + name: "LibraryA", + destinations: Destinations(Destination.allCases), + product: .staticFramework, + productName: nil, + bundleId: "", + sources: [ + SourceFile(path: sourcesLibraryAPath.appending(component: "File.swift")), + ], + filesGroup: .group(name: "LibraryA"), + dependencies: [ + .package( + product: "Alamofire", + type: .runtime, + condition: nil + ), + .project( + target: "LibraryB", + path: path.appending(component: "LibraryB"), + status: .required, + condition: nil + ), + .project( + target: "LibraryCProduct", + path: path.appending(component: "LibraryC"), + status: .required, + condition: .when([.ios]) + ), + .target( + name: "LibraryAHelpers", + status: .required, + condition: nil + ), + ] + ), + Target( + name: "LibraryATests", + destinations: Destinations(Destination.allCases), + product: .unitTests, + productName: nil, + bundleId: "", + sources: [ + SourceFile(path: path.appending(components: "Tests", "LibraryATests", "TestFile.swift")), + ], + filesGroup: .group(name: "LibraryATests"), + dependencies: [ + .target( + name: "LibraryA", + status: .required, + condition: nil + ), + ] + ), + Target( + name: "LibraryAHelpers", + destinations: Destinations(Destination.allCases), + product: .staticFramework, + productName: nil, + bundleId: "", + sources: [], + filesGroup: .group(name: "LibraryAHelpers"), + dependencies: [] + ), + ], + packages: [], + schemes: [], + ideTemplateMacros: nil, + additionalFiles: [], + resourceSynthesizers: [], + lastUpgradeCheck: nil, + type: .local + ) + ) + } + } +} diff --git a/Tests/XcodeGraphMapperTests/MapperTests/Project/PBXProjectMapperTests.swift b/Tests/XcodeGraphMapperTests/MapperTests/Project/PBXProjectMapperTests.swift index a36d42a5..b59bd3f8 100644 --- a/Tests/XcodeGraphMapperTests/MapperTests/Project/PBXProjectMapperTests.swift +++ b/Tests/XcodeGraphMapperTests/MapperTests/Project/PBXProjectMapperTests.swift @@ -1,3 +1,5 @@ +import FileSystem +import Mockable import Path import Testing import XcodeGraph @@ -5,7 +7,9 @@ import XcodeProj @testable import XcodeGraphMapper @Suite -struct PBXProjectMapperTests { +struct PBXProjectMapperTests: Sendable { + private let fileSystem = FileSystem() + @Test("Maps a basic project with default attributes") func testMapBasicProject() async throws { // Given @@ -47,6 +51,82 @@ struct PBXProjectMapperTests { #expect(project.lastUpgradeCheck == "1500") } + @Test("Maps a project with local packages") + func testMapProjectWithLocalPackages() async throws { + try await fileSystem.runInTemporaryDirectory(prefix: "PBXProjectMapperTests") { path in + // Given + let xcodeProj = try await XcodeProj.test( + path: path.appending(component: "App.xcodeproj") + ) + let pbxProj = xcodeProj.pbxproj + + let appGroup = try PBXFileSystemSynchronizedRootGroup( + path: "App", + exceptions: [] + ) + .addToMainGroup(in: xcodeProj.pbxproj) + let fileReference = PBXFileReference( + sourceTree: .group, + path: "LibraryB" + ) + let packagesGroup = try PBXGroup( + children: [ + fileReference, + ], + sourceTree: .sourceRoot, + path: "Group" + ) + .addToMainGroup(in: pbxProj) + // We need to keep these references, so they are not deinitialized + _ = appGroup + _ = packagesGroup + let target = try PBXNativeTarget(name: "App") + .add(to: pbxProj) + .add(to: pbxProj.rootObject) + + let libraryAPath = path.appending(components: "App", "LibraryA") + try await fileSystem.makeDirectory(at: libraryAPath) + try await fileSystem.touch(libraryAPath.appending(component: "Package.swift")) + + let libraryBPath = path.appending(components: "Group", "LibraryB") + try await fileSystem.makeDirectory(at: libraryBPath) + try await fileSystem.touch(libraryBPath.appending(component: "Package.swift")) + let targetMapper = MockPBXTargetMapping() + given(targetMapper) + .map( + pbxTarget: .any, + xcodeProj: .any, + projectNativeTargets: .any, + packages: .any + ) + .willReturn(.test()) + + let mapper = PBXProjectMapper(targetMapper: targetMapper) + + // When + let project = try await mapper.map( + xcodeProj: xcodeProj, + projectNativeTargets: [:] + ) + + // Then + try #expect( + project.packages.map(\.url).map { try AbsolutePath(validating: $0) }.map(\.basename).sorted() == [ + libraryAPath.basename, + libraryBPath.basename, + ] + ) + verify(targetMapper) + .map( + pbxTarget: .value(target), + xcodeProj: .value(xcodeProj), + projectNativeTargets: .any, + packages: .matching { $0.count == 2 } + ) + .called(1) + } + } + @Test("Maps a project with remote package dependencies") func testMapProjectWithRemotePackages() async throws { // Given diff --git a/Tests/XcodeGraphMapperTests/MapperTests/Schemes/XCSchemeMapperTests.swift b/Tests/XcodeGraphMapperTests/MapperTests/Schemes/XCSchemeMapperTests.swift index 8572fef3..81d9d9d2 100644 --- a/Tests/XcodeGraphMapperTests/MapperTests/Schemes/XCSchemeMapperTests.swift +++ b/Tests/XcodeGraphMapperTests/MapperTests/Schemes/XCSchemeMapperTests.swift @@ -208,7 +208,7 @@ struct XCSchemeMapperTests: Sendable { ), TestableTarget( target: TargetReference( - projectPath: temporaryPath, + projectPath: temporaryPath.appending(component: "Library"), name: "LibraryTests" ), parallelization: .all diff --git a/Tests/XcodeGraphMapperTests/MapperTests/Target/PBXTargetMapperTests.swift b/Tests/XcodeGraphMapperTests/MapperTests/Target/PBXTargetMapperTests.swift index cd122b0d..c63962c6 100644 --- a/Tests/XcodeGraphMapperTests/MapperTests/Target/PBXTargetMapperTests.swift +++ b/Tests/XcodeGraphMapperTests/MapperTests/Target/PBXTargetMapperTests.swift @@ -26,7 +26,12 @@ struct PBXTargetMapperTests: Sendable { // When let mapper = PBXTargetMapper() - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) // Then #expect(mapped.name == "App") @@ -50,7 +55,12 @@ struct PBXTargetMapperTests: Sendable { // When / Then await #expect(throws: PBXTargetMappingError.missingBundleIdentifier(targetName: "App")) { - _ = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + _ = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) } } @@ -70,7 +80,12 @@ struct PBXTargetMapperTests: Sendable { let mapper = PBXTargetMapper() // When - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) // Then #expect(mapped.environmentVariables["TEST_VAR"]?.value == "test_value") @@ -94,7 +109,12 @@ struct PBXTargetMapperTests: Sendable { let mapper = PBXTargetMapper() // When - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) // Then let expected = [ @@ -130,7 +150,12 @@ struct PBXTargetMapperTests: Sendable { let mapper = PBXTargetMapper() // When - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) // Then #expect(mapped.sources.count == 1) @@ -207,12 +232,24 @@ struct PBXTargetMapperTests: Sendable { try await fileSystem.makeDirectory(at: buildableGroupPath.appending(component: "Framework.framework")) try await fileSystem.makeDirectory(at: buildableGroupPath.appending(component: "Optional.framework")) + // Packages + let packagePath = buildableGroupPath.appending(component: "PackageLibrary") + try await fileSystem.makeDirectory(at: packagePath) + try await fileSystem.touch(packagePath.appending(component: "Package.swift")) + let mapper = PBXTargetMapper( fileSystem: fileSystem ) // When - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [ + packagePath, + ] + ) // Then #expect( @@ -285,7 +322,12 @@ struct PBXTargetMapperTests: Sendable { let mapper = PBXTargetMapper() // When - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) // Then #expect(mapped.metadata.tags == Set(["tag1", "tag2", "tag3"])) @@ -338,7 +380,12 @@ struct PBXTargetMapperTests: Sendable { let mapper = PBXTargetMapper() // When - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) // Then #expect(mapped.entitlements == .file( @@ -361,7 +408,12 @@ struct PBXTargetMapperTests: Sendable { // When / Then do { - _ = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + _ = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) Issue.record("Should throw an error") } catch { let err = try #require(error as? PBXObjectError) @@ -400,7 +452,12 @@ struct PBXTargetMapperTests: Sendable { let mapper = PBXTargetMapper() // When - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) // Then #expect({ @@ -446,7 +503,12 @@ struct PBXTargetMapperTests: Sendable { let mapper = PBXTargetMapper() // When - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) // Then #expect( @@ -491,7 +553,12 @@ struct PBXTargetMapperTests: Sendable { let mapper = PBXTargetMapper() // When - let mapped = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + let mapped = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) // Then #expect( @@ -530,7 +597,12 @@ struct PBXTargetMapperTests: Sendable { // When / Then await #expect { - _ = try await mapper.map(pbxTarget: target, xcodeProj: xcodeProj, projectNativeTargets: [:]) + _ = try await mapper.map( + pbxTarget: target, + xcodeProj: xcodeProj, + projectNativeTargets: [:], + packages: [] + ) } throws: { error in error.localizedDescription == "Failed to read a valid plist dictionary from file at: \(invalidPlistPath.pathString)." diff --git a/Tests/XcodeGraphTests/Models/PackageInfoTests.swift b/Tests/XcodeGraphTests/Models/PackageInfoTests.swift new file mode 100644 index 00000000..c8d45ce0 --- /dev/null +++ b/Tests/XcodeGraphTests/Models/PackageInfoTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +import XcodeGraph + +@testable import XcodeGraph + +struct PackageInfoTests { + @Test + func test_packageInfo_codable() throws { + // Given + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let subject = PackageInfo( + name: "tuist", + products: [ + PackageInfo.Product(name: "tuist", type: .executable, targets: ["tuist"]), + PackageInfo.Product(name: "tuist", type: .library(.dynamic), targets: ["ProjectDescription"]), + ], + targets: [ + PackageInfo.Target( + name: "tuist", + path: nil, + url: nil, + sources: nil, + resources: [], + exclude: [], + dependencies: [ + .target(name: "TuistKit", condition: nil), + .byName(name: "TuistSupport", condition: nil), + .product( + name: "ArgumentParser", + package: "argument-parser", + moduleAliases: ["TuistSupport": "InternalTuistSupport"], + condition: nil + ), + .product( + name: "ArgumentParser", + package: "argument-parser", + moduleAliases: nil, + condition: PackageInfo.PackageConditionDescription(platformNames: ["macOS"], config: nil) + ), + ], + publicHeadersPath: nil, + type: .executable, + settings: [ + PackageInfo.Target.TargetBuildSettingDescription.Setting( + tool: .linker, + name: .linkedLibrary, + condition: PackageInfo.PackageConditionDescription(platformNames: ["iOS"], config: nil), + value: ["ProjectDescription"] + ), + ], + checksum: nil + ), + ], + platforms: [ + PackageInfo.Platform(platformName: "iOS", version: "17.2", options: []), + ], + cLanguageStandard: nil, + cxxLanguageStandard: nil, + swiftLanguageVersions: [Version(stringLiteral: "5.4.9")], + toolsVersion: Version(5, 4, 9) + ) + + // When + let data = try encoder.encode(subject) + let decoded = try decoder.decode(PackageInfo.self, from: data) + + // Then + #expect(subject == decoded) + } +}