grammargem/Sources/GrammaGem/Grammar/SystemGrammarEngine.swift
BigMarc 3ea090ae4b Embed the real Harper grammar core via Rust C-FFI
Replace the NSSpellChecker + regex-stub engine with harper-core 2.5.0:
real subject-verb agreement, verb forms, punctuation, repetition and
spelling. New harper-ffi crate (staticlib) exposes harper_check over a C
ABI; the CHarper module bridges it to Swift; HarperEngine maps Harper's
char spans onto UTF-16 ranges and de-dups overlapping lints. NSSpellChecker
stays only as a case-preserving spelling supplement, which fixes the
problemm->Problem mis-casing. Bulk apply now skips no-op suggestions.

build.sh / scripts/build.sh build the universal static lib; target/ and
lib/ are gitignored (regenerate via harper-ffi/build.sh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 06:43:11 +02:00

91 lines
4.0 KiB
Swift

import Foundation
import AppKit
/// Layer 1 orchestrator. The real **Harper** core is the primary engine
/// (grammar, punctuation, repetition, capitalization, spelling); macOS's
/// `NSSpellChecker` is layered on top purely for *extra spelling coverage* from
/// its large system dictionary.
///
/// Two correctness rules make the combination trustworthy:
/// 1. **Harper wins on overlap** — a system spelling guess is dropped if Harper
/// already flagged the same span (Harper's fixes carry correct casing + a
/// real explanation).
/// 2. **Case-preserving** — a system spelling replacement is matched to the
/// original token's casing, so a mid-sentence word is never spuriously
/// capitalized (the historical `problemm` -> `Problem` bug). Genuine
/// sentence-start capitalization is surfaced separately by Harper.
final class SystemGrammarEngine: GrammarEngine {
var ignoreList: Set<String> = [] {
didSet { harper.ignoreList = ignoreList }
}
private let checker = NSSpellChecker.shared
private let tag = NSSpellChecker.uniqueSpellDocumentTag()
private let harper = HarperEngine()
func check(_ text: String) -> [Suggestion] {
guard text.count >= 2 else { return [] }
var out = harper.check(text)
// Supplement with NSSpellChecker spelling only (Harper owns grammar),
// de-duped against Harper spans and case-matched to the original token.
let spelling = onMain { self.spellingSupplement(text) }
for s in spelling where !out.contains(where: { overlaps($0.range, s.range) }) {
out.append(s)
}
return out.sorted { $0.location < $1.location }
}
private func spellingSupplement(_ text: String) -> [Suggestion] {
let ns = text as NSString
let full = NSRange(location: 0, length: ns.length)
let language = checker.language()
let results = checker.check(
text, range: full, types: NSTextCheckingResult.CheckingType.spelling.rawValue,
options: nil, inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
var out: [Suggestion] = []
for r in results where r.resultType == .spelling {
let word = ns.substring(with: r.range)
if ignoreList.contains(word.lowercased()) { continue }
guard let raw = checker.correction(
forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
?? checker.guesses(
forWordRange: r.range, in: text, language: language,
inSpellDocumentWithTag: tag)?.first
else { continue }
let fix = Self.matchingCase(of: word, in: raw)
guard fix != word else { continue } // never emit a no-op
out.append(Suggestion(
location: r.range.location, length: r.range.length,
original: word, replacement: fix,
kind: .spelling, message: "Possible spelling mistake"))
}
return out
}
/// Re-case `replacement` to follow `original`'s casing.
static func matchingCase(of original: String, in replacement: String) -> String {
guard let o = original.first, let r = replacement.first else { return replacement }
// ALL-CAPS original (with at least one cased letter) -> upper the whole thing.
if original.count > 1, original == original.uppercased(), original != original.lowercased() {
return replacement.uppercased()
}
if o.isLowercase, r.isUppercase {
return r.lowercased() + replacement.dropFirst()
}
if o.isUppercase, r.isLowercase {
return r.uppercased() + replacement.dropFirst()
}
return replacement
}
private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool {
NSIntersectionRange(a, b).length > 0
}
private func onMain<T>(_ work: @escaping () -> T) -> T {
Thread.isMainThread ? work() : DispatchQueue.main.sync(execute: work)
}
}