import Foundation import PathKit import ProjectSpec import xcproj struct SourceFile { let path: Path let fileReference: String let buildFile: PBXBuildFile let buildPhase: BuildPhase? } class SourceGenerator { var rootGroups: Set = [] private var fileReferencesByPath: [String: String] = [:] private var groupsByPath: [Path: ObjectReference] = [:] private var variantGroupsByPath: [Path: ObjectReference] = [:] private let spec: ProjectSpec var addObjectClosure: (String, PBXObject) -> String var targetSourceExcludePaths: Set = [] var defaultExcludedFiles = [ ".DS_Store", ] var targetName: String = "" private(set) var knownRegions: Set = [] init(spec: ProjectSpec, addObjectClosure: @escaping (String, PBXObject) -> String) { self.spec = spec self.addObjectClosure = addObjectClosure } func addObject(id: String, _ object: PBXObject) -> String { return addObjectClosure(id, object) } func createObject(id: String, _ object: T) -> ObjectReference { let reference = addObject(id: id, object) return ObjectReference(reference: reference, object: object) } func getAllSourceFiles(sources: [TargetSource]) throws -> [SourceFile] { return try sources.flatMap { try getSourceFiles(targetSource: $0, path: spec.basePath + $0.path) } } // get groups without build files. Use for Project.fileGroups func getFileGroups(path: String) throws { // TODO: call a seperate function that only creates groups not source files _ = try getGroupSources(targetSource: TargetSource(path: path), path: spec.basePath + path, isBaseGroup: true) } func generateSourceFile(targetSource: TargetSource, path: Path, buildPhase: BuildPhase? = nil) -> SourceFile { let fileReference = fileReferencesByPath[path.string.lowercased()]! var settings: [String: Any] = [:] let chosenBuildPhase: BuildPhase? if let buildPhase = buildPhase { chosenBuildPhase = buildPhase } else if let buildPhase = targetSource.buildPhase { chosenBuildPhase = buildPhase.buildPhase } else { chosenBuildPhase = getDefaultBuildPhase(for: path) } if chosenBuildPhase == .headers { let headerVisibility = targetSource.headerVisibility ?? .public if headerVisibility != .project { // Xcode doesn't write the default of project settings["ATTRIBUTES"] = [headerVisibility.settingName] } } if 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 = spec.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 models = (try? path.children()) ?? [] let modelFileReference = models .filter { $0.extension == "xcdatamodel" } .sorted() .map { path in createObject( id: path.byRemovingBase(path: spec.basePath).string, PBXFileReference( sourceTree: .group, lastKnownFileType: "wrapper.xcdatamodel", path: path.lastComponent ) ) } let versionGroup = addObject(id: fileReferencePath.string, XCVersionGroup( currentVersion: modelFileReference.first?.reference, path: fileReferencePath.string, sourceTree: sourceTree, versionGroupType: "wrapper.xcdatamodel", children: modelFileReference.map { $0.reference } )) fileReferencesByPath[fileReferenceKey] = versionGroup return versionGroup } else { let fileReference = createObject( id: path.byRemovingBase(path: spec.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) -> BuildPhase? { if path.lastComponent == "Info.plist" { return nil } if let fileExtension = path.extension { switch fileExtension { case "swift", "m", "mm", "cpp", "c", "S", "xcdatamodeld": return .sources case "h", "hh", "hpp", "ipp", "tpp", "hxx", "def": return .headers 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 { let groupReference: ObjectReference 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 spec base path let isOutOfBasePath = !path.absolute().string.contains(spec.basePath.absolute().string) // has no valid parent paths let isRootPath = isOutOfBasePath || path.parent() == spec.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: spec.basePath).string : path.lastComponent let group = PBXGroup( children: children, sourceTree: .group, name: groupName != groupPath ? groupName : nil, path: groupPath ) groupReference = createObject(id: path.byRemovingBase(path: spec.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 { let variantGroup: ObjectReference if let cachedGroup = variantGroupsByPath[path] { variantGroup = cachedGroup } else { let group = PBXVariantGroup( children: [], name: path.lastComponent, sourceTree: .group ) variantGroup = createObject(id: path.byRemovingBase(path: spec.basePath).string, group) variantGroupsByPath[path] = variantGroup } return variantGroup } /// Collects all the excluded paths within the targetSource private func getSourceExcludes(targetSource: TargetSource) -> Set { let rootSourcePath = spec.basePath + targetSource.path return Set( targetSource.excludes.map { Path.glob("\(rootSourcePath)/\($0)") .map { guard $0.isDirectory else { return [$0] } return (try? $0.recursiveChildren().filter { $0.isFile }) ?? [] } .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(targetSource: TargetSource, path: Path, isBaseGroup: Bool) throws -> (sourceFiles: [SourceFile], groups: [ObjectReference]) { 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(targetSource: targetSource, path: $0) } var groups: [ObjectReference] = [] for path in directories { let subGroups = try getGroupSources(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: spec.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: spec.options.createIntermediateGroups, isBaseGroup: isBaseGroup ) if spec.options.createIntermediateGroups { createIntermediaGroups(for: group.reference, at: path) } groups.insert(group, at: 0) return (allSourceFiles, groups) } /// creates source files private func getSourceFiles(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 = spec.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: spec.basePath, name: targetSource.name ?? folderPath.lastComponent, sourceTree: .sourceRoot, lastKnownFileType: "folder" ) if !createIntermediateGroups { rootGroups.insert(fileReference) } let sourceFile = generateSourceFile(targetSource: targetSource, path: folderPath, buildPhase: targetSource.buildPhase?.buildPhase ?? .resources) sourceFiles.append(sourceFile) sourceReference = fileReference case .file: let parentPath = path.parent() let fileReference = getFileReference(path: path, inPath: parentPath, name: targetSource.name) let sourceFile = generateSourceFile(targetSource: targetSource, path: path) let parentGroup = getGroup(path: parentPath, mergingChildren: [fileReference], createIntermediateGroups: createIntermediateGroups, isBaseGroup: true) sourcePath = parentPath sourceFiles.append(sourceFile) sourceReference = parentGroup.reference case .group: let (groupSourceFiles, groups) = try getGroupSources(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 != spec.basePath && path.string.contains(spec.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) } } }