crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }