From c3ceb5920f358e09e79828b56092ae4dd1f1fd22 Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Mon, 14 Sep 2026 13:10:30 +0530 Subject: [PATCH 1/4] Support destinationFilters for synced folders --- CHANGELOG.md | 1 + Sources/XcodeGenKit/PBXProjGenerator.swift | 10 ++-- Sources/XcodeGenKit/SourceGenerator.swift | 26 ++++++++++ .../SyncedSources/SyncedFile_iOS.swift | 1 + .../SyncedSources/SyncedFile_tvOS.swift | 1 + .../Project.xcodeproj/project.pbxproj | 29 +++++++++++ Tests/Fixtures/TestProject/project.yml | 4 ++ .../SourceGeneratorTests.swift | 52 +++++++++++++++++++ 8 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 Tests/Fixtures/TestProject/App_supportedDestinations/SyncedSources/SyncedFile_iOS.swift create mode 100644 Tests/Fixtures/TestProject/App_supportedDestinations/SyncedSources/SyncedFile_tvOS.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ff4ff89..e0af1c8fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Fixed - Fix `syncedFolder` source paths being relative to the spec directory instead of the project directory when they differ, which caused Xcode to treat the synced folder as empty #1636 @Ckitakishi +- Fix `destinationFilters` and `inferDestinationFiltersByPath` being ignored for `syncedFolder` sources. They are now written as `platformFiltersByRelativePath` on the synced folder's exception set #1646 @anandghegde - Fix nested target attributes (e.g. `attributes.SystemCapabilities`) being serialized as a stringified Swift `Dictionary` description instead of a proper nested plist dictionary, which also caused non-deterministic key ordering in generated `project.pbxproj` files across runs #1639 @imadaan @sergeyospanov ### Internal diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index 604ff473b..0fa83cf51 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1501,15 +1501,19 @@ public class PBXProjGenerator { } } - guard !exceptions.isEmpty else { return } + let platformFilters = sourceGenerator.syncedFolderPlatformFilters(for: targetSource, at: syncedPath) + .filter { !exceptions.contains($0.key) } + + guard !exceptions.isEmpty || !platformFilters.isEmpty else { return } let exceptionSet = PBXFileSystemSynchronizedBuildFileExceptionSet( target: targetObject, - membershipExceptions: exceptions.sorted(), + membershipExceptions: exceptions.isEmpty ? nil : exceptions.sorted(), publicHeaders: nil, privateHeaders: nil, additionalCompilerFlagsByRelativePath: nil, - attributesByRelativePath: nil + attributesByRelativePath: nil, + platformFiltersByRelativePath: platformFilters.isEmpty ? nil : platformFilters ) addObject(exceptionSet) syncedGroup.exceptions = (syncedGroup.exceptions ?? []) + [exceptionSet] diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 10313f47f..de381d327 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -433,6 +433,32 @@ class SourceGenerator { return exceptions } + /// Returns the platform filters for each file in a synced folder, keyed by path relative to the synced folder. + /// Xcode only honors file paths in `platformFiltersByRelativePath`, so directories are expanded into their files. + func syncedFolderPlatformFilters(for targetSource: TargetSource, at syncedPath: Path) -> [String: [String]] { + guard targetSource.destinationFilters?.isEmpty == false || targetSource.inferDestinationFiltersByPath == true else { + return [:] + } + + var platformFilters: [String: [String]] = [:] + + func findFiles(in path: Path) { + guard let children = try? path.children() else { return } + + for child in children where !child.lastComponent.hasPrefix(".") { + if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) { + findFiles(in: child) + } else if let filters = makeDestinationFilters(for: child, with: targetSource.destinationFilters, or: targetSource.inferDestinationFiltersByPath), + let relativePath = try? child.relativePath(from: syncedPath) { + platformFilters[relativePath.string] = filters + } + } + } + + findFiles(in: syncedPath) + return platformFilters + } + /// 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/App_supportedDestinations/SyncedSources/SyncedFile_iOS.swift b/Tests/Fixtures/TestProject/App_supportedDestinations/SyncedSources/SyncedFile_iOS.swift new file mode 100644 index 000000000..e795540cf --- /dev/null +++ b/Tests/Fixtures/TestProject/App_supportedDestinations/SyncedSources/SyncedFile_iOS.swift @@ -0,0 +1 @@ +let syncedPlatformName = "iOS" diff --git a/Tests/Fixtures/TestProject/App_supportedDestinations/SyncedSources/SyncedFile_tvOS.swift b/Tests/Fixtures/TestProject/App_supportedDestinations/SyncedSources/SyncedFile_tvOS.swift new file mode 100644 index 000000000..41fc53f39 --- /dev/null +++ b/Tests/Fixtures/TestProject/App_supportedDestinations/SyncedSources/SyncedFile_tvOS.swift @@ -0,0 +1 @@ +let syncedPlatformName = "tvOS" diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index bbfb8e41c..2c50979e1 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -841,6 +841,18 @@ ); target = 0867B0DACEF28C11442DE8F7 /* App_iOS */; }; + 9A8E6A7891C068692E5463B2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + platformFiltersByRelativePath = { + SyncedFile_iOS.swift = ( + ios, + ); + SyncedFile_tvOS.swift = ( + tvos, + ); + }; + target = C0570E2FB50D830D8D423396 /* App_supportedDestinations */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -869,6 +881,19 @@ path = SyncedFolder; sourceTree = ""; }; + E55B4EB82638DF874A0308EC /* SyncedSources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9A8E6A7891C068692E5463B2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + name = SyncedSources; + path = SyncedSources; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1448,6 +1473,7 @@ children = ( 85FEBB7D2103B020423407A2 /* Sources */, 69C61547C081D04364A5DE42 /* Storyboards */, + E55B4EB82638DF874A0308EC /* SyncedSources */, ); path = App_supportedDestinations; sourceTree = ""; @@ -2281,6 +2307,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + E55B4EB82638DF874A0308EC /* SyncedSources */, + ); name = App_supportedDestinations; packageProductDependencies = ( C7F9B7EDE85527EFEA85D46D /* Swinject */, diff --git a/Tests/Fixtures/TestProject/project.yml b/Tests/Fixtures/TestProject/project.yml index dbd4c317f..a826c30f8 100644 --- a/Tests/Fixtures/TestProject/project.yml +++ b/Tests/Fixtures/TestProject/project.yml @@ -257,6 +257,10 @@ targets: - path: App_supportedDestinations/Storyboards group: App_supportedDestinations destinationFilters: [iOS] + - path: App_supportedDestinations/SyncedSources + group: App_supportedDestinations + type: syncedFolder + inferDestinationFiltersByPath: true dependencies: - package: Swinject product: Swinject diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index f24ad71e4..5bcba9b16 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -353,6 +353,58 @@ class SourceGeneratorTests: XCTestCase { try expect(exceptions.contains("included.swift")) == false } + $0.it("adds destinationFilters as platform filters for synced folder files") { + let directories = """ + Sources: + - a.swift + - excluded.swift + - Nested: + - b.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", excludes: ["excluded.swift"], type: .syncedFolder, destinationFilters: [.iOS]) + let target = Target(name: "Test", type: .application, platform: .auto, supportedDestinations: [.iOS, .tvOS], 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) + try expect(exceptionSet.membershipExceptions) == ["excluded.swift"] + try expect(exceptionSet.platformFiltersByRelativePath) == [ + "a.swift": ["ios"], + "Nested/b.swift": ["ios"], + ] + } + + $0.it("infers platform filters by path for synced folder files") { + let directories = """ + Sources: + - common.swift + - File_tvOS.swift + - iOS: + - a.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", type: .syncedFolder, inferDestinationFiltersByPath: true) + let target = Target(name: "Test", type: .application, platform: .auto, supportedDestinations: [.iOS, .tvOS], 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) + try expect(exceptionSet.membershipExceptions) == nil + try expect(exceptionSet.platformFiltersByRelativePath) == [ + "File_tvOS.swift": ["tvos"], + "iOS/a.swift": ["ios"], + ] + } + $0.it("merges explicitFolders for synced folders across targets") { let directories = """ Sources: From 24fa6996889450c0d2674fb8a7073b6e31d4576c Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Mon, 14 Sep 2026 13:12:03 +0530 Subject: [PATCH 2/4] Use PR number in changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0af1c8fc..ec4bb5fba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Fixed - Fix `syncedFolder` source paths being relative to the spec directory instead of the project directory when they differ, which caused Xcode to treat the synced folder as empty #1636 @Ckitakishi -- Fix `destinationFilters` and `inferDestinationFiltersByPath` being ignored for `syncedFolder` sources. They are now written as `platformFiltersByRelativePath` on the synced folder's exception set #1646 @anandghegde +- Fix `destinationFilters` and `inferDestinationFiltersByPath` being ignored for `syncedFolder` sources. They are now written as `platformFiltersByRelativePath` on the synced folder's exception set #1647 @anandghegde - Fix nested target attributes (e.g. `attributes.SystemCapabilities`) being serialized as a stringified Swift `Dictionary` description instead of a proper nested plist dictionary, which also caused non-deterministic key ordering in generated `project.pbxproj` files across runs #1639 @imadaan @sergeyospanov ### Internal From a0a2012b37e638e553a7c6b3b4b82dfecfa86788 Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Thu, 15 Oct 2026 17:52:07 +0000 Subject: [PATCH 3/4] Filter explicit folders and localized files in synced folders Xcode treats an explicit folder as a single item, so give it one platform filter key instead of keys for the files inside it. Localized files are keyed by their variant group ("/Localized: Resources/Localizable.strings"), which Xcode honors for every language, through a helper that the membership exceptions now share. Also assert that membershipExceptions is nil instead of comparing it. --- Sources/XcodeGenKit/PBXProjGenerator.swift | 2 +- Sources/XcodeGenKit/SourceGenerator.swift | 26 ++++++-- .../SourceGeneratorTests.swift | 62 ++++++++++++++++++- 3 files changed, 83 insertions(+), 7 deletions(-) diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index fe9c05f1b..376efa0f1 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1492,7 +1492,7 @@ public class PBXProjGenerator { var exceptions: Set = Set( sourceGenerator.syncedFolderExceptions(for: targetSource, at: syncedPath) - .compactMap { try? $0.relativePath(from: syncedPath).string } + .compactMap { sourceGenerator.syncedFolderExceptionKey(for: $0, in: syncedPath) } ) for infoPlistPath in Set(infoPlistFiles.values) { diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index f0856a0d5..7b954620f 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -426,24 +426,40 @@ class SourceGenerator { return exceptions } - /// Returns the platform filters for each file in a synced folder, keyed by path relative to the synced folder. - /// Xcode only honors file paths in `platformFiltersByRelativePath`, so directories are expanded into their files. + /// Returns the key Xcode uses for a path in a synced folder's exception set. + /// Localized files are keyed by their variant group, e.g. `/Localized: Resources/Localizable.strings`, + /// since Xcode ignores per-language paths like `Resources/en.lproj/Localizable.strings`. + func syncedFolderExceptionKey(for path: Path, in syncedPath: Path) -> String? { + guard let relativePath = try? path.relativePath(from: syncedPath).string else { return nil } + + var components = relativePath.split(separator: "/") + guard let localizedIndex = components.firstIndex(where: { $0.hasSuffix(".lproj") }) else { return relativePath } + components.remove(at: localizedIndex) + return "/Localized: \(components.joined(separator: "/"))" + } + + /// Returns the platform filters for each item in a synced folder, keyed by `syncedFolderExceptionKey`. + /// Xcode ignores filters on plain folders, so they are expanded into their files. Explicit folders are + /// a single item to Xcode, so they get one key and their contents are ignored. func syncedFolderPlatformFilters(for targetSource: TargetSource, at syncedPath: Path) -> [String: [String]] { guard targetSource.destinationFilters?.isEmpty == false || targetSource.inferDestinationFiltersByPath == true else { return [:] } + let explicitFolders = Set(resolveExplicitFolders(targetSource: targetSource)) var platformFilters: [String: [String]] = [:] func findFiles(in path: Path) { guard let children = try? path.children() else { return } for child in children where !child.lastComponent.hasPrefix(".") { - if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) { + let isExplicitFolder = (try? child.relativePath(from: syncedPath).string).map(explicitFolders.contains) ?? false + + if child.isDirectory && !Xcode.isDirectoryFileWrapper(path: child) && !isExplicitFolder { findFiles(in: child) } else if let filters = makeDestinationFilters(for: child, with: targetSource.destinationFilters, or: targetSource.inferDestinationFiltersByPath), - let relativePath = try? child.relativePath(from: syncedPath) { - platformFilters[relativePath.string] = filters + let key = syncedFolderExceptionKey(for: child, in: syncedPath) { + platformFilters[key] = filters } } } diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 5bcba9b16..dc872b07e 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -398,13 +398,73 @@ class SourceGeneratorTests: XCTestCase { let syncedFolder = try unwrap(syncedFolders.first) let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet) - try expect(exceptionSet.membershipExceptions) == nil + try expect(exceptionSet.membershipExceptions).to.beNil() try expect(exceptionSet.platformFiltersByRelativePath) == [ "File_tvOS.swift": ["tvos"], "iOS/a.swift": ["ios"], ] } + $0.it("adds platform filters for explicit folders as a single item") { + let directories = """ + Sources: + - a.swift + - Bundle: + - data.txt + - Nested: + - more.txt + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", explicitFolders: ["Bundle"], type: .syncedFolder, destinationFilters: [.iOS]) + let target = Target(name: "Test", type: .application, platform: .auto, supportedDestinations: [.iOS, .macOS], 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) + try expect(exceptionSet.platformFiltersByRelativePath) == [ + "a.swift": ["ios"], + "Bundle": ["ios"], + ] + } + + $0.it("adds platform filters for localized files in the variant group form") { + let directories = """ + Sources: + - a.swift + - excluded.swift + - Resources: + - en.lproj: + - Localizable.strings + - Excluded.strings + - de.lproj: + - Localizable.strings + - Excluded.strings + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", excludes: ["excluded.swift", "Resources/*.lproj/Excluded.strings"], type: .syncedFolder, destinationFilters: [.iOS]) + let target = Target(name: "Test", type: .application, platform: .auto, supportedDestinations: [.iOS, .macOS], 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) + try expect(exceptionSet.membershipExceptions) == [ + "/Localized: Resources/Excluded.strings", + "excluded.swift", + ] + try expect(exceptionSet.platformFiltersByRelativePath) == [ + "a.swift": ["ios"], + "/Localized: Resources/Localizable.strings": ["ios"], + ] + } + $0.it("merges explicitFolders for synced folders across targets") { let directories = """ Sources: From 1c20421dc128f8a6e05a70898b4d53a68fc3e50b Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Thu, 15 Oct 2026 17:52:07 +0000 Subject: [PATCH 4/4] Document that synced folder destination filters are resolved at generation time --- Docs/ProjectSpec.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index c673fe1d1..382a9d544 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -532,6 +532,8 @@ A source can be provided via a string (the path) or an object of the form: - [ ] **explicitFolders**: **[String]** - Only valid for `syncedFolder` type. A list of global patterns in the same format as `excludes` to child folders that Xcode should treat as folder references. - [ ] **destinationFilters**: **[[Supported Destinations](#supported-destinations)]** - List of supported platform destinations the files should filter to. Defaults to all supported destinations. - [ ] **inferDestinationFiltersByPath**: **Bool** - This is a convenience filter that helps you to filter the files if their paths match these patterns `**//*` or `*_.swift`. Note, if you use `destinationFilters` this flag will be ignored. + +For `syncedFolder` sources, `destinationFilters` and `inferDestinationFiltersByPath` are resolved from the files present when the project is generated. A file added to the folder later is built for all supported destinations until the project is regenerated. - [ ] **createIntermediateGroups**: **Bool** - This overrides the value in [Options](#options). - [ ] **optional**: **Bool** - Disable missing path check. Defaults to false. - [ ] **buildPhase**: **String** - This manually sets the build phase this file or files in this directory will be added to, otherwise XcodeGen will guess based on the file extension. Note that `Info.plist` files will never be added to any build phases, no matter what this setting is. Possible values are: