Skip to content

Commit 0fcbd8a

Browse files
AlexNsbmrclaude
andcommitted
Fix membershipExceptions for localized files in synced folders
Xcode uses a `/Localized:` prefix format for membership exceptions of localized files in synced folders (e.g., `/Localized: Resources/AppShortcuts.strings`), rather than individual per-language paths (e.g., `Resources/de.lproj/AppShortcuts.strings`). Previously, XcodeGen generated per-language `.lproj` paths as membership exceptions, which Xcode silently ignored. This caused localized resources (`.strings` files inside `.lproj` directories) to remain as members of targets that should have excluded them via `includes`/`excludes` filters. This fix detects paths containing `.lproj` directory components and transforms them into the `/Localized:` variant-group format that Xcode expects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8d3d347 commit 0fcbd8a

File tree

2 files changed

+78
-1
lines changed

2 files changed

+78
-1
lines changed

Sources/XcodeGenKit/PBXProjGenerator.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1491,6 +1491,7 @@ public class PBXProjGenerator {
14911491
var exceptions: Set<String> = Set(
14921492
sourceGenerator.syncedFolderExceptions(for: targetSource, at: syncedPath)
14931493
.compactMap { try? $0.relativePath(from: syncedPath).string }
1494+
.map { syncedFolderLocalizedMembershipException($0) }
14941495
)
14951496

14961497
for infoPlistPath in Set(infoPlistFiles.values) {
@@ -1514,7 +1515,19 @@ public class PBXProjGenerator {
15141515
addObject(exceptionSet)
15151516
syncedGroup.exceptions = (syncedGroup.exceptions ?? []) + [exceptionSet]
15161517
}
1517-
1518+
1519+
/// In synced folders, Xcode expects localized file membership exceptions to use a
1520+
/// `/Localized:` prefix with the variant-group path (stripping the `.lproj` component), e.g.:
1521+
/// `Resources/de.lproj/Localizable.strings` → `/Localized: Resources/Localizable.strings`
1522+
private func syncedFolderLocalizedMembershipException(_ path: String) -> String {
1523+
var components = path.split(separator: "/").map(String.init)
1524+
guard let lprojIndex = components.firstIndex(where: { $0.hasSuffix(".lproj") }) else {
1525+
return path
1526+
}
1527+
components.remove(at: lprojIndex)
1528+
return "/Localized: " + components.joined(separator: "/")
1529+
}
1530+
15181531
private func makePlatformFilter(for filter: Dependency.PlatformFilter) -> String? {
15191532
switch filter {
15201533
case .all:

Tests/XcodeGenKitTests/SourceGeneratorTests.swift

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,70 @@ class SourceGeneratorTests: XCTestCase {
470470
try expect(exceptions.contains("c.swift")) == true
471471
}
472472

473+
$0.it("uses /Localized: prefix for lproj membership exceptions") {
474+
let directories = """
475+
Sources:
476+
- a.swift
477+
- Resources:
478+
- de.lproj:
479+
- Localizable.strings
480+
- AppShortcuts.strings
481+
- fr.lproj:
482+
- Localizable.strings
483+
- AppShortcuts.strings
484+
"""
485+
try createDirectories(directories)
486+
487+
let source = TargetSource(path: "Sources", includes: ["a.swift"], type: .syncedFolder)
488+
let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source])
489+
let project = Project(basePath: directoryPath, name: "Test", targets: [target])
490+
491+
let pbxProj = try project.generatePbxProj()
492+
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
493+
let syncedFolder = try unwrap(syncedFolders.first)
494+
495+
let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
496+
let exceptions = try unwrap(exceptionSet.membershipExceptions)
497+
498+
try expect(exceptions.contains("/Localized: Resources/Localizable.strings")) == true
499+
try expect(exceptions.contains("/Localized: Resources/AppShortcuts.strings")) == true
500+
try expect(exceptions.contains("Resources/de.lproj/Localizable.strings")) == false
501+
try expect(exceptions.contains("Resources/fr.lproj/Localizable.strings")) == false
502+
try expect(exceptions.contains("a.swift")) == false
503+
// de + fr variants deduplicate into one /Localized: entry per file
504+
let localizedCount = exceptions.filter { $0.hasPrefix("/Localized:") }.count
505+
try expect(localizedCount) == 2
506+
}
507+
508+
$0.it("preserves non-localized exceptions alongside /Localized: entries") {
509+
let directories = """
510+
Sources:
511+
- a.swift
512+
- b.swift
513+
- Resources:
514+
- de.lproj:
515+
- Localizable.strings
516+
- fr.lproj:
517+
- Localizable.strings
518+
"""
519+
try createDirectories(directories)
520+
521+
let source = TargetSource(path: "Sources", includes: ["a.swift"], type: .syncedFolder)
522+
let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source])
523+
let project = Project(basePath: directoryPath, name: "Test", targets: [target])
524+
525+
let pbxProj = try project.generatePbxProj()
526+
let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
527+
let syncedFolder = try unwrap(syncedFolders.first)
528+
529+
let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
530+
let exceptions = try unwrap(exceptionSet.membershipExceptions)
531+
532+
try expect(exceptions.contains("b.swift")) == true
533+
try expect(exceptions.contains("/Localized: Resources/Localizable.strings")) == true
534+
try expect(exceptions.contains("a.swift")) == false
535+
}
536+
473537
$0.it("deduplicates synced folders and both targets reference the same group object") {
474538
let directories = """
475539
Sources:

0 commit comments

Comments
 (0)