From d666e3c3b1b1beeeae110e71265cc0808bac3257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Wed, 4 Nov 2026 16:21:16 +0100 Subject: [PATCH] perf: speed up writing pbxproj files Making temporary references permanent removed each object from about 30 dictionaries by probing them one by one under a lock. The object's type already says which dictionary holds it, so add and remove now share one type switch, falling back to the full probe only when that dictionary has nothing under the reference. Also cache per type the type name and reference prefix that were rebuilt through reflection for every object, cache each reference's hash, hash strings for MD5 without intermediate Data and arrays, and build the build-file section without merged and flattened copies. The written output is byte-for-byte unchanged. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a0d7-4cfc-76a2-8b2d-344510b78c8c Co-authored-by: Amp --- Sources/XcodeProj/Extensions/String+md5.swift | 38 ++-- .../Objects/BuildPhase/PBXBuildFile.swift | 3 +- .../XcodeProj/Objects/Project/PBXObject.swift | 3 +- .../Objects/Project/PBXObjectReference.swift | 16 +- .../Objects/Project/PBXObjects.swift | 106 ++++++---- .../Objects/Project/PBXProjEncoder.swift | 6 +- .../XcodeProj/Utils/ReferenceGenerator.swift | 26 ++- .../Utils/ReferenceFixingTests.swift | 195 ++++++++++++++++++ 8 files changed, 324 insertions(+), 69 deletions(-) create mode 100644 Tests/XcodeProjTests/Utils/ReferenceFixingTests.swift diff --git a/Sources/XcodeProj/Extensions/String+md5.swift b/Sources/XcodeProj/Extensions/String+md5.swift index 04be85b4c..35be65b39 100644 --- a/Sources/XcodeProj/Extensions/String+md5.swift +++ b/Sources/XcodeProj/Extensions/String+md5.swift @@ -27,22 +27,34 @@ import Foundation extension String { var md5: String { - guard let data = data(using: .utf8, allowLossyConversion: true) else { - return self - } #if canImport(CryptoKit) if #available(OSX 10.15, iOS 13.0, *) { - return Insecure.MD5.hash(data: data) - .withUnsafeBytes { Array($0) }.hexString - } else { - return data.slowMD5 + // Hashes the UTF-8 bytes in place and writes the hex digits straight into the result, without the + // intermediate `Data`, byte array, and hex buffer. + var string = self + let digest = string.withUTF8 { Insecure.MD5.hash(data: UnsafeRawBufferPointer($0)) } + return digest.withUnsafeBytes { bytes in + String(unsafeUninitializedCapacity: bytes.count * 2) { hex in + for (offset, byte) in bytes.enumerated() { + hex[offset * 2] = hexDigit(byte >> 4) + hex[offset * 2 + 1] = hexDigit(byte & 0xF) + } + return bytes.count * 2 + } + } } - #else - return data.slowMD5 #endif + guard let data = data(using: .utf8, allowLossyConversion: true) else { + return self + } + return data.slowMD5 } } +private func hexDigit(_ value: UInt8) -> UInt8 { + (value > 9) ? (charA + value - 10) : (char0 + value) +} + private let charA = UInt8(UnicodeScalar("a").value) private let char0 = UInt8(UnicodeScalar("0").value) @@ -54,18 +66,14 @@ private extension DataProtocol { for _ in regions { for i in self { - hexChars[Int(offset * 2)] = itoh((i >> 4) & 0xF) - hexChars[Int(offset * 2 + 1)] = itoh(i & 0xF) + hexChars[Int(offset * 2)] = hexDigit((i >> 4) & 0xF) + hexChars[Int(offset * 2 + 1)] = hexDigit(i & 0xF) offset += 1 } } return String(bytes: hexChars, encoding: .utf8)! } - - func itoh(_ value: UInt8) -> UInt8 { - (value > 9) ? (charA + value - 10) : (char0 + value) - } } private extension Data { diff --git a/Sources/XcodeProj/Objects/BuildPhase/PBXBuildFile.swift b/Sources/XcodeProj/Objects/BuildPhase/PBXBuildFile.swift index 094132d79..830029c7c 100644 --- a/Sources/XcodeProj/Objects/BuildPhase/PBXBuildFile.swift +++ b/Sources/XcodeProj/Objects/BuildPhase/PBXBuildFile.swift @@ -186,7 +186,8 @@ final class PBXBuildPhaseFile: PlistSerializable, Equatable { } func plistKeyAndValue(proj _: PBXProj, reference: String) throws -> (key: CommentedString, value: PlistValue) { - var dictionary: [CommentedString: PlistValue] = [:] + // Room for every key below, so the dictionary isn't regrown while it's filled. + var dictionary = [CommentedString: PlistValue](minimumCapacity: 6) dictionary["isa"] = .string(CommentedString(PBXBuildFile.isa)) if let fileReference = buildFile.fileReference { let fileElement: PBXFileElement? = fileReference.getObject() diff --git a/Sources/XcodeProj/Objects/Project/PBXObject.swift b/Sources/XcodeProj/Objects/Project/PBXObject.swift index 6f205084a..bff10cda5 100644 --- a/Sources/XcodeProj/Objects/Project/PBXObject.swift +++ b/Sources/XcodeProj/Objects/Project/PBXObject.swift @@ -57,7 +57,8 @@ public class PBXObject: Hashable, Decodable, Equatable, AutoEquatable { /// Object isa (type id) public static var isa: String { - String(describing: self) + // What `String(describing: self)` prints for a class, without the reflection it goes through first. + _typeName(self, qualified: false) } public static func == (lhs: PBXObject, diff --git a/Sources/XcodeProj/Objects/Project/PBXObjectReference.swift b/Sources/XcodeProj/Objects/Project/PBXObjectReference.swift index 3f544af26..8bfa39bc8 100644 --- a/Sources/XcodeProj/Objects/Project/PBXObjectReference.swift +++ b/Sources/XcodeProj/Objects/Project/PBXObjectReference.swift @@ -7,7 +7,12 @@ class PBXObjectReference: NSObject, Comparable, NSCopying { private(set) var temporary: Bool /// String reference. - private(set) var value: String + private(set) var value: String { + didSet { valueHash = value.hashValue } + } + + /// The hash of `value`, which dictionaries keyed by references ask for again every time they move an entry. + private var valueHash: Int /// Weak reference to the objects instance that contains the project objects. weak var objects: PBXObjects? @@ -20,6 +25,7 @@ class PBXObjectReference: NSObject, Comparable, NSCopying { /// - Parameter reference: reference. init(_ reference: String, objects: PBXObjects) { value = reference + valueHash = reference.hashValue temporary = false self.objects = objects } @@ -27,6 +33,7 @@ class PBXObjectReference: NSObject, Comparable, NSCopying { /// Initializes a temporary reference init(objects: PBXObjects? = nil) { value = "TEMP_\(UUID().uuidString)" + valueHash = value.hashValue temporary = true self.objects = objects } @@ -36,6 +43,7 @@ class PBXObjectReference: NSObject, Comparable, NSCopying { /// - Parameter reference: reference. init(_ reference: String) { value = reference + valueHash = reference.hashValue temporary = false } @@ -44,6 +52,7 @@ class PBXObjectReference: NSObject, Comparable, NSCopying { /// - Parameter objectReference: object reference to be initialized from. required init(_ objectReference: PBXObjectReference) { value = objectReference.value + valueHash = objectReference.valueHash temporary = objectReference.temporary } @@ -51,7 +60,8 @@ class PBXObjectReference: NSObject, Comparable, NSCopying { /// /// - Parameter value: value. func fix(_ value: String) { - let object = objects?.delete(reference: self) + // The referenced object's type says which collection holds it, which saves probing every collection. + let object = object.flatMap { objects?.remove(reference: self, typeOf: $0) } ?? objects?.delete(reference: self) self.value = value temporary = false if let object { @@ -71,7 +81,7 @@ class PBXObjectReference: NSObject, Comparable, NSCopying { /// Hash value. override var hash: Int { - value.hashValue + valueHash } func copy(with _: NSZone? = nil) -> Any { diff --git a/Sources/XcodeProj/Objects/Project/PBXObjects.swift b/Sources/XcodeProj/Objects/Project/PBXObjects.swift index 9b7b13691..7575d6bf9 100644 --- a/Sources/XcodeProj/Objects/Project/PBXObjects.swift +++ b/Sources/XcodeProj/Objects/Project/PBXObjects.swift @@ -208,40 +208,70 @@ class PBXObjects: Equatable { } let objectReference: PBXObjectReference = object.reference objectReference.objects = self + update(object, reference: objectReference, inserting: true) + } + + /// Removes the object stored under `reference` from the collection that objects of `object`'s type belong to. + /// Unlike `delete(reference:)`, it doesn't probe every collection. + /// + /// - Returns: the removed object, or nil when that collection has nothing under `reference`. + func remove(reference: PBXObjectReference, typeOf object: PBXObject) -> PBXObject? { + lock.lock() + defer { lock.unlock() } + return update(object, reference: reference, inserting: false) + } + + /// Inserts `object` under `reference` into the collection its type belongs to, or removes what that collection + /// stores under `reference`, so `add` and `remove` always agree on the collection. + @discardableResult private func update( + _ object: PBXObject, + reference: PBXObjectReference, + inserting: Bool + ) -> PBXObject? { + func update(_ objects: inout [PBXObjectReference: T], _ object: T) -> PBXObject? { + if inserting { + objects[reference] = object + return object + } + return objects.removeValue(forKey: reference) + } switch object { // subclasses of PBXGroup; must be tested before PBXGroup - case let object as PBXVariantGroup: _variantGroups[objectReference] = object - case let object as XCVersionGroup: _versionGroups[objectReference] = object + case let object as PBXVariantGroup: return update(&_variantGroups, object) + case let object as XCVersionGroup: return update(&_versionGroups, object) // everything else - case let object as PBXBuildFile: _buildFiles[objectReference] = object - case let object as PBXAggregateTarget: _aggregateTargets[objectReference] = object - case let object as PBXLegacyTarget: _legacyTargets[objectReference] = object - case let object as PBXContainerItemProxy: _containerItemProxies[objectReference] = object - case let object as PBXCopyFilesBuildPhase: _copyFilesBuildPhases[objectReference] = object - case let object as PBXGroup: _groups[objectReference] = object - case let object as XCConfigurationList: _configurationLists[objectReference] = object - case let object as XCBuildConfiguration: _buildConfigurations[objectReference] = object - case let object as PBXTargetDependency: _targetDependencies[objectReference] = object - case let object as PBXSourcesBuildPhase: _sourcesBuildPhases[objectReference] = object - case let object as PBXShellScriptBuildPhase: _shellScriptBuildPhases[objectReference] = object - case let object as PBXResourcesBuildPhase: _resourcesBuildPhases[objectReference] = object - case let object as PBXFrameworksBuildPhase: _frameworksBuildPhases[objectReference] = object - case let object as PBXHeadersBuildPhase: _headersBuildPhases[objectReference] = object - case let object as PBXNativeTarget: _nativeTargets[objectReference] = object - case let object as PBXFileReference: _fileReferences[objectReference] = object - case let object as PBXProject: _projects[objectReference] = object - case let object as PBXReferenceProxy: _referenceProxies[objectReference] = object - case let object as PBXRezBuildPhase: _carbonResourcesBuildPhases[objectReference] = object - case let object as PBXBuildRule: _buildRules[objectReference] = object - case let object as XCRemoteSwiftPackageReference: _remoteSwiftPackageReferences[objectReference] = object - case let object as XCLocalSwiftPackageReference: _localSwiftPackageReferences[objectReference] = object - case let object as XCSwiftPackageProductDependency: _swiftPackageProductDependencies[objectReference] = object - case let object as PBXFileSystemSynchronizedRootGroup: _fileSystemSynchronizedRootGroups[objectReference] = object - case let object as PBXFileSystemSynchronizedBuildFileExceptionSet: _fileSystemSynchronizedBuildFileExceptionSets[objectReference] = object + case let object as PBXBuildFile: return update(&_buildFiles, object) + case let object as PBXAggregateTarget: return update(&_aggregateTargets, object) + case let object as PBXLegacyTarget: return update(&_legacyTargets, object) + case let object as PBXContainerItemProxy: return update(&_containerItemProxies, object) + case let object as PBXCopyFilesBuildPhase: return update(&_copyFilesBuildPhases, object) + case let object as PBXGroup: return update(&_groups, object) + case let object as XCConfigurationList: return update(&_configurationLists, object) + case let object as XCBuildConfiguration: return update(&_buildConfigurations, object) + case let object as PBXTargetDependency: return update(&_targetDependencies, object) + case let object as PBXSourcesBuildPhase: return update(&_sourcesBuildPhases, object) + case let object as PBXShellScriptBuildPhase: return update(&_shellScriptBuildPhases, object) + case let object as PBXResourcesBuildPhase: return update(&_resourcesBuildPhases, object) + case let object as PBXFrameworksBuildPhase: return update(&_frameworksBuildPhases, object) + case let object as PBXHeadersBuildPhase: return update(&_headersBuildPhases, object) + case let object as PBXNativeTarget: return update(&_nativeTargets, object) + case let object as PBXFileReference: return update(&_fileReferences, object) + case let object as PBXProject: return update(&_projects, object) + case let object as PBXReferenceProxy: return update(&_referenceProxies, object) + case let object as PBXRezBuildPhase: return update(&_carbonResourcesBuildPhases, object) + case let object as PBXBuildRule: return update(&_buildRules, object) + case let object as XCRemoteSwiftPackageReference: return update(&_remoteSwiftPackageReferences, object) + case let object as XCLocalSwiftPackageReference: return update(&_localSwiftPackageReferences, object) + case let object as XCSwiftPackageProductDependency: return update(&_swiftPackageProductDependencies, object) + case let object as PBXFileSystemSynchronizedRootGroup: return update(&_fileSystemSynchronizedRootGroups, object) + case let object as PBXFileSystemSynchronizedBuildFileExceptionSet: return update(&_fileSystemSynchronizedBuildFileExceptionSets, object) case let object as PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet: - _fileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet[objectReference] = object - default: fatalError("Unhandled PBXObject type for \(object), this is likely a bug / todo") + return update(&_fileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet, object) + default: + // Objects of these types are never added, so there is nothing to remove. + guard inserting else { return nil } + fatalError("Unhandled PBXObject type for \(object), this is likely a bug / todo") } } @@ -430,17 +460,15 @@ extension PBXObjects { // This is used to decode build files. (we need the name of the build phase) // Otherwise, we would have to go through all the build phases for each file. var buildPhaseFile: [PBXObjectReference: PBXBuildPhaseFile] { - let values: [[PBXBuildPhaseFile]] = buildPhases.values.map { buildPhase in - let files = buildPhase.files - let buildPhaseFile: [PBXBuildPhaseFile] = files?.compactMap { (file: PBXBuildFile) -> PBXBuildPhaseFile in - PBXBuildPhaseFile( - buildFile: file, - buildPhase: buildPhase - ) - } ?? [] - return buildPhaseFile + // A build file listed by several phases maps to the first of them. Visiting the phases and files backwards lets + // each entry be overwritten without checking it first, so the first occurrence is the one that stays. + var buildPhaseFile = [PBXObjectReference: PBXBuildPhaseFile](minimumCapacity: buildFiles.count) + for buildPhase in buildPhases.values.reversed() { + for file in (buildPhase.files ?? []).reversed() { + buildPhaseFile[file.reference] = PBXBuildPhaseFile(buildFile: file, buildPhase: buildPhase) + } } - return Dictionary(values.flatMap { $0 }.map { ($0.buildFile.reference, $0) }, uniquingKeysWith: { first, _ in first }) + return buildPhaseFile } /// Runs the given closure for each of the objects that are part of the project. diff --git a/Sources/XcodeProj/Objects/Project/PBXProjEncoder.swift b/Sources/XcodeProj/Objects/Project/PBXProjEncoder.swift index fa8710ffc..311bad70a 100644 --- a/Sources/XcodeProj/Objects/Project/PBXProjEncoder.swift +++ b/Sources/XcodeProj/Objects/Project/PBXProjEncoder.swift @@ -374,9 +374,11 @@ final class PBXProjEncoder { } } - let joinedArray: [String] = resultArray.flatMap { $0 } // should check multiline - output.append(contentsOf: joinedArray) + // Appended element by element, which saves flattening them into a temporary array first. + for lines in resultArray { + output.append(contentsOf: lines) + } write(string: "/* End \(section) section */", to: &output) writeNewLine(stateHolder: &stateHolder, to: &output) } diff --git a/Sources/XcodeProj/Utils/ReferenceGenerator.swift b/Sources/XcodeProj/Utils/ReferenceGenerator.swift index b197c55f4..221352355 100644 --- a/Sources/XcodeProj/Utils/ReferenceGenerator.swift +++ b/Sources/XcodeProj/Utils/ReferenceGenerator.swift @@ -16,6 +16,8 @@ protocol ReferenceGenerating: AnyObject { final class ReferenceGenerator: ReferenceGenerating { let outputSettings: PBXOutputSettings var references: Set = [] + /// The type name and reference acronym of each object type, which only depend on the type. + private var typeNamesAndAcronyms: [ObjectIdentifier: (typeName: String, acronym: String)] = [:] init(outputSettings: PBXOutputSettings) { self.outputSettings = outputSettings @@ -386,14 +388,7 @@ extension ReferenceGenerator { if let context = object.context { identifiers.append(context) } - let typeName = String(describing: type(of: object)) - - // Get acronym to be used as prefix for the reference. - // PBXFileReference is turned to FR. - let acronym = typeName - .replacingOccurrences(of: "PBX", with: "") - .replacingOccurrences(of: "XC", with: "") - .filter { String($0).lowercased() != String($0) } + let (typeName, acronym) = typeNameAndAcronym(of: object) var reference = "" var counter = 0 @@ -410,6 +405,21 @@ extension ReferenceGenerator { } } + private func typeNameAndAcronym(of object: some PBXObject) -> (typeName: String, acronym: String) { + let objectType = type(of: object) + if let cached = typeNamesAndAcronyms[ObjectIdentifier(objectType)] { return cached } + let typeName = String(describing: objectType) + + // Get acronym to be used as prefix for the reference. + // PBXFileReference is turned to FR. + let acronym = typeName + .replacingOccurrences(of: "PBX", with: "") + .replacingOccurrences(of: "XC", with: "") + .filter { String($0).lowercased() != String($0) } + typeNamesAndAcronyms[ObjectIdentifier(objectType)] = (typeName, acronym) + return (typeName, acronym) + } + private func generateReferenceFrom(acronym: String, typeName: String, identifiers: [String], diff --git a/Tests/XcodeProjTests/Utils/ReferenceFixingTests.swift b/Tests/XcodeProjTests/Utils/ReferenceFixingTests.swift new file mode 100644 index 000000000..1d365c82f --- /dev/null +++ b/Tests/XcodeProjTests/Utils/ReferenceFixingTests.swift @@ -0,0 +1,195 @@ +import Foundation +import PathKit +import Testing +@testable import XcodeProj + +/// Covers the bookkeeping that runs when temporary references are made permanent before a project is written: the +/// collection each object lives in, the generated reference values, and the digests and type names they are made of. +struct ReferenceFixingTests { + @Test func md5_matchesKnownDigests() { + // RFC 1321 test suite, plus multi-byte UTF-8 strings checked with `md5 -s`. + #expect("".md5 == "d41d8cd98f00b204e9800998ecf8427e") + #expect("abc".md5 == "900150983cd24fb0d6963f7d28e17f72") + #expect("message digest".md5 == "f96b697d7cb7938d525a2f31aaf161d0") + #expect("é".md5 == "66ddcd97cfdeabb2f6fb8a999b4bc76f") + #expect("PBXBuildFile-Target-Ünïcödé-🚀".md5 == "74d6591936a96f0ff81738e1b8e80f6d") + } + + @Test func md5_hashesBridgedStringsByTheirUTF8Bytes() { + let bridged = NSMutableString(string: "PBXBuildFile-Target-") + bridged.append("Ünïcödé-🚀") + + #expect((bridged as String).md5 == "74d6591936a96f0ff81738e1b8e80f6d") + } + + @Test func isa_isTheUnqualifiedClassName() { + #expect(PBXObject.isa == "PBXObject") + #expect(PBXBuildFile.isa == "PBXBuildFile") + #expect(PBXVariantGroup.isa == "PBXVariantGroup") + #expect(XCVersionGroup.isa == "XCVersionGroup") + #expect(PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet.isa == "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet") + let groupType: PBXFileElement.Type = PBXVariantGroup.self + #expect(groupType.isa == "PBXVariantGroup") + } + + #if os(macOS) || (os(Linux) && compiler(>=6.1)) + /// Writing a project whose references are all temporary moves every object it decodes to a permanent reference. + @Test(arguments: [ + "iOS/Project.xcodeproj", + "iOS/AppWithExtensions/AppWithExtensions.xcodeproj", + "iOS/ProjectWithXCLocalSwiftPackageReferences.xcodeproj", + "SynchronizedRootGroups/SynchronizedRootGroups.xcodeproj", + "TargetWithCustomBuildRules/TargetWithCustomBuildRules.xcodeproj", + "Xcode16ProjectReferenceOrder/Test.xcodeproj", + ]) + func writingAProjectWithTemporaryReferences_keepsEveryObject(fixture: String) throws { + let pbxproj = try PBXProj(path: fixturesPath() + fixture + "project.pbxproj") + let objects = objectCountsByType(pbxproj.objects) + pbxproj.objects.invalidateReferences() + + let data = try #require(try pbxproj.dataRepresentation()) + let written = try PBXProj(data: data) + + #expect(objectCountsByType(written.objects) == objects) + #expect(!String(decoding: data, as: UTF8.self).contains("TEMP_")) + } + #endif + + @Test func fixingReferences_movesEachObjectToItsNewReference() throws { + let pbxproj = PBXProj() + let objects = makeOneObjectOfEachType() + objects.forEach { pbxproj.add(object: $0.object) } + // One generator for every type, so the type name and prefix it caches for one type can't leak into another. + let generator = ReferenceGenerator(outputSettings: PBXOutputSettings(projReferenceFormat: .withPrefixAndSuffix)) + + for (object, typeName, acronym) in objects { + let temporaryValue = object.reference.value + generator.fixReference(for: object, identifiers: ["id"]) + + #expect(object.reference.value == "\(acronym)_\("\(typeName)-id".md5.uppercased())") + #expect(pbxproj.objects.get(reference: PBXObjectReference(object.reference.value)) === object) + #expect(pbxproj.objects.get(reference: PBXObjectReference(temporaryValue)) == nil) + } + #expect(objectCount(pbxproj.objects) == objects.count) + } + + @Test func fixingAReference_whoseObjectTypeHasNoCollection_leavesTheCollectionsUnchanged() { + let pbxproj = PBXProj() + pbxproj.add(object: PBXFileReference(name: "A.swift")) + let element = PBXFileElement(name: "Element") + element.reference.objects = pbxproj.objects + + element.reference.fix("ELEMENT") + + #expect(element.reference.value == "ELEMENT") + #expect(pbxproj.objects.get(reference: PBXObjectReference("ELEMENT")) == nil) + #expect(objectCount(pbxproj.objects) == 1) + } + + @Test func referenceHash_followsItsValue() throws { + let reference = PBXObjectReference() + + reference.fix("A") + #expect(reference.hash == PBXObjectReference("A").hash) + #expect(Set([PBXObjectReference("A")]).contains(reference)) + + reference.invalidate() + #expect(reference.hash == PBXObjectReference(reference.value).hash) + #expect(!Set([PBXObjectReference("A")]).contains(reference)) + + let copy = try #require(reference.copy() as? PBXObjectReference) + #expect(copy == reference) + #expect(copy.hash == reference.hash) + } + + @Test func buildPhaseFile_hasOneEntryPerBuildFile_inAPhaseThatListsIt() throws { + let pbxproj = PBXProj() + let fileA = PBXFileReference(name: "A.swift") + let fileB = PBXFileReference(name: "B.png") + let buildFileA = PBXBuildFile(file: fileA) + let buildFileB = PBXBuildFile(file: fileB) + let sources = PBXSourcesBuildPhase(files: [buildFileA, buildFileB, buildFileA]) + let resources = PBXResourcesBuildPhase(files: [buildFileB]) + [fileA, fileB, buildFileA, buildFileB, sources, resources].forEach { pbxproj.add(object: $0) } + + let buildPhaseFile = pbxproj.objects.buildPhaseFile + + #expect(buildPhaseFile.count == 2) + #expect(buildPhaseFile[buildFileA.reference]?.buildFile === buildFileA) + #expect(buildPhaseFile[buildFileA.reference]?.buildPhase === sources) + let phaseOfB = try #require(buildPhaseFile[buildFileB.reference]?.buildPhase) + #expect(phaseOfB === sources || phaseOfB === resources) + } + + // MARK: - Helpers + + private func objectCountsByType(_ objects: PBXObjects) -> [String: Int] { + var counts: [String: Int] = [:] + objects.forEach { counts[String(describing: type(of: $0)), default: 0] += 1 } + return counts + } + + private func objectCount(_ objects: PBXObjects) -> Int { + var count = 0 + objects.forEach { _ in count += 1 } + return count + } + + /// One object of every type `PBXObjects` stores, with the type name and reference prefix the generator should use. + private func makeOneObjectOfEachType() -> [(object: PBXObject, typeName: String, acronym: String)] { + let fileReference = PBXFileReference(name: "A.swift") + let target = PBXNativeTarget(name: "App") + let sourcesBuildPhase = PBXSourcesBuildPhase() + let configurationList = XCConfigurationList() + let remotePackage = XCRemoteSwiftPackageReference(repositoryURL: "https://example.com/package") + let containerItemProxy = PBXContainerItemProxy(containerPortal: .fileReference(fileReference)) + return [ + (fileReference, "PBXFileReference", "FR"), + (target, "PBXNativeTarget", "NT"), + (sourcesBuildPhase, "PBXSourcesBuildPhase", "SBP"), + (configurationList, "XCConfigurationList", "CL"), + (remotePackage, "XCRemoteSwiftPackageReference", "RSPR"), + (containerItemProxy, "PBXContainerItemProxy", "CIP"), + (PBXBuildFile(file: fileReference), "PBXBuildFile", "BF"), + (PBXAggregateTarget(name: "Aggregate"), "PBXAggregateTarget", "AT"), + (PBXLegacyTarget(name: "Legacy"), "PBXLegacyTarget", "LT"), + (PBXCopyFilesBuildPhase(), "PBXCopyFilesBuildPhase", "CFBP"), + (PBXGroup(), "PBXGroup", "G"), + (PBXVariantGroup(), "PBXVariantGroup", "VG"), + (XCVersionGroup(), "XCVersionGroup", "VG"), + (XCBuildConfiguration(name: "Debug"), "XCBuildConfiguration", "BC"), + (PBXTargetDependency(target: target), "PBXTargetDependency", "TD"), + (PBXShellScriptBuildPhase(), "PBXShellScriptBuildPhase", "SSBP"), + (PBXResourcesBuildPhase(), "PBXResourcesBuildPhase", "RBP"), + (PBXFrameworksBuildPhase(), "PBXFrameworksBuildPhase", "FBP"), + (PBXHeadersBuildPhase(), "PBXHeadersBuildPhase", "HBP"), + (PBXRezBuildPhase(), "PBXRezBuildPhase", "RBP"), + (PBXProject( + name: "Project", + buildConfigurationList: configurationList, + compatibilityVersion: nil, + preferredProjectObjectVersion: nil, + minimizedProjectReferenceProxies: nil, + mainGroup: PBXGroup() + ), "PBXProject", "P"), + (PBXReferenceProxy(remote: containerItemProxy), "PBXReferenceProxy", "RP"), + (PBXBuildRule(compilerSpec: "spec", fileType: "type"), "PBXBuildRule", "BR"), + (XCLocalSwiftPackageReference(relativePath: "Package"), "XCLocalSwiftPackageReference", "LSPR"), + (XCSwiftPackageProductDependency(productName: "Product", package: remotePackage), "XCSwiftPackageProductDependency", "SPPD"), + (PBXFileSystemSynchronizedRootGroup(path: "Sources"), "PBXFileSystemSynchronizedRootGroup", "FSSRG"), + (PBXFileSystemSynchronizedBuildFileExceptionSet( + target: target, + membershipExceptions: nil, + publicHeaders: nil, + privateHeaders: nil, + additionalCompilerFlagsByRelativePath: nil, + attributesByRelativePath: nil + ), "PBXFileSystemSynchronizedBuildFileExceptionSet", "FSSBFES"), + (PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet( + buildPhase: sourcesBuildPhase, + membershipExceptions: nil, + attributesByRelativePath: nil + ), "PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet", "FSSGBPMES"), + ] + } +}