From f7fa0abe601d9107b57ace968258da77640ef5a6 Mon Sep 17 00:00:00 2001 From: Erik Schwiebert Date: Fri, 21 Aug 2026 12:14:54 -0700 Subject: [PATCH 1/3] Keep localized variant groups separate between targets Localized resources can be shared by several targets, but each target may include a different set of languages. XcodeGen was reusing the same variant groups and file references across those targets. Since an Xcode file reference can only have one parent, processing a later target could pull a child away from an earlier group or leave a same-named group detached from the project hierarchy. Use a separate variant-group cache for each target, while still reusing groups across source entries within that target. Localized file references are now cached together with their owning variant group, while ordinary file references continue to be shared across the project. The new tests cover targets with both different and identical language selections, direct and localized uses of the same file, storyboard resources split across source entries, and the final relationships after writing and reopening the generated project. --- Sources/XcodeGenKit/SourceGenerator.swift | 61 +++- .../SourceGeneratorTests.swift | 268 ++++++++++++++++++ 2 files changed, 316 insertions(+), 13 deletions(-) diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 59b28588..7ba42182 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -13,9 +13,14 @@ struct SourceFile { class SourceGenerator { + private enum FileReferenceKey: Hashable { + case project(path: String) + case localized(variantGroup: ObjectIdentifier, path: String) + } + var rootGroups: Set = [] private let projectDirectory: Path? - private var fileReferencesByPath: [String: PBXFileElement] = [:] + private var fileReferencesByKey: [FileReferenceKey: PBXFileElement] = [:] private var groupsByPath: [Path: PBXGroup] = [:] private var variantGroupsByPath: [Path: PBXVariantGroup] = [:] private var syncedGroupsByPath: [String: PBXFileSystemSynchronizedRootGroup] = [:] @@ -94,7 +99,9 @@ class SourceGenerator { /// - sources: The array of sources defined as part of the targets spec. /// - buildPhases: A dictionary containing any build phases that should be applied to source files at specific paths in the event that the associated `TargetSource` didn't already define a `buildPhase`. Values from this dictionary are used in cases where the project generator knows more about a file than the spec/filesystem does (i.e if the file should be treated as the targets Info.plist and so on). func getAllSourceFiles(targetType: PBXProductType, sources: [TargetSource], buildPhases: [Path : BuildPhaseSpec]) throws -> [SourceFile] { - try sources.flatMap { try getSourceFiles(targetType: targetType, targetSource: $0, buildPhases: buildPhases) } + // Localized groups can select different languages in each target, so start each target with an empty cache. + variantGroupsByPath.removeAll() + return try sources.flatMap { try getSourceFiles(targetType: targetType, targetSource: $0, buildPhases: buildPhases) } } // get groups without build files. Use for Project.fileGroups @@ -127,7 +134,7 @@ class SourceGenerator { } func generateSourceFile(targetType: PBXProductType, targetSource: TargetSource, path: Path, fileReference: PBXFileElement? = nil, buildPhases: [Path: BuildPhaseSpec]) -> SourceFile { - let fileReference = fileReference ?? fileReferencesByPath[path.string.lowercased()]! + let fileReference = fileReference ?? fileReferencesByKey[.project(path: path.string.lowercased())]! var settings: [String: BuildFileSetting] = [:] let fileType = getFileType(path: path) var attributes: [String] = targetSource.attributes + (fileType?.attributes ?? []) @@ -227,9 +234,25 @@ class SourceGenerator { return fileReference } - func getFileReference(path: Path, inPath: Path, name: String? = nil, sourceTree: PBXSourceTree = .group, lastKnownFileType: String? = nil) -> PBXFileElement { - let fileReferenceKey = path.string.lowercased() - if let fileReference = fileReferencesByPath[fileReferenceKey] { + func getFileReference( + path: Path, + inPath: Path, + name: String? = nil, + sourceTree: PBXSourceTree = .group, + lastKnownFileType: String? = nil, + localizedIn variantGroup: PBXVariantGroup? = nil + ) -> PBXFileElement { + let normalizedPath = path.string.lowercased() + let fileReferenceKey: FileReferenceKey + if let variantGroup { + fileReferenceKey = .localized( + variantGroup: ObjectIdentifier(variantGroup), + path: normalizedPath + ) + } else { + fileReferenceKey = .project(path: normalizedPath) + } + if let fileReference = fileReferencesByKey[fileReferenceKey] { return fileReference } else { let fileReferencePath = (try? path.relativePath(from: inPath)) ?? path @@ -271,7 +294,7 @@ class SourceGenerator { versionGroupType: "wrapper.xcdatamodel", children: modelFileReferences )) - fileReferencesByPath[fileReferenceKey] = versionGroup + fileReferencesByKey[fileReferenceKey] = versionGroup return versionGroup } else { // For all extensions other than `xcdatamodeld` @@ -283,7 +306,7 @@ class SourceGenerator { path: fileReferencePath.string ) ) - fileReferencesByPath[fileReferenceKey] = fileReference + fileReferencesByKey[fileReferenceKey] = fileReference return fileReference } } @@ -339,8 +362,16 @@ class SourceGenerator { var cachedGroupChildren = cachedGroup.children for child in children { // only add the children that aren't already in the cachedGroup - // Check equality by path and sourceTree because XcodeProj.PBXObject.== is very slow. - if !cachedGroupChildren.contains(where: { $0.name == child.name && $0.path == child.path && $0.sourceTree == child.sourceTree }) { + // Variant groups with the same name may select different localizations for different + // targets, so only the same variant group object is a duplicate. + let alreadyContainsChild = cachedGroupChildren.contains { + if child is PBXVariantGroup { + return $0 === child + } + // Check equality by path and sourceTree because XcodeProj.PBXObject.== is very slow. + return $0.name == child.name && $0.path == child.path && $0.sourceTree == child.sourceTree + } + if !alreadyContainsChild { cachedGroupChildren.append(child) child.parent = cachedGroup } @@ -384,8 +415,10 @@ class SourceGenerator { /// Creates a variant group or returns an existing one at the path private func getVariantGroup(path: Path, inPath: Path) -> PBXVariantGroup { + // The base localization can vary by source entry, but the logical resource path remains the same. + let variantGroupPath = inPath + path.lastComponent let variantGroup: PBXVariantGroup - if let cachedGroup = variantGroupsByPath[path] { + if let cachedGroup = variantGroupsByPath[variantGroupPath] { variantGroup = cachedGroup } else { let group = PBXVariantGroup( @@ -393,7 +426,7 @@ class SourceGenerator { name: path.lastComponent ) variantGroup = addObject(group) - variantGroupsByPath[path] = variantGroup + variantGroupsByPath[variantGroupPath] = variantGroup } return variantGroup } @@ -640,12 +673,14 @@ class SourceGenerator { let fileReference = getFileReference( path: filePath, inPath: path, - name: variantGroup != nil ? localisationName : filePath.lastComponent + name: variantGroup != nil ? localisationName : filePath.lastComponent, + localizedIn: variantGroup ) if let variantGroup = variantGroup { if !variantGroup.children.contains(fileReference) { variantGroup.children.append(fileReference) + fileReference.parent = variantGroup } } else { // add SourceFile to group if there is no Base.lproj directory diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index b8450c5f..9fbf9f76 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -688,6 +688,274 @@ class SourceGeneratorTests: XCTestCase { } } + $0.it("keeps localized variant groups and children owned by their targets") { + // Both targets use the same physical resources but select different languages. They + // therefore need separate variant groups and child references under the shared group: + // + // Resources/ + // |-- Localizable.strings [English target] + // | `-- en -> en.lproj/Localizable.strings + // `-- Localizable.strings [AllLanguages target] + // |-- Base -> Base.lproj/Localizable.strings + // |-- de -> de.lproj/Localizable.strings + // `-- en -> en.lproj/Localizable.strings + // + // Both same-named variant groups must remain children of `Resources`; treating the + // second as a duplicate detaches it and leaves its localized paths without a base. + // A PBX file element can have only one parent, so even the two `en` children must be + // distinct objects owned by their respective variant groups. + let directories = """ + Resources: + Base.lproj: + - Localizable.strings + de.lproj: + - Localizable.strings + en.lproj: + - Localizable.strings + """ + try createDirectories(directories) + + let englishTarget = Target( + name: "English", + type: .application, + platform: .iOS, + sources: [ + TargetSource( + path: "Resources", + includes: ["en.lproj/Localizable.strings"] + ) + ] + ) + let allLanguagesTarget = Target( + name: "AllLanguages", + type: .application, + platform: .iOS, + sources: [ + TargetSource( + path: "Resources", + includes: [ + "Base.lproj/**", + "de.lproj/**", + "en.lproj/**", + ] + ) + ] + ) + let project = Project( + basePath: directoryPath, + name: "Test", + targets: [englishTarget, allLanguagesTarget] + ) + + let outputXcodeProj = try project.generateXcodeProject() + try outputXcodeProj.write(path: directoryPath) + let pbxProj = try XcodeProj(path: directoryPath).pbxproj + + func localizedVariantGroup(for targetName: String) throws -> PBXVariantGroup { + let target = try unwrap(pbxProj.nativeTargets.first { $0.name == targetName }) + let resources = try unwrap(target.buildPhases.compactMap { $0 as? PBXResourcesBuildPhase }.first) + return try unwrap( + resources.files? + .compactMap(\.file) + .first { $0.nameOrPath == "Localizable.strings" } as? PBXVariantGroup + ) + } + + let englishVariantGroup = try localizedVariantGroup(for: "English") + let allLanguagesVariantGroup = try localizedVariantGroup(for: "AllLanguages") + + try expect(englishVariantGroup === allLanguagesVariantGroup) == false + try expect(englishVariantGroup.parent == allLanguagesVariantGroup.parent) == true + let parentGroup = try unwrap(englishVariantGroup.parent as? PBXGroup) + try expect(parentGroup.children.contains { $0 === englishVariantGroup }) == true + try expect(parentGroup.children.contains { $0 === allLanguagesVariantGroup }) == true + try expect(englishVariantGroup.children.compactMap(\.name)) == ["en"] + try expect(allLanguagesVariantGroup.children.compactMap(\.name).sorted()) == ["Base", "de", "en"] + + for variantGroup in [englishVariantGroup, allLanguagesVariantGroup] { + for child in variantGroup.children { + try expect(child.parent === variantGroup) == true + } + } + + let englishReference = try unwrap(englishVariantGroup.children.first { $0.name == "en" }) + let allLanguagesEnglishReference = try unwrap(allLanguagesVariantGroup.children.first { $0.name == "en" }) + try expect(englishReference === allLanguagesEnglishReference) == false + } + + $0.it("keeps localized children owned across source entries") { + let directories = """ + Resources: + Base.lproj: + - LocalizedStoryboard.storyboard + en.lproj: + - LocalizedStoryboard.strings + """ + try createDirectories(directories) + + let target = Target( + name: "Test", + type: .application, + platform: .iOS, + sources: [ + TargetSource( + path: "Resources", + includes: [ + "Base.lproj/LocalizedStoryboard.storyboard", + "en.lproj/LocalizedStoryboard.strings", + ] + ), + TargetSource( + path: "Resources", + includes: ["en.lproj/LocalizedStoryboard.strings"] + ), + ] + ) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let storyboardGroup = try unwrap( + pbxProj.variantGroups.first { $0.name == "LocalizedStoryboard.storyboard" } + ) + let stringsGroup = try unwrap( + pbxProj.variantGroups.first { $0.name == "LocalizedStoryboard.strings" } + ) + + try expect(storyboardGroup.parent == stringsGroup.parent) == true + try expect(storyboardGroup.children.compactMap(\.name).sorted()) == ["Base", "en"] + try expect(stringsGroup.children.compactMap(\.name)) == ["en"] + for variantGroup in [storyboardGroup, stringsGroup] { + for child in variantGroup.children { + try expect(child.parent === variantGroup) == true + } + } + let storyboardStrings = try unwrap(storyboardGroup.children.first { $0.name == "en" }) + let localizedStrings = try unwrap(stringsGroup.children.first { $0.name == "en" }) + try expect(storyboardStrings === localizedStrings) == false + } + + $0.it("separates project and localized references to the same file") { + let directories = """ + Resources: + Base.lproj: + - Localizable.strings + en.lproj: + - Localizable.strings + """ + try createDirectories(directories) + + let standaloneTarget = Target( + name: "Standalone", + type: .application, + platform: .iOS, + sources: ["Resources/en.lproj/Localizable.strings"] + ) + let localizedTarget = Target( + name: "Localized", + type: .application, + platform: .iOS, + sources: ["Resources"] + ) + let project = Project( + basePath: directoryPath, + name: "Test", + targets: [standaloneTarget, localizedTarget] + ) + + let pbxProj = try project.generatePbxProj() + let standalone = try unwrap(pbxProj.nativeTargets.first { $0.name == "Standalone" }) + let standaloneResources = try unwrap( + standalone.buildPhases.compactMap { $0 as? PBXResourcesBuildPhase }.first + ) + let standaloneReference = try unwrap( + standaloneResources.files? + .compactMap(\.file) + .first { $0.nameOrPath == "Localizable.strings" } as? PBXFileReference + ) + + let localized = try unwrap(pbxProj.nativeTargets.first { $0.name == "Localized" }) + let localizedResources = try unwrap( + localized.buildPhases.compactMap { $0 as? PBXResourcesBuildPhase }.first + ) + let variantGroup = try unwrap( + localizedResources.files? + .compactMap(\.file) + .first { $0.nameOrPath == "Localizable.strings" } as? PBXVariantGroup + ) + let localizedReference = try unwrap( + variantGroup.children.first { $0.name == "en" } as? PBXFileReference + ) + + try expect(standaloneReference === localizedReference) == false + try expect(standaloneReference.parent === variantGroup) == false + try expect(localizedReference.parent === variantGroup) == true + } + + $0.it("keeps identical localization selections separate across targets") { + let directories = """ + Resources: + Base.lproj: + - Localizable.strings + en.lproj: + - Localizable.strings + """ + try createDirectories(directories) + + let firstTarget = Target( + name: "First", + type: .application, + platform: .iOS, + sources: ["Resources"] + ) + let secondTarget = Target( + name: "Second", + type: .application, + platform: .iOS, + sources: ["Resources"] + ) + let project = Project( + basePath: directoryPath, + name: "Test", + targets: [firstTarget, secondTarget] + ) + + let pbxProj = try project.generatePbxProj() + + func localizedVariantGroup(for targetName: String) throws -> PBXVariantGroup { + let target = try unwrap(pbxProj.nativeTargets.first { $0.name == targetName }) + let resources = try unwrap( + target.buildPhases.compactMap { $0 as? PBXResourcesBuildPhase }.first + ) + return try unwrap( + resources.files? + .compactMap(\.file) + .first { $0.nameOrPath == "Localizable.strings" } as? PBXVariantGroup + ) + } + + let firstVariantGroup = try localizedVariantGroup(for: "First") + let secondVariantGroup = try localizedVariantGroup(for: "Second") + + try expect(firstVariantGroup === secondVariantGroup) == false + try expect(firstVariantGroup.parent == secondVariantGroup.parent) == true + let parentGroup = try unwrap(firstVariantGroup.parent as? PBXGroup) + try expect(parentGroup.children.contains { $0 === firstVariantGroup }) == true + try expect(parentGroup.children.contains { $0 === secondVariantGroup }) == true + try expect(firstVariantGroup.children.compactMap(\.name).sorted()) == ["Base", "en"] + try expect(secondVariantGroup.children.compactMap(\.name).sorted()) == ["Base", "en"] + for language in ["Base", "en"] { + let firstReference = try unwrap( + firstVariantGroup.children.first { $0.name == language } + ) + let secondReference = try unwrap( + secondVariantGroup.children.first { $0.name == language } + ) + try expect(firstReference === secondReference) == false + try expect(firstReference.parent === firstVariantGroup) == true + try expect(secondReference.parent === secondVariantGroup) == true + } + } + $0.it("handles duplicate names") { let directories = """ Sources: From 5f93f5b211d287c744b2b8bfc80a8a3c88729752 Mon Sep 17 00:00:00 2001 From: Erik Schwiebert Date: Fri, 21 Aug 2026 12:37:21 -0700 Subject: [PATCH 2/3] Support localized folders in variant groups XcodeGen could create variant groups for localized files, but directories inside .lproj folders had no file type and were not added to the resources phase. This prevented resources such as localized Help folders from being copied by Xcode. Treat these directories as folder references and add their variant groups to the resources phase. Allow a non-base localization to create the group when necessary, while keeping folders separate from same-named localized files. Add coverage for serialized folder groups, mixed folder and XIB resources, target-specific language selections, folders missing from Base, and same-stem folder/file collisions. --- Sources/XcodeGenKit/SourceGenerator.swift | 46 +++- .../SourceGeneratorTests.swift | 205 ++++++++++++++++++ 2 files changed, 244 insertions(+), 7 deletions(-) diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 7ba42182..b8f9631b 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -116,6 +116,10 @@ class SourceGenerator { return nil } } + + private func isPlainDirectory(_ path: Path) -> Bool { + path.isDirectory && !Xcode.isDirectoryFileWrapper(path: path) + } private func makeDestinationFilters(for path: Path, with filters: [SupportedDestination]?, or inferDestinationFiltersByPath: Bool?) -> [String]? { if let filters = filters, !filters.isEmpty { @@ -148,6 +152,9 @@ class SourceGenerator { chosenBuildPhase = buildPhase } else if resolvedTargetSourceType(for: targetSource, at: path) == .folder { chosenBuildPhase = .resources + } else if fileReference is PBXVariantGroup, isPlainDirectory(path) { + // A directory discovered inside an .lproj is a folder resource even though its TargetSource is a group. + chosenBuildPhase = .resources } else if let buildPhase = buildPhases[path] { chosenBuildPhase = buildPhase } else { @@ -633,7 +640,8 @@ class SourceGenerator { knownRegions.formUnion(stringCatalogsLocales) // create variant groups of the base localisation first - var baseLocalisationVariantGroups: [PBXVariantGroup] = [] + var localisedVariantGroups: [PBXVariantGroup] = [] + var folderVariantGroups: Set = [] if let baseLocalisedDirectory = baseLocalisedDirectory { let filePaths = try baseLocalisedDirectory.children() @@ -642,7 +650,10 @@ class SourceGenerator { for filePath in filePaths { let variantGroup = getVariantGroup(path: filePath, inPath: path) groupChildren.append(variantGroup) - baseLocalisationVariantGroups.append(variantGroup) + localisedVariantGroups.append(variantGroup) + if isPlainDirectory(filePath) { + folderVariantGroups.insert(ObjectIdentifier(variantGroup)) + } let sourceFile = generateSourceFile(targetType: targetType, targetSource: targetSource, @@ -653,27 +664,48 @@ class SourceGenerator { } } - // add references to localised resources into base localisation variant groups + // add references to localised resources into their variant groups for localisedDirectory in localisedDirectories { let localisationName = localisedDirectory.lastComponentWithoutExtension let filePaths = try localisedDirectory.children() .filter { self.isIncludedPath($0, excludePaths: excludePaths, includePaths: includePaths) } .sorted { $0.lastComponent < $1.lastComponent } for filePath in filePaths { - // find base localisation variant group + // find matching localisation variant group // ex: Foo.strings will be added to Foo.strings or Foo.storyboard variant group - let variantGroup = baseLocalisationVariantGroups + var variantGroup = localisedVariantGroups .first { Path($0.name!).lastComponent == filePath.lastComponent - } ?? baseLocalisationVariantGroups.first { - Path($0.name!).lastComponentWithoutExtension == filePath.lastComponentWithoutExtension + } ?? localisedVariantGroups.first { + !isPlainDirectory(filePath) && + !folderVariantGroups.contains(ObjectIdentifier($0)) && + Path($0.name!).lastComponentWithoutExtension == filePath.lastComponentWithoutExtension } + // The folder might not be in the base localization, so create its variant group here if necessary. + if variantGroup == nil, isPlainDirectory(filePath) { + let folderVariantGroup = getVariantGroup(path: filePath, inPath: path) + groupChildren.append(folderVariantGroup) + localisedVariantGroups.append(folderVariantGroup) + folderVariantGroups.insert(ObjectIdentifier(folderVariantGroup)) + allSourceFiles.append( + generateSourceFile( + targetType: targetType, + targetSource: targetSource, + path: filePath, + fileReference: folderVariantGroup, + buildPhases: buildPhases + ) + ) + variantGroup = folderVariantGroup + } + let fileReference = getFileReference( path: filePath, inPath: path, name: variantGroup != nil ? localisationName : filePath.lastComponent, + lastKnownFileType: isPlainDirectory(filePath) ? "folder" : nil, localizedIn: variantGroup ) diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 9fbf9f76..8a0df890 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -625,6 +625,211 @@ class SourceGeneratorTests: XCTestCase { } } + $0.it("generates localized folder variant groups") { + // Plain directories inside localization folders are opaque folder references: + // + // Resources/ + // |-- Base.lproj/ + // | |-- Help/ + // | | `-- index.html + // | `-- View.xib + // `-- en.lproj/ + // |-- Help/ + // | `-- index.html + // `-- View.xib + // + // Help + // |-- Base -> Base.lproj/Help [folder] + // `-- en -> en.lproj/Help [folder] + // + // Xcode copies each Help directory as a unit, while View.xib remains an ordinary + // localized file that Xcode compiles. + let directories = """ + Resources: + Base.lproj: + - Help: + - index.html + - View.xib + en.lproj: + - Help: + - index.html + - View.xib + """ + try createDirectories(directories) + + let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Resources"]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let outputXcodeProj = try project.generateXcodeProject() + try outputXcodeProj.write(path: directoryPath) + let pbxProj = try XcodeProj(path: directoryPath).pbxproj + let nativeTarget = try unwrap(pbxProj.nativeTargets.first { $0.name == "Test" }) + let resources = try unwrap( + nativeTarget.buildPhases.compactMap { $0 as? PBXResourcesBuildPhase }.first + ) + let resourceElements = resources.files?.compactMap(\.file) ?? [] + let helpGroup = try unwrap( + resourceElements.first { $0.nameOrPath == "Help" } as? PBXVariantGroup + ) + let viewGroup = try unwrap( + resourceElements.first { $0.nameOrPath == "View.xib" } as? PBXVariantGroup + ) + + try expect(helpGroup.children.compactMap(\.name).sorted()) == ["Base", "en"] + for child in helpGroup.children { + let language = try unwrap(child.name) + try expect(child.path) == "\(language).lproj/Help" + try expect((child as? PBXFileReference)?.lastKnownFileType) == "folder" + try expect(child.parent === helpGroup) == true + } + + try expect(viewGroup.children.compactMap(\.name).sorted()) == ["Base", "en"] + try expect(pbxProj.fileReferences.contains { $0.path?.hasSuffix("Help/index.html") == true }) == false + } + + $0.it("keeps localized folder selections separate across targets") { + let directories = """ + Resources: + Base.lproj: + - Help: + - index.html + de.lproj: + - Help: + - index.html + en.lproj: + - Help: + - index.html + """ + try createDirectories(directories) + + let germanTarget = Target( + name: "German", + type: .application, + platform: .iOS, + sources: [ + TargetSource(path: "Resources", includes: ["de.lproj/Help/**"]) + ] + ) + let allLanguagesTarget = Target( + name: "AllLanguages", + type: .application, + platform: .iOS, + sources: ["Resources"] + ) + let project = Project( + basePath: directoryPath, + name: "Test", + targets: [germanTarget, allLanguagesTarget] + ) + + let pbxProj = try project.generatePbxProj() + + func helpVariantGroup(for targetName: String) throws -> PBXVariantGroup { + let target = try unwrap(pbxProj.nativeTargets.first { $0.name == targetName }) + let resources = try unwrap( + target.buildPhases.compactMap { $0 as? PBXResourcesBuildPhase }.first + ) + return try unwrap( + resources.files? + .compactMap(\.file) + .first { $0.nameOrPath == "Help" } as? PBXVariantGroup + ) + } + + let germanHelp = try helpVariantGroup(for: "German") + let allLanguagesHelp = try helpVariantGroup(for: "AllLanguages") + + try expect(germanHelp === allLanguagesHelp) == false + try expect(germanHelp.children.compactMap(\.name)) == ["de"] + try expect(allLanguagesHelp.children.compactMap(\.name).sorted()) == ["Base", "de", "en"] + for variantGroup in [germanHelp, allLanguagesHelp] { + for child in variantGroup.children { + try expect((child as? PBXFileReference)?.lastKnownFileType) == "folder" + try expect(child.parent === variantGroup) == true + } + } + } + + $0.it("generates localized folder variant groups missing from the base localization") { + let directories = """ + Resources: + Base.lproj: + - Localizable.strings + de.lproj: + - Help: + - index.html + en.lproj: + - Help: + - index.html + - Localizable.strings + """ + try createDirectories(directories) + + let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Resources"]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let nativeTarget = try unwrap(pbxProj.nativeTargets.first { $0.name == "Test" }) + let resources = try unwrap( + nativeTarget.buildPhases.compactMap { $0 as? PBXResourcesBuildPhase }.first + ) + let helpGroup = try unwrap( + resources.files? + .compactMap(\.file) + .first { $0.nameOrPath == "Help" } as? PBXVariantGroup + ) + + try expect(helpGroup.children.compactMap(\.name).sorted()) == ["de", "en"] + for child in helpGroup.children { + let language = try unwrap(child.name) + try expect(child.path) == "\(language).lproj/Help" + try expect((child as? PBXFileReference)?.lastKnownFileType) == "folder" + try expect(child.parent === helpGroup) == true + } + } + + $0.it("does not match localized folders with same-stem files") { + let directories = """ + Resources: + Base.lproj: + - Help: + - index.html + - Guide.strings + de.lproj: + - Help.strings + - Guide: + - index.html + """ + try createDirectories(directories) + + let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Resources"]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let nativeTarget = try unwrap(pbxProj.nativeTargets.first { $0.name == "Test" }) + let resources = try unwrap( + nativeTarget.buildPhases.compactMap { $0 as? PBXResourcesBuildPhase }.first + ) + let resourceElements = resources.files?.compactMap(\.file) ?? [] + let helpGroup = try unwrap( + resourceElements.first { $0.nameOrPath == "Help" } as? PBXVariantGroup + ) + let guideGroup = try unwrap( + resourceElements.first { $0.nameOrPath == "Guide" } as? PBXVariantGroup + ) + let guideStringsGroup = try unwrap( + resourceElements.first { $0.nameOrPath == "Guide.strings" } as? PBXVariantGroup + ) + let helpStrings = try unwrap( + resourceElements.first { $0.nameOrPath == "Help.strings" } as? PBXFileReference + ) + + try expect(helpGroup.children.compactMap(\.name)) == ["Base"] + try expect(guideGroup.children.compactMap(\.name)) == ["de"] + try expect(guideStringsGroup.children.compactMap(\.name)) == ["Base"] + try expect(helpStrings.path) == "de.lproj/Help.strings" + } + $0.it("handles localized resources") { let directories = """ App: From 195ac1865c8326cb14f0ca328b331bfad759b4fa Mon Sep 17 00:00:00 2001 From: Erik Schwiebert Date: Sat, 22 Aug 2026 16:20:29 -0700 Subject: [PATCH 3/3] Prevent cached groups from being reparented Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Sources/XcodeGenKit/SourceGenerator.swift | 11 ++- .../SourceGeneratorTests.swift | 77 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index b8f9631b..d6d391f6 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -368,6 +368,12 @@ class SourceGenerator { if let cachedGroup = groupsByPath[path] { var cachedGroupChildren = cachedGroup.children for child in children { + guard !(child is PBXGroup) + || (!rootGroups.contains(child) + && (child.parent == nil || child.parent === cachedGroup)) else { + continue + } + // only add the children that aren't already in the cachedGroup // Variant groups with the same name may select different localizations for different // targets, so only the same variant group object is a duplicate. @@ -403,9 +409,12 @@ class SourceGenerator { let groupName = name ?? path.lastComponent let groupPath = resolveGroupPath(path, isTopLevelGroup: hasCustomParent || isTopLevelGroup) + let unattachedChildren = children.filter { + !($0 is PBXGroup) || (!rootGroups.contains($0) && $0.parent == nil) + } let group = PBXGroup( - children: children, + children: unattachedChildren, sourceTree: .group, name: groupName != groupPath ? groupName : nil, path: groupPath diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 8a0df890..25a0f814 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -84,6 +84,83 @@ class SourceGeneratorTests: XCTestCase { try pbxProj.expectFile(paths: ["Sources", "A", "C2.0", "c.swift"], buildPhase: .sources) } + $0.it("does not reparent a top-level group discovered by a broader source") { + let directories = """ + Sources: + Resources: + - Localizable.strings + """ + try createDirectories(directories) + + let resourcesTarget = Target( + name: "Resources", + type: .bundle, + platform: .iOS, + sources: ["Sources/Resources"] + ) + let broadTarget = Target( + name: "Broad", + type: .application, + platform: .iOS, + sources: ["Sources"] + ) + let project = Project( + basePath: directoryPath, + name: "Test", + targets: [resourcesTarget, broadTarget] + ) + + let pbxProj = try project.generatePbxProj() + let mainGroup = try pbxProj.getMainGroup() + let resourcesGroups = pbxProj.groups.filter { $0.nameOrPath == "Resources" } + let resourcesGroup = try unwrap(resourcesGroups.first) + + try expect(resourcesGroups.count) == 1 + try expect(resourcesGroup.path) == "Sources/Resources" + try expect(mainGroup.children.contains { $0 === resourcesGroup }) == true + try expect( + pbxProj.groups + .first { $0.nameOrPath == "Sources" }? + .children + .contains { $0 === resourcesGroup } + ) == false + } + + $0.it("preserves an explicit custom group for a top-level folder reference") { + let directories = """ + Folder: + - resource.txt + """ + try createDirectories(directories) + + let target = Target( + name: "Test", + type: .application, + platform: .iOS, + sources: [ + TargetSource( + path: "Folder", + group: "CustomGroup", + type: .folder, + buildPhase: BuildPhaseSpec.none + ), + ] + ) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let mainGroup = try pbxProj.getMainGroup() + let customGroup = try unwrap( + pbxProj.groups.first { $0.nameOrPath == "CustomGroup" } + ) + let folderReference = try unwrap( + pbxProj.fileReferences.first { $0.nameOrPath == "Folder" } + ) + + try expect(mainGroup.children.contains { $0 === folderReference }) == true + try expect(customGroup.children.contains { $0 === folderReference }) == true + } + $0.it("generates synced folder") { let directories = """ Sources: