80 lines
3.3 KiB
Swift
80 lines
3.3 KiB
Swift
import Foundation
|
|
import SstCore
|
|
|
|
/// Whole-word, case-insensitive, single-pass replacement. Longest match wins, replaced text is never rescanned.
|
|
public struct CorrectionEngine: TextCorrecting {
|
|
private struct Rule {
|
|
let source: String
|
|
let wrote: String
|
|
}
|
|
|
|
private let rules: [Rule]
|
|
private let regex: NSRegularExpression?
|
|
|
|
public init(entries: [DictionaryEntry], knowledge: WordKnowledge) {
|
|
var seen = Set<String>()
|
|
var built: [(rule: Rule, order: Int)] = []
|
|
for (order, entry) in entries.enumerated() {
|
|
let source = entry.source.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let key = EntryKey.key(source)
|
|
guard !key.isEmpty, !seen.contains(key) else { continue }
|
|
// A single-word term that is also a real word only biases the model; capitalising it everywhere would corrupt normal text.
|
|
if case .term(let term) = entry.kind, EntryKey.parts(term).count == 1, knowledge.isWord(term) { continue }
|
|
seen.insert(key)
|
|
built.append((Rule(source: source, wrote: entry.wrote), order))
|
|
}
|
|
let ordered = built.sorted { a, b in
|
|
let la = a.rule.source.filter(\.isLetter).count
|
|
let lb = b.rule.source.filter(\.isLetter).count
|
|
return la != lb ? la > lb : a.order < b.order
|
|
}.map(\.rule)
|
|
rules = ordered
|
|
if ordered.isEmpty {
|
|
regex = nil
|
|
} else {
|
|
let alternatives = ordered.map { "(\(Self.pattern($0.source)))" }.joined(separator: "|")
|
|
regex = try? NSRegularExpression(
|
|
pattern: "(?<![\\p{L}\\p{N}])(?:\(alternatives))(?![\\p{L}\\p{N}])",
|
|
options: [.caseInsensitive]
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Parts joined by "zero or more spaces/hyphens", so "cloud code" also finds "cloudcode" and "Cloud-Code".
|
|
private static func pattern(_ source: String) -> String {
|
|
EntryKey.parts(source)
|
|
.map { NSRegularExpression.escapedPattern(for: $0) }
|
|
.joined(separator: "[\\s\\-‐‑–]*")
|
|
}
|
|
|
|
public func correct(_ text: String) -> CorrectionResult {
|
|
guard let regex, !text.isEmpty else { return CorrectionResult(text: text, corrections: []) }
|
|
let ns = text as NSString
|
|
var output = ""
|
|
var outputLength = 0
|
|
var last = 0
|
|
var applied: [AppliedCorrection] = []
|
|
|
|
for match in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
|
|
let gap = ns.substring(with: NSRange(location: last, length: match.range.location - last))
|
|
output += gap
|
|
outputLength += gap.utf16.count
|
|
|
|
let heard = ns.substring(with: match.range)
|
|
let group = (1..<match.numberOfRanges).first { match.range(at: $0).location != NSNotFound } ?? 1
|
|
let rule = rules[group - 1]
|
|
|
|
output += rule.wrote
|
|
if heard != rule.wrote {
|
|
applied.append(AppliedCorrection(
|
|
heard: heard, wrote: rule.wrote, rule: rule.source,
|
|
start: outputLength, length: rule.wrote.utf16.count))
|
|
}
|
|
outputLength += rule.wrote.utf16.count
|
|
last = match.range.location + match.range.length
|
|
}
|
|
output += ns.substring(from: last)
|
|
return CorrectionResult(text: output, corrections: applied)
|
|
}
|
|
}
|