main.swift (9388B)
1 import Foundation 2 3 /// Bumped whenever the manifest schema changes so the app can refuse or 4 /// migrate manifests it does not understand. 5 let manifestVersion = 1 6 7 struct BundlemakeError: Error, CustomStringConvertible { 8 let description: String 9 } 10 11 struct Options { 12 var directory: String? 13 var outputPath: String? 14 var name: String? 15 var bundleID: String? 16 } 17 18 /// The on-disk manifest: a lightweight index of a bundle's puzzles so the app 19 /// can draw grid thumbnails without parsing every `.xd` file. 20 struct Manifest: Encodable { 21 let version: Int 22 let bundleID: String 23 let name: String 24 let puzzles: [PuzzleEntry] 25 } 26 27 struct PuzzleEntry: Encodable { 28 let id: String 29 let title: String 30 let publisher: String? 31 let gridWidth: Int 32 let gridHeight: Int 33 /// Row-major block mask: `#` is a block, `.` is an open cell. 34 let blockMask: String 35 } 36 37 func parseOptions(_ arguments: [String]) throws -> Options { 38 var options = Options() 39 var index = 1 40 41 func requireValue(_ name: String) throws -> String { 42 guard index + 1 < arguments.count else { 43 throw BundlemakeError(description: "Missing value for \(name)") 44 } 45 index += 1 46 return arguments[index] 47 } 48 49 while index < arguments.count { 50 let arg = arguments[index] 51 switch arg { 52 case "-o", "--output": 53 options.outputPath = try requireValue(arg) 54 case "--name": 55 options.name = try requireValue(arg) 56 case "--bundle-id": 57 options.bundleID = try requireValue(arg) 58 case "-h", "--help": 59 printUsage() 60 exit(0) 61 default: 62 guard options.directory == nil else { 63 throw BundlemakeError(description: "Unexpected extra argument: \(arg)") 64 } 65 options.directory = arg 66 } 67 index += 1 68 } 69 70 guard options.directory != nil else { 71 throw BundlemakeError(description: "Missing <bundle-directory>. See --help.") 72 } 73 return options 74 } 75 76 func printUsage() { 77 print(""" 78 Usage: Bundlemake [options] <bundle-directory> 79 80 Writes manifest.json for a puzzle bundle: a lightweight index of every 81 .xd file's title, publisher, and grid block mask, so the app can render 82 puzzle thumbnails without parsing each puzzle. 83 84 Options: 85 -o, --output PATH Manifest output path. Default: <directory>/manifest.json 86 --name NAME Bundle display name. Default: the directory's name. 87 --bundle-id ID Bundle id. Required when the puzzles carry no 88 Bundle: field (e.g. dev fixtures). 89 -h, --help Show this help. 90 """) 91 } 92 93 /// Splits an `.xd` source into its sections. Mirrors the app's XD parser 94 /// (`XD.splitIntoSections`): a run of two or more blank lines, or a `## ` 95 /// header line, ends a section. Sections are metadata, grid, then clues. 96 func splitIntoSections(_ source: String) -> [[String]] { 97 let lines = source 98 .split(separator: "\n", omittingEmptySubsequences: false) 99 .map(String.init) 100 101 var sections: [[String]] = [] 102 var current: [String] = [] 103 var blankRun = 0 104 105 func flush() { 106 while current.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { 107 current.removeLast() 108 } 109 while current.first?.trimmingCharacters(in: .whitespaces).isEmpty == true { 110 current.removeFirst() 111 } 112 if !current.isEmpty { 113 sections.append(current) 114 } 115 current = [] 116 } 117 118 for rawLine in lines { 119 let line = rawLine.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) 120 let trimmed = line.trimmingCharacters(in: .whitespaces) 121 122 if trimmed.hasPrefix("## ") || trimmed == "##" { 123 flush() 124 blankRun = 0 125 continue 126 } 127 if trimmed.isEmpty { 128 blankRun += 1 129 if blankRun >= 2 { 130 flush() 131 } 132 continue 133 } 134 blankRun = 0 135 current.append(line) 136 } 137 flush() 138 return sections 139 } 140 141 /// Reads `Key: Value` metadata lines, keeping the first value seen per key. 142 func parseMetadata(_ lines: [String]) -> [String: String] { 143 var entries: [String: String] = [:] 144 for line in lines { 145 guard let colon = line.firstIndex(of: ":") else { continue } 146 let key = line[..<colon].trimmingCharacters(in: .whitespaces) 147 let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) 148 if !key.isEmpty, entries[key] == nil { 149 entries[key] = value 150 } 151 } 152 return entries 153 } 154 155 struct Grid { 156 let width: Int 157 let height: Int 158 let blockMask: String 159 } 160 161 /// Builds the block mask from a grid section. `#` and `_` are blocks (matching 162 /// the app's `XD.gridCell`); every other character is an open cell. 163 func parseGrid(_ lines: [String]) throws -> Grid { 164 var rows: [String] = [] 165 var width: Int? 166 for line in lines { 167 let trimmed = line.trimmingCharacters(in: .whitespaces) 168 if trimmed.isEmpty { continue } 169 if let width, trimmed.count != width { 170 throw BundlemakeError(description: "grid rows have inconsistent widths") 171 } 172 width = trimmed.count 173 rows.append(trimmed) 174 } 175 guard let width, !rows.isEmpty else { 176 throw BundlemakeError(description: "grid section is empty") 177 } 178 179 var mask = "" 180 mask.reserveCapacity(width * rows.count) 181 for row in rows { 182 for character in row { 183 mask.append(character == "#" || character == "_" ? "#" : ".") 184 } 185 } 186 return Grid(width: width, height: rows.count, blockMask: mask) 187 } 188 189 struct ParsedPuzzle { 190 let entry: PuzzleEntry 191 let bundleID: String? 192 } 193 194 func parsePuzzle(at path: String) throws -> ParsedPuzzle { 195 let id = URL(fileURLWithPath: path).deletingPathExtension().lastPathComponent 196 let source = try String(contentsOfFile: path, encoding: .utf8) 197 let sections = splitIntoSections(source) 198 guard sections.count >= 2 else { 199 throw BundlemakeError(description: "\(id): .xd source has no grid section") 200 } 201 202 let metadata = parseMetadata(sections[0]) 203 let grid: Grid 204 do { 205 grid = try parseGrid(sections[1]) 206 } catch let error as BundlemakeError { 207 throw BundlemakeError(description: "\(id): \(error.description)") 208 } 209 210 func nonEmpty(_ key: String) -> String? { 211 metadata[key].flatMap { $0.isEmpty ? nil : $0 } 212 } 213 214 let entry = PuzzleEntry( 215 id: id, 216 title: nonEmpty("Title") ?? id, 217 publisher: nonEmpty("Publisher"), 218 gridWidth: grid.width, 219 gridHeight: grid.height, 220 blockMask: grid.blockMask 221 ) 222 return ParsedPuzzle(entry: entry, bundleID: nonEmpty("Bundle")) 223 } 224 225 func run() throws { 226 let options = try parseOptions(CommandLine.arguments) 227 let directory = options.directory! 228 let fileManager = FileManager.default 229 230 var isDirectory: ObjCBool = false 231 guard fileManager.fileExists(atPath: directory, isDirectory: &isDirectory), 232 isDirectory.boolValue else { 233 throw BundlemakeError(description: "Not a directory: \(directory)") 234 } 235 236 let directoryURL = URL(fileURLWithPath: directory) 237 let xdPaths = try fileManager 238 .contentsOfDirectory(at: directoryURL, includingPropertiesForKeys: nil) 239 .filter { $0.pathExtension.lowercased() == "xd" } 240 .map(\.path) 241 .sorted() 242 guard !xdPaths.isEmpty else { 243 throw BundlemakeError(description: "No .xd puzzles found in \(directory)") 244 } 245 246 let parsed = try xdPaths.map { try parsePuzzle(at: $0) } 247 248 // Resolve the bundle id. Puzzles normally declare a `Bundle:` field, and 249 // every puzzle in a bundle must agree on it. Dirs whose puzzles carry no 250 // such field (dev fixtures) need an explicit --bundle-id instead. 251 let declaredIDs = Set(parsed.compactMap(\.bundleID)) 252 guard declaredIDs.count <= 1 else { 253 let listed = declaredIDs.sorted().joined(separator: ", ") 254 throw BundlemakeError(description: "Puzzles disagree on their Bundle: id (\(listed))") 255 } 256 if let declared = declaredIDs.first, 257 let override = options.bundleID, 258 declared != override { 259 throw BundlemakeError( 260 description: "--bundle-id \(override) contradicts the Bundle: \(declared) in the puzzles") 261 } 262 guard let bundleID = options.bundleID ?? declaredIDs.first else { 263 throw BundlemakeError( 264 description: "No Bundle: id in \(directory); pass --bundle-id to name the bundle") 265 } 266 267 let manifest = Manifest( 268 version: manifestVersion, 269 bundleID: bundleID, 270 name: options.name ?? directoryURL.lastPathComponent, 271 puzzles: parsed.map(\.entry) 272 ) 273 274 let encoder = JSONEncoder() 275 encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] 276 var data = try encoder.encode(manifest) 277 data.append(0x0A) 278 279 let outputURL = options.outputPath.map { URL(fileURLWithPath: $0) } 280 ?? directoryURL.appendingPathComponent("manifest.json") 281 try data.write(to: outputURL) 282 283 fputs("Wrote \(outputURL.path): \(manifest.puzzles.count) puzzle(s) in bundle \"\(bundleID)\".\n", stderr) 284 } 285 286 do { 287 try run() 288 } catch { 289 fputs("Bundlemake: \(error)\n", stderr) 290 exit(1) 291 }