import Foundation import ProjectSpec import XcodeProj import PathKit public func generateSpec(xcodeProj: XcodeProj, projectDirectory: Path) throws -> Project? { guard let pbxproj = xcodeProj.pbxproj.rootObject else { return nil } return try generateProjectSpec(pbxproj: pbxproj, projectDirectory: projectDirectory) } private func generateProjectSpec(pbxproj: PBXProject, projectDirectory: Path) throws -> Project { 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 proj = Project(basePath: Path(pbxproj.projectDirPath), name: pbxproj.name, targets: targets, aggregateTargets: aggregateTargets, settings: settings, options: options, attributes: pbxproj.attributes) return try removeDefault(project: proj, sourceRoot: sourceRoot) } private extension BuildSettingsProvider.Variant { init(_ configType: ConfigType) { switch configType { case .debug: self = .debug case .release: self = .release } } } 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 } 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 } } 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 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 BuildSettings { 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?.compactMap { $0.file } } .reduce([], { $0 + $1 }) let dependencies: [Dependency] = frameworks.compactMap { fileElement in guard let path = fileElement.path else { return nil } if let sourceTree = fileElement.sourceTree, sourceTree == .sdkRoot { return Dependency(type: .sdk(root: Path(path).parent().string), reference: fileElement.name ?? Path(path).lastComponent) } return Dependency(type: .framework, reference: path) } let targetSources = sources + headers + implicitHeaders + resources let buildScripts = target.buildPhases .compactMap { $0 as? PBXShellScriptBuildPhase } .map(BuildScript.init) 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, postBuildScripts: buildScripts, buildRules: buildRules) } 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 } } } 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 }) } }