Last active
February 1, 2023 12:51
-
-
Save samdods/1ac451370729c0e9df72cce8bfcaa828 to your computer and use it in GitHub Desktop.
This file contains hidden or 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/swift | |
import Foundation | |
extension Array where Element == String { | |
func sortedWithPodsLast() -> [String] { | |
self.sorted { lhs, rhs in | |
if lhs.hasPrefix("Pods_") && rhs.hasPrefix("Pods_") { | |
return lhs < rhs | |
} else if lhs.hasPrefix("Pods_") { | |
return false | |
} else if rhs.hasPrefix("Pods_") { | |
return true | |
} | |
return lhs < rhs | |
} | |
} | |
func uniqued() -> [String] { | |
return NSOrderedSet(array: self).array as? [String] ?? [] | |
} | |
} | |
enum Option: Equatable { | |
case list(path: String) | |
case read(path: String) | |
case trawl(path: String) | |
static func ==(lhs: Option, rhs: Option) -> Bool { | |
switch (lhs, rhs) { | |
case (.list, .list): | |
return true | |
case (.read, .read): | |
return true | |
case (.trawl, .trawl): | |
return true | |
default: | |
return false | |
} | |
} | |
enum Name: String { | |
case list | |
case read | |
case trawl | |
} | |
} | |
func usageFail(_ syntax: String) -> Never { | |
fatalError("Usage: \(CommandLine.appName) \(syntax)") | |
} | |
extension CommandLine { | |
static var appName: String { arguments.first! } | |
static var options: [Option] { | |
let args = Array(arguments.dropFirst()) | |
return parseOptions(startWith: [], from: args) | |
} | |
static var listPath: String? { | |
for option in options { | |
if case .list(let path) = option { | |
return path | |
} | |
} | |
return nil | |
} | |
static var trawlPath: String? { | |
for option in options { | |
if case .trawl(let path) = option { | |
return path | |
} | |
} | |
return nil | |
} | |
static var readPath: String? { | |
for option in options { | |
if case .read(let path) = option { | |
return path | |
} | |
} | |
return nil | |
} | |
private static func parseOptions(startWith options: [Option], from args: [String]) -> [Option] { | |
let toDrop: Int | |
var options = options | |
guard let first = args.first else { | |
return options | |
} | |
guard first.hasPrefix("--"), | |
let name = Option.Name.init(rawValue: String(first.dropFirst(2))) else { | |
fatalError("Unexpected argument: \(first)") | |
} | |
switch name { | |
case .list: | |
guard let path = args.dropFirst().first else { | |
usageFail("--\(name.rawValue) <path>") | |
} | |
toDrop = 2 | |
options.safeAdd(.list(path: path)) | |
case .read: | |
guard let path = args.dropFirst().first else { | |
usageFail("--\(name.rawValue) <path>") | |
} | |
toDrop = 2 | |
options.safeAdd(.read(path: path)) | |
case .trawl: | |
guard let path = args.dropFirst().first else { | |
usageFail("--\(name.rawValue) <path>") | |
} | |
toDrop = 2 | |
options.safeAdd(.trawl(path: path)) | |
} | |
let remaining = Array(args.dropFirst(toDrop)) | |
return parseOptions(startWith: options, from: remaining) | |
} | |
} | |
extension Array where Element == Option { | |
mutating func safeAdd(_ option: Option) { | |
if self.contains(option) { | |
fatalError("Option cannot be set twice: \(option)") | |
} | |
self.append(option) | |
} | |
} | |
class DependencyMapper { | |
func dependencyNames(forModule path: String) -> [String] { | |
let path = path + "/project.pbxproj" | |
guard let data = FileManager.default.contents(atPath: path) else { | |
// print("File not found at path: \(path)") | |
return [] | |
} | |
guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { | |
fatalError("Invalid property list at path: \(path)") | |
} | |
return dependencyNames(forProject: plist).sortedWithPodsLast().uniqued().filter { $0.hasSuffix(".framework") && !$0.hasPrefix("System/Library") } | |
} | |
private func dependencyNames(forProject plist: [String: Any]) -> [String] { | |
guard let rootID = plist["rootObject"] as? String, | |
let objects = plist["objects"] as? [String: Any], | |
let root = objects[rootID] as? [String: Any], | |
let targetIDs = root["targets"] as? [String] else { | |
fatalError("Invalid plist file") | |
} | |
var nonTestTargets: [[String: Any]] = [] | |
for targetID in targetIDs { | |
guard let target = objects[targetID] as? [String: Any], | |
let type = target["productType"] as? String else { | |
// print("Invalid plist file") | |
return [] | |
} | |
if type.contains("unit-test") { | |
continue | |
} | |
nonTestTargets.append(target) | |
} | |
var dependencyNames: [String] = [] | |
for target in nonTestTargets { | |
guard let buildPhase = frameworksBuildPhase(forTarget: target, projectObjects: objects), | |
let dependencyRefIDs = buildPhase["files"] as? [String] else { | |
fatalError("Invalid input file") | |
} | |
for refID in dependencyRefIDs { | |
guard let ref = objects[refID] as? [String: Any], | |
let fileRef = ref["fileRef"] as? String, | |
let dependency = objects[fileRef] as? [String: Any], | |
let path = dependency["path"] as? String else { | |
fatalError("Invalid input file") | |
} | |
dependencyNames.append(path) | |
} | |
} | |
return dependencyNames | |
} | |
private func frameworksBuildPhase(forTarget target: [String: Any], projectObjects objects: [String: Any]) -> [String: Any]? { | |
guard let buildPhaseIDs = target["buildPhases"] as? [String] else { | |
fatalError("Invalid input file") | |
} | |
for buildPhaseID in buildPhaseIDs { | |
guard let buildPhase = objects[buildPhaseID] as? [String: Any], | |
let isa = buildPhase["isa"] as? String else { | |
fatalError("Invalid input file") | |
} | |
if isa == "PBXFrameworksBuildPhase" { | |
return buildPhase | |
} | |
} | |
return nil | |
} | |
} | |
class ModuleMapper { | |
func findModules(in trawlPath: String) -> [String] { | |
findFilesByExtension(".xcodeproj", in: trawlPath).map { fileName in | |
trawlPath + "/" + fileName | |
} | |
} | |
private func findFilesByExtension(_ fileExtension: String, in path: String) -> [String] { | |
var result: [String] = [] | |
if let trawler = FileManager().enumerator(atPath: path) { | |
for case let filePath as String in trawler where filePath.hasSuffix(fileExtension) { | |
result.append(filePath) | |
} | |
} else { | |
fatalError("Failed to search in path: \(path)") | |
} | |
return result | |
} | |
private func findFileByName(_ fileName: String, in path: String) -> String { | |
var result: [String] = [] | |
let fileName = fileName.components(separatedBy: "/").last! | |
if let trawler = FileManager().enumerator(atPath: path) { | |
for case let filePath as String in trawler where filePath == fileName || filePath.hasSuffix("/" + fileName) { | |
result.append(filePath) | |
} | |
} else { | |
fatalError("Failed to search in path: \(path)") | |
} | |
guard result.count > 0 else { | |
fatalError("File not found \(fileName) in path \(path)") | |
} | |
// guard result.count == 1 else { | |
// fatalError("Multiple matches for file \(fileName): \(result)") | |
// } | |
return result[0] | |
} | |
func findFilesForModule(at path: String, onlyNames: [String]) -> [String] { | |
let path = path + "/project.pbxproj" | |
guard let data = FileManager().contents(atPath: path) else { | |
// print("File not found at path: \(path)") | |
return [] | |
} | |
guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { | |
fatalError("Invalid property list at path: \(path)") | |
} | |
let twoBackPath = path.components(separatedBy: "/").dropLast(2).joined(separator: "/") | |
let names = fileNames(forProject: plist).filter { name in | |
onlyNames.contains(name) | |
} | |
return names.map { name in | |
twoBackPath + "/" + findFileByName(name, in: twoBackPath) | |
} | |
} | |
private func fileNames(forProject plist: [String: Any]) -> [String] { | |
guard let rootID = plist["rootObject"] as? String, | |
let objects = plist["objects"] as? [String: Any], | |
let root = objects[rootID] as? [String: Any], | |
let targetIDs = root["targets"] as? [String] else { | |
fatalError("Invalid plist file") | |
} | |
var nonTestTargets: [[String: Any]] = [] | |
for targetID in targetIDs { | |
guard let target = objects[targetID] as? [String: Any], | |
let type = target["productType"] as? String else { | |
// print("Invalid plist file") | |
return [] | |
} | |
if type.contains("unit-test") { | |
continue | |
} | |
nonTestTargets.append(target) | |
} | |
var dependencyNames: [String] = [] | |
for target in nonTestTargets { | |
guard let buildPhase = sourcesBuildPhase(forTarget: target, projectObjects: objects), | |
let dependencyRefIDs = buildPhase["files"] as? [String] else { | |
fatalError("Invalid input file") | |
} | |
for refID in dependencyRefIDs { | |
guard let ref = objects[refID] as? [String: Any], | |
let fileRef = ref["fileRef"] as? String, | |
let dependency = objects[fileRef] as? [String: Any], | |
let path = dependency["path"] as? String else { | |
fatalError("Invalid input file") | |
} | |
dependencyNames.append(path) | |
} | |
} | |
return dependencyNames | |
} | |
private func sourcesBuildPhase(forTarget target: [String: Any], projectObjects objects: [String: Any]) -> [String: Any]? { | |
guard let buildPhaseIDs = target["buildPhases"] as? [String] else { | |
fatalError("Invalid input file") | |
} | |
for buildPhaseID in buildPhaseIDs { | |
guard let buildPhase = objects[buildPhaseID] as? [String: Any], | |
let isa = buildPhase["isa"] as? String else { | |
fatalError("Invalid input file") | |
} | |
if isa == "PBXSourcesBuildPhase" { | |
return buildPhase | |
} | |
} | |
return nil | |
} | |
} | |
private extension Array where Element == String { | |
func asFrameworks() -> [String] { | |
self.map { name in | |
name.components(separatedBy: "/").last!.replacingOccurrences(of: ".xcodeproj", with: ".framework") | |
} | |
} | |
} | |
class ImpactChecker { | |
private let moduleMapper = ModuleMapper() | |
func impact(ofChangedFiles files: [String], under trawlPath: String) -> [String] { | |
let modifiedModules = affectedModules(fromAffectedFiles: files, at: trawlPath) | |
var totalImpacted: [String] = modifiedModules | |
var stillGoing = true | |
while stillGoing { | |
var newlyImpacted = impactedModules(fromModified: totalImpacted.asFrameworks(), at: trawlPath) | |
newlyImpacted.removeAll(where: totalImpacted.contains) | |
totalImpacted.append(contentsOf: newlyImpacted) | |
if newlyImpacted.isEmpty { stillGoing = false } | |
} | |
return totalImpacted.sortedWithPodsLast().uniqued() | |
} | |
func affectedModules(fromAffectedFiles affectedFiles: [String], at trawlPath: String) -> [String] { | |
let affectedFiles = affectedFiles.map { name in | |
trawlPath + "/" + name | |
} | |
let onlyNames = affectedFiles.map { name in | |
name.components(separatedBy: "/").last! | |
} | |
let modules = moduleMapper.findModules(in: trawlPath) | |
var affectedModules = [String]() | |
for module in modules { | |
let sources = moduleMapper.findFilesForModule(at: module, onlyNames: onlyNames) | |
if sources.contains(where: { source in | |
affectedFiles.contains(source) | |
}) { | |
affectedModules.append(module) | |
continue | |
} | |
} | |
return affectedModules | |
} | |
func impactedModules(fromModified modifiedModules: [String], at trawlPath: String) -> [String] { | |
let allModules = ModuleMapper().findModules(in: trawlPath) | |
var impacted: [String] = [] | |
for module in allModules { | |
let dependencies = DependencyMapper().dependencyNames(forModule: module) | |
if dependencies.contains(where: modifiedModules.contains) { | |
impacted.append(module) | |
} | |
} | |
return impacted | |
} | |
} | |
let impactChecker = ImpactChecker() | |
let listPath: String? = CommandLine.listPath | |
let trawlPath = CommandLine.trawlPath ?? FileManager.default.currentDirectoryPath | |
let fileToRead: String? = CommandLine.readPath | |
if let fileToRead = fileToRead { | |
guard let data = FileManager.default.contents(atPath: fileToRead), | |
let listString = String(data: data, encoding: .utf8) else { | |
fatalError("Input file not found or invalid: \(fileToRead)") | |
} | |
let files = listString.components(separatedBy: .newlines) | |
let impacted = impactChecker.impact(ofChangedFiles: files, under: trawlPath) | |
print(impacted.joined(separator: "\n")) | |
} else { | |
var files: [String] = [] | |
while let line = readLine() { | |
files.append(line) | |
} | |
let impacted = impactChecker.impact(ofChangedFiles: files, under: trawlPath) | |
print(impacted.joined(separator: "\n")) | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment