Skip to content

Idea - Chaining spec generation using projectReferences #891

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Sources/ProjectSpec/ProjectReference.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -17,6 +19,7 @@ extension ProjectReference: PathContainer {
[
.dictionary([
.string("path"),
.string("spec")
]),
]
}
Expand All @@ -26,13 +29,15 @@ 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")
}
}

extension ProjectReference: JSONEncodable {
public func toJSONValue() -> Any {
[
"path": path,
"spec": spec
]
}
}
78 changes: 78 additions & 0 deletions Sources/ProjectSpec/SpecLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions Sources/XcodeGenCLI/Commands/GenerateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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?
Expand Down
8 changes: 5 additions & 3 deletions Sources/XcodeGenCLI/Commands/ProjectCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
4 changes: 2 additions & 2 deletions Tests/ProjectSpecTests/ProjectSpecTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion Tests/ProjectSpecTests/SpecLoadingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) == [
Expand Down
2 changes: 1 addition & 1 deletion Tests/XcodeGenKitTests/ProjectGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions Tests/XcodeGenKitTests/SchemeGeneratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down