Skip to content

Instantly share code, notes, and snippets.

@samdods
Last active February 1, 2023 12:51
Show Gist options
  • Save samdods/1ac451370729c0e9df72cce8bfcaa828 to your computer and use it in GitHub Desktop.
Save samdods/1ac451370729c0e9df72cce8bfcaa828 to your computer and use it in GitHub Desktop.
#!/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