Advertisement

← poker-tutor

Sources/PokerTests/BannedWriting.swift

6.6 KB Download file
import Foundation

/// The turns of phrase this project does not use, and a way to look for them.
///
/// These are different from the poker words `Glossary` watches. A poker word is banned from what
/// the player reads because it has not been explained; these are banned from everything the owner
/// writes, comments included, because they are habits of writing rather than vocabulary. So the
/// same list is run over the screens, over the coach, and over every comment in `Sources`.
///
/// Kept as data with a plain function over it, so adding a phrase is one line and the same rule
/// then holds everywhere at once — the last three of these were each added after being found by
/// hand in one file, having survived in four others.
public enum BannedWriting {

    /// Words banned outright, matched whole.
    ///
    /// The first family is a synonym for interesting or salient, which is the sense that was ruled
    /// out; it is also poker jargon for a good player, so there is no reading of it that belongs
    /// here. The second is a metaphor for "this part is doing the work", which is to be written out
    /// in words instead.
    static let bannedWords = ["sharp", "sharper", "sharpest", "sharply",
                              "load-bearing", "load bearing"]

    /// The reveal: a negation, then a comma or a dash, then the correction. It reads as a flourish
    /// and it always costs the sentence a fact, because the half before the comma says nothing.
    private static let corrections = [", it is ", ", it's ", ", its ", "; it is ", "; it's ",
                                      " — it is ", " — it's ", " – it is ", " – it's ",
                                      " -- it is ", " -- it's ",
                                      ", they are ", ", they're ", " — they are ", " — they're "]

    /// Only the flat negations. "No" on its own was tried and taken out again: it convicts "no
    /// longer", "no room" and "nobody", none of which are the construction being looked for, and a
    /// rule that cries wolf is one somebody switches off.
    private static let negations = [" not ", "n't ", "isn't", "aren't", "wasn't", "weren't"]

    /// Which rules a piece of writing breaks. Empty is the only acceptable answer.
    public static func offences(in text: String) -> [String] {
        var broken: [String] = []
        for word in bannedWords where !wholeWordRanges(of: word, in: text).isEmpty {
            broken.append("the banned word \"\(word)\"")
        }
        if hasReveal(text) { broken.append("the banned reveal construction") }
        return broken
    }

    /// A negation followed within the same sentence by a correction.
    ///
    /// Bounded at the sentence in front of it, so an ordinary "not" two sentences earlier does not
    /// convict a perfectly plain sentence later on.
    private static func hasReveal(_ text: String) -> Bool {
        let lower = text.lowercased()
        for connector in corrections {
            var from = lower.startIndex
            while let hit = lower.range(of: connector, range: from..<lower.endIndex) {
                from = hit.upperBound
                let sentenceStart = lower[lower.startIndex..<hit.lowerBound]
                    .lastIndex(where: { $0 == "." || $0 == "?" || $0 == "!" })
                    .map { lower.index(after: $0) } ?? lower.startIndex
                let window = " " + lower[sentenceStart..<hit.lowerBound]
                if negations.contains(where: { window.contains($0) }) { return true }
            }
        }
        return false
    }

    /// The comment on a line of Swift, or nil where there is none.
    ///
    /// Deliberately crude: it takes the text after the first `//` that is not inside a string
    /// literal on that line. A crude reading over every file beats an exact one over a chosen few,
    /// and the only cost of being wrong is reading a few characters that were not a comment.
    public static func comment(in line: String) -> String? {
        var inString = false
        var previous: Character?
        var index = line.startIndex
        while index < line.endIndex {
            let character = line[index]
            if character == "\"", previous != "\\" { inString.toggle() }
            if !inString, character == "/", previous == "/" {
                let start = line.index(after: index)
                let text = String(line[start...]).trimmingCharacters(in: .whitespaces)
                return text.isEmpty ? nil : text
            }
            previous = character
            index = line.index(after: index)
        }
        return nil
    }

    /// Every Swift file in `Sources`, found from where this file was compiled from.
    ///
    /// The limit is worth stating: this only finds anything when the binary was built from the
    /// checkout it is being run in. That is how `pokerlab test` is always run, and the test that
    /// uses this fails loudly rather than quietly passing if the sources are not there.
    public static func swiftFiles() -> [URL] {
        let here = URL(fileURLWithPath: #filePath)
        let sources = here.deletingLastPathComponent().deletingLastPathComponent()
        guard let walk = FileManager.default.enumerator(at: sources,
                                                        includingPropertiesForKeys: nil) else {
            return []
        }
        return walk.compactMap { $0 as? URL }.filter { $0.pathExtension == "swift" }.sorted {
            $0.path < $1.path
        }
    }

    /// Whole words only, so a banned word does not light up inside a longer one.
    private static func wholeWordRanges(of phrase: String, in text: String) -> [Range<String.Index>] {
        var found: [Range<String.Index>] = []
        var searchFrom = text.startIndex
        while searchFrom < text.endIndex,
              let range = text.range(of: phrase, options: [.caseInsensitive],
                                     range: searchFrom..<text.endIndex) {
            searchFrom = range.upperBound
            let before = range.lowerBound == text.startIndex
                ? nil : text[text.index(before: range.lowerBound)]
            let after = range.upperBound == text.endIndex ? nil : text[range.upperBound]
            let isBoundary = { (character: Character?) in
                guard let character else { return true }
                return !(character.isLetter || character.isNumber)
            }
            if isBoundary(before), isBoundary(after) { found.append(range) }
        }
        return found
    }
}