crossmate

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

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 }