diff --git a/Sources/ProjectSpec/ProjectReference.swift b/Sources/ProjectSpec/ProjectReference.swift index 9ddc29306..ddd331da4 100644 --- a/Sources/ProjectSpec/ProjectReference.swift +++ b/Sources/ProjectSpec/ProjectReference.swift @@ -4,10 +4,12 @@ import JSONUtilities public struct ProjectReference: Hashable { public var name: String public var path: String + public var spec: String? - public init(name: String, path: String) { + public init(name: String, path: String, spec: String?) { self.name = name self.path = path + self.spec = spec } } @@ -17,6 +19,7 @@ extension ProjectReference: PathContainer { [ .dictionary([ .string("path"), + .string("spec") ]), ] } @@ -26,6 +29,7 @@ extension ProjectReference: NamedJSONDictionaryConvertible { public init(name: String, jsonDictionary: JSONDictionary) throws { self.name = name self.path = try jsonDictionary.json(atKeyPath: "path") + self.spec = jsonDictionary.json(atKeyPath: "spec") } } @@ -33,6 +37,7 @@ extension ProjectReference: JSONEncodable { public func toJSONValue() -> Any { [ "path": path, + "spec": spec ] } } diff --git a/Sources/ProjectSpec/SpecLoader.swift b/Sources/ProjectSpec/SpecLoader.swift index 142d3bf1d..f65f64ca6 100644 --- a/Sources/ProjectSpec/SpecLoader.swift +++ b/Sources/ProjectSpec/SpecLoader.swift @@ -5,6 +5,19 @@ import XcodeProj import Yams import Version +private extension Project { + func referencesSatisfied(with projects: [Project]) -> Bool { + // FIXME: Project.projectReferences do not contain enough information to assess this correclty all the time. + // This is because we don't know the references 'correct' output path until we load the spec (and read the real `name`). + // To overcome this, we should pass some data from `loadedProjects` that helps better resolve a spec path to a loaded project, that way we can be 100% sure... + // we'd also need to be a bit careful about cases where differnet specs reference the same spec to different output directories. This is something we could assess at laod time and error about. + let availableProjectPaths = projects.map({ $0.basePath.absolute() }) + return projectReferences + .filter { $0.spec != nil } + .allSatisfy { availableProjectPaths.contains((basePath + $0.path).parent().absolute()) } + } +} + public class SpecLoader { var project: Project! @@ -15,6 +28,71 @@ public class SpecLoader { self.version = version } + public func loadProjects(path: Path, projectRoot: Path? = nil, variables: [String: String] = [:]) throws -> [Project] { + // 1: Load the root project. + let rootProject = try loadProject(path: path, projectRoot: projectRoot, variables: variables) + + // 2: Find references and recursevly load them until we have everything in memory. + var loadedProjects: [Path: Project] = [rootProject.defaultProjectPath.absolute(): rootProject] + try loadReferencedProjects(in: rootProject, variables: variables, into: &loadedProjects, relativeTo: path.parent()) + + // 3: Order the projects to generate without missing references + var projects: [Project] = [] + while !loadedProjects.isEmpty { + // 4. Find the first project from `loadedProject` that can be generated with the items currently defined in `projects`. This helps determine the correct order to run the generator command. + guard let (key, project) = loadedProjects.first(where: { $0.value.referencesSatisfied(with: projects) }) else { + throw NSError(domain: "", code: 0, userInfo: nil) // TODO: Add to GeneratorError with correct order + } + + // 5. Remove from `loadedProjects` and insert in `projects` since we've now resolved this project + loadedProjects[key] = nil + projects.append(project) + } + + // 4. Return the projects ready for generating in defined order + print("Resolved projects in order:") + projects.enumerated().forEach { print("\($0.offset + 1).", $0.element.defaultProjectPath.string) } + return projects + } + + private func loadReferencedProjects( + in project: Project, + variables: [String: String], + into store: inout [Path: Project], + relativeTo relativePath: Path + ) throws { + // Enumerate dependencies and see if there are other specs to load + for projectReference in project.projectReferences { + // If the refernece doesn't specify a spec then ignore it since we assume that it's a non-generated project + guard let spec = projectReference.spec else { continue } + + // Work out the path to the spec that we need to load + let path = (relativePath + spec).absolute() + + // Work out the directory which the project will be generated into, ignore the project name since that will be decided based on the spec once loaded. + // We might want to warn or error if there re inconsistencies though. + let projectRoot = (relativePath + projectReference.path).parent() + + // Load the project, read the path that it resolved to (this uses the name from inside the spec, rather than the reference name that could be wrong) + let project = try loadProject(path: path, projectRoot: projectRoot, variables: variables) + let projectPath = project.defaultProjectPath.absolute() + + // TODO: Error if a matching loaded project in the `store` originated from a different spec. + // This could be a scenario where two differnet spec files define the same `projectReference.path` but associate the `spec`'s to differnet yaml files. + + // Skip this reference if we've already loaded the project once before, no need to do so twice + guard store[projectPath] == nil else { + continue + } + + // Store the loaded project so that we don't load it again if it's referenced by a different spec + store[projectPath] = project + + // Repeat the process for any references in the newly loaded project + try loadReferencedProjects(in: project, variables: variables, into: &store, relativeTo: path.parent()) + } + } + public func loadProject(path: Path, projectRoot: Path? = nil, variables: [String: String] = [:]) throws -> Project { let spec = try SpecFile(path: path) let resolvedDictionary = spec.resolvedDictionary(variables: variables) diff --git a/Sources/XcodeGenCLI/Commands/GenerateCommand.swift b/Sources/XcodeGenCLI/Commands/GenerateCommand.swift index bfea587e0..238c2d4ac 100644 --- a/Sources/XcodeGenCLI/Commands/GenerateCommand.swift +++ b/Sources/XcodeGenCLI/Commands/GenerateCommand.swift @@ -31,7 +31,9 @@ class GenerateCommand: ProjectCommand { override func execute(specLoader: SpecLoader, projectSpecPath: Path, project: Project) throws { - let projectDirectory = self.projectDirectory?.absolute() ?? projectSpecPath.parent() + // TODO: Is it this easy? + let projectDirectory = project.basePath + let projectPath = project.defaultProjectPath // validate project dictionary do { @@ -40,8 +42,6 @@ class GenerateCommand: ProjectCommand { warning("\(error)") } - let projectPath = projectDirectory + "\(project.name).xcodeproj" - let cacheFilePath = self.cacheFilePath ?? Path("~/.xcodegen/cache/\(projectSpecPath.absolute().string.md5)").absolute() var cacheFile: CacheFile? diff --git a/Sources/XcodeGenCLI/Commands/ProjectCommand.swift b/Sources/XcodeGenCLI/Commands/ProjectCommand.swift index 7866661a1..00293c6db 100644 --- a/Sources/XcodeGenCLI/Commands/ProjectCommand.swift +++ b/Sources/XcodeGenCLI/Commands/ProjectCommand.swift @@ -36,17 +36,19 @@ class ProjectCommand: Command { } let specLoader = SpecLoader(version: version) - let project: Project + let projects: [Project] let variables: [String: String] = disableEnvExpansion ? [:] : ProcessInfo.processInfo.environment do { - project = try specLoader.loadProject(path: projectSpecPath, projectRoot: projectRoot, variables: variables) + projects = try specLoader.loadProjects(path: projectSpecPath, projectRoot: projectRoot, variables: variables) } catch { throw GenerationError.projectSpecParsingError(error) } - try execute(specLoader: specLoader, projectSpecPath: projectSpecPath, project: project) + for project in projects { + try execute(specLoader: specLoader, projectSpecPath: projectSpecPath, project: project) + } } func execute(specLoader: SpecLoader, projectSpecPath: Path, project: Project) throws {} diff --git a/Tests/ProjectSpecTests/ProjectSpecTests.swift b/Tests/ProjectSpecTests/ProjectSpecTests.swift index fe1d015ab..0ef863f19 100644 --- a/Tests/ProjectSpecTests/ProjectSpecTests.swift +++ b/Tests/ProjectSpecTests/ProjectSpecTests.swift @@ -261,7 +261,7 @@ class ProjectSpecTests: XCTestCase { $0.it("fails with invalid project reference path") { var project = baseProject - let reference = ProjectReference(name: "InvalidProj", path: "invalid_path") + let reference = ProjectReference(name: "InvalidProj", path: "invalid_path", spec: nil) project.projectReferences = [reference] try expectValidationError(project, .invalidProjectReferencePath(reference)) } @@ -283,7 +283,7 @@ class ProjectSpecTests: XCTestCase { var project = baseProject let externalProjectPath = fixturePath + "TestProject/AnotherProject/AnotherProject.xcodeproj" project.projectReferences = [ - ProjectReference(name: "validProjectRef", path: externalProjectPath.string) + ProjectReference(name: "validProjectRef", path: externalProjectPath.string, spec: nil) ] project.targets = [ Target( diff --git a/Tests/ProjectSpecTests/SpecLoadingTests.swift b/Tests/ProjectSpecTests/SpecLoadingTests.swift index 052982abb..a65e9e7f6 100644 --- a/Tests/ProjectSpecTests/SpecLoadingTests.swift +++ b/Tests/ProjectSpecTests/SpecLoadingTests.swift @@ -59,7 +59,7 @@ class SpecLoadingTests: XCTestCase { ) try expect(project.projectReferences) == [ - ProjectReference(name: "ProjX", path: "TestProject/Project.xcodeproj"), + ProjectReference(name: "ProjX", path: "TestProject/Project.xcodeproj", spec: nil), ] try expect(project.aggregateTargets) == [ diff --git a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift index 393548a01..0e8b2736b 100644 --- a/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/ProjectGeneratorTests.swift @@ -361,7 +361,7 @@ class ProjectGeneratorTests: XCTestCase { subproject = xcodeProject.pbxproj } let externalProjectPath = fixturePath + "TestProject/AnotherProject/AnotherProject.xcodeproj" - let projectReference = ProjectReference(name: "AnotherProject", path: externalProjectPath.string) + let projectReference = ProjectReference(name: "AnotherProject", path: externalProjectPath.string, spec: nil) var target = app target.dependencies = [ Dependency(type: .target, reference: "AnotherProject/ExternalTarget") diff --git a/Tests/XcodeGenKitTests/SchemeGeneratorTests.swift b/Tests/XcodeGenKitTests/SchemeGeneratorTests.swift index 07cfe748f..30aff44ff 100644 --- a/Tests/XcodeGenKitTests/SchemeGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SchemeGeneratorTests.swift @@ -277,7 +277,7 @@ class SchemeGeneratorTests: XCTestCase { try! writer.writePlists() } let externalProjectPath = fixturePath + "scheme_test/TestProject.xcodeproj" - let projectReference = ProjectReference(name: "ExternalProject", path: externalProjectPath.string) + let projectReference = ProjectReference(name: "ExternalProject", path: externalProjectPath.string, spec: nil) let target = Scheme.BuildTarget(target: .init(name: "ExternalTarget", location: .project("ExternalProject"))) let scheme = Scheme( name: "ExternalProjectScheme", @@ -326,7 +326,7 @@ class SchemeGeneratorTests: XCTestCase { targets: [framework], schemes: [scheme], projectReferences: [ - ProjectReference(name: "TestProject", path: externalProject.string), + ProjectReference(name: "TestProject", path: externalProject.string, spec: nil), ] ) let xcodeProject = try project.generateXcodeProject()