XD.swift (26545B)
1 import Foundation 2 3 /// Minimal `.xd` decoder. See https://github.com/century-arcade/xd for the 4 /// full specification. Supports just enough of the format to parse our 5 /// bundled puzzles: metadata, grid (with rebus), and across/down clues. 6 struct XD: Sendable { 7 static let currentCmVersion = 2 8 9 let title: String? 10 let publisher: String? 11 let author: String? 12 let copyright: String? 13 let date: Date? 14 let cmVersion: Int 15 let specialKind: Puzzle.Special? 16 let width: Int 17 let height: Int 18 let cells: [[Cell]] 19 let acrossClues: [Clue] 20 let downClues: [Clue] 21 let relatives: [[ClueRef]] 22 23 /// A single clue identified by its number and direction. Used to describe 24 /// groups of mutually-related clues (a theme's revealer plus the answers 25 /// it references, or a simple "See 14-Across" pair). 26 struct ClueRef: Sendable, Hashable { 27 let number: Int 28 let direction: Puzzle.Direction 29 } 30 31 /// A single grid cell as it appears in the .xd source. Open cells carry 32 /// an optional solution string which may be 1+ characters long once any 33 /// `Rebus:` mapping has been applied, plus a flag indicating whether the 34 /// cell is "special" (circled or shaded — the kind is per-puzzle and 35 /// lives on `XD.specialKind`). 36 enum Cell: Sendable, Equatable { 37 case block 38 case open(solution: String?, acceptedSolutions: Set<String>, isSpecial: Bool) 39 } 40 41 struct Clue: Sendable, Equatable { 42 let number: Int 43 let text: String 44 let answer: String? 45 let metadata: [String: [String]] 46 47 var acceptedAnswers: [String] { 48 metadata["Accept", default: []].flatMap(Self.parseEscapedTokens) 49 } 50 51 private static func parseEscapedTokens(_ source: String) -> [String] { 52 var tokens: [String] = [] 53 var current = "" 54 var escaping = false 55 56 for ch in source { 57 if escaping { 58 current.append(ch) 59 escaping = false 60 } else if ch == "\\" { 61 escaping = true 62 } else if ch.isWhitespace { 63 if !current.isEmpty { 64 tokens.append(current) 65 current = "" 66 } 67 } else { 68 current.append(ch) 69 } 70 } 71 72 if escaping { 73 current.append("\\") 74 } 75 if !current.isEmpty { 76 tokens.append(current) 77 } 78 return tokens 79 } 80 } 81 82 enum ParseError: Error, CustomStringConvertible { 83 case missingGrid 84 case missingClues 85 case raggedGrid 86 case malformedClue(String) 87 case unknownGridCharacter(Character) 88 89 var description: String { 90 switch self { 91 case .missingGrid: 92 return ".xd source has no grid section" 93 case .missingClues: 94 return ".xd source has no clues section" 95 case .raggedGrid: 96 return ".xd grid rows have inconsistent widths" 97 case .malformedClue(let line): 98 return "malformed .xd clue: \(line)" 99 case .unknownGridCharacter(let ch): 100 return "unknown .xd grid character: \(ch)" 101 } 102 } 103 } 104 105 static func parse(_ source: String) throws -> XD { 106 let sections = splitIntoSections(source) 107 guard sections.count >= 2 else { throw ParseError.missingGrid } 108 guard sections.count >= 3 else { throw ParseError.missingClues } 109 110 let metadata = parseMetadata(sections[0]) 111 let rebus = parseRebusHeader(metadata["Rebus"]) 112 let specialKind = parseSpecialHeader(metadata["Special"]) 113 let relatives = parseRelativesHeader(metadata["Relatives"]) 114 let (rawCells, width, height) = try parseGrid(sections[1], rebus: rebus) 115 let (across, down) = try parseClues(sections[2]) 116 let cells = applyAcceptedAnswers(cells: rawCells, across: across, down: down) 117 118 return XD( 119 title: metadata["Title"], 120 publisher: metadata["Publisher"], 121 author: metadata["Author"], 122 copyright: metadata["Copyright"], 123 date: parseDateHeader(metadata["Date"]), 124 cmVersion: parseCmVersionHeader(metadata["CmVer"]), 125 specialKind: specialKind, 126 width: width, 127 height: height, 128 cells: cells, 129 acrossClues: across, 130 downClues: down, 131 relatives: relatives 132 ) 133 } 134 135 // MARK: - Sections 136 137 /// Splits the source into top-level sections. Per the .xd spec, sections 138 /// are delimited either by runs of two or more blank lines, or by 139 /// `## SectionName` header lines. We accept both: blank-line runs end the 140 /// current section, and a `##` line also ends the current section (the 141 /// header line itself is consumed and discarded — section identity comes 142 /// from implicit order). 143 private static func splitIntoSections(_ source: String) -> [[String]] { 144 let lines = source 145 .split(separator: "\n", omittingEmptySubsequences: false) 146 .map(String.init) 147 148 var sections: [[String]] = [] 149 var current: [String] = [] 150 var blankRun = 0 151 152 func flush() { 153 while current.last?.trimmingCharacters(in: .whitespaces).isEmpty == true { 154 current.removeLast() 155 } 156 while current.first?.trimmingCharacters(in: .whitespaces).isEmpty == true { 157 current.removeFirst() 158 } 159 if !current.isEmpty { 160 sections.append(current) 161 } 162 current = [] 163 } 164 165 for rawLine in lines { 166 let line = rawLine.trimmingCharacters(in: CharacterSet(charactersIn: "\r")) 167 let trimmed = line.trimmingCharacters(in: .whitespaces) 168 169 if trimmed.hasPrefix("## ") || trimmed == "##" { 170 flush() 171 blankRun = 0 172 continue 173 } 174 175 if trimmed.isEmpty { 176 blankRun += 1 177 if blankRun >= 2 { 178 flush() 179 } 180 continue 181 } 182 183 blankRun = 0 184 current.append(line) 185 } 186 flush() 187 return sections 188 } 189 190 // MARK: - Metadata 191 192 private static func parseMetadata(_ lines: [String]) -> [String: String] { 193 var dict: [String: String] = [:] 194 for line in lines { 195 guard let colon = line.firstIndex(of: ":") else { continue } 196 let key = line[..<colon].trimmingCharacters(in: .whitespaces) 197 let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) 198 if !key.isEmpty { 199 dict[key] = value 200 } 201 } 202 return dict 203 } 204 205 /// Parses a `Rebus:` header value such as `1=ONE 2=TWO 3=THREE` into a 206 /// map from grid placeholder character to its expanded solution string. 207 private static func parseRebusHeader(_ value: String?) -> [Character: String] { 208 guard let value, !value.isEmpty else { return [:] } 209 var map: [Character: String] = [:] 210 for entry in value.split(whereSeparator: { $0.isWhitespace }) { 211 guard let equals = entry.firstIndex(of: "=") else { continue } 212 let key = entry[..<equals] 213 let val = entry[entry.index(after: equals)...] 214 guard key.count == 1, let keyChar = key.first, !val.isEmpty else { continue } 215 map[keyChar] = String(val) 216 } 217 return map 218 } 219 220 /// Parses a `Date:` header value as strict ISO `YYYY-MM-DD`. Returns 221 /// `nil` if the value is missing, empty, or in any other format. 222 private static func parseDateHeader(_ value: String?) -> Date? { 223 guard let value else { return nil } 224 let trimmed = value.trimmingCharacters(in: .whitespaces) 225 guard let match = trimmed.firstMatch(of: /^(\d{4})-(\d{2})-(\d{2})$/), 226 let year = Int(match.1), 227 let month = Int(match.2), 228 let day = Int(match.3) 229 else { return nil } 230 var comps = DateComponents() 231 comps.year = year 232 comps.month = month 233 comps.day = day 234 return Calendar(identifier: .gregorian).date(from: comps) 235 } 236 237 /// Parses a Crossmate `CmVer:` header. Missing versions are version 1 238 /// so legacy sources establish the baseline content version. 239 private static func parseCmVersionHeader(_ value: String?) -> Int { 240 guard let value else { return 1 } 241 let trimmed = value.trimmingCharacters(in: .whitespaces) 242 guard let version = Int(trimmed), version >= 1 else { return 1 } 243 return version 244 } 245 246 /// Parses a `Relatives:` header value into groups of cross-referenced 247 /// clues. Groups are separated by `;`, tokens within a group by `,`, and 248 /// each token is `{number}{A|D}` (e.g. `17A`, `57A`). This is a Crossmate 249 /// extension to `.xd`; unknown/invalid tokens are silently dropped. 250 /// Single-token groups are allowed so formatted NYT clues can mark their 251 /// own answer cells as thematic. 252 private static func parseRelativesHeader(_ value: String?) -> [[ClueRef]] { 253 guard let value, !value.isEmpty else { return [] } 254 var groups: [[ClueRef]] = [] 255 for groupSlice in value.split(separator: ";") { 256 var refs: [ClueRef] = [] 257 for tokenSlice in groupSlice.split(separator: ",") { 258 let token = tokenSlice.trimmingCharacters(in: .whitespaces) 259 guard let last = token.last else { continue } 260 let direction: Puzzle.Direction 261 switch last { 262 case "A", "a": direction = .across 263 case "D", "d": direction = .down 264 default: continue 265 } 266 guard let number = Int(token.dropLast()), number > 0 else { continue } 267 refs.append(ClueRef(number: number, direction: direction)) 268 } 269 if !refs.isEmpty { groups.append(refs) } 270 } 271 return groups 272 } 273 274 /// Parses a `Special:` header value into a `Puzzle.Special` kind. The 275 /// .xd spec recognises `shaded` and `circle` as the two values; we accept 276 /// either case-insensitively and treat anything else as no special kind. 277 private static func parseSpecialHeader(_ value: String?) -> Puzzle.Special? { 278 guard let value else { return nil } 279 switch value.trimmingCharacters(in: .whitespaces).lowercased() { 280 case "shaded": return .shaded 281 case "circle", "circled": return .circled 282 default: return nil 283 } 284 } 285 286 // MARK: - Grid 287 288 private static func parseGrid( 289 _ lines: [String], 290 rebus: [Character: String] 291 ) throws -> (cells: [[Cell]], width: Int, height: Int) { 292 var rows: [[Cell]] = [] 293 var width: Int? = nil 294 295 for line in lines { 296 let trimmed = line.trimmingCharacters(in: .whitespaces) 297 if trimmed.isEmpty { continue } 298 299 var row: [Cell] = [] 300 for ch in trimmed { 301 row.append(try gridCell(for: ch, rebus: rebus)) 302 } 303 304 if let w = width, row.count != w { 305 throw ParseError.raggedGrid 306 } 307 width = row.count 308 rows.append(row) 309 } 310 311 guard let w = width, !rows.isEmpty else { throw ParseError.missingGrid } 312 return (rows, w, rows.count) 313 } 314 315 private static func gridCell( 316 for ch: Character, 317 rebus: [Character: String] 318 ) throws -> Cell { 319 // Blocks: '#' is a normal block; '_' marks a non-existing cell on the 320 // edge of an irregularly-shaped grid. Both render as non-playable. 321 if ch == "#" || ch == "_" { 322 return .block 323 } 324 // '.' is an open cell with no known solution. 325 if ch == "." { 326 return .open(solution: nil, acceptedSolutions: [], isSpecial: false) 327 } 328 // Per the .xd spec, lowercase letters always indicate a special cell 329 // (circled or shaded — the kind is in the `Special:` header). They 330 // may *also* appear in the Rebus header, in which case the solution 331 // is the rebus expansion; otherwise it's the uppercased letter. 332 let isLowercaseLetter = ch.isLetter && ch.isLowercase 333 if let expansion = rebus[ch] { 334 return .open(solution: expansion.uppercased(), acceptedSolutions: [], isSpecial: isLowercaseLetter) 335 } 336 if ch.isLetter { 337 return .open(solution: String(ch).uppercased(), acceptedSolutions: [], isSpecial: isLowercaseLetter) 338 } 339 throw ParseError.unknownGridCharacter(ch) 340 } 341 342 // MARK: - Clues 343 344 private static func parseClues( 345 _ lines: [String] 346 ) throws -> (across: [Clue], down: [Clue]) { 347 struct ClueKey: Hashable { 348 let number: Int 349 let direction: Character 350 } 351 352 var clueTexts: [ClueKey: String] = [:] 353 var clueAnswers: [ClueKey: String] = [:] 354 var clueOrder: [ClueKey] = [] 355 var metadataByClue: [ClueKey: [String: [String]]] = [:] 356 357 for rawLine in lines { 358 let line = rawLine.trimmingCharacters(in: .whitespaces) 359 if line.isEmpty { continue } 360 361 guard let leading = line.first, leading == "A" || leading == "D" else { 362 throw ParseError.malformedClue(line) 363 } 364 365 if let match = line.firstMatch(of: /^([AD])(\d+)\s+\^([^:]+):\s*(.*)$/) { 366 guard let number = Int(match.2) else { throw ParseError.malformedClue(line) } 367 let key = ClueKey(number: number, direction: Character(String(match.1))) 368 let metadataKey = String(match.3).trimmingCharacters(in: .whitespaces) 369 guard !metadataKey.isEmpty else { throw ParseError.malformedClue(line) } 370 metadataByClue[key, default: [:]][metadataKey, default: []].append(String(match.4)) 371 continue 372 } 373 374 guard let dot = line.firstIndex(of: ".") else { 375 throw ParseError.malformedClue(line) 376 } 377 378 let numberSlice = line[line.index(after: line.startIndex)..<dot] 379 guard let number = Int(numberSlice) else { 380 throw ParseError.malformedClue(line) 381 } 382 let key = ClueKey(number: number, direction: leading) 383 384 var afterDot = line[line.index(after: dot)...] 385 .trimmingCharacters(in: .whitespaces) 386 387 if let tilde = afterDot.range(of: " ~ ", options: .backwards) { 388 let answer = afterDot[tilde.upperBound...] 389 .trimmingCharacters(in: .whitespaces) 390 if !answer.isEmpty { 391 clueAnswers[key] = answer 392 } 393 afterDot = String(afterDot[..<tilde.lowerBound]) 394 .trimmingCharacters(in: .whitespaces) 395 } 396 397 if clueTexts[key] == nil { 398 clueOrder.append(key) 399 } 400 clueTexts[key] = afterDot 401 } 402 403 var across: [Clue] = [] 404 var down: [Clue] = [] 405 for key in clueOrder { 406 let clue = Clue( 407 number: key.number, 408 text: clueTexts[key] ?? "", 409 answer: clueAnswers[key], 410 metadata: metadataByClue[key] ?? [:] 411 ) 412 if key.direction == "A" { 413 across.append(clue) 414 } else { 415 down.append(clue) 416 } 417 } 418 return (across, down) 419 } 420 421 private static func applyAcceptedAnswers( 422 cells: [[Cell]], 423 across: [Clue], 424 down: [Clue] 425 ) -> [[Cell]] { 426 var cells = cells 427 let acrossByNumber = Dictionary(uniqueKeysWithValues: across.map { ($0.number, $0) }) 428 let downByNumber = Dictionary(uniqueKeysWithValues: down.map { ($0.number, $0) }) 429 let positionsOutsideExactCellAnswers = positionsOutsideExactCellAnswers( 430 cells: cells, 431 across: acrossByNumber, 432 down: downByNumber 433 ) 434 435 for r in cells.indices { 436 for c in cells[r].indices { 437 guard case .open(let solution, let acceptedSolutions, let isSpecial) = cells[r][c] else { continue } 438 let position = Position(row: r, col: c) 439 let effectiveSolution = positionsOutsideExactCellAnswers.contains(position) ? nil : solution 440 var merged = acceptedSolutions 441 if let accepted = acceptedCellValues( 442 atRow: r, 443 col: c, 444 direction: .across, 445 cells: cells, 446 cluesByNumber: acrossByNumber 447 ) { 448 merged.formUnion(accepted) 449 } 450 if let accepted = acceptedCellValues( 451 atRow: r, 452 col: c, 453 direction: .down, 454 cells: cells, 455 cluesByNumber: downByNumber 456 ) { 457 merged.formUnion(accepted) 458 } 459 if effectiveSolution != solution || merged != acceptedSolutions { 460 cells[r][c] = .open(solution: effectiveSolution, acceptedSolutions: merged, isSpecial: isSpecial) 461 } 462 } 463 } 464 465 return cells 466 } 467 468 private struct Position: Hashable { 469 let row: Int 470 let col: Int 471 } 472 473 private static func positionsOutsideExactCellAnswers( 474 cells: [[Cell]], 475 across: [Int: Clue], 476 down: [Int: Clue] 477 ) -> Set<Position> { 478 var positions: Set<Position> = [] 479 var seenWords: Set<WordKey> = [] 480 for r in cells.indices { 481 for c in cells[r].indices { 482 positions.formUnion( 483 positionsOutsideExactCellAnswer( 484 fromRow: r, 485 col: c, 486 direction: .across, 487 cells: cells, 488 cluesByNumber: across, 489 seenWords: &seenWords 490 ) 491 ) 492 positions.formUnion( 493 positionsOutsideExactCellAnswer( 494 fromRow: r, 495 col: c, 496 direction: .down, 497 cells: cells, 498 cluesByNumber: down, 499 seenWords: &seenWords 500 ) 501 ) 502 } 503 } 504 return positions 505 } 506 507 private struct WordKey: Hashable { 508 let direction: Direction 509 let row: Int 510 let col: Int 511 } 512 513 private static func positionsOutsideExactCellAnswer( 514 fromRow row: Int, 515 col: Int, 516 direction: Direction, 517 cells: [[Cell]], 518 cluesByNumber: [Int: Clue], 519 seenWords: inout Set<WordKey> 520 ) -> Set<Position> { 521 let word = wordCells(fromRow: row, col: col, direction: direction, cells: cells) 522 guard let first = word.first else { return [] } 523 let key = WordKey(direction: direction, row: first.row, col: first.col) 524 guard seenWords.insert(key).inserted, 525 word.count > 1, 526 let number = clueNumber(forWord: word, cells: cells), 527 let clue = cluesByNumber[number] 528 else { return [] } 529 530 guard let answer = clue.answer else { return [] } 531 let solutions = word.compactMap { position -> String? in 532 guard case .open(let solution?, _, _) = cells[position.row][position.col] else { return nil } 533 return solution 534 } 535 guard solutions.count == word.count else { return [] } 536 537 if normalizedAnswer(answer) == normalizedAnswer(solutions.joined()) { 538 return [] 539 } 540 541 let matchingIndices = solutions.indices.filter { normalizedAnswer(answer) == normalizedAnswer(solutions[$0]) } 542 guard matchingIndices.count == 1, let matchingIndex = matchingIndices.first else { return [] } 543 544 return Set(word.indices.compactMap { index in 545 index == matchingIndex ? nil : Position(row: word[index].row, col: word[index].col) 546 }) 547 } 548 549 private static func acceptedCellValues( 550 atRow row: Int, 551 col: Int, 552 direction: Direction, 553 cells: [[Cell]], 554 cluesByNumber: [Int: Clue] 555 ) -> Set<String>? { 556 let word = wordCells(fromRow: row, col: col, direction: direction, cells: cells) 557 guard let wordIndex = word.firstIndex(where: { $0.row == row && $0.col == col }) else { return nil } 558 guard let number = clueNumber(forWord: word, cells: cells), 559 let clue = cluesByNumber[number], 560 !clue.acceptedAnswers.isEmpty else { return nil } 561 562 if let solution = solution(atRow: row, col: col, cells: cells), 563 let answer = clue.answer, 564 normalizedAnswer(answer) == normalizedAnswer(solution) { 565 return Set(clue.acceptedAnswers) 566 } 567 568 if word.count == 1 { 569 return Set(clue.acceptedAnswers) 570 } 571 572 let solutions = word.compactMap { position -> String? in 573 guard case .open(let solution?, _, _) = cells[position.row][position.col] else { return nil } 574 return solution 575 } 576 guard solutions.count == word.count else { return nil } 577 578 let canonicalAnswer = clue.answer ?? solutions.joined() 579 guard canonicalAnswer == solutions.joined() else { return nil } 580 581 var accepted: Set<String> = [] 582 for acceptedAnswer in clue.acceptedAnswers { 583 guard let segments = segmentAcceptedAnswer(acceptedAnswer, canonicalSegments: solutions), 584 segments.indices.contains(wordIndex), 585 segments[wordIndex] != solutions[wordIndex] else { continue } 586 accepted.insert(segments[wordIndex]) 587 } 588 return accepted 589 } 590 591 private static func solution(atRow row: Int, col: Int, cells: [[Cell]]) -> String? { 592 guard case .open(let solution?, _, _) = cells[row][col] else { return nil } 593 return solution 594 } 595 596 private static func normalizedAnswer(_ value: String) -> String { 597 value.precomposedStringWithCanonicalMapping.uppercased() 598 } 599 600 private static func segmentAcceptedAnswer( 601 _ acceptedAnswer: String, 602 canonicalSegments: [String] 603 ) -> [String]? { 604 let accepted = Array(acceptedAnswer) 605 let canonical = canonicalSegments.map(Array.init) 606 607 for replacedIndex in canonical.indices { 608 let prefixLength = canonical[..<replacedIndex].reduce(0) { $0 + $1.count } 609 let suffixLength = canonical[canonical.index(after: replacedIndex)...].reduce(0) { $0 + $1.count } 610 guard accepted.count >= prefixLength + suffixLength else { continue } 611 612 let prefix = canonical[..<replacedIndex].flatMap { $0 } 613 let suffix = canonical[canonical.index(after: replacedIndex)...].flatMap { $0 } 614 guard accepted.prefix(prefix.count).elementsEqual(prefix) else { continue } 615 guard accepted.suffix(suffix.count).elementsEqual(suffix) else { continue } 616 617 let replacementEnd = accepted.count - suffixLength 618 let replacement = Array(accepted[prefixLength..<replacementEnd]) 619 guard !replacement.isEmpty, replacement != canonical[replacedIndex] else { continue } 620 621 var segments = canonical.map { String($0) } 622 segments[replacedIndex] = String(replacement) 623 return segments 624 } 625 626 return nil 627 } 628 629 private enum Direction: Hashable { 630 case across 631 case down 632 633 var delta: (row: Int, col: Int) { 634 switch self { 635 case .across: return (0, 1) 636 case .down: return (1, 0) 637 } 638 } 639 } 640 641 private static func clueNumber(atRow row: Int, col: Int, direction: Direction, cells: [[Cell]]) -> Int? { 642 let word = wordCells(fromRow: row, col: col, direction: direction, cells: cells) 643 return clueNumber(forWord: word, cells: cells) 644 } 645 646 private static func clueNumber(forWord word: [(row: Int, col: Int)], cells: [[Cell]]) -> Int? { 647 guard let first = word.first else { return nil } 648 return computedNumber(atRow: first.row, col: first.col, cells: cells) 649 } 650 651 private static func wordCells( 652 fromRow row: Int, 653 col: Int, 654 direction: Direction, 655 cells: [[Cell]] 656 ) -> [(row: Int, col: Int)] { 657 guard !isBlock(cells, row, col) else { return [] } 658 let delta = direction.delta 659 var startRow = row 660 var startCol = col 661 while isOpen(cells, startRow - delta.row, startCol - delta.col) { 662 startRow -= delta.row 663 startCol -= delta.col 664 } 665 666 var result: [(row: Int, col: Int)] = [] 667 var r = startRow 668 var c = startCol 669 while isOpen(cells, r, c) { 670 result.append((r, c)) 671 r += delta.row 672 c += delta.col 673 } 674 return result 675 } 676 677 private static func computedNumber(atRow row: Int, col: Int, cells: [[Cell]]) -> Int? { 678 var counter = 1 679 for r in cells.indices { 680 for c in cells[r].indices { 681 guard isOpen(cells, r, c) else { continue } 682 let startsAcross = !isOpen(cells, r, c - 1) && isOpen(cells, r, c + 1) 683 let startsDown = !isOpen(cells, r - 1, c) && isOpen(cells, r + 1, c) 684 if startsAcross || startsDown { 685 if r == row, c == col { return counter } 686 counter += 1 687 } 688 } 689 } 690 return nil 691 } 692 693 private static func isOpen(_ cells: [[Cell]], _ row: Int, _ col: Int) -> Bool { 694 guard row >= 0, row < cells.count, col >= 0, col < cells[row].count else { return false } 695 return !isBlock(cells, row, col) 696 } 697 698 private static func isBlock(_ cells: [[Cell]], _ row: Int, _ col: Int) -> Bool { 699 guard row >= 0, row < cells.count, col >= 0, col < cells[row].count else { return true } 700 if case .block = cells[row][col] { return true } 701 return false 702 } 703 }