diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 59b28588..d6d391f6 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 @@ -109,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 { @@ -127,7 +138,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 ?? []) @@ -141,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 { @@ -227,9 +241,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 +301,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 +313,7 @@ class SourceGenerator { path: fileReferencePath.string ) ) - fileReferencesByPath[fileReferenceKey] = fileReference + fileReferencesByKey[fileReferenceKey] = fileReference return fileReference } } @@ -338,9 +368,23 @@ 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 - // 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 } @@ -365,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 @@ -384,8 +431,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 +442,7 @@ class SourceGenerator { name: path.lastComponent ) variantGroup = addObject(group) - variantGroupsByPath[path] = variantGroup + variantGroupsByPath[variantGroupPath] = variantGroup } return variantGroup } @@ -600,7 +649,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() @@ -609,7 +659,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, @@ -620,32 +673,55 @@ 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 + name: variantGroup != nil ? localisationName : filePath.lastComponent, + lastKnownFileType: isPlainDirectory(filePath) ? "folder" : nil, + 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..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: @@ -625,6 +702,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: @@ -688,6 +970,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: