Files
XcodeGen/Sources/XcodeGenKit/SpecGenerator.swift
2022-01-05 13:50:25 +11:00

685 lines
27 KiB
Swift

import Foundation
import ProjectSpec
import XcodeProj
import PathKit
public enum SpecGenerationError: Error, CustomStringConvertible {
case rootObjectNotFound
public var description: String {
switch self {
case .rootObjectNotFound:
return "Project does not contain root project"
}
}
}
/**
Generate project spec from XcodeProj
Basically, it is a process of mapping the types defined in XcodeProj to the types defined in XcodeGen.
XcodeProj -> Project
PBXNativeTarget -> Target
For one-to-one relationship between types, the conversion is implemented by extension, and for other types, the mapping is done by functions such as generateTargetSpec.
*/
public func generateSpec(xcodeProj: XcodeProj, projectDirectory: Path) throws -> Project {
guard let pbxproj = xcodeProj.pbxproj.rootObject else {
throw SpecGenerationError.rootObjectNotFound
}
let sourceRoot = projectDirectory + pbxproj.projectDirPath
let targets = try pbxproj.targets
.compactMap { $0 as? PBXNativeTarget }
.map { try generateTargetSpec(target: $0,
mainGroup: pbxproj.mainGroup,
sourceRoot: sourceRoot) }
let aggregateTargets = pbxproj.targets
.compactMap { $0 as? PBXAggregateTarget }
.map(AggregateTarget.init)
let configSettings = Dictionary(uniqueKeysWithValues: pbxproj.buildConfigurationList.buildConfigurations.map {
($0.name, Settings(buildSettings: $0.buildSettings))
})
let settings = Settings(buildSettings: [:], configSettings: configSettings, groups: [])
let options = SpecOptions(defaultConfig: pbxproj.buildConfigurationList.defaultConfigurationName)
let schems = xcodeProj.sharedData?.schemes.compactMap(Scheme.init) ?? []
let configs = pbxproj.buildConfigurationList.buildConfigurations.map {
Config(name: $0.name, type: $0.buildSettings.configType)
}
let proj = Project(basePath: Path(pbxproj.projectDirPath),
name: pbxproj.name,
configs: configs,
targets: targets,
aggregateTargets: aggregateTargets,
settings: settings,
schemes: schems,
options: options,
attributes: pbxproj.attributes
)
var optimizedProj = try removeDefault(project: proj, sourceRoot: sourceRoot)
optimizedProj = deintegrateCocoapods(optimizedProj)
optimizedProj = deintegrateCarthage(optimizedProj)
return optimizedProj
}
private extension BuildSettings {
var configType: ConfigType {
guard let args = self["GCC_PREPROCESSOR_DEFINITIONS"] as? [String] else {
return .release
}
return args.contains("DEBUG=1") ? .debug : .release
}
func subtracting(_ other: BuildSettings) -> BuildSettings {
func isEqualValue(_ a: Any, _ b: Any) -> Bool {
switch (a, b) {
case let (a, b) as (String, String):
return a != b
case let (a, b) as (Bool, Bool):
return a != b
case let (a, b) as (Double, Double):
return a != b
case let (a, b) as ([Any], [Any]):
return zip(a, b).allSatisfy(isEqualValue)
default:
return false
}
}
return filter {
guard let otherValue = other[$0.key] else {
return true
}
return isEqualValue($0.value, otherValue)
}
}
}
private func generateTargetSpec(target: PBXNativeTarget, mainGroup: PBXGroup, sourceRoot: Path) throws -> Target {
let sources = try target.sourceFiles().compactMap { fileElement -> TargetSource? in
guard let path = try fileElement.fullPath(sourceRoot: sourceRoot) else {
return nil
}
return TargetSource(path: try path.relativePath(from: sourceRoot).string,
name: fileElement.name)
}
let headers = try target.buildPhases
.compactMap { $0 as? PBXHeadersBuildPhase }
.compactMap { $0.files }
.reduce([], { $0 + $1 })
.compactMap { buildFile -> TargetSource? in
guard let fileElement = buildFile.file,
let path = try fileElement.fullPath(sourceRoot: sourceRoot) else {
return nil
}
let headerVisibility = TargetSource.HeaderVisibility(attribute: (buildFile.settings?["ATTRIBUTES"] as? [String])?.first)
return TargetSource(path: try path.relativePath(from: sourceRoot).string,
name: fileElement.name,
headerVisibility: headerVisibility)
}
// For application targets, header files are not included in the build phase. The project should also contain header files, so search for them from groups and add to source.
let implicitHeaders: [TargetSource]
if let productType = target.productType, productType == .application || productType == .unitTestBundle {
let targetRootGroup = mainGroup.children
.compactMap { $0 as? PBXGroup }
.first { $0.path == target.name }
let headerFiles = targetRootGroup?.allHeaderFiles ?? []
implicitHeaders = try headerFiles
.compactMap { fileElement -> TargetSource? in
guard let path = try fileElement.fullPath(sourceRoot: sourceRoot) else {
return nil
}
return TargetSource(path: try path.relativePath(from: sourceRoot).string,
name: fileElement.name)
}
} else {
implicitHeaders = []
}
let resources = try target.resourcesBuildPhase()?.files?
.compactMap { $0.file }
.compactMap { fileElement -> [TargetSource]? in
if let variantGroup = fileElement as? PBXVariantGroup {
return try variantGroup.children.compactMap { fileElement -> TargetSource? in
guard let parent = try fileElement.parent?.parent?.fullPath(sourceRoot: sourceRoot),
let path = fileElement.path else {
return nil
}
let fullpath = parent + path
return TargetSource(path: try fullpath.relativePath(from: sourceRoot).string,
name: fileElement.name)
}
}
guard let path = try fileElement.fullPath(sourceRoot: sourceRoot) else {
return nil
}
return [TargetSource(path: try path.relativePath(from: sourceRoot).string,
name: fileElement.name)]
}.reduce([], { $0 + $1 }) ?? []
let frameworks = target.buildPhases
.compactMap { $0 as? PBXFrameworksBuildPhase }
.compactMap { $0.files }
.reduce([], { $0 + $1 })
let targetDependencies: [Dependency] = target.dependencies.compactMap {
guard let name = $0.target?.name else {
return nil
}
return Dependency(type: .target, reference: name)
}
let targetDependencyProductNames = target.dependencies.compactMap { $0.target?.productNameWithExtension() }
let frameworkDependencies: [Dependency] = frameworks.compactMap { file in
guard let fileElement = file.file,
let path = fileElement.path else {
return nil
}
if let sourceTree = fileElement.sourceTree {
switch sourceTree {
case .sdkRoot:
return Dependency(type: .sdk(root: Path(path).parent().string),
reference: fileElement.name ?? Path(path).lastComponent)
case .buildProductsDir:
let file = Path(path).lastComponent
if targetDependencyProductNames.contains(file) {
return nil // skip target dependency
}
return Dependency(type: .target,
reference: file)
default:
break
}
}
return Dependency(type: .framework, reference: path)
}
let dependencies = targetDependencies + frameworkDependencies
let targetSources = sources + headers + implicitHeaders + resources
var preBuildScripts = [BuildScript]()
var postCompileScripts: [BuildScript]?
var postBuildScripts: [BuildScript]?
for buildPhase in target.buildPhases {
if postBuildScripts != nil {
if let buildPhase = buildPhase as? PBXShellScriptBuildPhase {
postBuildScripts?.append(BuildScript(buildPhase: buildPhase))
}
} else if postCompileScripts != nil {
// Scripts between the compile and non-script phases
if let buildPhase = buildPhase as? PBXShellScriptBuildPhase {
postCompileScripts?.append(BuildScript(buildPhase: buildPhase))
} else {
postBuildScripts = [BuildScript]()
}
} else {
// Script before the compile phase
if buildPhase is PBXSourcesBuildPhase {
postCompileScripts = [BuildScript]()
} else if let buildPhase = buildPhase as? PBXShellScriptBuildPhase {
preBuildScripts.append(BuildScript(buildPhase: buildPhase))
}
}
}
let buildRules = target.buildRules.map(BuildRule.init)
let sdkRoot = target.settings.buildSettings["SDKROOT"] as? String
let platform = Platform.allCases.first { $0.sdkRoot == sdkRoot } ?? .iOS
return Target(name: target.name,
type: target.productType ?? .application,
platform: platform,
productName: target.productName,
settings: target.settings,
sources: targetSources,
dependencies: dependencies,
preBuildScripts: preBuildScripts,
postCompileScripts: postCompileScripts ?? [],
postBuildScripts: postBuildScripts ?? [],
buildRules: buildRules)
}
// MARK: - Cleaning the spec
private func removeDefault(project: Project, sourceRoot: Path) throws -> Project {
func removeDefaultsFromProjectSettings(_ settings: Settings) -> Settings {
var newSettings = settings
for case (let key, var settings) in newSettings.configSettings {
let variant = BuildSettingsProvider.Variant(key) ?? .debug
let defaultSettings = BuildSettingsProvider.projectDefault(variant: .all)
.merged(BuildSettingsProvider.projectDefault(variant: variant))
settings.buildSettings = settings.buildSettings.subtracting(defaultSettings)
newSettings.configSettings[key] = settings
}
return newSettings
}
func removeDefaultsFromTargetSettings(_ settings: Settings, in target: Target) -> Settings {
var newSettings = settings
for case (let key, var settings) in newSettings.configSettings {
let variant = BuildSettingsProvider.Variant(key) ?? .debug
let projectBuildSettings = project.settings.configSettings[key]?.buildSettings
let sdkRoot = projectBuildSettings?["SDKROOT"] as? String
let platform = BuildSettingsProvider.Platform(sdkRoot: sdkRoot)
let product = BuildSettingsProvider.Product(product: target.type)
let swift = projectBuildSettings?["SWIFT_OPTIMIZATION_LEVEL"] as? String != nil
let defaultSettings = BuildSettingsProvider.projectDefault(variant: .all)
.merged(BuildSettingsProvider.targetDefault(
variant: variant,
platform: platform,
product: product,
swift: swift))
settings.buildSettings = settings.buildSettings.subtracting(defaultSettings)
newSettings.configSettings[key] = settings
}
return newSettings
}
var project = project
project.settings = removeDefaultsFromProjectSettings(project.settings)
project.targets = try project.targets.map { target in
var target = target
target.settings = removeDefaultsFromTargetSettings(target.settings, in: target)
target.sources = try optimizeSources(target.sources, sourceRoot: sourceRoot)
return target
}
return project
}
private func optimizeSources(_ sources: [TargetSource], sourceRoot: Path) throws -> [TargetSource] {
let allSourcePaths = sources.map { sourceRoot + Path($0.path) }
var merged = [TargetSource]()
let completed = try sources
.sorted { Path($0.path).components.count > Path($1.path).components.count }
.compactMap { targetSource -> TargetSource? in
let parent = (sourceRoot + Path(targetSource.path)).parent()
// skip when parent directory is already added
if merged.contains(where: { (sourceRoot + Path($0.path)) == parent }) {
return nil
}
let sameLevelFiles = try parent.children().filter {
// ingore files that will specified in build configs
$0.lastComponent != "Info.plist" &&
$0.lastComponent != ".DS_Store" &&
$0.extension != "modulemap" &&
$0.extension != "entitlements"
}
// merge files into a directory if all its contents are in the target
if sameLevelFiles.allSatisfy({ allSourcePaths.contains($0) }) {
merged.append(TargetSource(path: try parent.relativePath(from: sourceRoot).string,
name: parent.lastComponent))
return nil
}
return targetSource
}
let result = merged.count > 0 ? try optimizeSources(completed + merged, sourceRoot: sourceRoot) : completed
return result.sorted { $0.path < $1.path }
}
// MARK: - Cocoapods/Carthage deintegration
private extension PBXGroup {
var allHeaderFiles: [PBXFileElement] {
return children.compactMap { file in
if let group = file as? PBXGroup {
return group.allHeaderFiles
}
if let path = file.path,
path.hasSuffix(".h") || path.hasSuffix(".hpp") {
return [file]
}
return nil
}.reduce([], { $0 + $1 })
}
}
private func deintegrateCocoapods(_ project: Project) -> Project {
var p = project
p.targets = p.targets.map(deintegrateCocoapods)
return p
}
private func not<T>(_ fn: @escaping (T) -> Bool) -> (T) -> Bool {
return { v in !fn(v) }
}
private func deintegrateCocoapods(target: Target) -> Target {
func isCocoapodsBuildScript(buildScript: BuildScript) -> Bool {
// https://github.com/CocoaPods/CocoaPods/blob/master/lib/cocoapods/installer/user_project_integrator/target_integrator.rb#L16
return buildScript.name?.starts(with: "[CP] ") ?? false
}
var t = target
t.preBuildScripts = target.preBuildScripts.filter(not(isCocoapodsBuildScript))
t.postCompileScripts = target.postCompileScripts.filter(not(isCocoapodsBuildScript))
t.postBuildScripts = target.postBuildScripts.filter(not(isCocoapodsBuildScript))
// https://github.com/CocoaPods/cocoapods-deintegrate/blob/master/lib/cocoapods/deintegrator.rb#L5
let frameworkNames = try! NSRegularExpression(pattern: "^(libPods.*\\.a)|(Pods.*\\.framework)$")
t.dependencies = t.dependencies.filter {
!frameworkNames.isMatch(to: $0.reference)
}
return t
}
private func deintegrateCarthage(_ project: Project) -> Project {
var p = project
p.targets = p.targets.map(deintegrateCarthage)
return p
}
private func deintegrateCarthage(target: Target) -> Target {
func isCarthageBuildScript(buildScript: BuildScript) -> Bool {
guard case .script(let script) = buildScript.script else {
return false
}
return script.contains("carthage copy-frameworks")
}
var t = target
t.preBuildScripts = target.preBuildScripts.filter(not(isCarthageBuildScript))
t.postCompileScripts = target.postCompileScripts.filter(not(isCarthageBuildScript))
t.postBuildScripts = target.postBuildScripts.filter(not(isCarthageBuildScript))
t.dependencies = t.dependencies.map {
if $0.reference.starts(with: "Carthage/Build/") {
return Dependency(
type: .carthage(findFrameworks: nil,
linkType: .default),
reference: Path($0.reference).lastComponentWithoutExtension
)
}
return $0
}
let frameworkSearchPaths = "FRAMEWORK_SEARCH_PATHS"
for key in t.settings.configSettings.keys {
if let searchPaths = t.settings.configSettings[key]?.buildSettings[frameworkSearchPaths] as? [String] {
t.settings.configSettings[key]?.buildSettings[frameworkSearchPaths] = searchPaths.filter {
!$0.starts(with: "$(PROJECT_DIR)/Carthage/Build")
}
}
}
return t
}
// MARK: - Extensions for type conversion
private extension BuildSettingsProvider.Variant {
init(_ configType: ConfigType) {
switch configType {
case .debug: self = .debug
case .release: self = .release
}
}
}
private extension BuildSettingsProvider.Product {
init?(product: PBXProductType) {
switch product {
case .bundle:
self = .bundle
case .application, .messagesApplication, .watch2App, .watchApp:
self = .application
case .framework, .staticFramework:
self = .framework
case .uiTestBundle:
self = .uiTests
case .unitTestBundle:
self = .unitTests
default:
return nil
}
}
}
private extension BuildSettingsProvider.Platform {
init?(sdkRoot: String?) {
guard let sdkRoot = sdkRoot else {
return nil
}
switch sdkRoot {
case "iphoneos": self = .iOS
case "appletvos": self = .tvOS
case "watchos": self = .watchOS
case "macosx": self = .macOS
default: return nil
}
}
}
private extension BuildSettingsProvider.Variant {
init?(_ string: String) {
switch string {
case "Debug":
self = .debug
case "Release":
self = .release
default:
return nil
}
}
}
private extension BuildScript {
init(buildPhase: PBXShellScriptBuildPhase) {
self.init(script: .script(buildPhase.shellScript ?? ""),
name: buildPhase.name,
inputFiles: buildPhase.inputPaths,
outputFiles: buildPhase.outputPaths,
inputFileLists: buildPhase.inputFileListPaths ?? [],
outputFileLists: buildPhase.outputFileListPaths ?? [],
shell: buildPhase.shellPath,
runOnlyWhenInstalling: buildPhase.runOnlyForDeploymentPostprocessing,
showEnvVars: buildPhase.showEnvVarsInLog)
}
}
private extension PBXTarget {
var settings: Settings {
let configSettings = buildConfigurationList?.buildConfigurations.map {
($0.name, Settings(buildSettings: $0.buildSettings))
}
return Settings(configSettings: configSettings.flatMap(Dictionary.init) ?? [:])
}
}
private extension AggregateTarget {
init(target: PBXAggregateTarget) {
let buildScripts = target.buildPhases
.compactMap { $0 as? PBXShellScriptBuildPhase }
.map(BuildScript.init)
self.init(
name: target.name,
targets: target.dependencies.compactMap { $0.target?.name },
settings: target.settings,
buildScripts: buildScripts)
}
}
private extension BuildRule {
init(buildRule: PBXBuildRule) {
let fileType: BuildRule.FileType
if let filePatterns = buildRule.filePatterns {
fileType = .pattern(filePatterns)
} else {
fileType = .type(buildRule.fileType)
}
let compilerSpec: BuildRule.Action
if buildRule.compilerSpec == "com.apple.compilers.proxy.script" {
compilerSpec = .script(buildRule.script ?? "")
} else {
compilerSpec = .compilerSpec(buildRule.compilerSpec)
}
self.init(fileType: fileType,
action: compilerSpec,
name: buildRule.name,
outputFiles: buildRule.outputFiles,
outputFilesCompilerFlags: buildRule.outputFilesCompilerFlags ?? [])
}
}
private extension TargetSource.HeaderVisibility {
init?(attribute: String?) {
guard let attribute = attribute else {
return nil
}
switch attribute {
case TargetSource.HeaderVisibility.private.settingName:
self = .private
case TargetSource.HeaderVisibility.public.settingName:
self = .public
case TargetSource.HeaderVisibility.project.settingName:
self = .project
default:
return nil
}
}
}
private extension Scheme {
init?(scheme: XCScheme) {
guard let buildAction = scheme.buildAction,
let buildableReference = buildAction.buildActionEntries.first?.buildableReference else {
return nil
}
self.init(
name: scheme.name,
build: Scheme.Build(
targets: [BuildTarget(target: TargetReference(name: buildableReference.blueprintName, location: .local))],
parallelizeBuild: buildAction.parallelizeBuild,
buildImplicitDependencies: buildAction.buildImplicitDependencies,
preActions: buildAction.preActions.map(Scheme.ExecutionAction.init),
postActions: buildAction.postActions.map(Scheme.ExecutionAction.init)
),
run: scheme.launchAction.flatMap { launchAction in
Scheme.Run(
config: launchAction.buildConfiguration,
executable: launchAction.runnable?.buildableReference?.blueprintName,
commandLineArguments: launchAction.commandlineArguments?.toDictionary() ?? [:],
preActions: launchAction.preActions.map(Scheme.ExecutionAction.init),
postActions: launchAction.postActions.map(Scheme.ExecutionAction.init),
environmentVariables: launchAction.environmentVariables ?? [],
disableMainThreadChecker: launchAction.disableMainThreadChecker,
stopOnEveryMainThreadCheckerIssue: launchAction.stopOnEveryMainThreadCheckerIssue,
language: launchAction.language,
region: launchAction.region,
askForAppToLaunch: launchAction.askForAppToLaunch,
launchAutomaticallySubstyle: launchAction.launchAutomaticallySubstyle,
debugEnabled: !launchAction.selectedDebuggerIdentifier.isEmpty,
simulateLocation: launchAction.locationScenarioReference.flatMap {
SimulateLocation(allow: launchAction.allowLocationSimulation,
defaultLocation: $0.identifier)
},
customLLDBInit: launchAction.customLLDBInitFile)
},
test: scheme.testAction.flatMap { testAction in
let targets = testAction.testables.map {
Scheme.Test.TestTarget(
targetReference: TargetReference(
name: $0.buildableReference.blueprintName,
location: .local),
randomExecutionOrder: $0.parallelizable,
parallelizable: $0.parallelizable,
skipped: $0.skipped,
skippedTests: $0.skippedTests.map { $0.identifier })
}
return Scheme.Test(
config: testAction.buildConfiguration,
gatherCoverageData: testAction.codeCoverageEnabled,
coverageTargets: testAction.codeCoverageTargets.map {
TargetReference(name: $0.blueprintName, location: .local)
},
disableMainThreadChecker: testAction.disableMainThreadChecker,
randomExecutionOrder: targets.allSatisfy { $0.randomExecutionOrder },
parallelizable: targets.allSatisfy { $0.parallelizable },
commandLineArguments: testAction.commandlineArguments?.toDictionary() ?? [:],
targets: targets,
preActions: testAction.preActions.map(Scheme.ExecutionAction.init),
postActions: testAction.postActions.map(Scheme.ExecutionAction.init),
environmentVariables: testAction.environmentVariables ?? [],
language: testAction.language,
region: testAction.region,
debugEnabled: !testAction.selectedDebuggerIdentifier.isEmpty,
customLLDBInit: testAction.customLLDBInitFile)
},
profile: scheme.profileAction.flatMap {
Scheme.Profile(
config: $0.buildConfiguration,
commandLineArguments: $0.commandlineArguments?.toDictionary() ?? [:],
preActions: $0.preActions.map(Scheme.ExecutionAction.init),
postActions: $0.postActions.map(Scheme.ExecutionAction.init),
environmentVariables: $0.environmentVariables ?? [])
},
analyze: scheme.analyzeAction.flatMap {
Scheme.Analyze(config: $0.buildConfiguration)
},
archive: scheme.archiveAction.flatMap {
Scheme.Archive(
config: $0.buildConfiguration,
customArchiveName: $0.customArchiveName,
revealArchiveInOrganizer: $0.revealArchiveInOrganizer,
preActions: $0.preActions.map(Scheme.ExecutionAction.init),
postActions: $0.postActions.map(Scheme.ExecutionAction.init))
})
}
}
private extension Scheme.ExecutionAction {
init(action: XCScheme.ExecutionAction) {
self.init(
name: action.title,
script: action.scriptText,
settingsTarget: action.environmentBuildable?.blueprintName
)
}
}
private extension XCScheme.CommandLineArguments {
func toDictionary() -> Dictionary<String, Bool> {
return Dictionary(uniqueKeysWithValues: arguments.map { ($0.name, $0.enabled) })
}
}