|
| 1 | +import Foundation |
| 2 | + |
| 3 | +/// A simple build system |
| 4 | +struct MiniMake { |
| 5 | + /// Attributes of a task |
| 6 | + enum TaskAttribute { |
| 7 | + /// Task is phony, meaning it must be built even if its inputs are up to date |
| 8 | + case phony |
| 9 | + } |
| 10 | + /// A task to build |
| 11 | + struct Task { |
| 12 | + /// Key of the task |
| 13 | + let key: TaskKey |
| 14 | + /// Display name of the task |
| 15 | + let displayName: String |
| 16 | + /// Input tasks not yet built |
| 17 | + var wants: Set<TaskKey> |
| 18 | + /// Set of files that must be built before this task |
| 19 | + let inputs: [String] |
| 20 | + /// Output task name |
| 21 | + let output: String |
| 22 | + /// Attributes of the task |
| 23 | + let attributes: Set<TaskAttribute> |
| 24 | + /// Build operation |
| 25 | + let build: (Task) throws -> Void |
| 26 | + /// Whether the task is done |
| 27 | + var isDone: Bool |
| 28 | + } |
| 29 | + |
| 30 | + /// A task key |
| 31 | + struct TaskKey: Hashable, Comparable, CustomStringConvertible { |
| 32 | + let id: String |
| 33 | + var description: String { self.id } |
| 34 | + |
| 35 | + fileprivate init(id: String) { |
| 36 | + self.id = id |
| 37 | + } |
| 38 | + |
| 39 | + static func < (lhs: TaskKey, rhs: TaskKey) -> Bool { lhs.id < rhs.id } |
| 40 | + } |
| 41 | + |
| 42 | + private var tasks: [TaskKey: Task] |
| 43 | + private var shouldExplain: Bool |
| 44 | + /// Current working directory at the time the build started |
| 45 | + private let buildCwd: String |
| 46 | + |
| 47 | + init(explain: Bool = false) { |
| 48 | + self.tasks = [:] |
| 49 | + self.shouldExplain = explain |
| 50 | + self.buildCwd = FileManager.default.currentDirectoryPath |
| 51 | + } |
| 52 | + |
| 53 | + mutating func addTask(inputFiles: [String] = [], inputTasks: [TaskKey] = [], output: String, attributes: Set<TaskAttribute> = [], build: @escaping (Task) throws -> Void) -> TaskKey { |
| 54 | + let displayName = output.hasPrefix(self.buildCwd) ? String(output.dropFirst(self.buildCwd.count + 1)) : output |
| 55 | + let taskKey = TaskKey(id: output) |
| 56 | + self.tasks[taskKey] = Task(key: taskKey, displayName: displayName, wants: Set(inputTasks), inputs: inputFiles, output: output, attributes: attributes, build: build, isDone: false) |
| 57 | + return taskKey |
| 58 | + } |
| 59 | + |
| 60 | + private func explain(_ message: @autoclosure () -> String) { |
| 61 | + if self.shouldExplain { |
| 62 | + print(message()) |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + private func violated(_ message: @autoclosure () -> String) { |
| 67 | + print(message()) |
| 68 | + } |
| 69 | + |
| 70 | + /// Prints progress of the build |
| 71 | + struct ProgressPrinter { |
| 72 | + /// Total number of tasks to build |
| 73 | + let total: Int |
| 74 | + /// Number of tasks built so far |
| 75 | + var built: Int |
| 76 | + |
| 77 | + init(total: Int) { |
| 78 | + self.total = total |
| 79 | + self.built = 0 |
| 80 | + } |
| 81 | + |
| 82 | + private static var green: String { "\u{001B}[32m" } |
| 83 | + private static var yellow: String { "\u{001B}[33m" } |
| 84 | + private static var reset: String { "\u{001B}[0m" } |
| 85 | + |
| 86 | + mutating func started(_ task: Task) { |
| 87 | + self.print(task.displayName, "\(Self.green)building\(Self.reset)") |
| 88 | + } |
| 89 | + |
| 90 | + mutating func skipped(_ task: Task) { |
| 91 | + self.print(task.displayName, "\(Self.yellow)skipped\(Self.reset)") |
| 92 | + } |
| 93 | + |
| 94 | + private mutating func print(_ subjectPath: String, _ message: @autoclosure () -> String) { |
| 95 | + Swift.print("[\(self.built + 1)/\(self.total)] \(subjectPath): \(message())") |
| 96 | + self.built += 1 |
| 97 | + } |
| 98 | + } |
| 99 | + |
| 100 | + private func computeTotalTasks(task: Task) -> Int { |
| 101 | + var visited = Set<TaskKey>() |
| 102 | + func visit(task: Task) -> Int { |
| 103 | + guard !visited.contains(task.key) else { return 0 } |
| 104 | + visited.insert(task.key) |
| 105 | + var total = 1 |
| 106 | + for want in task.wants { |
| 107 | + total += visit(task: self.tasks[want]!) |
| 108 | + } |
| 109 | + return total |
| 110 | + } |
| 111 | + return visit(task: task) |
| 112 | + } |
| 113 | + |
| 114 | + mutating func build(output: TaskKey) throws { |
| 115 | + /// Returns true if any of the task's inputs have a modification date later than the task's output |
| 116 | + func shouldBuild(task: Task) -> Bool { |
| 117 | + if task.attributes.contains(.phony) { |
| 118 | + return true |
| 119 | + } |
| 120 | + let outputURL = URL(fileURLWithPath: task.output) |
| 121 | + if !FileManager.default.fileExists(atPath: task.output) { |
| 122 | + explain("Task \(task.output) should be built because it doesn't exist") |
| 123 | + return true |
| 124 | + } |
| 125 | + let outputMtime = try? outputURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate |
| 126 | + return task.inputs.contains { input in |
| 127 | + let inputURL = URL(fileURLWithPath: input) |
| 128 | + // Ignore directory modification times |
| 129 | + var isDirectory: ObjCBool = false |
| 130 | + let fileExists = FileManager.default.fileExists(atPath: input, isDirectory: &isDirectory) |
| 131 | + if fileExists && isDirectory.boolValue { |
| 132 | + return false |
| 133 | + } |
| 134 | + |
| 135 | + let inputMtime = try? inputURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate |
| 136 | + let shouldBuild = outputMtime == nil || inputMtime == nil || outputMtime! < inputMtime! |
| 137 | + if shouldBuild { |
| 138 | + explain("Task \(task.output) should be re-built because \(input) is newer: \(outputMtime?.timeIntervalSince1970 ?? 0) < \(inputMtime?.timeIntervalSince1970 ?? 0)") |
| 139 | + } |
| 140 | + return shouldBuild |
| 141 | + } |
| 142 | + } |
| 143 | + var progressPrinter = ProgressPrinter(total: self.computeTotalTasks(task: self.tasks[output]!)) |
| 144 | + |
| 145 | + func runTask(taskKey: TaskKey) throws { |
| 146 | + guard var task = self.tasks[taskKey] else { |
| 147 | + violated("Task \(taskKey) not found") |
| 148 | + return |
| 149 | + } |
| 150 | + guard !task.isDone else { return } |
| 151 | + |
| 152 | + // Build dependencies first |
| 153 | + for want in task.wants { |
| 154 | + try runTask(taskKey: want) |
| 155 | + } |
| 156 | + |
| 157 | + if shouldBuild(task: task) { |
| 158 | + progressPrinter.started(task) |
| 159 | + try task.build(task) |
| 160 | + } else { |
| 161 | + progressPrinter.skipped(task) |
| 162 | + } |
| 163 | + task.isDone = true |
| 164 | + self.tasks[taskKey] = task |
| 165 | + } |
| 166 | + try runTask(taskKey: output) |
| 167 | + } |
| 168 | +} |
0 commit comments