Files
XcodeGen/Sources/XcodeGenKit/SourceGenerator.swift
T
Brentley Jones e6a0af79a8 Allow specifying phase order for copy files phases
Copy Files phases don't stop dependent targets from starting to compile. If some of the files being copied are needed for the dependent target to compile then they need to happen before the Compile Sources phase. This change allows specifying that.
2018-09-19 09:00:59 -05:00

537 lines
22 KiB
Swift

import Foundation
import PathKit
import ProjectSpec
import xcproj
struct SourceFile {
let path: Path
let fileReference: String
let buildFile: PBXBuildFile
let buildPhase: TargetSource.BuildPhase?
}
class SourceGenerator {
var rootGroups: Set<String> = []
private var fileReferencesByPath: [String: String] = [:]
private var groupsByPath: [Path: ObjectReference<PBXGroup>] = [:]
private var variantGroupsByPath: [Path: ObjectReference<PBXVariantGroup>] = [:]
private let project: Project
var addObjectClosure: (String, PBXObject) -> String
var targetSourceExcludePaths: Set<Path> = []
var defaultExcludedFiles = [
".DS_Store",
]
var targetName: String = ""
private(set) var knownRegions: Set<String> = []
init(project: Project, addObjectClosure: @escaping (String, PBXObject) -> String) {
self.project = project
self.addObjectClosure = addObjectClosure
}
func addObject(id: String, _ object: PBXObject) -> String {
return addObjectClosure(id, object)
}
func createObject<T: PBXObject>(id: String, _ object: T) -> ObjectReference<T> {
let reference = addObject(id: id, object)
return ObjectReference(reference: reference, object: object)
}
func getAllSourceFiles(targetType: PBXProductType, sources: [TargetSource]) throws -> [SourceFile] {
return try sources.flatMap { try getSourceFiles(targetType: targetType, targetSource: $0, path: project.basePath + $0.path) }
}
// get groups without build files. Use for Project.fileGroups
func getFileGroups(path: String) throws {
let fullPath = project.basePath + path
_ = try getSourceFiles(targetType: .none, targetSource: TargetSource(path: path), path: fullPath)
}
func generateSourceFile(targetType: PBXProductType, targetSource: TargetSource, path: Path, buildPhase: TargetSource.BuildPhase? = nil) -> SourceFile {
let fileReference = fileReferencesByPath[path.string.lowercased()]!
var settings: [String: Any] = [:]
var chosenBuildPhase: TargetSource.BuildPhase?
let headerVisibility = targetSource.headerVisibility ?? .public
if let buildPhase = buildPhase {
chosenBuildPhase = buildPhase
} else if let buildPhase = targetSource.buildPhase {
chosenBuildPhase = buildPhase
} else {
chosenBuildPhase = getDefaultBuildPhase(for: path, targetType: targetType)
}
if chosenBuildPhase == .headers && targetType == .staticLibrary {
// Static libraries don't support the header build phase
// For public headers they need to be copied
if headerVisibility == .public {
chosenBuildPhase = .copyFiles(TargetSource.BuildPhase.CopyFilesSettings(
destination: .productsDirectory,
subpath: "include/$(PRODUCT_NAME)",
phaseOrder: .preCompile
))
} else {
chosenBuildPhase = nil
}
}
if chosenBuildPhase == .headers {
if headerVisibility != .project {
// Xcode doesn't write the default of project
settings["ATTRIBUTES"] = [headerVisibility.settingName]
}
}
if chosenBuildPhase == .sources && targetSource.compilerFlags.count > 0 {
settings["COMPILER_FLAGS"] = targetSource.compilerFlags.joined(separator: " ")
}
let buildFile = PBXBuildFile(fileRef: fileReference, settings: settings.isEmpty ? nil : settings)
return SourceFile(
path: path,
fileReference: fileReference,
buildFile: buildFile,
buildPhase: chosenBuildPhase
)
}
func getContainedFileReference(path: Path) -> String {
let createIntermediateGroups = project.options.createIntermediateGroups
let parentPath = path.parent()
let fileReference = getFileReference(path: path, inPath: parentPath)
let parentGroup = getGroup(
path: parentPath,
mergingChildren: [fileReference],
createIntermediateGroups: createIntermediateGroups,
isBaseGroup: true
)
if createIntermediateGroups {
createIntermediaGroups(for: parentGroup.reference, at: parentPath)
}
return fileReference
}
func getFileReference(path: Path, inPath: Path, name: String? = nil, sourceTree: PBXSourceTree = .group, lastKnownFileType: String? = nil) -> String {
let fileReferenceKey = path.string.lowercased()
if let fileReference = fileReferencesByPath[fileReferenceKey] {
return fileReference
} else {
let fileReferencePath = path.byRemovingBase(path: inPath)
var fileReferenceName: String? = name ?? fileReferencePath.lastComponent
if fileReferencePath.string == fileReferenceName {
fileReferenceName = nil
}
let lastKnownFileType = lastKnownFileType ?? PBXFileReference.fileType(path: path)
if path.extension == "xcdatamodeld" {
let versionedModels = (try? path.children()) ?? []
// Sort the versions alphabetically
let sortedPaths = versionedModels
.filter { $0.extension == "xcdatamodel" }
.sorted { $0.string.localizedStandardCompare($1.string) == .orderedAscending }
let modelFileReference =
sortedPaths.map { path in
createObject(
id: path.byRemovingBase(path: project.basePath).string,
PBXFileReference(
sourceTree: .group,
lastKnownFileType: "wrapper.xcdatamodel",
path: path.lastComponent
)
)
}
// If no current version path is found we fall back to alphabetical
// order by taking the last item in the sortedPaths array
let currentVersionPath = findCurrentCoreDataModelVersionPath(using: versionedModels) ?? sortedPaths.last
let currentVersion: ObjectReference<PBXFileReference>? = {
guard let indexOf = sortedPaths.index(where: { $0 == currentVersionPath }) else { return nil }
return modelFileReference[indexOf]
}()
let versionGroup = addObject(id: fileReferencePath.string, XCVersionGroup(
currentVersion: currentVersion?.reference,
path: fileReferencePath.string,
sourceTree: sourceTree,
versionGroupType: "wrapper.xcdatamodel",
children: modelFileReference.map { $0.reference }
))
fileReferencesByPath[fileReferenceKey] = versionGroup
return versionGroup
} else {
// For all extensions other than `xcdatamodeld`
let fileReference = createObject(
id: path.byRemovingBase(path: project.basePath).string,
PBXFileReference(
sourceTree: sourceTree,
name: fileReferenceName,
lastKnownFileType: lastKnownFileType,
path: fileReferencePath.string
)
)
fileReferencesByPath[fileReferenceKey] = fileReference.reference
return fileReference.reference
}
}
}
/// returns a default build phase for a given path. This is based off the filename
private func getDefaultBuildPhase(for path: Path, targetType: PBXProductType) -> TargetSource.BuildPhase? {
if path.lastComponent == "Info.plist" {
return nil
}
if let fileExtension = path.extension {
switch fileExtension {
case "swift", "m", "mm", "cpp", "c", "cc", "S", "xcdatamodeld", "metal": return .sources
case "h", "hh", "hpp", "ipp", "tpp", "hxx", "def": return .headers
case "modulemap":
guard targetType == .staticLibrary else { return nil }
return .copyFiles(TargetSource.BuildPhase.CopyFilesSettings(
destination: .productsDirectory,
subpath: "include/$(PRODUCT_NAME)",
phaseOrder: .preCompile
))
case "framework": return .frameworks
case "xpc": return .copyFiles(.xpcServices)
case "xcconfig", "entitlements", "gpx", "lproj", "apns": return nil
default: return .resources
}
}
return nil
}
/// Create a group or return an existing one at the path.
/// Any merged children are added to a new group or merged into an existing one.
private func getGroup(path: Path, name: String? = nil, mergingChildren children: [String], createIntermediateGroups: Bool, isBaseGroup: Bool) -> ObjectReference<PBXGroup> {
let groupReference: ObjectReference<PBXGroup>
if let cachedGroup = groupsByPath[path] {
// only add the children that aren't already in the cachedGroup
cachedGroup.object.children = Array(Set(cachedGroup.object.children + children))
groupReference = cachedGroup
} else {
// lives outside the project base path
let isOutOfBasePath = !path.absolute().string.contains(project.basePath.absolute().string)
// has no valid parent paths
let isRootPath = isOutOfBasePath || path.parent() == project.basePath
// is a top level group in the project
let isTopLevelGroup = (isBaseGroup && !createIntermediateGroups) || isRootPath
let groupName = name ?? path.lastComponent
let groupPath = isTopLevelGroup ?
path.byRemovingBase(path: project.basePath).string :
path.lastComponent
let group = PBXGroup(
children: children,
sourceTree: .group,
name: groupName != groupPath ? groupName : nil,
path: groupPath
)
groupReference = createObject(id: path.byRemovingBase(path: project.basePath).string, group)
groupsByPath[path] = groupReference
if isTopLevelGroup {
rootGroups.insert(groupReference.reference)
}
}
return groupReference
}
/// Creates a variant group or returns an existing one at the path
private func getVariantGroup(path: Path, inPath: Path) -> ObjectReference<PBXVariantGroup> {
let variantGroup: ObjectReference<PBXVariantGroup>
if let cachedGroup = variantGroupsByPath[path] {
variantGroup = cachedGroup
} else {
let group = PBXVariantGroup(
children: [],
sourceTree: .group,
name: path.lastComponent
)
variantGroup = createObject(id: path.byRemovingBase(path: project.basePath).string, group)
variantGroupsByPath[path] = variantGroup
}
return variantGroup
}
/// Collects all the excluded paths within the targetSource
private func getSourceExcludes(targetSource: TargetSource) -> Set<Path> {
let rootSourcePath = project.basePath + targetSource.path
return Set(
targetSource.excludes.map {
Path.glob("\(rootSourcePath)/\($0)")
.map {
guard $0.isDirectory else {
return [$0]
}
return (try? $0.recursiveChildren()) ?? []
}
.reduce([], +)
}
.reduce([], +)
)
}
/// Checks whether the path is not in any default or TargetSource excludes
func isIncludedPath(_ path: Path) -> Bool {
return !defaultExcludedFiles.contains(where: { path.lastComponent.contains($0) })
&& !targetSourceExcludePaths.contains(path)
}
/// Gets all the children paths that aren't excluded
private func getSourceChildren(targetSource: TargetSource, dirPath: Path) throws -> [Path] {
return try dirPath.children()
.filter {
if $0.isDirectory {
let children = try $0.children().filter(isIncludedPath)
return !children.isEmpty
} else if $0.isFile {
return isIncludedPath($0)
} else {
return false
}
}
}
/// creates all the source files and groups they belong to for a given targetSource
private func getGroupSources(targetType: PBXProductType, targetSource: TargetSource, path: Path, isBaseGroup: Bool)
throws -> (sourceFiles: [SourceFile], groups: [ObjectReference<PBXGroup>]) {
let children = try getSourceChildren(targetSource: targetSource, dirPath: path)
let directories = children
.filter { $0.isDirectory && $0.extension == nil && $0.extension != "lproj" }
.sorted { $0.lastComponent < $1.lastComponent }
let filePaths = children
.filter { $0.isFile || $0.extension != nil && $0.extension != "lproj" }
.sorted { $0.lastComponent < $1.lastComponent }
let localisedDirectories = children
.filter { $0.extension == "lproj" }
.sorted { $0.lastComponent < $1.lastComponent }
var groupChildren: [String] = filePaths.map { getFileReference(path: $0, inPath: path) }
var allSourceFiles: [SourceFile] = filePaths.map {
generateSourceFile(targetType: targetType, targetSource: targetSource, path: $0)
}
var groups: [ObjectReference<PBXGroup>] = []
for path in directories {
let subGroups = try getGroupSources(targetType: targetType, targetSource: targetSource, path: path, isBaseGroup: false)
guard !subGroups.sourceFiles.isEmpty else {
continue
}
allSourceFiles += subGroups.sourceFiles
guard let first = subGroups.groups.first else {
continue
}
groupChildren.append(first.reference)
groups += subGroups.groups
}
// find the base localised directory
let baseLocalisedDirectory: Path? = {
func findLocalisedDirectory(by languageId: String) -> Path? {
return localisedDirectories.first { $0.lastComponent == "\(languageId).lproj" }
}
return findLocalisedDirectory(by: "Base") ??
findLocalisedDirectory(by: NSLocale.canonicalLanguageIdentifier(from: project.options.developmentLanguage ?? "en"))
}()
knownRegions.formUnion(localisedDirectories.map { $0.lastComponentWithoutExtension })
// create variant groups of the base localisation first
var baseLocalisationVariantGroups: [PBXVariantGroup] = []
if let baseLocalisedDirectory = baseLocalisedDirectory {
for filePath in try baseLocalisedDirectory.children()
.filter(isIncludedPath)
.sorted() {
let variantGroup = getVariantGroup(path: filePath, inPath: path)
groupChildren.append(variantGroup.reference)
baseLocalisationVariantGroups.append(variantGroup.object)
let sourceFile = SourceFile(
path: filePath,
fileReference: variantGroup.reference,
buildFile: PBXBuildFile(fileRef: variantGroup.reference),
buildPhase: .resources
)
allSourceFiles.append(sourceFile)
}
}
// add references to localised resources into base localisation variant groups
for localisedDirectory in localisedDirectories {
let localisationName = localisedDirectory.lastComponentWithoutExtension
for filePath in try localisedDirectory.children()
.filter(isIncludedPath)
.sorted { $0.lastComponent < $1.lastComponent } {
// find base localisation variant group
// ex: Foo.strings will be added to Foo.strings or Foo.storyboard variant group
let variantGroup = baseLocalisationVariantGroups
.first {
Path($0.name!).lastComponent == filePath.lastComponent
} ?? baseLocalisationVariantGroups.first {
Path($0.name!).lastComponentWithoutExtension == filePath.lastComponentWithoutExtension
}
let fileReference = getFileReference(
path: filePath,
inPath: path,
name: variantGroup != nil ? localisationName : filePath.lastComponent
)
if let variantGroup = variantGroup {
if !variantGroup.children.contains(fileReference) {
variantGroup.children.append(fileReference)
}
} else {
// add SourceFile to group if there is no Base.lproj directory
let sourceFile = SourceFile(
path: filePath,
fileReference: fileReference,
buildFile: PBXBuildFile(fileRef: fileReference),
buildPhase: .resources
)
allSourceFiles.append(sourceFile)
groupChildren.append(fileReference)
}
}
}
let group = getGroup(
path: path,
mergingChildren: groupChildren,
createIntermediateGroups: project.options.createIntermediateGroups,
isBaseGroup: isBaseGroup
)
if project.options.createIntermediateGroups {
createIntermediaGroups(for: group.reference, at: path)
}
groups.insert(group, at: 0)
return (allSourceFiles, groups)
}
/// creates source files
private func getSourceFiles(targetType: PBXProductType, targetSource: TargetSource, path: Path) throws -> [SourceFile] {
// generate excluded paths
targetSourceExcludePaths = getSourceExcludes(targetSource: targetSource)
let type = targetSource.type ?? (path.isFile || path.extension != nil ? .file : .group)
let createIntermediateGroups = project.options.createIntermediateGroups
var sourceFiles: [SourceFile] = []
let sourceReference: String
var sourcePath = path
switch type {
case .folder:
let folderPath = Path(targetSource.path)
let fileReference = getFileReference(
path: folderPath,
inPath: project.basePath,
name: targetSource.name ?? folderPath.lastComponent,
sourceTree: .sourceRoot,
lastKnownFileType: "folder"
)
if !createIntermediateGroups || path.parent() == project.basePath {
rootGroups.insert(fileReference)
}
let buildPhase: TargetSource.BuildPhase?
if let targetBuildPhase = targetSource.buildPhase {
buildPhase = targetBuildPhase
} else {
buildPhase = .resources
}
let sourceFile = generateSourceFile(targetType: targetType, targetSource: targetSource, path: folderPath, buildPhase: buildPhase)
sourceFiles.append(sourceFile)
sourceReference = fileReference
case .file:
let parentPath = path.parent()
let fileReference = getFileReference(path: path, inPath: parentPath, name: targetSource.name)
let sourceFile = generateSourceFile(targetType: targetType, targetSource: targetSource, path: path)
if parentPath == project.basePath {
sourcePath = path
sourceReference = fileReference
rootGroups.insert(fileReference)
} else {
let parentGroup = getGroup(path: parentPath, mergingChildren: [fileReference], createIntermediateGroups: createIntermediateGroups, isBaseGroup: true)
sourcePath = parentPath
sourceReference = parentGroup.reference
}
sourceFiles.append(sourceFile)
case .group:
let (groupSourceFiles, groups) = try getGroupSources(targetType: targetType, targetSource: targetSource, path: path, isBaseGroup: true)
let group = groups.first!
if let name = targetSource.name {
group.object.name = name
}
sourceFiles += groupSourceFiles
sourceReference = group.reference
}
if createIntermediateGroups {
createIntermediaGroups(for: sourceReference, at: sourcePath)
}
return sourceFiles
}
// Add groups for all parents recursively
private func createIntermediaGroups(for groupReference: String, at path: Path) {
let parentPath = path.parent()
guard parentPath != project.basePath && path.string.contains(project.basePath.string) else {
// we've reached the top or are out of the root directory
return
}
let hasParentGroup = groupsByPath[parentPath] != nil
let parentGroup = getGroup(path: parentPath, mergingChildren: [groupReference], createIntermediateGroups: true, isBaseGroup: false)
if !hasParentGroup {
createIntermediaGroups(for: parentGroup.reference, at: parentPath)
}
}
private func findCurrentCoreDataModelVersionPath(using versionedModels: [Path]) -> Path? {
// Find and parse the current version model stored in the .xccurrentversion file
guard
let versionPath = versionedModels.first(where: { $0.lastComponent == ".xccurrentversion" }),
let data = try? versionPath.read(),
let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any],
let versionString = plist?["_XCCurrentVersionName"] as? String else {
return nil
}
return versionedModels.first(where: { $0.lastComponent == versionString })
}
}