diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index 2aca0696..1f20e850 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1489,7 +1489,7 @@ public class PBXProjGenerator { }) else { return } var exceptions: Set = Set( - sourceGenerator.expandedExcludes(for: targetSource) + sourceGenerator.syncedFolderExceptions(for: targetSource, at: syncedPath) .compactMap { try? $0.relativePath(from: syncedPath).string } ) diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 82e100ef..b4f74c53 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -18,6 +18,7 @@ class SourceGenerator { private var fileReferencesByPath: [String: PBXFileElement] = [:] private var groupsByPath: [Path: PBXGroup] = [:] private var variantGroupsByPath: [Path: PBXVariantGroup] = [:] + private var syncedGroupsByPath: [String: PBXFileSystemSynchronizedRootGroup] = [:] private let project: Project let pbxProj: PBXProj @@ -377,6 +378,34 @@ class SourceGenerator { getSourceMatches(targetSource: targetSource, patterns: targetSource.excludes) } + /// Returns the expanded set of exception paths for a synced folder, including excludes and non-included files. + func syncedFolderExceptions(for targetSource: TargetSource, at syncedPath: Path) -> Set { + let excludePaths = expandedExcludes(for: targetSource) + if targetSource.includes.isEmpty { + return excludePaths + } + + let includePaths = SortedArray(getSourceMatches(targetSource: targetSource, patterns: targetSource.includes)) + var exceptions: Set = [] + + func findExceptions(in path: Path) { + guard let children = try? path.children() else { return } + + for child in children { + if isIncludedPath(child, excludePaths: excludePaths, includePaths: includePaths) { + if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) { + findExceptions(in: child) + } + } else { + exceptions.insert(child) + } + } + } + + findExceptions(in: syncedPath) + return exceptions + } + /// Collects all the excluded paths within the targetSource private func getSourceMatches(targetSource: TargetSource, patterns: [String]) -> Set { let rootSourcePath = project.basePath + targetSource.path @@ -711,15 +740,25 @@ class SourceGenerator { let relativePath = (try? path.relativePath(from: project.basePath)) ?? path let resolvedExplicitFolders = resolveExplicitFolders(targetSource: targetSource) - let syncedRootGroup = PBXFileSystemSynchronizedRootGroup( - sourceTree: .group, - path: relativePath.string, - name: targetSource.name, - explicitFileTypes: [:], - exceptions: [], - explicitFolders: resolvedExplicitFolders - ) - addObject(syncedRootGroup) + let syncedRootGroup: PBXFileSystemSynchronizedRootGroup + if let existingGroup = syncedGroupsByPath[relativePath.string] { + syncedRootGroup = existingGroup + let newExplicitFolders = Set(syncedRootGroup.explicitFolders ?? []) + .union(resolvedExplicitFolders) + .sorted() + syncedRootGroup.explicitFolders = newExplicitFolders + } else { + syncedRootGroup = PBXFileSystemSynchronizedRootGroup( + sourceTree: .group, + path: relativePath.string, + name: targetSource.name, + explicitFileTypes: [:], + exceptions: [], + explicitFolders: resolvedExplicitFolders + ) + addObject(syncedRootGroup) + syncedGroupsByPath[relativePath.string] = syncedRootGroup + } sourceReference = syncedRootGroup if !(createIntermediateGroups || hasCustomParent) || path.parent() == project.basePath { diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index be7eb005..be430a1a 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -290,6 +290,207 @@ class SourceGeneratorTests: XCTestCase { try expect(hasResourcesPhase) == true } + $0.it("deduplicates synced folders across targets") { + let directories = """ + Sources: + - a.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder) + let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source]) + let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2]) + + let pbxProj = try project.generatePbxProj() + let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + + try expect(syncedFolders.count) == 1 + } + + $0.it("supports includes for synced folders") { + let directories = """ + Sources: + - included.swift + - excluded.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", includes: ["included.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("excluded.swift")) == true + try expect(exceptions.contains("included.swift")) == false + } + + $0.it("merges explicitFolders for synced folders across targets") { + let directories = """ + Sources: + - a.swift + - FolderA: + - b.swift + - FolderB: + - c.swift + """ + try createDirectories(directories) + + let source1 = TargetSource(path: "Sources", explicitFolders: ["FolderA"], type: .syncedFolder) + let source2 = TargetSource(path: "Sources", explicitFolders: ["FolderB"], type: .syncedFolder) + let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source1]) + let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source2]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2]) + + let pbxProj = try project.generatePbxProj() + let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(syncedFolders.first) + + try expect(syncedFolder.explicitFolders?.sorted()) == ["FolderA", "FolderB"] + } + + $0.it("supports different includes for the same synced folder across targets") { + let directories = """ + Sources: + - target1.swift + - target2.swift + - common.swift + """ + try createDirectories(directories) + + let source1 = TargetSource(path: "Sources", includes: ["target1.swift", "common.swift"], type: .syncedFolder) + let source2 = TargetSource(path: "Sources", includes: ["target2.swift", "common.swift"], type: .syncedFolder) + let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source1]) + let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source2]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2]) + + 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 } + try expect(exceptionSets?.count) == 2 + + let t1Exceptions = try unwrap(exceptionSets?.first { $0.target?.name == "Target1" }?.membershipExceptions) + try expect(t1Exceptions.contains("target2.swift")) == true + try expect(t1Exceptions.contains("target1.swift")) == false + try expect(t1Exceptions.contains("common.swift")) == false + + let t2Exceptions = try unwrap(exceptionSets?.first { $0.target?.name == "Target2" }?.membershipExceptions) + try expect(t2Exceptions.contains("target1.swift")) == true + try expect(t2Exceptions.contains("target2.swift")) == false + try expect(t2Exceptions.contains("common.swift")) == false + } + + $0.it("correctly identifies exceptions for nested directories in includes") { + let directories = """ + Sources: + - a.swift + - Nested: + - b.swift + - c.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", includes: ["Nested/b.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 exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions.contains("a.swift")) == true + try expect(exceptions.contains("Nested/c.swift")) == true + try expect(exceptions.contains("Nested/b.swift")) == false + } + + $0.it("excludes entire subdirectory as single exception when no files in it are included") { + let directories = """ + Sources: + - a.swift + - ExcludedDir: + - x.swift + - y.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", includes: ["a.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 exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + // The whole directory should be a single exception entry, not each file within it + try expect(exceptions.contains("ExcludedDir")) == true + try expect(exceptions.contains("ExcludedDir/x.swift")) == false + try expect(exceptions.contains("ExcludedDir/y.swift")) == false + try expect(exceptions.contains("a.swift")) == false + } + + $0.it("respects excludes when includes are also specified") { + let directories = """ + Sources: + - a.swift + - b.swift + - c.swift + """ + try createDirectories(directories) + + // includes a.swift and b.swift, but b.swift is also excluded → only a.swift is effectively included + let source = TargetSource(path: "Sources", excludes: ["b.swift"], includes: ["a.swift", "b.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 exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions.contains("a.swift")) == false + try expect(exceptions.contains("b.swift")) == true + try expect(exceptions.contains("c.swift")) == true + } + + $0.it("deduplicates synced folders and both targets reference the same group object") { + let directories = """ + Sources: + - a.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder) + let target1 = Target(name: "App", type: .application, platform: .iOS, sources: [source]) + let target2 = Target(name: "Tests", type: .unitTestBundle, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2]) + + let pbxProj = try project.generatePbxProj() + let nativeTargets = pbxProj.nativeTargets + let appTarget = try unwrap(nativeTargets.first { $0.name == "App" }) + let testsTarget = try unwrap(nativeTargets.first { $0.name == "Tests" }) + + let appGroup = try unwrap(appTarget.fileSystemSynchronizedGroups?.first) + let testsGroup = try unwrap(testsTarget.fileSystemSynchronizedGroups?.first) + try expect(appGroup === testsGroup) == true + } + $0.it("supports frameworks in sources") { let directories = """ Sources: