Skip to content
Open
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
16 changes: 16 additions & 0 deletions Sources/XcodeGenCLI/Commands/GenerateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class GenerateCommand: ProjectCommand {
@Flag("--only-plists", description: "Generate only plist files")
var onlyPlists: Bool

@Flag("--dry-run", description: "Generate project in memory and print a JSON diff of what would change without writing any files")
var dryRun: Bool

init(version: Version) {
super.init(version: version,
name: "generate",
Expand Down Expand Up @@ -110,6 +113,19 @@ class GenerateCommand: ProjectCommand {
throw GenerationError.generationError(error)
}

// dry-run: diff and exit without writing
if dryRun {
let existingPbxprojPath = XcodeProj.pbxprojPath(projectPath)
let existingXcodeprojPath = projectExists ? projectPath : nil
let diff = ProjectDiff(from: xcodeProject, against: existingXcodeprojPath)
do {
stdout.print(try diff.jsonString())
} catch {
throw GenerationError.writingError(error)
}
return
}

// write project
info("⚙️ Writing project...")
do {
Expand Down
99 changes: 99 additions & 0 deletions Sources/XcodeGenCLI/Commands/ValidateCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Foundation
import PathKit
import ProjectSpec
import SwiftCLI
import XcodeGenKit
import Version

class ValidateCommand: ProjectCommand {

init(version: Version) {
super.init(version: version,
name: "validate",
shortDescription: "Validate the project spec without generating a project")
}

// Fully override execute() so that parsing errors are also captured as JSON
override func execute() throws {
var specPaths: [Path] = []
if let spec = spec {
specPaths = spec.components(separatedBy: ",").map { Path($0).absolute() }
} else {
specPaths = [Path("project.yml").absolute()]
}

var allErrors: [ValidationIssue] = []
var allWarnings: [ValidationIssue] = []

for specPath in specPaths {
guard specPath.exists else {
allErrors.append(ValidationIssue(stage: "parsing",
message: "No project spec found at \(specPath)"))
continue
}

let specLoader = SpecLoader(version: version)
let variables: [String: String] = disableEnvExpansion ? [:] : ProcessInfo.processInfo.environment

let project: Project
do {
project = try specLoader.loadProject(path: specPath, projectRoot: projectRoot, variables: variables)
} catch {
allErrors.append(ValidationIssue(stage: "parsing", message: error.localizedDescription))
continue
}

do {
try specLoader.validateProjectDictionaryWarnings()
} catch let e as SpecValidationError {
allWarnings += e.errors.map { ValidationIssue(stage: "validation", message: $0.description) }
} catch {
allWarnings.append(ValidationIssue(stage: "validation", message: error.localizedDescription))
}

do {
try project.validateMinimumXcodeGenVersion(version)
try project.validate()
} catch let e as SpecValidationError {
allErrors += e.errors.map { ValidationIssue(stage: "validation", message: $0.description) }
} catch {
allErrors.append(ValidationIssue(stage: "validation", message: error.localizedDescription))
}
}

let result = ValidationResult(valid: allErrors.isEmpty, errors: allErrors, warnings: allWarnings)
stdout.print(try result.jsonString())

if !result.valid {
throw ValidationFailed()
}
}

// Not called — execute() is fully overridden above
override func execute(specLoader: SpecLoader, projectSpecPath: Path, project: Project) throws {}
}

// MARK: - JSON output types

private struct ValidationIssue: Encodable {
let stage: String
let message: String
}

private struct ValidationResult: Encodable {
let valid: Bool
let errors: [ValidationIssue]
let warnings: [ValidationIssue]

func jsonString() throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(self)
return String(data: data, encoding: .utf8)!
}
}

private struct ValidationFailed: ProcessError {
var message: String? { nil }
var exitStatus: Int32 { 1 }
}
1 change: 1 addition & 0 deletions Sources/XcodeGenCLI/XcodeGenCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class XcodeGenCLI {
generateCommand,
CacheCommand(version: version),
DumpCommand(version: version),
ValidateCommand(version: version),
]
)
cli.parser.routeBehavior = .searchWithFallback(generateCommand)
Expand Down
61 changes: 61 additions & 0 deletions Sources/XcodeGenKit/ProjectDiff.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation
import XcodeProj
import PathKit

/// Computes a structural diff between two XcodeProj objects.
/// Comparison is by name/path — UUIDs are ignored for stability.
public struct ProjectDiff: Encodable {

public let changed: Bool
public let targetsAdded: [String]
public let targetsRemoved: [String]
public let filesAdded: [String]
public let filesRemoved: [String]

public init(from newProject: XcodeProj, against existingPath: Path?) {
let newTargetNames = Set(newProject.pbxproj.nativeTargets.map { $0.name })
let newFilePaths = ProjectDiff.sourcePaths(from: newProject)

guard let existingPath = existingPath, existingPath.exists,
let existing = try? XcodeProj(path: existingPath) else {
// No existing project — everything is "added"
targetsAdded = newTargetNames.sorted()
targetsRemoved = []
filesAdded = newFilePaths.sorted()
filesRemoved = []
changed = !targetsAdded.isEmpty || !filesAdded.isEmpty
return
}

let existingTargetNames = Set(existing.pbxproj.nativeTargets.map { $0.name })
let existingFilePaths = ProjectDiff.sourcePaths(from: existing)

targetsAdded = newTargetNames.subtracting(existingTargetNames).sorted()
targetsRemoved = existingTargetNames.subtracting(newTargetNames).sorted()
filesAdded = newFilePaths.subtracting(existingFilePaths).sorted()
filesRemoved = existingFilePaths.subtracting(newFilePaths).sorted()
changed = !targetsAdded.isEmpty || !targetsRemoved.isEmpty
|| !filesAdded.isEmpty || !filesRemoved.isEmpty
}

private static func sourcePaths(from project: XcodeProj) -> Set<String> {
var paths = Set<String>()
for target in project.pbxproj.nativeTargets {
let files = (try? target.sourceFiles()) ?? []
for file in files {
if let path = file.path {
paths.insert(path)
}
}
}
return paths
}

public func jsonString() throws -> String {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(self)
return String(data: data, encoding: .utf8)!
}
}