diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa70da5b..ae5d0a6f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,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 #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 - Fix `.DS_Store` and `.orig` files causing projects to be regenerated when using `--use-cache` #1622 @kimjune01 - Fix generated "Copy Bundle Resources" phases missing `dstPath`, which caused Xcode 27.2 project conversion to fail #1654 @Ckitakishi 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: diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index c2a6b6a4a..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) { @@ -1503,15 +1503,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 d07737aef..7b954620f 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -426,6 +426,48 @@ class SourceGenerator { return exceptions } + /// 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(".") { + 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 key = syncedFolderExceptionKey(for: child, in: syncedPath) { + platformFilters[key] = 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 d0c7cbcb8..babf0aad1 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -842,6 +842,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 */ @@ -870,6 +882,19 @@ path = SyncedFolder; sourceTree = ""; }; + E55B4EB82638DF874A0308EC /* SyncedSources */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 9A8E6A7891C068692E5463B2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + name = SyncedSources; + path = SyncedSources; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1449,6 +1474,7 @@ children = ( 85FEBB7D2103B020423407A2 /* Sources */, 69C61547C081D04364A5DE42 /* Storyboards */, + E55B4EB82638DF874A0308EC /* SyncedSources */, ); path = App_supportedDestinations; sourceTree = ""; @@ -2282,6 +2308,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..dc872b07e 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -353,6 +353,118 @@ 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).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: