Last active
May 14, 2024 05:02
-
-
Save brantwedel/5961ed7af9a700bf71d30a9b47f47910 to your computer and use it in GitHub Desktop.
XCode project file sync script
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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