import PathKit
import ProjectSpec
import Spectre
@testable import XcodeGenKit
import XcodeProj
import XCTest
import Yams
import TestSupport

class SourceGeneratorTests: XCTestCase {

    func testSourceGenerator() throws {
        try skipIfNecessary()
        describe {

            let directoryPath = Path("TestDirectory")
            let outOfRootPath = Path("OtherDirectory")

            func createDirectories(_ directories: String) throws {

                let yaml = try Yams.load(yaml: directories)!

                func getFiles(_ file: Any, path: Path) -> [Path] {
                    if let array = file as? [Any] {
                        return array.flatMap { getFiles($0, path: path) }
                    } else if let string = file as? String {
                        return [path + string]
                    } else if let dictionary = file as? [String: Any] {
                        var array: [Path] = []
                        for (key, value) in dictionary {
                            array += getFiles(value, path: path + key)
                        }
                        return array
                    } else {
                        return []
                    }
                }

                let files = getFiles(yaml, path: directoryPath).filter { $0.extension != nil }
                for file in files {
                    try file.parent().mkpath()
                    try file.write("")
                }
            }
            
            func createFile(at relativePath: Path, content: String) throws -> Path {
                let filePath = directoryPath + relativePath
                try filePath.parent().mkpath()
                try filePath.write(content)
                return filePath
            }

            func removeDirectories() {
                try? directoryPath.delete()
                try? outOfRootPath.delete()
            }

            $0.before {
                removeDirectories()
            }

            $0.after {
                removeDirectories()
            }

            $0.it("generates source groups") {
                let directories = """
                Sources:
                  A:
                    - a.swift
                    - B:
                      - b.swift
                    - C2.0:
                      - c.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Sources"])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources", "A", "a.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources", "A", "B", "b.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources", "A", "C2.0", "c.swift"], buildPhase: .sources)
            }

            $0.it("generates synced folder") {
                let directories = """
                Sources:
                  A:
                    - a.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [.init(path: "Sources", type: .syncedFolder)])
                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)

                try expect([syncedFolder]) == pbxProj.nativeTargets.first?.fileSystemSynchronizedGroups
            }

            $0.it("generates synced folder with explicitFolders") {
                let directories = """
                Sources:
                  Images:
                    - image.png
                  MainSuite:
                    FeatureATests:
                      - __Snapshots__:
                        - snap.png
                    FeatureBTests:
                      - __Snapshots__:
                        - snap.png
                  NotATest:
                    - file.swift
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", explicitFolders: ["Images", "**/*Tests"], 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)

                try expect(syncedFolder.explicitFolders) == ["Images", "MainSuite/FeatureATests", "MainSuite/FeatureBTests"]
            }

            $0.it("generates synced folder with createIntermediateGroups") {
                let directories = """
                Parent:
                  Child:
                    - a.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [.init(path: "Parent/Child", type: .syncedFolder)])
                let options = SpecOptions(createIntermediateGroups: true)
                let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options)

                let pbxProj = try project.generatePbxProj()
                let mainGroup = try pbxProj.getMainGroup()

                let rootSyncedFolders = mainGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
                try expect(rootSyncedFolders.count) == 0

                let parentGroup = try unwrap(mainGroup.children.compactMap({ $0 as? PBXGroup }).first(where: { $0.nameOrPath == "Parent" }))
                let nestedSyncedFolders = parentGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
                let syncedFolder = try unwrap(nestedSyncedFolders.first)

                try expect(syncedFolder.path) == "Child"
                try expect([syncedFolder]) == pbxProj.nativeTargets.first?.fileSystemSynchronizedGroups
            }

            $0.it("respects defaultSourceDirectoryType") {
                let directories = """
                Sources:
                  A:
                    - a.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Sources"])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: .init(defaultSourceDirectoryType: .syncedFolder))

                let pbxProj = try project.generatePbxProj()
                let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
                let syncedFolder = try unwrap(syncedFolders.first)

                try expect([syncedFolder]) == pbxProj.nativeTargets.first?.fileSystemSynchronizedGroups
            }

            $0.it("adds excludes as membership exceptions for synced folder") {
                let directories = """
                Sources:
                  - a.swift
                  - b.swift
                  - Generated:
                    - c.generated.swift
                    - d.generated.swift
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", excludes: ["b.swift", "Generated/*.generated.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 exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }
                let exceptionSet = try unwrap(exceptionSets?.first)
                let exceptions = try unwrap(exceptionSet.membershipExceptions)

                try expect(exceptions.contains("b.swift")) == true
                try expect(exceptions.contains("Generated/c.generated.swift")) == true
                try expect(exceptions.contains("Generated/d.generated.swift")) == true
                try expect(exceptions.contains("a.swift")) == false
            }

            $0.it("adds membership exceptions for nested synced folder with intermediate groups") {
                let directories = """
                Sources:
                  Nested:
                    - a.swift
                    - b.swift
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources/Nested", excludes: ["b.swift"], type: .syncedFolder)
                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: .init(createIntermediateGroups: true))

                let pbxProj = try project.generatePbxProj()
                let sourcesGroup = try unwrap(try pbxProj.getMainGroup().children.first { $0.nameOrPath == "Sources" } as? PBXGroup)
                let syncedFolder = try unwrap(sourcesGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }.first)

                let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet)
                let exceptions = try unwrap(exceptionSet.membershipExceptions)

                try expect(exceptions) == ["b.swift"]
            }

            $0.it("auto-excludes Info.plist from synced folder membership") {
                let directories = """
                Sources:
                  - a.swift
                  - Info.plist
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", type: .syncedFolder)
                let target = Target(
                    name: "Test",
                    type: .application,
                    platform: .iOS,
                    settings: try Settings(jsonDictionary: ["INFOPLIST_FILE": "Sources/Info.plist"]),
                    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 exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }
                let exceptionSet = try unwrap(exceptionSets?.first)
                let exceptions = try unwrap(exceptionSet.membershipExceptions)

                try expect(exceptions.contains("Info.plist")) == true
            }

            $0.it("creates no exception set for synced folder without excludes") {
                let directories = """
                Sources:
                  - a.swift
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", 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)

                try expect(syncedFolder.exceptions?.isEmpty ?? true) == true
            }

            $0.it("adds empty resources build phase for synced folder") {
                let directories = """
                Sources:
                  - a.swift
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", 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 nativeTarget = try unwrap(pbxProj.nativeTargets.first)
                let hasResourcesPhase = nativeTarget.buildPhases.contains { $0 is PBXResourcesBuildPhase }
                try expect(hasResourcesPhase) == true
            }

            $0.it("deduplicates synced folders across targets") {
                let directories = """
                Sources:
                  - a.swift
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", type: .syncedFolder)
                let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source])
                let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])

                let pbxProj = try project.generatePbxProj()
                let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }

                try expect(syncedFolders.count) == 1
            }

            $0.it("supports includes for synced folders") {
                let directories = """
                Sources:
                  - included.swift
                  - excluded.swift
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", includes: ["included.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 exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }
                let exceptionSet = try unwrap(exceptionSets?.first)
                let exceptions = try unwrap(exceptionSet.membershipExceptions)

                try expect(exceptions.contains("excluded.swift")) == true
                try expect(exceptions.contains("included.swift")) == false
            }

            $0.it("merges explicitFolders for synced folders across targets") {
                let directories = """
                Sources:
                  - a.swift
                  - FolderA:
                    - b.swift
                  - FolderB:
                    - c.swift
                """
                try createDirectories(directories)

                let source1 = TargetSource(path: "Sources", explicitFolders: ["FolderA"], type: .syncedFolder)
                let source2 = TargetSource(path: "Sources", explicitFolders: ["FolderB"], type: .syncedFolder)
                let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source1])
                let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source2])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])

                let pbxProj = try project.generatePbxProj()
                let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
                let syncedFolder = try unwrap(syncedFolders.first)

                try expect(syncedFolder.explicitFolders?.sorted()) == ["FolderA", "FolderB"]
            }

            $0.it("supports different includes for the same synced folder across targets") {
                let directories = """
                Sources:
                  - target1.swift
                  - target2.swift
                  - common.swift
                """
                try createDirectories(directories)

                let source1 = TargetSource(path: "Sources", includes: ["target1.swift", "common.swift"], type: .syncedFolder)
                let source2 = TargetSource(path: "Sources", includes: ["target2.swift", "common.swift"], type: .syncedFolder)
                let target1 = Target(name: "Target1", type: .application, platform: .iOS, sources: [source1])
                let target2 = Target(name: "Target2", type: .application, platform: .iOS, sources: [source2])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])

                let pbxProj = try project.generatePbxProj()
                let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
                let syncedFolder = try unwrap(syncedFolders.first)

                let exceptionSets = syncedFolder.exceptions?.compactMap { $0 as? PBXFileSystemSynchronizedBuildFileExceptionSet }
                try expect(exceptionSets?.count) == 2

                let t1Exceptions = try unwrap(exceptionSets?.first { $0.target?.name == "Target1" }?.membershipExceptions)
                try expect(t1Exceptions.contains("target2.swift")) == true
                try expect(t1Exceptions.contains("target1.swift")) == false
                try expect(t1Exceptions.contains("common.swift")) == false

                let t2Exceptions = try unwrap(exceptionSets?.first { $0.target?.name == "Target2" }?.membershipExceptions)
                try expect(t2Exceptions.contains("target1.swift")) == true
                try expect(t2Exceptions.contains("target2.swift")) == false
                try expect(t2Exceptions.contains("common.swift")) == false
            }

            $0.it("correctly identifies exceptions for nested directories in includes") {
                let directories = """
                Sources:
                  - a.swift
                  - Nested:
                    - b.swift
                    - c.swift
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", includes: ["Nested/b.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("a.swift")) == true
                try expect(exceptions.contains("Nested/c.swift")) == true
                try expect(exceptions.contains("Nested/b.swift")) == false
            }

            $0.it("excludes individual files in subdirectory when no files in it are included") {
                let directories = """
                Sources:
                  - a.swift
                  - ExcludedDir:
                    - x.swift
                    - y.swift
                """
                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)

                // Xcode does not recursively exclude directory contents from membershipExceptions,
                // so individual files must be listed instead of the directory name
                try expect(exceptions.contains("ExcludedDir")) == false
                try expect(exceptions.contains("ExcludedDir/x.swift")) == true
                try expect(exceptions.contains("ExcludedDir/y.swift")) == true
                try expect(exceptions.contains("a.swift")) == false
            }

            $0.it("respects excludes when includes are also specified") {
                let directories = """
                Sources:
                  - a.swift
                  - b.swift
                  - c.swift
                """
                try createDirectories(directories)

                // includes a.swift and b.swift, but b.swift is also excluded → only a.swift is effectively included
                let source = TargetSource(path: "Sources", excludes: ["b.swift"], includes: ["a.swift", "b.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("a.swift")) == false
                try expect(exceptions.contains("b.swift")) == true
                try expect(exceptions.contains("c.swift")) == true
            }

            $0.it("deduplicates synced folders and both targets reference the same group object") {
                let directories = """
                Sources:
                  - a.swift
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", type: .syncedFolder)
                let target1 = Target(name: "App", type: .application, platform: .iOS, sources: [source])
                let target2 = Target(name: "Tests", type: .unitTestBundle, platform: .iOS, sources: [source])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])

                let pbxProj = try project.generatePbxProj()
                let nativeTargets = pbxProj.nativeTargets
                let appTarget = try unwrap(nativeTargets.first { $0.name == "App" })
                let testsTarget = try unwrap(nativeTargets.first { $0.name == "Tests" })

                let appGroup = try unwrap(appTarget.fileSystemSynchronizedGroups?.first)
                let testsGroup = try unwrap(testsTarget.fileSystemSynchronizedGroups?.first)
                try expect(appGroup === testsGroup) == true
            }

            $0.it("does not create duplicate group for configFiles inside synced folder") {
                let directories = """
                Sources:
                  - a.swift
                  - Config:
                    - config.xcconfig
                """
                try createDirectories(directories)

                let source = TargetSource(path: "Sources", type: .syncedFolder)
                let target = Target(name: "Target1", type: .application, platform: .iOS, sources: [source])
                let project = Project(
                    basePath: directoryPath,
                    name: "Test",
                    targets: [target],
                    configFiles: ["Debug": "Sources/Config/config.xcconfig"]
                )

                let pbxProj = try project.generatePbxProj()
                let mainGroup = try pbxProj.getMainGroup()

                let sourcesChildren = mainGroup.children.filter { $0.path == "Sources" || $0.name == "Sources" }
                try expect(sourcesChildren.count) == 1

                let syncedFolders = mainGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }
                try expect(syncedFolders.count) == 1
            }

            $0.it("supports frameworks in sources") {
                let directories = """
                Sources:
                  - Foo.framework
                  - Bar.swift
                """

                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Sources"])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])
                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources", "Bar.swift"], buildPhase: .sources)
                let buildPhase = pbxProj.copyFilesBuildPhases.first
                try expect(buildPhase?.dstSubfolderSpec) == .frameworks
                let fileReference = pbxProj.getFileReference(
                    paths: ["Sources", "Foo.framework"],
                    names: ["Sources", "Foo.framework"]
                )
                let buildFile = try unwrap(pbxProj.buildFiles
                    .first(where: { $0.file == fileReference }))
                try expect(buildPhase?.files?.count) == 1
                try expect(buildPhase?.files?.contains(buildFile)) == true
            }

            $0.it("generates core data models") {
                let directories = """
                Sources:
                    model.xcdatamodeld:
                        - .xccurrentversion
                        - model.xcdatamodel
                        - model1.xcdatamodel
                        - model2.xcdatamodel
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Sources"])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                let fileReference = try unwrap(pbxProj.fileReferences.first(where: { $0.nameOrPath == "model2.xcdatamodel" }))
                let versionGroup = try unwrap(pbxProj.versionGroups.first)
                try expect(versionGroup.currentVersion) == fileReference
                try expect(versionGroup.children.count) == 3
                try expect(versionGroup.path) == "model.xcdatamodeld"
                try expect(fileReference.path) == "model2.xcdatamodel"
            }

            $0.it("generates core data mapping models") {
                let directories = """
                Sources:
                    model.xcmappingmodel:
                        - xcmapping.xml
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Sources"])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources", "model.xcmappingmodel"], buildPhase: .sources)
            }

            $0.it("generates variant groups") {
                let directories = """
                Sources:
                    Base.lproj:
                        - LocalizedStoryboard.storyboard
                    en.lproj:
                        - LocalizedStoryboard.strings
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Sources"])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()

                func getFileReferences(_ path: String) -> [PBXFileReference] {
                    pbxProj.fileReferences.filter { $0.path == path }
                }

                func getVariableGroups(_ name: String?) -> [PBXVariantGroup] {
                    pbxProj.variantGroups.filter { $0.name == name }
                }

                let resourceName = "LocalizedStoryboard.storyboard"
                let baseResource = "Base.lproj/LocalizedStoryboard.storyboard"
                let localizedResource = "en.lproj/LocalizedStoryboard.strings"

                let variableGroup = try unwrap(getVariableGroups(resourceName).first)

                do {
                    let refs = getFileReferences(baseResource)
                    try expect(refs.count) == 1
                    try expect(variableGroup.children.filter { $0 == refs.first }.count) == 1
                }

                do {
                    let refs = getFileReferences(localizedResource)
                    try expect(refs.count) == 1
                    try expect(variableGroup.children.filter { $0 == refs.first }.count) == 1
                }
            }

            $0.it("handles localized resources") {
                let directories = """
                App:
                    Resources:
                        en-CA.lproj:
                            - empty.json
                            - Localizable.strings
                        en-US.lproj:
                            - empty.json
                            - Localizable.strings
                        en.lproj:
                            - empty.json
                            - Localizable.strings
                        fonts:
                            SFUI:
                                - SFUILight.ttf
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [TargetSource(path: "App/Resources")])

                let options = SpecOptions(createIntermediateGroups: true)
                let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options)

                let outputXcodeProj = try project.generateXcodeProject()
                try outputXcodeProj.write(path: directoryPath)

                let inputXcodeProj = try XcodeProj(path: directoryPath)
                let pbxProj = inputXcodeProj.pbxproj

                func getFileReferences(_ path: String) -> [PBXFileReference] {
                    pbxProj.fileReferences.filter { $0.path == path }
                }

                func getVariableGroups(_ name: String?) -> [PBXVariantGroup] {
                    pbxProj.variantGroups.filter { $0.name == name }
                }

                let stringsResourceName = "Localizable.strings"
                let jsonResourceName = "empty.json"

                let stringsVariableGroup = try unwrap(getVariableGroups(stringsResourceName).first)

                let jsonVariableGroup = try unwrap(getVariableGroups(jsonResourceName).first)

                let stringsResource = "en.lproj/Localizable.strings"
                let jsonResource = "en-CA.lproj/empty.json"

                do {
                    let refs = getFileReferences(stringsResource)
                    try expect(refs.count) == 1
                    try expect(refs.first!.uuid.hasPrefix("TEMP")) == false
                    try expect(stringsVariableGroup.children.filter { $0 == refs.first }.count) == 1
                }

                do {
                    let refs = getFileReferences(jsonResource)
                    try expect(refs.count) == 1
                    try expect(refs.first!.uuid.hasPrefix("TEMP")) == false
                    try expect(jsonVariableGroup.children.filter { $0 == refs.first }.count) == 1
                }
            }

            $0.it("handles duplicate names") {
                let directories = """
                Sources:
                  - a.swift
                  - a:
                    - a.swift
                    - a:
                      - a.swift

                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["Sources"])
                let project = Project(
                    basePath: directoryPath,
                    name: "Test",
                    targets: [target],
                    fileGroups: ["Sources"]
                )

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources", "a.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources", "a", "a.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources", "a", "a", "a.swift"], buildPhase: .sources)
            }

            $0.it("renames sources") {
                let directories = """
                Sources:
                    - a.swift
                OtherSource:
                    - b.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    TargetSource(path: "Sources", name: "NewSource"),
                    TargetSource(path: "OtherSource/b.swift", name: "c.swift"),
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources", "a.swift"], names: ["NewSource", "a.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["OtherSource", "b.swift"], names: ["OtherSource", "c.swift"], buildPhase: .sources)
            }

            $0.it("excludes sources") {
                let directories = """
                Sources:
                  - A:
                    - a.swift
                    - B:
                      - b.swift
                      - b.ignored
                      - b.alsoIgnored
                    - a.ignored
                    - a.alsoIgnored
                  - B:
                    - b.swift
                  - D:
                    - d.h
                    - d.m
                  - E:
                    - e.jpg
                    - e.h
                    - e.m
                    - F:
                      - f.swift
                  - G:
                    - H:
                      - h.swift
                  - types:
                    - a.swift
                    - a.m
                    - a.h
                    - a.x
                  - numbers:
                    - file1.a
                    - file2.a
                    - file3.a
                    - file4.a
                  - partial:
                    - file_part
                  - ignore.file
                  - a.ignored
                  - project.xcodeproj:
                    - project.pbxproj
                  - a.playground:
                    - Sources:
                      - a.swift
                    - Resources
                """
                try createDirectories(directories)

                let excludes = [
                    "B",
                    "d.m",
                    "E/F/*.swift",
                    "G/H/",
                    "types/*.[hx]",
                    "numbers/file[2-3].a",
                    "partial/*_part",
                    "ignore.file",
                    "*.ignored",
                    "*.xcodeproj",
                    "*.playground",
                    "**/*.ignored",
                    "A/B/**/*.alsoIgnored",
                ]

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [TargetSource(path: "Sources", excludes: excludes)])

                func test(generateEmptyDirectories: Bool) throws {
                    let options = SpecOptions(generateEmptyDirectories: generateEmptyDirectories)
                    let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options)
                    let pbxProj = try project.generatePbxProj()
                    try pbxProj.expectFile(paths: ["Sources", "A", "a.swift"])
                    try pbxProj.expectFile(paths: ["Sources", "A", "a.alsoIgnored"])
                    try pbxProj.expectFile(paths: ["Sources", "D", "d.h"])
                    try pbxProj.expectFile(paths: ["Sources", "D", "d.m"])
                    try pbxProj.expectFile(paths: ["Sources", "E", "e.jpg"])
                    try pbxProj.expectFile(paths: ["Sources", "E", "e.m"])
                    try pbxProj.expectFile(paths: ["Sources", "E", "e.h"])
                    try pbxProj.expectFile(paths: ["Sources", "types", "a.swift"])
                    try pbxProj.expectFile(paths: ["Sources", "numbers", "file1.a"])
                    try pbxProj.expectFile(paths: ["Sources", "numbers", "file4.a"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "B", "b.swift"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "E", "F", "f.swift"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "G", "H", "h.swift"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "types", "a.h"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "types", "a.x"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "numbers", "file2.a"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "numbers", "file3.a"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "partial", "file_part"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "a.ignored"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "ignore.file"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "project.xcodeproj"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "a.playground"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "A", "a.ignored"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "A", "B", "b.ignored"])
                    try pbxProj.expectFileMissing(paths: ["Sources", "A", "B", "b.alsoIgnored"])
                }

                try test(generateEmptyDirectories: false)
                try test(generateEmptyDirectories: true)
            }

            $0.it("excludes certain ignored files") {
                let directories = """
                Sources:
                  A:
                    - a.swift
                    - .DS_Store
                    - a.swift.orig
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [TargetSource(path: "Sources")])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])
                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources", "A", "a.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "A", ".DS_Store"])
                try pbxProj.expectFileMissing(paths: ["Sources", "A", "a.swift.orig"])
            }

            $0.it("generates file sources") {
                let directories = """
                Sources:
                  A:
                    - a.swift
                    - Assets.xcassets
                    - B:
                      - b.swift
                      - c.jpg
                    - D2.0:
                      - d.swift
                    - E.bundle:
                      - e.json
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    "Sources/A/a.swift",
                    "Sources/A/B/b.swift",
                    "Sources/A/D2.0/d.swift",
                    "Sources/A/Assets.xcassets",
                    "Sources/A/E.bundle/e.json",
                    "Sources/A/B/c.jpg",
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources/A", "a.swift"], names: ["A", "a.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources/A/B", "b.swift"], names: ["B", "b.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources/A/D2.0", "d.swift"], names: ["D2.0", "d.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources/A/B", "c.jpg"], names: ["B", "c.jpg"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["Sources/A", "Assets.xcassets"], names: ["A", "Assets.xcassets"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["Sources/A/E.bundle", "e.json"], names: ["E.bundle", "e.json"], buildPhase: .resources)
            }

            $0.it("generates shared sources") {
                let directories = """
                Sources:
                  A:
                    - a.swift
                    - B:
                      - b.swift
                      - c.jpg
                """
                try createDirectories(directories)

                let target1 = Target(name: "Test1", type: .framework, platform: .iOS, sources: ["Sources"])
                let target2 = Target(name: "Test2", type: .framework, platform: .tvOS, sources: ["Sources"])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target1, target2])

                _ = try project.generatePbxProj()
                // TODO: check there are build files for both targets
            }

            $0.it("generates intermediate groups") {

                let directories = """
                Sources:
                  A:
                    - b.swift
                  F:
                    - G:
                      - h.swift
                  B:
                    - b.swift
                """
                try createDirectories(directories)
                let outOfSourceFile = outOfRootPath + "C/D/e.swift"
                try outOfSourceFile.parent().mkpath()
                try outOfSourceFile.write("")

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    "Sources/A/b.swift",
                    "Sources/F/G/h.swift",
                    "../OtherDirectory/C/D/e.swift",
                    TargetSource(path: "Sources/B", createIntermediateGroups: false),
                ])
                let options = SpecOptions(createIntermediateGroups: true)
                let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options)

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources", "A", "b.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources", "F", "G", "h.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["..", "OtherDirectory", "C", "D", "e.swift"], names: [".", "OtherDirectory", "C", "D", "e.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources/B", "b.swift"], names: ["B", "b.swift"], buildPhase: .sources)
            }

            $0.it("generates intermediate groups with different projectDirectory") {

                let directories = """
                Sources:
                  - a.swift
                  - b.swift
                Modules:
                  - m.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    "../Sources",
                    "../Modules",
                ])
                let options = SpecOptions(createIntermediateGroups: true)
                // basePath is a subdirectory (simulating spec in a subdir like ProjectRoot/XcodeGen/)
                // projectDirectory is the parent (simulating --project pointing to ProjectRoot/)
                let subdir = directoryPath + "SubDir"
                try subdir.mkpath()
                let project = Project(basePath: subdir, name: "Test", targets: [target], options: options)

                let generator = PBXProjGenerator(project: project, projectDirectory: directoryPath)
                let pbxProj = try generator.generate()

                // Sources and Modules should have path = "Sources"/"Modules" (not "../Sources")
                // The intermediate group for TestDirectory should have path = "."
                // So Xcode resolves: projectDir/./Sources = correct
                // Before fix: path was "../Sources", resolving to projectDir/./../Sources = wrong
                try pbxProj.expectFile(paths: [".", "Sources", "a.swift"], names: ["TestDirectory", "Sources", "a.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: [".", "Sources", "b.swift"], names: ["TestDirectory", "Sources", "b.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: [".", "Modules", "m.swift"], names: ["TestDirectory", "Modules", "m.swift"], buildPhase: .sources)
            }

            $0.it("generates custom groups") {

                let directories = """
                - Sources:
                  - a.swift
                  - A:
                    - b.swift
                  - F:
                    - G:
                      - h.swift
                      - i.swift
                  - B:
                    - b.swift
                    - C:
                      - c.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    TargetSource(path: "Sources/a.swift", group: "CustomGroup1"),
                    TargetSource(path: "Sources/A/b.swift", group: "CustomGroup1"),
                    TargetSource(path: "Sources/F/G/h.swift", group: "CustomGroup1"),
                    TargetSource(path: "Sources/B", group: "CustomGroup2", createIntermediateGroups: false),
                    TargetSource(path: "Sources/F/G/i.swift", group: "Sources/F/G/CustomGroup3"),
                ])

                let options = SpecOptions(createIntermediateGroups: true)
                let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options)

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["CustomGroup1", "Sources/a.swift"], names: ["CustomGroup1", "a.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["CustomGroup1", "Sources/A/b.swift"], names: ["CustomGroup1", "b.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["CustomGroup1", "Sources/F/G/h.swift"], names: ["CustomGroup1", "h.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources", "F", "G", "CustomGroup3", "i.swift"], names: ["Sources", "F", "G", "CustomGroup3", "i.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["CustomGroup2", "Sources/B", "b.swift"], names: ["CustomGroup2", "B", "b.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["CustomGroup2", "Sources/B", "C", "c.swift"], names: ["CustomGroup2", "B", "C", "c.swift"], buildPhase: .sources)
            }

            $0.it("generates folder references") {
                let directories = """
                Sources:
                  A:
                    - a.resource
                    - b.resource
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    TargetSource(path: "Sources/A", type: .folder),
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources/A"], names: ["A"], buildPhase: .resources)
                try pbxProj.expectFileMissing(paths: ["Sources", "A", "a.swift"])
            }

            $0.it("generates folder references with createIntermediateGroups") {
                let directories = """
                Sources:
                  A:
                    - a.resource
                    - b.resource
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    TargetSource(path: "Sources/A", type: .folder),
                ])
                let project = Project(
                    basePath: directoryPath,
                    name: "Test",
                    targets: [target],
                    options: .init(createIntermediateGroups: true)
                )

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources", "Sources/A"], names: ["Sources", "A"], buildPhase: .resources)
            }

            $0.it("adds files to correct build phase") {
                let directories = """
                  A:
                    - file.swift
                    - file.xcassets
                    - file.h
                    - GoogleService-Info.plist
                    - file.xcconfig
                    - Localizable.xcstrings
                  B:
                    - file.swift
                    - file.xcassets
                    - file.h
                    - Sample.plist
                    - file.xcconfig
                  C:
                    - file.swift
                    - file.m
                    - file.mm
                    - file.cpp
                    - file.c
                    - file.S
                    - file.h
                    - file.hh
                    - file.hpp
                    - file.ipp
                    - file.tpp
                    - file.hxx
                    - file.def
                    - file.xcconfig
                    - file.entitlements
                    - file.gpx
                    - file.apns
                    - file.123
                    - file.xcassets
                    - file.metal
                    - file.mlmodel
                    - file.mlpackage
                    - file.mlmodelc
                    - Info.plist
                    - Intent.intentdefinition
                    - Configuration.storekit
                    - Settings.bundle:
                      - en.lproj:
                        - Root.strings
                      - Root.plist
                    - WithPeriod2.0:
                      - file.swift
                    - Documentation.docc
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .framework, platform: .iOS, sources: [
                    TargetSource(path: "A", buildPhase: .resources),
                    TargetSource(path: "B", buildPhase: BuildPhaseSpec.none),
                    TargetSource(path: "C", buildPhase: nil),
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["A", "file.swift"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["A", "file.xcassets"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["A", "file.h"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["A", "GoogleService-Info.plist"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["A", "file.xcconfig"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["A", "Localizable.xcstrings"], buildPhase: .resources)

                try pbxProj.expectFile(paths: ["B", "file.swift"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["B", "file.xcassets"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["B", "file.h"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["B", "Sample.plist"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["B", "file.xcconfig"], buildPhase: BuildPhaseSpec.none)

                try pbxProj.expectFile(paths: ["C", "file.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "file.m"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "file.mm"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "file.cpp"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "file.c"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "file.S"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "file.h"], buildPhase: .headers)
                try pbxProj.expectFile(paths: ["C", "file.hh"], buildPhase: .headers)
                try pbxProj.expectFile(paths: ["C", "file.hpp"], buildPhase: .headers)
                try pbxProj.expectFile(paths: ["C", "file.ipp"], buildPhase: .headers)
                try pbxProj.expectFile(paths: ["C", "file.tpp"], buildPhase: .headers)
                try pbxProj.expectFile(paths: ["C", "file.hxx"], buildPhase: .headers)
                try pbxProj.expectFile(paths: ["C", "file.def"], buildPhase: .headers)
                try pbxProj.expectFile(paths: ["C", "file.xcconfig"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["C", "file.entitlements"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["C", "file.gpx"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["C", "file.apns"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["C", "file.xcconfig"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["C", "file.xcconfig"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["C", "file.xcconfig"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["C", "file.xcassets"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["C", "file.123"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["C", "Info.plist"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["C", "file.metal"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "file.mlmodel"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "file.mlpackage"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "file.mlmodelc"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["C", "Intent.intentdefinition"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "Configuration.storekit"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["C", "Settings.bundle"], buildPhase: .resources)
                try pbxProj.expectFileMissing(paths: ["C", "Settings.bundle", "en.lproj"])
                try pbxProj.expectFileMissing(paths: ["C", "Settings.bundle", "en.lproj", "Root.strings"])
                try pbxProj.expectFileMissing(paths: ["C", "Settings.bundle", "Root.plist"])
                try pbxProj.expectFileMissing(paths: ["C", "WithPeriod2.0"])
                try pbxProj.expectFile(paths: ["C", "WithPeriod2.0", "file.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["C", "Documentation.docc"], buildPhase: .sources)
            }

            $0.it("only omits the defined Info.plist from resource build phases but not other plists") {
                try createDirectories("""
                  A:
                    - A-Info.plist
                  B:
                    - Info.plist
                    - GoogleServices-Info.plist
                  C:
                    - Info.plist
                    - Info-Production.plist
                  D:
                    - Info-Staging.plist
                    - Info-Production.plist
                """)

                // Explicit plist.path value is respected
                let targetA = Target(
                    name: "A",
                    type: .application,
                    platform: .iOS,
                    sources: ["A"],
                    info: Plist(path: "A/A-Info.plist")
                )

                // Automatically picks first 'Info.plist' at the top-level
                let targetB = Target(
                    name: "B",
                    type: .application,
                    platform: .iOS,
                    sources: ["B"]
                )

                // Also respects INFOPLIST_FILE, ignores other files named Info.plist
                let targetC = Target(
                    name: "C",
                    type: .application,
                    platform: .iOS,
                    settings: Settings(buildSettings: [
                        "INFOPLIST_FILE": "C/Info-Production.plist"
                    ]),
                    sources: ["C"]
                )

                // Does not support INFOPLIST_FILE value that requires expanding
                let targetD = Target(
                    name: "D",
                    type: .application,
                    platform: .iOS,
                    settings: Settings(buildSettings: [
                        "ENVIRONMENT": "Production",
                        "INFOPLIST_FILE": "D/Info-${ENVIRONMENT}.plist"
                    ]),
                    sources: ["D"]
                )

                let project = Project(basePath: directoryPath.absolute(), name: "Test", targets: [targetA, targetB, targetC, targetD])
                let pbxProj = try project.generatePbxProj()

                try pbxProj.expectFile(paths: ["A", "A-Info.plist"], buildPhase: BuildPhaseSpec.none)

                try pbxProj.expectFile(paths: ["B", "Info.plist"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["B", "GoogleServices-Info.plist"], buildPhase: .resources)

                try pbxProj.expectFile(paths: ["C", "Info.plist"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["C", "Info-Production.plist"], buildPhase: BuildPhaseSpec.none)

                try pbxProj.expectFile(paths: ["D", "Info-Staging.plist"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["D", "Info-Production.plist"], buildPhase: .resources)
            }

            $0.it("sets file type properties") {
                let directories = """
                  A:
                    - file.resource1
                    - file.source1
                    - file.abc:
                        - file.a
                    - file.exclude1
                    - file.unphased1
                    - ignored.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .framework, platform: .iOS, sources: [
                    TargetSource(path: "A"),
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: .init(fileTypes: [
                    "abc": FileType(buildPhase: .sources),
                    "source1": FileType(buildPhase: .sources, attributes: ["a1", "a2"], resourceTags: ["r1", "r2"], compilerFlags: ["-c1", "-c2"]),
                    "resource1": FileType(buildPhase: .resources, attributes: ["a1", "a2"], resourceTags: ["r1", "r2"], compilerFlags: ["-c1", "-c2"]),
                    "unphased1": FileType(buildPhase: BuildPhaseSpec.none),
                    "swift": FileType(buildPhase: .resources),
                ]))

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["A", "file.abc"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["A", "file.source1"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["A", "file.resource1"], buildPhase: .resources)
                try pbxProj.expectFile(paths: ["A", "file.unphased1"], buildPhase: BuildPhaseSpec.none)
                try pbxProj.expectFile(paths: ["A", "ignored.swift"], buildPhase: .resources)

                do {
                    let fileReference = try unwrap(pbxProj.getFileReference(paths: ["A", "file.resource1"], names: ["A", "file.resource1"]))
                    let buildFile = try unwrap(pbxProj.buildFiles.first(where: { $0.file === fileReference }))
                    let settings = buildFile.settings ?? [:]
                    try expect(settings["ATTRIBUTES"]?.arrayValue) == ["a1", "a2"]
                    try expect(settings["ASSET_TAGS"]?.arrayValue) == ["r1", "r2"]
                }
                do {
                    let fileReference = try unwrap(pbxProj.getFileReference(paths: ["A", "file.source1"], names: ["A", "file.source1"]))
                    let buildFile = try unwrap(pbxProj.buildFiles.first(where: { $0.file === fileReference }))
                    let settings = buildFile.settings ?? [:]
                    try expect(settings["ATTRIBUTES"]?.arrayValue) == ["a1", "a2"]
                    try expect(settings["COMPILER_FLAGS"]?.stringValue) == "-c1 -c2"
                }
            }

            $0.it("duplicate TargetSource is included once in sources build phase") {
                let directories = """
                Sources:
                  A:
                    - a.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    "Sources/A/a.swift",
                    "Sources/A/a.swift",
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources/A", "a.swift"], names: ["A", "a.swift"], buildPhase: .sources)

                let sourcesBuildPhase = pbxProj.buildPhases.first(where: { $0.buildPhase == BuildPhase.sources })!

                try expect(sourcesBuildPhase.files?.count) == 1
            }

            $0.it("add only carthage dependencies with same platform") {
                let directories = """
                    A:
                    - file.swift
                """
                try createDirectories(directories)

                let watchTarget = Target(name: "Watch", type: .watch2App, platform: .watchOS, sources: ["A"], dependencies: [Dependency(type: .carthage(findFrameworks: false, linkType: .dynamic), reference: "Alamofire_watch")])
                let watchDependency = Dependency(type: .target, reference: "Watch")
                let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["A"], dependencies: [Dependency(type: .carthage(findFrameworks: false, linkType: .dynamic), reference: "Alamofire"), watchDependency])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target, watchTarget])

                let pbxProj = try project.generatePbxProj()
                let carthagePhase = pbxProj.nativeTargets.first(where: { $0.name == "Test" })?.buildPhases.first(where: { $0 is PBXShellScriptBuildPhase }) as? PBXShellScriptBuildPhase
                try expect(carthagePhase?.inputPaths) == ["$(SRCROOT)/Carthage/Build/iOS/Alamofire.framework"]
            }

            $0.it("derived directories are sorted last") {
                let directories = """
                    A:
                    - file.swift
                    P:
                    - file.swift
                    S:
                    - file.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: ["A", "P", "S"], dependencies: [Dependency(type: .carthage(findFrameworks: false, linkType: .dynamic), reference: "Alamofire")])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                let groups = try pbxProj.getMainGroup().children.map { $0.nameOrPath }
                try expect(groups) == ["A", "P", "S", "Frameworks", "Products"]
            }

            $0.it("sorts files") {
                let directories = """
                    A:
                    - A.swift
                    Source:
                        - file.swift
                    Sources:
                    - file3.swift
                    - file.swift
                    - 10file.a
                    - 1file.a
                    - file2.swift
                    - group2:
                        - file.swift
                    - group:
                        - file.swift
                    Z:
                    - A:
                        - file.swift
                    B:
                    - file.swift
                """
                try createDirectories(directories)

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    "Sources",
                    TargetSource(path: "Source", name: "S"),
                    "A",
                    TargetSource(path: "Z/A", name: "B"),
                    "B",
                ], dependencies: [Dependency(type: .carthage(findFrameworks: false, linkType: .dynamic), reference: "Alamofire")])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                let mainGroup = try pbxProj.getMainGroup()
                let mainGroupNames = mainGroup.children.prefix(5).map { $0.name }
                try expect(mainGroupNames) == [
                    nil,
                    nil,
                    "B",
                    "S",
                    nil,
                ]
                let mainGroupPaths = mainGroup.children.prefix(5).map { $0.path }
                try expect(mainGroupPaths) == [
                    "A",
                    "B",
                    "Z/A",
                    "Source",
                    "Sources",
                ]

                let group = mainGroup.children.compactMap { $0 as? PBXGroup }.first { $0.path == "Sources" }!
                let names = group.children.map { $0.name }
                try expect(names) == [
                    nil,
                    nil,
                    nil,
                    nil,
                    nil,
                    nil,
                    nil,
                ]
                let paths = group.children.map { $0.path }
                try expect(paths) == [
                    "1file.a",
                    "10file.a",
                    "file.swift",
                    "file2.swift",
                    "file3.swift",
                    "group",
                    "group2",
                ]
            }

            $0.it("adds missing optional files and folders") {

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    TargetSource(path: "File1.swift", optional: true),
                    TargetSource(path: "File2.swift", type: .file, optional: true),
                    TargetSource(path: "Group", type: .folder, optional: true),
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])
                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["File1.swift"])
                try pbxProj.expectFile(paths: ["File2.swift"])
            }

            $0.it("allows missing optional groups") {

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    TargetSource(path: "Group1", optional: true),
                    TargetSource(path: "Group2", type: .group, optional: true),
                    TargetSource(path: "Group3", type: .group, optional: true),
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])
                _ = try project.generatePbxProj()
            }

            $0.it("relative path items outside base path are grouped together") {
                let directories = """
                Sources:
                  - Inside:
                    - a.swift
                    - Inside2:
                        - b.swift
                """
                try createDirectories(directories)

                let outOfSourceFile1 = outOfRootPath + "Outside/a.swift"
                try outOfSourceFile1.parent().mkpath()
                try outOfSourceFile1.write("")

                let outOfSourceFile2 = outOfRootPath + "Outside/Outside2/b.swift"
                try outOfSourceFile2.parent().mkpath()
                try outOfSourceFile2.write("")

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [
                    "Sources",
                    "../OtherDirectory",
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()
                try pbxProj.expectFile(paths: ["Sources", "Inside", "a.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["Sources", "Inside", "Inside2", "b.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["../OtherDirectory", "Outside", "a.swift"], names: ["OtherDirectory", "Outside", "a.swift"], buildPhase: .sources)
                try pbxProj.expectFile(paths: ["../OtherDirectory", "Outside", "Outside2", "b.swift"], names: ["OtherDirectory", "Outside", "Outside2", "b.swift"], buildPhase: .sources)
            }

            $0.it("correctly adds target source attributes") {
                let directories = """
                A:
                  - Intent.intentdefinition
                """
                try createDirectories(directories)

                let definition: String = "Intent.intentdefinition"

                let target = Target(name: "Test", type: .framework, platform: .iOS, sources: [
                    TargetSource(path: "A/\(definition)", buildPhase: .sources, attributes: ["no_codegen"]),
                ])
                let project = Project(basePath: directoryPath, name: "Test", targets: [target])

                let pbxProj = try project.generatePbxProj()

                let fileReference = pbxProj.getFileReference(
                    paths: ["A", definition],
                    names: ["A", definition]
                )
                let buildFile = try unwrap(pbxProj.buildFiles.first(where: { $0.file == fileReference }))

                try pbxProj.expectFile(paths: ["A", definition], buildPhase: .sources)

                if buildFile.settings?["ATTRIBUTES"]?.arrayValue != ["no_codegen"] {
                    throw failure("File does not contain no_codegen attribute")
                }
            }

            $0.it("includes only the specified files when includes is present") {
                let directories = """
                Sources:
                  - file3.swift
                  - file3Tests.swift
                  - file2.swift
                  - file2Tests.swift
                  - group2:
                    - file.swift
                    - fileTests.swift
                  - group:
                    - file.swift
                  - group3:
                    - group4:
                      - group5:
                        - file.swift
                        - file5Tests.swift
                        - file6Tests.m
                        - file6Tests.h
                """
                try createDirectories(directories)

                let includes = [
                    "**/*Tests.*",
                ]

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [TargetSource(path: "Sources", includes: includes)])

                let options = SpecOptions(createIntermediateGroups: true, generateEmptyDirectories: true)
                let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options)
                let pbxProj = try project.generatePbxProj()

                try pbxProj.expectFile(paths: ["Sources", "file2Tests.swift"])
                try pbxProj.expectFile(paths: ["Sources", "file3Tests.swift"])
                try pbxProj.expectFile(paths: ["Sources", "group2", "fileTests.swift"])
                try pbxProj.expectFile(paths: ["Sources", "group3", "group4", "group5", "file5Tests.swift"])
                try pbxProj.expectFile(paths: ["Sources", "group3", "group4", "group5", "file6Tests.h"])
                try pbxProj.expectFile(paths: ["Sources", "group3", "group4", "group5", "file6Tests.m"])
                try pbxProj.expectFileMissing(paths: ["Sources", "file2.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "file3.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "group2", "file.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "group", "file.swift"])
            }

            $0.it("handles includes with no matches correctly") {
                let directories = """
                Sources:
                  - file3.swift
                  - file3Tests.swift
                  - file2.swift
                  - file2Tests.swift
                  - group2:
                    - file.swift
                    - fileTests.swift
                  - group:
                    - file.swift
                """
                try createDirectories(directories)

                let includes = [
                    "**/*NonExistent.*",
                ]

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [TargetSource(path: "Sources", includes: includes)])

                let project = Project(basePath: directoryPath, name: "Test", targets: [target])
                let pbxProj = try project.generatePbxProj()

                try pbxProj.expectFileMissing(paths: ["Sources", "file2.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "file3.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "file2Tests.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "file3Tests.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "group2", "file.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "group2", "fileTests.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "group", "file.swift"])
            }

            $0.it("prioritizes excludes over includes when both are present") {
                let directories = """
                Sources:
                  - file3.swift
                  - file3Tests.swift
                  - file2.swift
                  - file2Tests.swift
                  - group2:
                    - file.swift
                    - fileTests.swift
                  - group:
                    - file.swift
                """
                try createDirectories(directories)

                let includes = [
                    "**/*Tests.*",
                ]

                let excludes = [
                    "group2",
                ]

                let target = Target(name: "Test", type: .application, platform: .iOS, sources: [TargetSource(path: "Sources", excludes: excludes, includes: includes)])

                let project = Project(basePath: directoryPath, name: "Test", targets: [target])
                let pbxProj = try project.generatePbxProj()

                try pbxProj.expectFile(paths: ["Sources", "file2Tests.swift"])
                try pbxProj.expectFile(paths: ["Sources", "file3Tests.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "group2", "fileTests.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "file2.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "file3.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "group2", "file.swift"])
                try pbxProj.expectFileMissing(paths: ["Sources", "group", "file.swift"])
            }

            $0.describe("Localized sources") {
                $0.context("With localized sources") {
                    $0.it("*.intentdefinition should be added to source phase") {
                        let directories = """
                        Sources:
                            Base.lproj:
                                - Intents.intentdefinition
                            en.lproj:
                                - Intents.strings
                            ja.lproj:
                                - Intents.strings
                        """
                        try createDirectories(directories)
                        let directoryPath = Path("TestDirectory")

                        let target = Target(name: "IntentDefinitions",
                                            type: .application,
                                            platform: .iOS,
                                            sources: [TargetSource(path: "Sources")])
                        let project = Project(basePath: directoryPath,
                                              name: "IntendDefinitions",
                                              targets: [target])
                        let pbxProj = try project.generatePbxProj()
                        let sourceBuildPhase = try unwrap(pbxProj.buildPhases.first { $0.buildPhase == .sources })
                        try expect(sourceBuildPhase.files?.compactMap { $0.file?.nameOrPath }) == ["Intents.intentdefinition"]
                    }
                }

                $0.context("With localized sources with buildPhase") {
                    $0.it("*.intentdefinition with buildPhase should be added to resource phase") {
                        let directories = """
                        Sources:
                            Base.lproj:
                                - Intents.intentdefinition
                            en.lproj:
                                - Intents.strings
                            ja.lproj:
                                - Intents.strings
                        """
                        try createDirectories(directories)
                        let directoryPath = Path("TestDirectory")

                        let target = Target(name: "IntentDefinitions",
                                            type: .application,
                                            platform: .iOS,
                                            sources: [TargetSource(path: "Sources", buildPhase: .resources)])
                        let project = Project(basePath: directoryPath,
                                              name: "IntendDefinitions",
                                              targets: [target])
                        let pbxProj = try project.generatePbxProj()
                        let sourceBuildPhase = try unwrap(pbxProj.buildPhases.first { $0.buildPhase == .sources })
                        let resourcesBuildPhase = try unwrap(pbxProj.buildPhases.first { $0.buildPhase == .resources })
                        try expect(sourceBuildPhase.files) == []
                        try expect(resourcesBuildPhase.files?.compactMap { $0.file?.nameOrPath }) == ["Intents.intentdefinition"]
                    }
                }

                $0.it("generates resource tags") {
                    let directories = """
                    A:
                        - resourceFile.mp4
                        - resourceFile2.mp4
                        - sourceFile.swift
                    """
                    try createDirectories(directories)

                    let target = Target(
                        name: "Test",
                        type: .application,
                        platform: .iOS,
                        sources: [
                            TargetSource(path: "A/resourceFile.mp4", buildPhase: .resources, resourceTags: ["tag1", "tag2"]),
                            TargetSource(path: "A/resourceFile2.mp4", buildPhase: .resources, resourceTags: ["tag2", "tag3"]),
                            TargetSource(path: "A/sourceFile.swift", buildPhase: .sources, resourceTags: ["tag1", "tag2"]),
                        ]
                    )

                    let project = Project(basePath: directoryPath,
                                          name: "Test",
                                          targets: [target])

                    let pbxProj = try project.generatePbxProj()

                    let resourceFileReference = try unwrap(pbxProj.getFileReference(
                        paths: ["A", "resourceFile.mp4"],
                        names: ["A", "resourceFile.mp4"]
                    ))

                    let resourceFileReference2 = try unwrap(pbxProj.getFileReference(
                        paths: ["A", "resourceFile2.mp4"],
                        names: ["A", "resourceFile2.mp4"]
                    ))

                    let sourceFileReference = try unwrap(pbxProj.getFileReference(
                        paths: ["A", "sourceFile.swift"],
                        names: ["A", "sourceFile.swift"]
                    ))

                    try pbxProj.expectFile(paths: ["A", "resourceFile.mp4"], buildPhase: .resources)
                    try pbxProj.expectFile(paths: ["A", "resourceFile2.mp4"], buildPhase: .resources)
                    try pbxProj.expectFile(paths: ["A", "sourceFile.swift"], buildPhase: .sources)

                    let resourceBuildFile = try unwrap(pbxProj.buildFiles.first(where: { $0.file == resourceFileReference }))
                    let resourceBuildFile2 = try unwrap(pbxProj.buildFiles.first(where: { $0.file == resourceFileReference2 }))
                    let sourceBuildFile = try unwrap(pbxProj.buildFiles.first(where: { $0.file == sourceFileReference }))

                    if resourceBuildFile.settings?["ASSET_TAGS"]?.arrayValue != ["tag1", "tag2"] {
                        throw failure("File does not contain tag1 and tag2 ASSET_TAGS")
                    }

                    if resourceBuildFile2.settings?["ASSET_TAGS"]?.arrayValue != ["tag2", "tag3"] {
                        throw failure("File does not contain tag2 and tag3 ASSET_TAGS")
                    }

                    if sourceBuildFile.settings != nil {
                        throw failure("File that buildPhase is source contain settings")
                    }

                    if !pbxProj.rootObject!.attributes.keys.contains("knownAssetTags") {
                        throw failure("PBXProject does not contain knownAssetTags")
                    }

                    try expect(pbxProj.rootObject!.attributes["knownAssetTags"]?.arrayValue) == ["tag1", "tag2", "tag3"]
                }
                
                $0.it("Detects all locales present in a String Catalog") {
                    /// This is a catalog with gaps:
                    /// - String "foo" is translated into English (en) and Spanish (es)
                    /// - String "bar" is translated into English (en) and Italian (it)
                    ///
                    /// It is aimed at representing real world scenarios where translators have not finished translating all strings into their respective languages.
                    /// The expectation in this kind of cases is that `includedLocales` returns all locales found at least once in the catalog.
                    /// In this example, `includedLocales` is expected to be a set only containing "en", "es" and "it".
                    let stringCatalogContent = """
                    {
                      "sourceLanguage" : "en",
                      "strings" : {
                        "foo" : {
                          "comment" : "Sample string in an asset catalog",
                          "extractionState" : "manual",
                          "localizations" : {
                            "en" : {
                              "stringUnit" : {
                                "state" : "translated",
                                "value" : "Foo English"
                              }
                            },
                            "es" : {
                              "stringUnit" : {
                                "state" : "translated",
                                "value" : "Foo Spanish"
                              }
                            }
                          }
                        },
                        "bar" : {
                          "comment" : "Another sample string in an asset catalog",
                          "extractionState" : "manual",
                          "localizations" : {
                            "en" : {
                              "stringUnit" : {
                                "state" : "translated",
                                "value" : "Bar English"
                              }
                            },
                            "it" : {
                              "stringUnit" : {
                                "state" : "translated",
                                "value" : "Bar Italian"
                              }
                            }
                          }
                        }
                      },
                      "version" : "1.0"
                    }
                    """
                    
                    let testStringCatalogRelativePath = Path("Localizable.xcstrings")
                    let testStringCatalogPath = try createFile(at: testStringCatalogRelativePath, content: stringCatalogContent)

                    guard let stringCatalog = StringCatalog(from: testStringCatalogPath) else {
                        throw failure("Failed decoding string catalog from \(testStringCatalogPath)")
                    }
                    
                    try expect(stringCatalog.includedLocales.sorted(by: { $0 < $1 })) == ["en", "es", "it"]
                }
            }
        }
    }
}

extension PBXProj {

    /// expect a file within groups of the paths, using optional different names
    func expectFile(paths: [String], names: [String]? = nil, buildPhase: BuildPhaseSpec? = nil, file: String = #file, line: Int = #line) throws {
        guard let fileReference = getFileReference(paths: paths, names: names ?? paths) else {
            var error = "Could not find file at path \(paths.joined(separator: "/").quoted)"
            if let names = names, names != paths {
                error += " and name \(names.joined(separator: "/").quoted)"
            }
            error += "\n\(self.printGroups())"
            throw failure(error, file: file, line: line)
        }

        if let buildPhase = buildPhase {
            let buildFile = buildFiles
                .first(where: { $0.file === fileReference })
            let actualBuildPhase = buildFile
                .flatMap { buildFile in buildPhases.first { $0.files?.contains(buildFile) ?? false } }?.buildPhase

            var error: String?
            if let buildPhase = buildPhase.buildPhase {
                if actualBuildPhase != buildPhase {
                    if let actualBuildPhase = actualBuildPhase {
                        error = "is in the \(actualBuildPhase.rawValue) build phase instead of the expected \(buildPhase.rawValue.quoted)"
                    } else {
                        error = "isn't in a build phase when it's expected to be in \(buildPhase.rawValue.quoted)"
                    }
                }
            } else if let actualBuildPhase = actualBuildPhase {
                error = "is in the \(actualBuildPhase.rawValue.quoted) build phase when it's expected to not be in any"
            }
            if let error = error {
                throw failure("File \(paths.joined(separator: "/").quoted) \(error)", file: file, line: line)
            }
        }
    }

    /// expect a missing file within groups of the paths, using optional different names
    func expectFileMissing(paths: [String], names: [String]? = nil, file: String = #file, line: Int = #line) throws {
        let names = names ?? paths
        if getFileReference(paths: paths, names: names) != nil {
            throw failure("Found unexpected file at path \(paths.joined(separator: "/").quoted) and name \(paths.joined(separator: "/").quoted)", file: file, line: line)
        }
    }

    func getFileReference(paths: [String], names: [String], file: String = #file, line: Int = #line) -> PBXFileReference? {
        guard let mainGroup = projects.first?.mainGroup else { return nil }

        return getFileReference(group: mainGroup, paths: paths, names: names)
    }

    private func getFileReference(group: PBXGroup, paths: [String], names: [String]) -> PBXFileReference? {
        guard !paths.isEmpty else {
            return nil
        }

        let path = paths.first!
        let name = names.first!
        let restOfPath = Array(paths.dropFirst())
        let restOfName = Array(names.dropFirst())
        if restOfPath.isEmpty {
            let fileReferences: [PBXFileReference] = group.children.compactMap { $0 as? PBXFileReference }
            return fileReferences.first { ($0.path == nil || $0.path == path) && $0.nameOrPath == name }
        } else {
            let groups = group.children.compactMap { $0 as? PBXGroup }
            guard let group = groups.first(where: { ($0.path == nil || $0.path == path) && $0.nameOrPath == name }) else {
                return nil
            }
            return getFileReference(group: group, paths: restOfPath, names: restOfName)
        }
    }
}
