Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ let package = Package(
dependencies: [
.package(url: "https://github.com/Flight-School/AnyCodable", .upToNextMajor(from: "0.6.7")),
.package(url: "https://github.com/tuist/Path.git", .upToNextMajor(from: "0.3.8")),
.package(url: "https://github.com/tuist/XcodeProj", from: "8.26.7"),
.package(url: "https://github.com/tuist/XcodeProj", .upToNextMajor(from: "8.27.0")),
.package(url: "https://github.com/tuist/Command.git", from: "0.12.2"),
.package(url: "https://github.com/tuist/FileSystem.git", .upToNextMajor(from: "0.7.7")),
.package(url: "https://github.com/apple/swift-service-context", .upToNextMajor(from: "1.2.0")),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,29 @@ protocol PBXCopyFilesBuildPhaseMapping {
/// - xcodeProj: The `XcodeProj` containing project configuration and file references.
/// - Returns: An array of mapped `CopyFilesAction`s.
/// - Throws: If any file paths are invalid or cannot be resolved.
func map(_ copyFilesPhases: [PBXCopyFilesBuildPhase], xcodeProj: XcodeProj) throws -> [CopyFilesAction]
func map(
_ copyFilesPhases: [PBXCopyFilesBuildPhase],
fileSystemSynchronizedGroups: [PBXFileSystemSynchronizedRootGroup],
xcodeProj: XcodeProj
) throws -> [CopyFilesAction]
}

/// A mapper that converts `PBXCopyFilesBuildPhase` objects into `CopyFilesAction` domain models.
struct PBXCopyFilesBuildPhaseMapper: PBXCopyFilesBuildPhaseMapping {
/// Maps the provided copy files phases to sorted `CopyFilesAction` models.
func map(_ copyFilesPhases: [PBXCopyFilesBuildPhase], xcodeProj: XcodeProj) throws -> [CopyFilesAction] {
func map(
_ copyFilesPhases: [PBXCopyFilesBuildPhase],
fileSystemSynchronizedGroups: [PBXFileSystemSynchronizedRootGroup],
xcodeProj: XcodeProj
) throws -> [CopyFilesAction] {
try copyFilesPhases
.compactMap { try mapCopyFilesPhase($0, xcodeProj: xcodeProj) }
.compactMap {
try mapCopyFilesPhase(
$0,
fileSystemSynchronizedGroups: fileSystemSynchronizedGroups,
xcodeProj: xcodeProj
)
}
.sorted { $0.name < $1.name }
}

Expand All @@ -33,6 +47,7 @@ struct PBXCopyFilesBuildPhaseMapper: PBXCopyFilesBuildPhaseMapping {
/// - Throws: If file paths are invalid or unresolved.
private func mapCopyFilesPhase(
_ phase: PBXCopyFilesBuildPhase,
fileSystemSynchronizedGroups: [PBXFileSystemSynchronizedRootGroup],
xcodeProj: XcodeProj
) throws -> CopyFilesAction? {
let files = try (phase.files ?? [])
Expand All @@ -50,15 +65,47 @@ struct PBXCopyFilesBuildPhaseMapper: PBXCopyFilesBuildPhaseMapping {
return .file(path: absolutePath, condition: nil, codeSignOnCopy: codeSignOnCopy)
}
.sorted { $0.path < $1.path }
let groupsFiles = fileSystemSynchronizedGroupsFiles(
phase,
fileSystemSynchronizedGroups: fileSystemSynchronizedGroups,
xcodeProj: xcodeProj
)

return CopyFilesAction(
name: phase.name ?? BuildPhaseConstants.copyFilesDefault,
destination: mapDstSubfolderSpec(phase.dstSubfolderSpec),
subpath: (phase.dstPath?.isEmpty == true) ? nil : phase.dstPath,
files: files
files: files + groupsFiles
)
}

private func fileSystemSynchronizedGroupsFiles(
_ phase: PBXCopyFilesBuildPhase,
fileSystemSynchronizedGroups: [PBXFileSystemSynchronizedRootGroup],
xcodeProj: XcodeProj
) -> [CopyFileElement] {
var files: [CopyFileElement] = []
for fileSystemSynchronizedGroup in fileSystemSynchronizedGroups {
if let path = fileSystemSynchronizedGroup.path {
let buildPhaseExceptions = fileSystemSynchronizedGroup.exceptions?
.compactMap { $0 as? PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet }
.filter { $0.buildPhase == phase } ?? []
let groupFiles = buildPhaseExceptions.compactMap {
$0.membershipExceptions?.map {
return CopyFileElement.file(
path: xcodeProj.srcPath.appending(component: path).appending(RelativePath($0)),
condition: nil,
codeSignOnCopy: true
)
}
}
.flatMap { $0 }
files.append(contentsOf: groupFiles)
}
}
return files
}

/// Maps a `PBXCopyFilesBuildPhase.SubFolder` to a `CopyFilesAction.Destination`.
private func mapDstSubfolderSpec(
_ subfolderSpec: PBXCopyFilesBuildPhase.SubFolder?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ struct PBXProjectMapper: PBXProjectMapping {
let pathString = try file.fullPath(sourceRoot: xcodeProj.srcPathString)
{
let path = try AbsolutePath(validating: pathString)
if try await fileSystem.exists(path, isDirectory: true),
if (try? await fileSystem.exists(path, isDirectory: true)) ?? false,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fly-by fix: if path is not valid, we'd throw here. Instead, we default to false now

try await fileSystem.exists(path.appending(component: "Package.swift"))
{
packages.insert(path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ extension PBXTarget {
func bundleIdentifier() throws -> String {
if let bundleId = debugBuildSettings.string(for: .productBundleIdentifier) {
return bundleId
} else {
return "Unknown"
Comment on lines +11 to +12
Copy link
Member Author

@fortmarek fortmarek Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fly-by fix: The bundle identifier can defined in an xcconfig in which case we don't read it. To properly fix this, we should read and parse xcconfigs to get the correct value. For now, we default to Unknown instead of failing when the project is correctly defined.

}
throw PBXTargetMappingError.missingBundleIdentifier(targetName: name)
}

/// Returns an array of all `PBXCopyFilesBuildPhase` instances for this target.
Expand Down
29 changes: 16 additions & 13 deletions Sources/XcodeGraphMapper/Mappers/Targets/PBXTargetMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ enum PBXTargetMappingError: LocalizedError, Equatable {
case noProjectsFound(path: String)
case missingFilesGroup(targetName: String)
case invalidPlist(path: String)
case missingBundleIdentifier(targetName: String)

var errorDescription: String? {
switch self {
Expand All @@ -20,8 +19,6 @@ enum PBXTargetMappingError: LocalizedError, Equatable {
return "The files group is missing for the target '\(targetName)'."
case let .invalidPlist(path):
return "Failed to read a valid plist dictionary from file at: \(path)."
case let .missingBundleIdentifier(targetName):
return "The bundle identifier is missing for the target '\(targetName)'."
}
}
}
Expand Down Expand Up @@ -150,7 +147,11 @@ struct PBXTargetMapper: PBXTargetMapping {
let rawScriptBuildPhases = scriptsMapper.mapRawScriptBuildPhases(runScriptPhases)

let copyFilesPhases = pbxTarget.copyFilesBuildPhases()
let copyFiles = try copyFilesMapper.map(copyFilesPhases, xcodeProj: xcodeProj)
let copyFiles = try copyFilesMapper.map(
copyFilesPhases,
fileSystemSynchronizedGroups: pbxTarget.fileSystemSynchronizedGroups ?? [],
xcodeProj: xcodeProj
)

// Core Data models
let resourceFiles = try pbxTarget.resourcesBuildPhase()?.files ?? []
Expand Down Expand Up @@ -390,8 +391,9 @@ struct PBXTargetMapper: PBXTargetMapping {
for fileSystemSynchronizedGroup in fileSystemSynchronizedGroups {
if let path = fileSystemSynchronizedGroup.path {
let membershipExceptions = membershipExceptions(for: fileSystemSynchronizedGroup)
let additionalCompilerFlagsByRelativePath = fileSystemSynchronizedGroup.exceptions?
let additionalCompilerFlagsByRelativePath: [String: String]? = fileSystemSynchronizedGroup.exceptions?
.reduce(into: [:]) { acc, element in
guard let element = element as? PBXFileSystemSynchronizedBuildFileExceptionSet else { return }
acc.merge(element.additionalCompilerFlagsByRelativePath ?? [:], uniquingKeysWith: { $1 })
}
let directory = xcodeProj.srcPath.appending(component: path)
Expand Down Expand Up @@ -429,10 +431,7 @@ struct PBXTargetMapper: PBXTargetMapping {
for fileSystemSynchronizedGroup in fileSystemSynchronizedGroups {
guard let path = fileSystemSynchronizedGroup.path else { continue }
let directory = xcodeProj.srcPath.appending(component: path)
let membershipExceptions = Set(
fileSystemSynchronizedGroup.exceptions
.map { $0.compactMap(\.membershipExceptions).flatMap { $0 } } ?? []
)
let membershipExceptions = membershipExceptions(for: fileSystemSynchronizedGroup)

let groupResources = try await globFiles(
directory: directory,
Expand Down Expand Up @@ -463,9 +462,10 @@ struct PBXTargetMapper: PBXTargetMapping {
guard let path = fileSystemSynchronizedGroup.path else { continue }
let directory = xcodeProj.srcPath.appending(component: path)
let membershipExceptions = membershipExceptions(for: fileSystemSynchronizedGroup)
let attributesByRelativePath = fileSystemSynchronizedGroup.exceptions?.reduce([:]) { acc, element in
acc.merging(element.attributesByRelativePath ?? [:], uniquingKeysWith: { $1 })
}
let attributesByRelativePath = fileSystemSynchronizedGroup.exceptions?
.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }.reduce([:]) { acc, element in
acc.merging(element.attributesByRelativePath ?? [:], uniquingKeysWith: { $1 })
}

let groupFrameworks: [TargetDependency] = try await globFiles(
directory: directory,
Expand Down Expand Up @@ -500,7 +500,9 @@ struct PBXTargetMapper: PBXTargetMapping {
for fileSystemSynchronizedGroup in fileSystemSynchronizedGroups {
guard let path = fileSystemSynchronizedGroup.path else { continue }
let directory = xcodeProj.srcPath.appending(component: path)
for synchronizedBuildFileSystemExceptionSet in fileSystemSynchronizedGroup.exceptions ?? [] {
for synchronizedBuildFileSystemExceptionSet in fileSystemSynchronizedGroup.exceptions?
.compactMap({ $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }) ?? []
{
for publicHeader in synchronizedBuildFileSystemExceptionSet.publicHeaders ?? [] {
publicHeaders.append(directory.appending(component: publicHeader))
}
Expand Down Expand Up @@ -539,6 +541,7 @@ struct PBXTargetMapper: PBXTargetMapping {
private func membershipExceptions(for fileSystemSynchronizedGroup: PBXFileSystemSynchronizedRootGroup) -> Set<String> {
Set(
fileSystemSynchronizedGroup.exceptions
.map { $0.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } }
.map { $0.compactMap(\.membershipExceptions).flatMap { $0 } } ?? []
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ struct PBXCopyFilesBuildPhaseMapperTests {
let mapper = PBXCopyFilesBuildPhaseMapper()

// When
let copyActions = try mapper.map([copyFilesPhase], xcodeProj: xcodeProj)
let copyActions = try mapper.map(
[copyFilesPhase],
fileSystemSynchronizedGroups: [],
xcodeProj: xcodeProj
)

// Then
#expect(copyActions.count == 1)
Expand All @@ -58,4 +62,70 @@ struct PBXCopyFilesBuildPhaseMapperTests {
#expect(fileAction.codeSignOnCopy == true)
#expect(fileAction.path.basename == "MyLibrary.dylib")
}

@Test("Maps copy files actions with a synchronized group")
func testMapCopyFilesWithSynchronizedGroup() async throws {
// Given
let xcodeProj = try await XcodeProj.test(
path: "/tmp/TestProject/Project.xcodeproj"
)
let pbxProj = xcodeProj.pbxproj

let copyFilesPhase = PBXCopyFilesBuildPhase(
dstPath: "XPC Services",
dstSubfolderSpec: .productsDirectory,
name: "Copy files",
files: []
)
.add(to: pbxProj)

try PBXNativeTarget.test(
name: "App",
buildPhases: [copyFilesPhase],
productType: .application
)
.add(to: pbxProj)
.add(to: pbxProj.rootObject)

let mapper = PBXCopyFilesBuildPhaseMapper()
let exceptionSet = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet(
buildPhase: copyFilesPhase,
membershipExceptions: [
"XCPService.xpc",
],
attributesByRelativePath: nil
)
let rootGroup = PBXFileSystemSynchronizedRootGroup(
path: "SynchronizedRootGroup",
exceptions: [
exceptionSet,
]
)

// When
let copyActions = try mapper.map(
[copyFilesPhase],
fileSystemSynchronizedGroups: [
rootGroup,
],
xcodeProj: xcodeProj
)

// Then
#expect(copyActions.count == 1)

let action = try #require(copyActions.first)
#expect(action.name == "Copy files")
#expect(action.destination == .productsDirectory)
#expect(action.subpath == "XPC Services")
#expect(
action.files == [
.file(
path: "/tmp/TestProject/SynchronizedRootGroup/XCPService.xpc",
condition: nil,
codeSignOnCopy: true
),
]
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ struct PBXTargetMapperTests: Sendable {
#expect(mapped.bundleId == "com.example.app")
}

@Test("Throws an error if the target is missing a bundle identifier")
@Test("Defaults to unknown if the target is missing a bundle identifier")
func testMapTargetWithMissingBundleId() async throws {
// Given
let xcodeProj = try await XcodeProj.test()
Expand All @@ -53,15 +53,16 @@ struct PBXTargetMapperTests: Sendable {
)
let mapper = PBXTargetMapper()

// When / Then
await #expect(throws: PBXTargetMappingError.missingBundleIdentifier(targetName: "App")) {
_ = try await mapper.map(
pbxTarget: target,
xcodeProj: xcodeProj,
projectNativeTargets: [:],
packages: []
)
}
// When
let mapped = try await mapper.map(
pbxTarget: target,
xcodeProj: xcodeProj,
projectNativeTargets: [:],
packages: []
)

// Then
#expect(mapped.bundleId == "Unknown")
}

@Test("Maps a target with environment variables")
Expand Down