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
Original file line number Diff line number Diff line change
Expand Up @@ -11,61 +11,117 @@ protocol PBXResourcesBuildPhaseMapping {
/// - xcodeProj: The `XcodeProj` used for path resolution.
/// - Returns: An array of `ResourceFileElement`s, representing file paths or grouped variants.
/// - Throws: If any file references are missing or paths cannot be resolved.
func map(_ resourcesBuildPhase: PBXResourcesBuildPhase, xcodeProj: XcodeProj) throws -> [ResourceFileElement]
func map(
_ resourcesBuildPhase: PBXResourcesBuildPhase,
xcodeProj: XcodeProj,
projectNativeTargets: [String: ProjectNativeTarget]
) throws -> (resources: [ResourceFileElement], resourceDependencies: [TargetDependency])
}

/// A mapper that converts a `PBXResourcesBuildPhase` into a list of `ResourceFileElement`.
struct PBXResourcesBuildPhaseMapper: PBXResourcesBuildPhaseMapping {
func map(
_ resourcesBuildPhase: PBXResourcesBuildPhase,
xcodeProj: XcodeProj
) throws -> [ResourceFileElement] {
xcodeProj: XcodeProj,
projectNativeTargets: [String: ProjectNativeTarget]
) throws -> (resources: [ResourceFileElement], resourceDependencies: [TargetDependency]) {
let files = resourcesBuildPhase.files ?? []
let elements = try files.flatMap { buildFile in
try mapResourceElement(buildFile, xcodeProj: xcodeProj)
let (resources, resourceDependencies): ([ResourceFileElement], [TargetDependency]) = try files.reduce((
[],
[]
)) { acc, buildFile in
let result = try mapResourceElement(buildFile, xcodeProj: xcodeProj, projectNativeTargets: projectNativeTargets)
return (acc.0 + result.0, acc.1 + result.1)
}
return elements.sorted { $0.path < $1.path }

return (
resources.sorted(by: { $0.path < $1.path }),
resourceDependencies.sorted(by: { $0.name < $1.name })
)
}

// MARK: - Private Helpers

/// Maps a single `PBXBuildFile` to one or more `ResourceFileElement`s.
private func mapResourceElement(
_ buildFile: PBXBuildFile,
xcodeProj: XcodeProj
) throws -> [ResourceFileElement] {
xcodeProj: XcodeProj,
projectNativeTargets: [String: ProjectNativeTarget]
) throws -> ([ResourceFileElement], [TargetDependency]) {
let fileElement = try buildFile.file
.throwing(PBXResourcesMappingError.missingFileReference)

// If it's a PBXVariantGroup, map each child within that group.
if let variantGroup = fileElement as? PBXVariantGroup {
return try mapVariantGroup(variantGroup, xcodeProj: xcodeProj)
return try mapVariantGroup(variantGroup, xcodeProj: xcodeProj, projectNativeTargets: projectNativeTargets)
} else {
// Otherwise, it's a straightforward file or reference.
return try mapFileElement(fileElement, xcodeProj: xcodeProj)
return try mapFileElement(
fileElement,
xcodeProj: xcodeProj,
projectNativeTargets: projectNativeTargets
)
}
}

/// Maps a simple (non-variant) file element to a list (usually a single entry) of `ResourceFileElement`.
private func mapFileElement(
_ fileElement: PBXFileElement,
xcodeProj: XcodeProj
) throws -> [ResourceFileElement] {
xcodeProj: XcodeProj,
projectNativeTargets: [String: ProjectNativeTarget]
) throws -> ([ResourceFileElement], [TargetDependency]) {
switch fileElement.sourceTree {
case .buildProductsDir:
guard let path = fileElement.path else { break }
let name = path.replacingOccurrences(of: ".bundle", with: "")
if let target = xcodeProj.pbxproj.targets(named: name).first {
return (
[],
[
.target(
name: target.name,
status: .required,
condition: nil
),
]
)
} else if let projectNativeTarget = projectNativeTargets[name] {
return (
[],
[
.project(
target: projectNativeTarget.nativeTarget.name,
path: projectNativeTarget.project.projectPath.parentDirectory,
status: .required,
condition: nil
),
]
)
}
default:
break
}

let pathString = try fileElement
.fullPath(sourceRoot: xcodeProj.srcPathString)
.throwing(PBXResourcesMappingError.missingFullPath(fileElement.name ?? "Unknown"))

let absolutePath = try AbsolutePath(validating: pathString)
return [.file(path: absolutePath)]
return ([.file(path: absolutePath)], [])
}

/// Maps a PBXVariantGroup by expanding each child into a `ResourceFileElement`.
private func mapVariantGroup(
_ variantGroup: PBXVariantGroup,
xcodeProj: XcodeProj
) throws -> [ResourceFileElement] {
try variantGroup.children.flatMap { child in
try mapFileElement(child, xcodeProj: xcodeProj)
xcodeProj: XcodeProj,
projectNativeTargets: [String: ProjectNativeTarget]
) throws -> ([ResourceFileElement], [TargetDependency]) {
try variantGroup.children.reduce(([], [])) { acc, child in
let result = try mapFileElement(
child,
xcodeProj: xcodeProj,
projectNativeTargets: projectNativeTargets
)
return (acc.0 + result.0, acc.1 + result.1)
}
}
}
Expand Down
37 changes: 9 additions & 28 deletions Sources/XcodeGraphMapper/Mappers/Targets/PBXTargetMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,13 @@ struct PBXTargetMapper: PBXTargetMapping {
packages: packages
) + sources

var resources = try pbxTarget.resourcesBuildPhase().map {
try resourcesMapper.map($0, xcodeProj: xcodeProj)
} ?? []
var (resources, resourceDependencies) = try pbxTarget.resourcesBuildPhase().map {
try resourcesMapper.map(
$0,
xcodeProj: xcodeProj,
projectNativeTargets: projectNativeTargets
)
} ?? ([], [])
resources = try await fileSystemSynchronizedGroupsResources(
from: pbxTarget,
xcodeProj: xcodeProj
Expand Down Expand Up @@ -202,7 +206,7 @@ struct PBXTargetMapper: PBXTargetMapping {
let projectNativeTargets = try pbxTarget.dependencies.compactMap {
try dependencyMapper.map($0, xcodeProj: xcodeProj)
}
let allDependencies = (projectNativeTargets + frameworks).sorted { $0.name < $1.name }
let allDependencies = (projectNativeTargets + frameworks + resourceDependencies).sorted { $0.name < $1.name }

// Construct final Target
return Target(
Expand Down Expand Up @@ -275,8 +279,7 @@ struct PBXTargetMapper: PBXTargetMapping {
} else {
xcodeProj.srcPath.appending(try RelativePath(validating: pathString))
}
let plistDictionary = try await readPlistAsDictionary(at: path)
return .dictionary(plistDictionary, configuration: config)
return .file(path: path, configuration: config)
}
return .dictionary([:])
}
Expand Down Expand Up @@ -334,28 +337,6 @@ struct PBXTargetMapper: PBXTargetMapping {
return sources.filter { $0.path.fileExtension == .playground }.map(\.path)
}

/// Reads and parses a plist file into a `[String: Plist.Value]` dictionary.
private func readPlistAsDictionary(
Comment on lines -337 to -338
Copy link
Member Author

Choose a reason for hiding this comment

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

for some reason, when mapping a generated Tuist.xcworkspace in tuist/tuist, this would always throw with the same error as we've seen in the past: tuist/tuist#7018

Since we don't strictly need the plist dictionary need, I decided to, at least for now, return a plain path reference instead of parsing the plist.

at path: AbsolutePath,
fileSystem: FileSysteming = FileSystem()
) async throws -> [String: Plist.Value] {
var format = PropertyListSerialization.PropertyListFormat.xml

let data = try await fileSystem.readFile(at: path)

guard let plist = try? PropertyListSerialization.propertyList(
from: data,
options: .mutableContainersAndLeaves,
format: &format
) as? [String: Any] else {
throw PBXTargetMappingError.invalidPlist(path: path.pathString)
}

return try plist.reduce(into: [String: Plist.Value]()) { result, item in
result[item.key] = try convertToPlistValue(item.value)
}
}

/// Converts a raw plist value into a `Plist.Value`.
private func convertToPlistValue(_ value: Any) throws -> Plist.Value {
switch value {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct PBXResourcesBuildPhaseMapperTests {
let mapper = PBXResourcesBuildPhaseMapper()

// When
let resources = try mapper.map(resourcesPhase, xcodeProj: xcodeProj)
let (resources, _) = try mapper.map(resourcesPhase, xcodeProj: xcodeProj, projectNativeTargets: [:])

// Then
#expect(resources.count == 1)
Expand All @@ -47,6 +47,85 @@ struct PBXResourcesBuildPhaseMapperTests {
}
}

@Test("Maps resource bundle target dependencies from resources phase")
func testMapResourceBundleTargets() async throws {
// Given
let xcodeProj = try await XcodeProj.test()
let pbxProj = xcodeProj.pbxproj

let targetABundle = try PBXFileReference(
sourceTree: .buildProductsDir,
path: "TargetA.bundle"
)
.add(to: pbxProj)
.addToMainGroup(in: pbxProj)
.add(to: pbxProj)

let buildFile = PBXBuildFile(file: targetABundle).add(to: pbxProj)

let projectTargetPath = xcodeProj.projectPath.parentDirectory.appending(
components: "AnotherProject",
"AnotherProject.xcodeproj"
)
let targetBFrameworkRef = PBXFileReference(
sourceTree: .buildProductsDir,
path: "TargetB.bundle"
)
let targetBFrameworkBuildFile = PBXBuildFile(file: targetBFrameworkRef).add(to: pbxProj)

let resourcesPhase = PBXResourcesBuildPhase(files: [buildFile, targetBFrameworkBuildFile]).add(to: pbxProj)

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

PBXNativeTarget(
name: "TargetA",
buildPhases: [resourcesPhase],
productType: .bundle
)
.add(to: pbxProj)

let mapper = PBXResourcesBuildPhaseMapper()

// When
let (_, resourceDependencies) = try await mapper.map(
resourcesPhase,
xcodeProj: xcodeProj,
projectNativeTargets: [
"TargetB": ProjectNativeTarget(
nativeTarget: .test(
name: "TargetB"
),
project: .test(
path: projectTargetPath
)
),
]
)

// Then
#expect(
resourceDependencies == [
.target(
name: "TargetA",
status: .required,
condition: nil
),
.project(
target: "TargetB",
path: projectTargetPath.parentDirectory,
status: .required,
condition: nil
),
]
)
}

@Test("Maps localized variant groups from resources")
func testMapVariantGroup() async throws {
// Given
Expand Down Expand Up @@ -78,7 +157,7 @@ struct PBXResourcesBuildPhaseMapperTests {
let mapper = PBXResourcesBuildPhaseMapper()

// When
let resources = try mapper.map(resourcesPhase, xcodeProj: xcodeProj)
let (resources, _) = try mapper.map(resourcesPhase, xcodeProj: xcodeProj, projectNativeTargets: [:])

// Then
#expect(resources.count == 2)
Expand Down
Loading