← poker-tutor
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
}
}