diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index 7c7f64f5a..b8ab9d371 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1141,7 +1141,8 @@ public class PBXProjGenerator { func addResourcesBuildPhase() { let resourcesBuildPhaseFiles = getBuildFilesForPhase(.resources) + copyResourcesReferences - if !resourcesBuildPhaseFiles.isEmpty { + let hasSynchronizedRootGroups = sourceFiles.contains { $0.fileReference is PBXFileSystemSynchronizedRootGroup } + if !resourcesBuildPhaseFiles.isEmpty || hasSynchronizedRootGroups { let resourcesBuildPhase = addObject(PBXResourcesBuildPhase(files: resourcesBuildPhaseFiles)) buildPhases.append(resourcesBuildPhase) } @@ -1460,9 +1461,57 @@ public class PBXProjGenerator { // add fileSystemSynchronizedGroups let synchronizedRootGroups = sourceFiles.compactMap { $0.fileReference as? PBXFileSystemSynchronizedRootGroup } if !synchronizedRootGroups.isEmpty { + for syncedGroup in synchronizedRootGroups { + configureMembershipExceptions( + for: syncedGroup, + target: target, + targetObject: targetObject, + infoPlistFiles: infoPlistFiles + ) + } targetObject.fileSystemSynchronizedGroups = synchronizedRootGroups } } + + private func configureMembershipExceptions( + for syncedGroup: PBXFileSystemSynchronizedRootGroup, + target: Target, + targetObject: PBXTarget, + infoPlistFiles: [Config: String] + ) { + guard let syncedGroupPath = syncedGroup.path else { return } + let syncedPath = (project.basePath + Path(syncedGroupPath)).normalize() + + guard let targetSource = target.sources.first(where: { + (project.basePath + $0.path).normalize() == syncedPath + }) else { return } + + var exceptions: Set = Set( + sourceGenerator.expandedExcludes(for: targetSource) + .compactMap { try? $0.relativePath(from: syncedPath).string } + ) + + for infoPlistPath in Set(infoPlistFiles.values) { + let relative = try? (project.basePath + infoPlistPath).normalize() + .relativePath(from: syncedPath) + if let rel = relative?.string, !rel.hasPrefix("..") { + exceptions.insert(rel) + } + } + + guard !exceptions.isEmpty else { return } + + let exceptionSet = PBXFileSystemSynchronizedBuildFileExceptionSet( + target: targetObject, + membershipExceptions: exceptions.sorted(), + publicHeaders: nil, + privateHeaders: nil, + additionalCompilerFlagsByRelativePath: nil, + attributesByRelativePath: nil + ) + addObject(exceptionSet) + syncedGroup.exceptions = (syncedGroup.exceptions ?? []) + [exceptionSet] + } private func makePlatformFilter(for filter: Dependency.PlatformFilter) -> String? { switch filter { diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 35c914fc4..06486f113 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -372,6 +372,11 @@ class SourceGenerator { return variantGroup } + /// Returns the expanded set of excluded paths for a target source by resolving its exclude glob patterns. + func expandedExcludes(for targetSource: TargetSource) -> Set { + getSourceMatches(targetSource: targetSource, patterns: targetSource.excludes) + } + /// Collects all the excluded paths within the targetSource private func getSourceMatches(targetSource: TargetSource, patterns: [String]) -> Set { let rootSourcePath = project.basePath + targetSource.path diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index 10ff1c3a2..eabdb4b1d 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -830,9 +830,23 @@ FED40A89162E446494DDE7C7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 9A259ACEBCE19CC5F22B6DD4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ExcludedFile.swift, + Info.plist, + ); + target = 0867B0DACEF28C11442DE8F7 /* App_iOS */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ AE2AB2772F70DFFF402AA02B /* SyncedFolder */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9A259ACEBCE19CC5F22B6DD4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, + ); explicitFileTypes = { }; explicitFolders = ( diff --git a/Tests/Fixtures/TestProject/SyncedFolder/ExcludedFile.swift b/Tests/Fixtures/TestProject/SyncedFolder/ExcludedFile.swift new file mode 100644 index 000000000..d1c13a1fb --- /dev/null +++ b/Tests/Fixtures/TestProject/SyncedFolder/ExcludedFile.swift @@ -0,0 +1 @@ +// excluded diff --git a/Tests/Fixtures/TestProject/SyncedFolder/Info.plist b/Tests/Fixtures/TestProject/SyncedFolder/Info.plist new file mode 100644 index 000000000..0c67376eb --- /dev/null +++ b/Tests/Fixtures/TestProject/SyncedFolder/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Tests/Fixtures/TestProject/project.yml b/Tests/Fixtures/TestProject/project.yml index 801d4275a..599da8e23 100644 --- a/Tests/Fixtures/TestProject/project.yml +++ b/Tests/Fixtures/TestProject/project.yml @@ -166,6 +166,9 @@ targets: - String Catalogs/LocalizableStrings.xcstrings - path: SyncedFolder type: syncedFolder + excludes: + - ExcludedFile.swift + - Info.plist settings: INFOPLIST_FILE: App_iOS/Info.plist PRODUCT_BUNDLE_IDENTIFIER: com.project.app diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 3712f42ed..55f896b55 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -120,6 +120,99 @@ class SourceGeneratorTests: XCTestCase { try expect([syncedFolder]) == pbxProj.nativeTargets.first?.fileSystemSynchronizedGroups } + $0.it("adds excludes as membership exceptions for synced folder") { + let directories = """ + Sources: + - a.swift + - b.swift + - Generated: + - c.generated.swift + - d.generated.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", excludes: ["b.swift", "Generated/*.generated.swift"], type: .syncedFolder) + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(syncedFolders.first) + + let exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } + let exceptionSet = try unwrap(exceptionSets?.first) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions.contains("b.swift")) == true + try expect(exceptions.contains("Generated/c.generated.swift")) == true + try expect(exceptions.contains("Generated/d.generated.swift")) == true + try expect(exceptions.contains("a.swift")) == false + } + + $0.it("auto-excludes Info.plist from synced folder membership") { + let directories = """ + Sources: + - a.swift + - Info.plist + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder) + let target = Target( + name: "Test", + type: .application, + platform: .iOS, + settings: try Settings(jsonDictionary: ["INFOPLIST_FILE": "Sources/Info.plist"]), + sources: [source] + ) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(syncedFolders.first) + + let exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet } + let exceptionSet = try unwrap(exceptionSets?.first) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions.contains("Info.plist")) == true + } + + $0.it("creates no exception set for synced folder without excludes") { + let directories = """ + Sources: + - a.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder) + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(syncedFolders.first) + + try expect(syncedFolder.exceptions?.isEmpty ?? true) == true + } + + $0.it("adds empty resources build phase for synced folder") { + let directories = """ + Sources: + - a.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder) + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let nativeTarget = try unwrap(pbxProj.nativeTargets.first) + let hasResourcesPhase = nativeTarget.buildPhases.contains { $0 is PBXResourcesBuildPhase } + try expect(hasResourcesPhase) == true + } + $0.it("supports frameworks in sources") { let directories = """ Sources: