Skip to content

Instantly share code, notes, and snippets.

@brantwedel
Last active May 14, 2024 05:02
Show Gist options
  • Save brantwedel/5961ed7af9a700bf71d30a9b47f47910 to your computer and use it in GitHub Desktop.
Save brantwedel/5961ed7af9a700bf71d30a9b47f47910 to your computer and use it in GitHub Desktop.
XCode project file sync script
#!/usr/bin/env swift
import Foundation
// usage:
// swift xcode-sync.swift XCodeProject.xcodeproj --sync FolderToSync
struct ANSI {
// Foreground Colors
static let black = "\u{001B}[30m"
static let red = "\u{001B}[31m"
static let green = "\u{001B}[32m"
static let yellow = "\u{001B}[33m"
static let blue = "\u{001B}[34m"
static let magenta = "\u{001B}[35m"
static let cyan = "\u{001B}[36m"
static let white = "\u{001B}[37m"
// Bright Foreground Colors
static let brightBlack = "\u{001B}[30;1m"
static let brightRed = "\u{001B}[31;1m"
static let brightGreen = "\u{001B}[32;1m"
static let brightYellow = "\u{001B}[33;1m"
static let brightBlue = "\u{001B}[34;1m"
static let brightMagenta = "\u{001B}[35;1m"
static let brightCyan = "\u{001B}[36;1m"
static let brightWhite = "\u{001B}[37;1m"
// Dim Foreground Colors
static let dimBlack = "\u{001B}[30;2m"
static let dimRed = "\u{001B}[31;2m"
static let dimGreen = "\u{001B}[32;2m"
static let dimYellow = "\u{001B}[33;2m"
static let dimBlue = "\u{001B}[34;2m"
static let dimMagenta = "\u{001B}[35;2m"
static let dimCyan = "\u{001B}[36;2m"
static let dimWhite = "\u{001B}[37;2m"
// Background Colors
static let blackBackground = "\u{001B}[40m"
static let redBackground = "\u{001B}[41m"
static let greenBackground = "\u{001B}[42m"
static let yellowBackground = "\u{001B}[43m"
static let blueBackground = "\u{001B}[44m"
static let magentaBackground = "\u{001B}[45m"
static let cyanBackground = "\u{001B}[46m"
static let whiteBackground = "\u{001B}[47m"
// Bright Background Colors
static let brightBlackBackground = "\u{001B}[40;1m"
static let brightRedBackground = "\u{001B}[41;1m"
static let brightGreenBackground = "\u{001B}[42;1m"
static let brightYellowBackground = "\u{001B}[43;1m"
static let brightBlueBackground = "\u{001B}[44;1m"
static let brightMagentaBackground = "\u{001B}[45;1m"
static let brightCyanBackground = "\u{001B}[46;1m"
static let brightWhiteBackground = "\u{001B}[47;1m"
// Formatting
static let bold = "\u{001B}[1m"
static let underline = "\u{001B}[4m"
static let italic = "\u{001B}[3m"
static let inverse = "\u{001B}[7m"
// Reset
static let reset = "\u{001B}[0m"
}
var isXcodeTerm = false
extension String {
func matches(_ regex: String) -> Bool {
return self.range(of: regex, options: .regularExpression) != nil
}
func regexReplace(_ regex: String, _ template: String) -> String {
let regex = try! NSRegularExpression(pattern: regex, options: [])
return regex.stringByReplacingMatches(in: self, range: NSRange(location: 0, length: self.count), withTemplate: template)
}
func pathExtension() -> String {
return (self as NSString).pathExtension.lowercased()
}
func pathParts() -> [String] {
return self.split(separator: "/").map { String($0) }
}
func trimmingSuffix(_ suffix: String) -> String {
guard self.hasSuffix(suffix) else { return self }
return String(self.dropLast(suffix.count))
}
/// Trims the specified characters only from the end of the string.
/// - Parameter characters: A set of characters to trim from the end.
/// - Returns: The string after the specified characters have been removed from the end.
func trimmingTrailingCharacters(_ characters: CharacterSet) -> String {
var newString = self
while let last = newString.last, characters.contains(last.unicodeScalars.first!) {
newString = String(newString.dropLast())
}
return newString
}
}
class Entry {
let id: String
let type: String
var path: String
var properties: [String: [String]]
let start: Int
let end: Int
let source: String
init(id: String, type: String, path: String, properties: [String: [String]], start: Int, end: Int, source: String) {
self.id = id
self.type = type
self.path = path
self.properties = properties
self.start = start
self.end = end
self.source = source
}
}
class XcodeProjectParser {
func parseEntries(_ content: String, entryType: String, includeFilter: String? = nil, excludeFilter: String? = nil) -> [Entry] {
// Simplified regex pattern to match the structure of an entry more directly
let pattern = "[ \t]*([A-Z0-9]+)\\s*(/\\*.*\\*/)?\\s*=\\s*\\{[^}]*?isa\\s*=\\s*\(entryType);[^}]*?\\};"
var results = [Entry]()
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let matches = regex.matches(in: content, options: [], range: NSRange(content.startIndex..., in: content))
for match in matches {
guard let range = Range(match.range, in: content) else { continue }
let entryString = String(content[range])
// Apply include and exclude filters to the entire entry string
if let includeFilter = includeFilter, !entryString.matches(includeFilter) {
continue
}
if let excludeFilter = excludeFilter, entryString.matches(excludeFilter) {
continue
}
let id = String(content[Range(match.range(at: 1), in: content)!])
var properties = [String: [String]]()
parseProperties(entryString, into: &properties)
let entry = Entry(id: id, type: entryType, path: properties["path"]?.first ?? properties["name"]?.first ?? "", properties: properties, start: content.distance(from: content.startIndex, to: range.lowerBound), end: content.distance(from: content.startIndex, to: range.upperBound), source: entryString)
results.append(entry)
}
return results
}
private func parseProperties(_ content: String, into properties: inout [String: [String]]) {
let propertiesPattern = "(\\w+)\\s*=([^;]*?)(?=;\\s*(\\w+\\s*=|\\}))" // Lookahead to ensure we end at the correct semicolon
let regex = try! NSRegularExpression(pattern: propertiesPattern, options: [])
let matches = regex.matches(in: content, options: [], range: NSRange(content.startIndex..., in: content))
for match in matches {
if let keyRange = Range(match.range(at: 1), in: content), let valueRange = Range(match.range(at: 2), in: content) {
let key = String(content[keyRange]).trimmingCharacters(in: .whitespacesAndNewlines)
var value = String(content[valueRange]).trimmingCharacters(in: .whitespacesAndNewlines)
// Remove inline comments from the property value
value = removeInlineComments(from: value)
// Parse array or single value
properties[key] = parsePropertyValue(value.trimmingCharacters(in: [" "]))
}
}
}
private func removeInlineComments(from value: String) -> String {
do {
let regex = try NSRegularExpression(pattern: "/\\*.*?\\*/", options: [])
return regex.stringByReplacingMatches(in: value, range: NSRange(value.startIndex..., in: value), withTemplate: "")
} catch {
return value
}
}
private func parsePropertyValue(_ value: String) -> [String] {
if value.hasPrefix("(") && value.hasSuffix(")") {
// Extract values inside parentheses and split by comma not within quotes
let arrayValues = value.dropFirst().dropLast()
return arrayValues.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
} else if value.hasPrefix("\"") && value.hasSuffix("\"") {
return [String(value.dropFirst().dropLast())]
} else {
return [value]
}
}
}
func parsePbxproj() {
let args = CommandLine.arguments // Get all command-line arguments
var absoluteProjectFilePath: String?
var syncDirs = [String]()
var modes = [String]()
var nextIsSyncDirs = false
var nextIsMode = false
// Search command-line arguments for a file path ending with .pbxproj or .xcodeproj
for arg in args {
if arg.hasSuffix(".pbxproj") {
absoluteProjectFilePath = arg
} else if arg.hasSuffix(".xcodeproj") {
absoluteProjectFilePath = arg.appending("/project.pbxproj")
} else {
if arg.starts(with: "-") {
if arg == "-d" || arg == "--directories" || arg == "--sync" || arg == "-s" {
nextIsSyncDirs = true
}
if arg == "--changes" || arg == "--mode" {
nextIsMode = true
nextIsSyncDirs = false
}
if arg == "--fail" || arg == "-f" || arg == "--rebuild" {
modes.append("fail")
}
if arg == "--confirm" || arg == "--ask" || arg == "-c" {
modes.append("confirm")
}
if arg == "--xcode" {
isXcodeTerm = true
}
if arg == "--dry" {
modes.append("dry")
}
} else if nextIsSyncDirs {
syncDirs.append(arg)
} else if nextIsMode {
modes.append(arg)
}
}
}
// If no matching argument is found, scan the current directory for a .xcodeproj file
if absoluteProjectFilePath == nil {
let fileManager = FileManager.default
let currentDirectoryPath = fileManager.currentDirectoryPath
if let enumerator = fileManager.enumerator(atPath: currentDirectoryPath) {
for case let file as String in enumerator {
if file.hasSuffix(".xcodeproj") {
absoluteProjectFilePath = (currentDirectoryPath as NSString).appendingPathComponent(file).appending("/project.pbxproj")
break
}
}
}
}
guard let projectFilePath = absoluteProjectFilePath else {
print("No project file path found.")
return
}
// Update the working directory to the project file's directory
let projectFileURL = URL(fileURLWithPath: projectFilePath)
let projectDir = projectFileURL.deletingLastPathComponent().deletingLastPathComponent().path
FileManager.default.changeCurrentDirectoryPath(projectDir)
guard var content = try? String(contentsOfFile: absoluteProjectFilePath!, encoding: .utf8) else {
print("Failed to read project file.")
return
}
let backupContent = content
let parser = XcodeProjectParser()
var groups = parser.parseEntries(content, entryType: "PBXGroup")
var folderRefs = parser.parseEntries(content, entryType: "PBXFileReference", includeFilter: "lastKnownFileType\\s*=\\s*folder;")
var fileRefs = parser.parseEntries(content, entryType: "PBXFileReference", excludeFilter: "lastKnownFileType\\s*=\\s*folder;")
let targets = parser.parseEntries(content, entryType: "PBXNativeTarget")
var buildPhases = [Entry]()
buildPhases += parser.parseEntries(content, entryType: "PBXSourcesBuildPhase")
buildPhases += parser.parseEntries(content, entryType: "PBXResourcesBuildPhase")
buildPhases += parser.parseEntries(content, entryType: "PBXFrameworksBuildPhase")
let buildFiles = parser.parseEntries(content, entryType: "PBXBuildFile")
for index in groups.indices {
let group = groups[index]
if group.path.isEmpty {
continue
}
if let childrenIds = group.properties["children"] {
for childIndex in groups.indices {
if childrenIds.contains(groups[childIndex].id) {
let child = groups[childIndex]
var childPath = group.path
childPath += "/" + child.path
child.path = childPath // Update child path
groups[childIndex] = child // Reassign modified child back to groups
}
}
}
}
for index in groups.indices {
let group = groups[index]
if group.path == "" {
continue
}
if let childrenIds = group.properties["children"] {
for childIndex in fileRefs.indices {
if childrenIds.contains(fileRefs[childIndex].id) {
let child = fileRefs[childIndex]
var childPath = group.path
childPath += "/" + child.path
child.path = childPath // Update child path
fileRefs[childIndex] = child // Reassign modified child back to groups
}
}
}
}
for index in groups.indices {
let group = groups[index]
if group.path == "" {
continue
}
if let childrenIds = group.properties["children"] {
for childIndex in folderRefs.indices {
if childrenIds.contains(folderRefs[childIndex].id) {
let child = folderRefs[childIndex]
var childPath = group.path
childPath += "/" + child.path
child.path = childPath // Update child path
folderRefs[childIndex] = child // Reassign modified child back to groups
}
}
}
}
// print("\nFOLDERS:")
// logEntries(entries: folderRefs, includedProperties: ["ID", "Path"])
// print("\nGROUPS:")
// logEntries(entries: groups, includedProperties: ["ID", "Path", "children"])
// print("\nREFS:")
// logEntries(entries: fileRefs, includedProperties: ["ID", "Path", "*"])
// print("\nTARGETS:")
// logEntries(entries: targets, includedProperties: ["ID", "name", "buildPhases"])
// print("\nBUILD PHASES:")
// logEntries(entries: buildPhases, includedProperties: ["ID", "Type", "files"])
// print("\nBUILD FILES:")
// logEntries(entries: buildFiles, includedProperties: ["ID", "fileRef", "productRef"])
var allEntries = [Entry]()
allEntries += folderRefs
allEntries += groups
allEntries += fileRefs
allEntries += targets
allEntries += buildPhases
allEntries += buildFiles
var addedFiles = [Entry]()
var removedFiles = [Entry]()
var addedGroups = [Entry]()
var removedGroups = [Entry]()
var updatedGroups = [Entry]()
var addedBuildFiles = [Entry]()
var removedBuildFiles = [Entry]()
var updatedBuildPhases = [Entry]()
for folderIndex in folderRefs.indices {
let folderRef = folderRefs[folderIndex]
if !syncDirs.contains(folderRef.path) {
syncDirs.append(folderRef.path)
}
}
// let fileManager = FileManager.default
for syncDir in syncDirs {
let baseDir = syncDir + "/"
let entries = getAllFiles(in: syncDir, basePath: "", existingGroups: groups, existingFiles: fileRefs)
allEntries += entries
fileRefs.forEach {
let ref = $0
if ref.path.starts(with: baseDir) && !entries.contains(where: { $0.id == ref.id }) {
removedFiles.append(ref)
}
}
folderRefs.forEach {
let ref = $0
if ref.path.starts(with: baseDir) && !entries.contains(where: { $0.id == ref.id }) {
removedFiles.append(ref)
}
}
groups.forEach {
let ref = $0
if (ref.path == baseDir || ref.path.starts(with: baseDir)) && !entries.contains(where: { $0.id == ref.id }) {
removedGroups.append(ref)
}
}
entries.forEach {
let entry = $0
if entry.properties["_new"]?.first == "YES" {
if entry.type == "PBXFileReference" {
addedFiles.append(entry)
} else {
addedGroups.append(entry)
}
}
if entry.properties["_children"] != nil {
updatedGroups.append(entry)
}
}
// print("\nFILES: \(folderRef.path)")
// logEntries(entries: entries, includedProperties: ["ID", "Path", "*"])
// print("\n\n\nOUTPUT: \(folderRef.path)")
// entries.forEach { entry in
// if entry.type == "PBXGroup" {
// // entry.coloredDescription(includedProperties: ["ID", "PATH"])
// print(entry.output(entries: entries))
// }
// }
}
removedFiles.forEach { file in
buildFiles.forEach {
if $0.properties["fileRef"]?.first == file.id {
removedBuildFiles.append($0)
}
}
}
buildPhases.forEach { buildPhase in
var fileIds = buildPhase.properties["files"] ?? [String]()
var hasUpdates = false
fileIds.removeAll(where: { $0 == "" })
removedBuildFiles.forEach { removedBuildFile in
if fileIds.contains(removedBuildFile.id) {
fileIds.removeAll(where: { $0 == removedBuildFile.id })
hasUpdates = true
}
}
addedFiles.forEach { file in
if buildPhase.type == "PBX\(buildPhaseMapping[file.path.pathExtension()] ?? "")BuildPhase" {
var properties = [String: [String]]()
properties["isa"] = ["BuildFile"]
properties["fileRef"] = [file.id]
let buildFileId = "\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(24))"
let entry = Entry(id: buildFileId, type: "PBXBuildFile", path: "", properties: properties, start: 0, end: 0, source: "")
fileIds.append(buildFileId)
allEntries.append(entry)
addedBuildFiles.append(entry)
hasUpdates = true
}
}
fileIds.append("")
if hasUpdates {
buildPhase.properties["_files"] = buildPhase.properties["files"]
buildPhase.properties["files"] = fileIds
updatedBuildPhases.append(buildPhase)
}
}
var anyChanges = false
removedFiles.forEach { entry in
content = content.replacingOccurrences(of: entry.source + "\n", with: "")
anyChanges = true
}
removedBuildFiles.forEach { entry in
content = content.replacingOccurrences(of: entry.source + "\n", with: "")
anyChanges = true
}
removedGroups.forEach { entry in
content = content.replacingOccurrences(of: entry.source + "\n", with: "")
anyChanges = true
}
updatedGroups.forEach { entry in
content = content.replacingOccurrences(of: entry.source, with: entry.output(entries: allEntries))
anyChanges = true
}
updatedBuildPhases.forEach { entry in
content = content.replacingOccurrences(of: entry.source, with: entry.output(entries: allEntries))
anyChanges = true
}
addedFiles.forEach { entry in
content = content.replacingOccurrences(of: "/* End \(entry.type) section */", with: entry.output(entries: allEntries) + "\n/* End \(entry.type) section */")
anyChanges = true
}
addedBuildFiles.forEach { entry in
content = content.replacingOccurrences(of: "/* End \(entry.type) section */", with: entry.output(entries: allEntries) + "\n/* End \(entry.type) section */")
anyChanges = true
}
addedGroups.forEach { entry in
content = content.replacingOccurrences(of: "/* End \(entry.type) section */", with: entry.output(entries: allEntries) + "\n/* End \(entry.type) section */")
anyChanges = true
}
if !anyChanges {
print("No changes to sync")
} else {
// print("\nCHANGES:")
// print("\n\(ANSI.yellow)REMOVED REFS:\(ANSI.reset)")
// logEntries(entries: removedFiles, includedProperties: ["ID", "Path", "Source"], allEntries: allEntries)
// print("\n\(ANSI.yellow)ADDED REFS:\(ANSI.reset)")
// logEntries(entries: addedFiles, includedProperties: ["ID", "Path"], allEntries: allEntries)
// print("\n\(ANSI.yellow)REMOVED BUILD FILES:\(ANSI.reset)")
// logEntries(entries: removedBuildFiles, includedProperties: ["ID", "Source"], allEntries: allEntries)
// print("\n\(ANSI.yellow)ADDED BUILD FILES:\(ANSI.reset)")
// logEntries(entries: addedBuildFiles, includedProperties: ["ID", "Output"], allEntries: allEntries)
// print("\n\(ANSI.yellow)REMOVED GROUPS:\(ANSI.reset)")
// logEntries(entries: removedGroups, includedProperties: ["ID", "Path", "Source"], allEntries: allEntries)
// print("\n\(ANSI.yellow)ADDED GROUPS:\(ANSI.reset)")
// logEntries(entries: addedGroups, includedProperties: ["ID", "Path", "--*"], allEntries: allEntries)
// print("\n\(ANSI.yellow)UPDATED GROUPS:\(ANSI.reset)")
// logEntries(entries: updatedGroups, includedProperties: ["ID", "Path"], allEntries: allEntries)
if isXcodeTerm {
print("Changes:")
} else {
print("\(ANSI.brightYellow)Changes:\(ANSI.reset)")
}
logEntries(entries: updatedBuildPhases, includedProperties: ["ID", "Type"], allEntries: allEntries)
if modes.contains("dry") {
print("")
print("Dry run. Changes have not been synced.")
return
}
if modes.contains("ask") || modes.contains("confirm") {
print("")
if promptForConfirm() != true {
print("")
print("Changes have not been synced.")
return
}
}
// Get current date
let now = Date()
// Get current timestamp in milliseconds
let timestampInMilliseconds = Int(now.timeIntervalSince1970 * 1000)
do {
try backupContent.write(toFile: absoluteProjectFilePath!.replacingOccurrences(of: ".pbxproj", with: ".bak-\(timestampInMilliseconds).pbxproj"), atomically: true, encoding: String.Encoding.utf8)
try backupContent.write(toFile: absoluteProjectFilePath!.replacingOccurrences(of: ".pbxproj", with: ".bak.pbxproj"), atomically: true, encoding: String.Encoding.utf8)
} catch {
print("\nFailed to backup project file. Changes have not been synced.")
return
}
do {
try content.write(toFile: absoluteProjectFilePath!, atomically: true, encoding: String.Encoding.utf8)
} catch {
print("\nFailed to update project file.")
return
}
if modes.contains("fail") {
printToStdErr("\nerror: Changes have synced to project file. Rebuild required.\n")
exit(1)
} else {
print("\nChanges have synced to project file.")
}
}
func printToStdErr(_ message: String) {
let stderr = FileHandle.standardError
if let data = "\(message)\n".data(using: .utf8) {
stderr.write(data)
}
}
// Function to read a line of text, returning nil if Ctrl+C or Escape is pressed
func customReadLine() -> String? {
var line = ""
while true {
if let key = readSingleKey() {
switch key {
case "\u{1B}": // Escape key
return nil
case "\u{03}": // Ctrl+C
print("")
exit(1)
case "\n", "\r", "\r\n": // Enter key
return line
default:
line += key
}
}
}
}
// Function to read a single key press with echoing characters
func readSingleKey() -> String? {
var term = termios()
tcgetattr(STDIN_FILENO, &term)
var oldt = term
// Enable ECHO, disable ICANON and ISIG
term.c_lflag &= ~(UInt(ICANON | ISIG))
term.c_lflag |= UInt(ECHO)
tcsetattr(STDIN_FILENO, TCSANOW, &term)
let key = getchar()
tcsetattr(STDIN_FILENO, TCSANOW, &oldt)
return key == -1 ? nil : String(UnicodeScalar(UInt8(key)))
}
func promptForConfirm() -> Bool? {
print("Changes detected, would you like to update the project file? (yes/no): ", terminator: "")
guard let response = customReadLine()?.lowercased() else {
print("Invalid input.")
return false
}
switch response {
case "yes", "y":
return true
case "no", "n":
return false
default:
print("Please enter 'yes' or 'no'.")
return promptForConfirm() // Recursively prompt again
}
}
}
let fileTypeMapping: [String: String] = [
"swift": "sourcecode.swift",
"m": "sourcecode.c.objc",
"h": "sourcecode.c.h",
"cpp": "sourcecode.cpp.cpp",
"hpp": "sourcecode.cpp.h",
"c": "sourcecode.c.c",
"s": "sourcecode.asm",
"mm": "sourcecode.cpp.objcpp",
"plist": "text.plist.xml",
"json": "text.json",
"png": "image.png",
"jpeg": "image.jpeg",
"jpg": "image.jpeg",
"gif": "image.gif",
"pdf": "image.pdf",
"storyboard": "file.storyboard",
"xib": "file.xib",
"xcassets": "folder.assetcatalog",
"lproj": "folder.localized"
]
let buildPhaseMapping: [String: String] = [
"swift": "Sources",
"m": "Sources",
"cpp": "Sources",
"c": "Sources",
"s": "Sources",
"mm": "Sources",
"json": "Resources",
"png": "Resources",
"jpeg": "Resources",
"jpg": "Resources",
"gif": "Resources",
"pdf": "Resources",
"plist": "Resources",
"storyboard": "Resources",
"xib": "Resources",
"xcassets": "Resources",
"lproj": "Resources"
]
func getAllFiles(in folderPath: String, basePath: String, existingGroups: [Entry] = [], existingFiles: [Entry] = []) -> [Entry] {
let fileManager = FileManager.default
var files = [Entry]()
var children = [Entry]()
// Attempt to retrieve the directory contents
guard let items = try? fileManager.contentsOfDirectory(atPath: folderPath) else { return [] }
for item in items {
let fullPath = "\(folderPath)/\(item)"
var isDir: ObjCBool = false
// Check if the current item is a directory
if fileManager.fileExists(atPath: fullPath, isDirectory: &isDir) {
if isDir.boolValue {
// Recursively gather files from the subdirectory
let groupFiles = getAllFiles(in: fullPath, basePath: basePath, existingGroups: existingGroups, existingFiles: existingFiles)
if groupFiles.last?.type == "PBXGroup" {
children += [groupFiles.last!]
}
files += groupFiles
} else {
// Calculate the relative path and create an Entry
let relativePath = fullPath.replacingOccurrences(of: basePath + "NO_OP_NO_OP/", with: "")
var properties = [String: [String]]()
properties["isa"] = ["PBXFileReference"]
properties["fileEncoding"] = ["4"]
properties["lastKnownFileType"] = ["\(fileTypeMapping[item.pathExtension()] ?? "")"]
properties["path"] = [item]
properties["sourceTree"] = ["<group>"]
var fileId = "\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(24))"
let existingFile = existingFiles.first(where: { $0.path == relativePath })
fileId = existingFile != nil ? existingFile!.id : fileId
properties["_new"] = existingFile != nil ? ["NO"] : ["YES"]
let entry = Entry(id: fileId, type: "PBXFileReference", path: relativePath, properties: properties, start: 0, end: 0, source: "")
children.append(entry)
files.append(entry)
}
}
}
var properties = [String: [String]]()
properties["isa"] = ["PBXGroup"]
properties["children"] = children.map { $0.id } + [""]
properties["path"] = [folderPath.pathParts().last!]
properties["sourceTree"] = ["<group>"]
var groupId = "\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(24))"
let existingGroup = existingGroups.first(where: { $0.path == folderPath })
groupId = existingGroup != nil ? existingGroup!.id : groupId
properties["_new"] = existingGroup != nil ? ["NO"] : ["YES"]
if existingGroup != nil {
if properties["children"]?.sorted() != existingGroup!.properties["children"]?.sorted() {
properties["_children"] = existingGroup!.properties["children"]
}
let groupEntry = Entry(id: groupId, type: "PBXGroup", path: folderPath, properties: properties, start: existingGroup!.start, end: existingGroup!.end, source: existingGroup!.source)
files.append(groupEntry)
} else {
let groupEntry = Entry(id: groupId, type: "PBXGroup", path: folderPath, properties: properties, start: 0, end: 0, source: "")
files.append(groupEntry)
}
return files
}
parsePbxproj()
func logEntries(entries: [Entry], includedProperties: [String], allEntries: [Entry] = []) {
entries.forEach { entry in
print(entry.display(entries: allEntries, detail: true))
// print(entry.coloredDescription(includedProperties: includedProperties, entries: allEntries))
}
}
func wrap(_ str: String) -> String {
if str.contains(" ") || str.contains("<") || str.contains("-") || str.contains("+") || str.contains("=") || str.contains("$") || str.contains("@") || str.contains("(") || str.isEmpty {
return "\"" + str + "\""
} else {
return str
}
}
extension Entry {
// swiftlint:disable:next function_body_length
func output(updateProperties: [String] = ["*"], entries: [Entry] = []) -> String {
if self.type == "PBXResourcesBuildPhase" || self.type == "PBXSourcesBuildPhase" || self.type == "PBXFrameworksBuildPhase" {
var src = """
\t\t\(self.id) /* \(self.type.regexReplace("PBX", "").regexReplace("BuildPhase", "")) */ = {
\t\t\tisa = \(self.type);
\t\t\tbuildActionMask = \(self.properties["buildActionMask"]?.first ?? "2147483647");
\t\t\tfiles = ();
\t\t\trunOnlyForDeploymentPostprocessing = \(wrap(self.properties["runOnlyForDeploymentPostprocessing"]?.first ?? "0"));
\t\t};
"""
// C05B024923C883BD00C692AF /* Resources */ = {
// isa = PBXResourcesBuildPhase;
// buildActionMask = 2147483647;
// files = (
// C4AB60212BEC3AC40008C449 /* InfoBeta.plist in Resources */,
// C4AB600E2BEC2DDC0008C449 /* Core in Resources */,
// C474476B2845CAE8000E00F9 /* Assets.xcassets in Resources */,
// C4AB60302BEC3CC50008C449 /* Main.storyboard in Resources */,
// C4F417082BECA6AC00C7C938 /* GridConfigView.xib in Resources */,
// C4F417112BECA6AC00C7C938 /* PreferencesView.xib in Resources */,
// );
// runOnlyForDeploymentPostprocessing = 0;
// };
// C4B546532819A66700EB2B9B /* Sources */ = {
// isa = PBXSourcesBuildPhase;
// buildActionMask = 2147483647;
// files = (
// C4F417042BECA6AC00C7C938 /* AXManager.swift in Sources */,
// C4F416EF2BECA6AC00C7C938 /* HotKeySelector.swift in Sources */,
// C4F416F82BECA6AC00C7C938 /* IconGenerator.swift in Sources */,
// );
// runOnlyForDeploymentPostprocessing = 0;
// };
if !self.source.isEmpty {
src = self.source
}
let filesSrc = self.properties["files"]!.map {
let childId = $0
let fileRefId = entries.first(where: { $0.id == childId })?.properties["fileRef"]?.first ?? ""
let refEntry = entries.first(where: { $0.id == fileRefId })
let path = refEntry?.properties["name"]?.first ?? refEntry?.properties["path"]?.first ?? ""
let phase = buildPhaseMapping[path.pathExtension()] ?? "Sources"
if refEntry != nil {
return "\t\t" + $0 + " /* \(path) in \(phase) */"
}
return "\t" + $0
}.joined(separator: ",\n\t\t")
src = src.regexReplace("files\\s*=\\s*\\([^;]*;", "files = (\n\t\t\(filesSrc.trimmingTrailingCharacters(["\t"]))\t\t\t);")
// let fileRef = self.properties["fileRef"]?.first ?? ""
// let path = entries.first(where: { $0.id == childId })?.path ?? ""
// let phase = buildPhaseMapping[path.pathExtension()] ?? "Resources"
// var src = "\t\t\(entry.id) /* \(path) in \(phase) */ = {isa = PBXBuildFile; fileRef = \(fileRef) /* \(path) */ };"
return src
}
if self.type == "PBXBuildFile" {
// C4F417112BECA6AC00C7C938 /* PreferencesView.xib in Resources */ = {isa = PBXBuildFile; fileRef = C4F416DF2BECA6AC00C7C938 /* PreferencesView.xib */; };
// C4AB60132BEC31C90008C449 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = C4AB60122BEC31C90008C449 /* KeyboardShortcuts */; };
if !self.source.isEmpty {
return self.source
}
if self.properties["fileRef"] != nil {
let fileRef = self.properties["fileRef"]?.first ?? ""
let path = entries.first(where: { $0.id == fileRef })?.properties["path"]?.first ?? ""
let phase = buildPhaseMapping[path.pathExtension()] ?? "Resources"
let src = "\t\t\(self.id) /* \(path) in \(phase) */ = {isa = PBXBuildFile; fileRef = \(fileRef) /* \(path) */; };"
return src
} else {
let productRef = self.properties["productRef"]?.first ?? ""
let productName = entries.first(where: { $0.id == productRef })?.properties["productName"]?.first ?? ""
let phase = "Framework"
let src = "\t\t\(self.id) /* \(productName) in \(phase) */ = {isa = PBXBuildFile; productRef = \(productRef) /* \(productName) */; };"
return src
}
}
if self.type == "PBXFileReference" {
// C4F416CA2BECA6AC00C7C938 /* BackgroundNSButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundNSButton.swift; sourceTree = "<group>"; };
let path = self.properties["path"]?.first ?? ""
let src = "\t\t\(self.id) /* \(path) */ = {isa = PBXFileReference; fileEncoding = \(self.properties["fileEncoding"]?.first ?? "4"); lastKnownFileType = \(self.properties["lastKnownFileType"]?.first ?? ""); path = \(wrap(path)); sourceTree = \"<group>\"; };"
return src
}
if self.type == "PBXGroup" {
// C4F416C72BECA6AC00C7C938 /* Core */ = {
// isa = PBXGroup;
// children = (
// C4F416C82BECA6AC00C7C938 /* ViewController.swift */,
// C4F416C92BECA6AC00C7C938 /* Custom Elements */,
// C4F416CE2BECA6AC00C7C938 /* ExUtilities.m */,
// C4F416CF2BECA6AC00C7C938 /* Test.swift */,
// C4F416D02BECA6AC00C7C938 /* IconGenerator.swift */,
// C4F416D12BECA6AC00C7C938 /* GoogleChrome.h */,
// C4F416D22BECA6AC00C7C938 /* SnapManager.swift */,
// C4F416D32BECA6AC00C7C938 /* WebViewAssets.swift */,
// C4F416D42BECA6AC00C7C938 /* ExUtilities.h */,
// C4F416D52BECA6AC00C7C938 /* AXUIElementExtension.swift */,
// C4F416D62BECA6AC00C7C938 /* AXManager.swift */,
// C4F416D72BECA6AC00C7C938 /* AppDelegate.swift */,
// C4F416D82BECA6AC00C7C938 /* Safari.h */,
// C4F416D92BECA6AC00C7C938 /* Bridging-Header.h */,
// C4F416DA2BECA6AC00C7C938 /* Firefox.h */,
// C4F416DB2BECA6AC00C7C938 /* Custom Views */,
// );
// path = Core;
// sourceTree = "<group>";
// };
var src = """
\t\t\(self.id) /* \(self.properties["path"]?.first ?? "") */ = {
\t\t\tisa = PBXGroup;
\t\t\tchildren = ();
\t\t\tpath = \(wrap(self.properties["path"]?.first ?? ""));
\t\t\tsourceTree = "<group>";
\t\t};
"""
if !self.source.isEmpty {
src = self.source
}
let childrenSrc = self.properties["children"]!.map {
let childId = $0
let refEntry = entries.first(where: { $0.id == childId })
if refEntry != nil {
return "\t\t" + $0 + " /* \((refEntry?.properties["name"] ?? refEntry!.properties["path"])!.first!) */"
}
return "\t\t" + $0
}.joined(separator: ",\n\t\t")
src = src.regexReplace("children\\s*=\\s*\\([^;]*;", "children = (\n\t\t\(childrenSrc.trimmingTrailingCharacters(["\t"]))\t\t\t);")
return src
}
return self.source
}
func display(updateProperties: [String] = ["*"], entries: [Entry] = [], detail: Bool = false) -> String {
if self.type == "PBXResourcesBuildPhase" || self.type == "PBXSourcesBuildPhase" || self.type == "PBXFrameworksBuildPhase" {
let target = entries.first(where: { tgt in
tgt.type == "PBXNativeTarget" && tgt.properties["buildPhases"]?.contains(self.id) == true
})?.properties["name"]?.first ?? "NONE"
if !detail || self.properties["_files"] == nil {
if isXcodeTerm {
return "\(target): \(self.type)"
} else {
return "\(ANSI.brightBlue)\(target):\(ANSI.reset) \(ANSI.brightCyan)\(self.type)\(ANSI.reset)"
}
}
var changes = [String]()
(self.properties["_files"] ?? []).forEach { fileId in
if self.properties["files"]?.contains(fileId) != true {
let fileRefId = entries.first(where: { $0.id == fileId })?.properties["fileRef"]?.first ?? ""
let refEntry = entries.first(where: { $0.id == fileRefId })
let path = refEntry?.path ?? refEntry?.properties["name"]?.first ?? refEntry?.properties["path"]?.first ?? (fileRefId.isEmpty ? fileId : fileRefId)
if isXcodeTerm {
changes.append("-\(path)")
} else {
changes.append("\(ANSI.brightRed)-\(ANSI.reset)\(ANSI.black)\(path)\(ANSI.reset)")
}
}
}
(self.properties["files"] ?? []).forEach { fileId in
if self.properties["_files"]?.contains(fileId) != true {
let fileRefId = entries.first(where: { $0.id == fileId })?.properties["fileRef"]?.first ?? ""
let refEntry = entries.first(where: { $0.id == fileRefId })
let path = refEntry?.path ?? refEntry?.properties["name"]?.first ?? refEntry?.properties["path"]?.first ?? (fileRefId.isEmpty ? fileId : fileRefId)
if isXcodeTerm {
changes.append("+\(path)")
} else {
changes.append("\(ANSI.brightGreen)+\(ANSI.reset)\(ANSI.green)\(path)\(ANSI.reset)")
}
}
}
if isXcodeTerm {
return "\(target): \(self.type) \(changes.joined(separator: " "))"
} else {
return "\(ANSI.brightBlue)\(target):\(ANSI.reset) \(ANSI.brightCyan)\(self.type)\(ANSI.reset) \(changes.joined(separator: " "))"
}
}
if self.type == "PBXBuildFile" {
// C4F417112BECA6AC00C7C938 /* PreferencesView.xib in Resources */ = {isa = PBXBuildFile; fileRef = C4F416DF2BECA6AC00C7C938 /* PreferencesView.xib */; };
// C4AB60132BEC31C90008C449 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = C4AB60122BEC31C90008C449 /* KeyboardShortcuts */; };
if !self.source.isEmpty {
return self.source
}
if self.properties["fileRef"] != nil {
let fileRef = self.properties["fileRef"]?.first ?? ""
let path = entries.first(where: { $0.id == fileRef })?.properties["path"]?.first ?? ""
let phase = buildPhaseMapping[path.pathExtension()] ?? "Resources"
let src = "\t\t\(self.id) /* \(path) in \(phase) */ = {isa = PBXBuildFile; fileRef = \(fileRef) /* \(path) */; };"
return src
} else {
let productRef = self.properties["productRef"]?.first ?? ""
let productName = entries.first(where: { $0.id == productRef })?.properties["productName"]?.first ?? ""
let phase = "Framework"
let src = "\t\t\(self.id) /* \(productName) in \(phase) */ = {isa = PBXBuildFile; productRef = \(productRef) /* \(productName) */; };"
return src
}
}
if self.type == "PBXFileReference" {
// C4F416CA2BECA6AC00C7C938 /* BackgroundNSButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BackgroundNSButton.swift; sourceTree = "<group>"; };
let path = self.properties["path"]?.first ?? ""
let src = "\t\t\(self.id) /* \(path) */ = {isa = PBXFileReference; fileEncoding = \(self.properties["fileEncoding"]?.first ?? "4"); lastKnownFileType = \(self.properties["lastKnownFileType"]?.first ?? ""); path = \(wrap(path)); sourceTree = \"<group>\"; };"
return src
}
if self.type == "PBXGroup" {
// C4F416C72BECA6AC00C7C938 /* Core */ = {
// isa = PBXGroup;
// children = (
// C4F416C82BECA6AC00C7C938 /* ViewController.swift */,
// C4F416C92BECA6AC00C7C938 /* Custom Elements */,
// C4F416CE2BECA6AC00C7C938 /* ExUtilities.m */,
// C4F416CF2BECA6AC00C7C938 /* Test.swift */,
// C4F416D02BECA6AC00C7C938 /* IconGenerator.swift */,
// C4F416D12BECA6AC00C7C938 /* GoogleChrome.h */,
// C4F416D22BECA6AC00C7C938 /* SnapManager.swift */,
// C4F416D32BECA6AC00C7C938 /* WebViewAssets.swift */,
// C4F416D42BECA6AC00C7C938 /* ExUtilities.h */,
// C4F416D52BECA6AC00C7C938 /* AXUIElementExtension.swift */,
// C4F416D62BECA6AC00C7C938 /* AXManager.swift */,
// C4F416D72BECA6AC00C7C938 /* AppDelegate.swift */,
// C4F416D82BECA6AC00C7C938 /* Safari.h */,
// C4F416D92BECA6AC00C7C938 /* Bridging-Header.h */,
// C4F416DA2BECA6AC00C7C938 /* Firefox.h */,
// C4F416DB2BECA6AC00C7C938 /* Custom Views */,
// );
// path = Core;
// sourceTree = "<group>";
// };
var src = """
\t\t\(self.id) /* \(self.properties["path"]?.first ?? "") */ = {
\t\t\tisa = PBXGroup;
\t\t\tchildren = ();
\t\t\tpath = \(wrap(self.properties["path"]?.first ?? ""));
\t\t\tsourceTree = "<group>";
\t\t};
"""
if !self.source.isEmpty {
src = self.source
}
let childrenSrc = self.properties["children"]!.map {
let childId = $0
let refEntry = entries.first(where: { $0.id == childId })
if refEntry != nil {
return "\t\t" + $0 + " /* \((refEntry?.properties["name"] ?? refEntry!.properties["path"])!.first!) */"
}
return "\t\t" + $0
}.joined(separator: ",\n\t\t")
src = src.regexReplace("children\\s*=\\s*\\([^;]*;", "children = (\n\t\t\(childrenSrc));")
return src
}
return self.source
}
func coloredDescription(includedProperties: [String], entries: [Entry] = [], allEntries: [Entry] = []) -> String {
let idStr = "\(ANSI.cyan)ID: \(id)\(ANSI.reset)"
let typeStr = "\(ANSI.green)Type: \(type)\(ANSI.reset)"
let pathStr = "\(ANSI.green)Path: \(path)\(ANSI.reset)"
let propertiesStr = properties.filter { includedProperties.contains($0.key) || includedProperties.contains("*") }
.map { key, value -> String in
let values = value.count >= 2
? value.map { $0.isEmpty ? "" : "\n \(ANSI.yellow)- \($0)\(ANSI.reset)" }.joined()
: value.joined(separator: ", ")
return "\(ANSI.cyan)\(key):\(ANSI.reset) \(values)"
}.joined(separator: ", ")
var out = "\(propertiesStr)"
if includedProperties.contains("Type") {
out = "\(typeStr), " + out
}
if includedProperties.contains("Path") {
out = "\(pathStr), " + out
}
if includedProperties.contains("ID") {
out = "\(idStr), " + out
}
if includedProperties.contains("Source") {
if self.source.contains("\n") {
out += "\n\(ANSI.brightGreen)Source:\(ANSI.reset)\n\(self.source)"
} else {
out += "\n\(ANSI.brightGreen)Source:\(ANSI.reset) \(self.source.regexReplace("^\\s+", ""))"
}
}
if includedProperties.contains("Output") {
let src = self.output(entries: entries)
if src.contains("\n") {
out += "\n\(ANSI.brightGreen)Output:\(ANSI.reset)\n\(src)"
} else {
out += "\n\(ANSI.brightGreen)Output:\(ANSI.reset) \(src.regexReplace("^\\s+", ""))"
}
}
return out.trimmingCharacters(in: CharacterSet.init(charactersIn: ", "))
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment