diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index 1f20e850..cef9c565 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1491,6 +1491,7 @@ public class PBXProjGenerator { var exceptions: Set = Set( sourceGenerator.syncedFolderExceptions(for: targetSource, at: syncedPath) .compactMap { try? $0.relativePath(from: syncedPath).string } + .map { syncedFolderLocalizedMembershipException($0) } ) for infoPlistPath in Set(infoPlistFiles.values) { @@ -1514,7 +1515,19 @@ public class PBXProjGenerator { addObject(exceptionSet) syncedGroup.exceptions = (syncedGroup.exceptions ?? []) + [exceptionSet] } - + + /// In synced folders, Xcode expects localized file membership exceptions to use a + /// `/Localized:` prefix with the variant-group path (stripping the `.lproj` component), e.g.: + /// `Resources/de.lproj/Localizable.strings` → `/Localized: Resources/Localizable.strings` + private func syncedFolderLocalizedMembershipException(_ path: String) -> String { + var components = path.split(separator: "/").map(String.init) + guard let lprojIndex = components.firstIndex(where: { $0.hasSuffix(".lproj") }) else { + return path + } + components.remove(at: lprojIndex) + return "/Localized: " + components.joined(separator: "/") + } + private func makePlatformFilter(for filter: Dependency.PlatformFilter) -> String? { switch filter { case .all: diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index b8450c5f..4f5a93f2 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -470,6 +470,70 @@ class SourceGeneratorTests: XCTestCase { try expect(exceptions.contains("c.swift")) == true } + $0.it("uses /Localized: prefix for lproj membership exceptions") { + let directories = """ + Sources: + - a.swift + - Resources: + - de.lproj: + - Localizable.strings + - AppShortcuts.strings + - fr.lproj: + - Localizable.strings + - AppShortcuts.strings + """ + 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) + + try expect(exceptions.contains("/Localized: Resources/Localizable.strings")) == true + try expect(exceptions.contains("/Localized: Resources/AppShortcuts.strings")) == true + try expect(exceptions.contains("Resources/de.lproj/Localizable.strings")) == false + try expect(exceptions.contains("Resources/fr.lproj/Localizable.strings")) == false + try expect(exceptions.contains("a.swift")) == false + // de + fr variants deduplicate into one /Localized: entry per file + let localizedCount = exceptions.filter { $0.hasPrefix("/Localized:") }.count + try expect(localizedCount) == 2 + } + + $0.it("preserves non-localized exceptions alongside /Localized: entries") { + let directories = """ + Sources: + - a.swift + - b.swift + - Resources: + - de.lproj: + - Localizable.strings + - fr.lproj: + - Localizable.strings + """ + 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) + + try expect(exceptions.contains("b.swift")) == true + try expect(exceptions.contains("/Localized: Resources/Localizable.strings")) == true + try expect(exceptions.contains("a.swift")) == false + } + $0.it("deduplicates synced folders and both targets reference the same group object") { let directories = """ Sources: