mirror of
https://github.com/BigMarc/grammargem.git
synced 2026-10-13 17:01:55 +02:00
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>
This commit is contained in:
parent
e3ba7ff7e0
commit
3ea090ae4b
5
.gitignore
vendored
5
.gitignore
vendored
@ -3,3 +3,8 @@ dist/
|
|||||||
*.xcodeproj/xcuserdata/
|
*.xcodeproj/xcuserdata/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
|
|
||||||
|
# Rust / Harper FFI build artifacts (regenerate with harper-ffi/build.sh).
|
||||||
|
# Cargo.lock IS committed (binary crate) for reproducible builds.
|
||||||
|
harper-ffi/target/
|
||||||
|
harper-ffi/lib/
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
// swift-tools-version:5.10
|
// swift-tools-version:5.10
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
|
import Foundation
|
||||||
|
|
||||||
// GrammaGem — native macOS menu-bar writing assistant.
|
// GrammaGem — native macOS menu-bar writing assistant.
|
||||||
//
|
//
|
||||||
// This package builds with ONLY Apple frameworks so it compiles cleanly out of
|
// Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust
|
||||||
// the box (`swift build`). The heavyweight third-party pieces from the spec —
|
// C-FFI static library (see `harper-ffi/`). Build the lib first with
|
||||||
// Harper (Rust grammar core), MLX (local LLM), KeyboardShortcuts, Sparkle — are
|
// `harper-ffi/build.sh` (universal arm64+x86_64 -> harper-ffi/lib/libharper_ffi.a);
|
||||||
// abstracted behind protocols with working stub implementations and clearly
|
// `scripts/build.sh` does this automatically. The remaining heavyweight pieces
|
||||||
// marked `TODO(real-integration)` seams. See README.md for wiring the real deps.
|
// (MLX local LLM, Sparkle, KeyboardShortcuts) are still behind protocol seams.
|
||||||
|
|
||||||
|
// Absolute path to the prebuilt Harper static lib, independent of build cwd.
|
||||||
|
let packageRoot = URL(fileURLWithPath: #filePath).deletingLastPathComponent().path
|
||||||
|
let harperLibDir = packageRoot + "/harper-ffi/lib"
|
||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "GrammaGem",
|
name: "GrammaGem",
|
||||||
platforms: [
|
platforms: [
|
||||||
@ -16,17 +22,17 @@ let package = Package(
|
|||||||
products: [
|
products: [
|
||||||
.executable(name: "GrammaGem", targets: ["GrammaGem"])
|
.executable(name: "GrammaGem", targets: ["GrammaGem"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
|
||||||
// TODO(real-integration): add when wiring real engines / distribution.
|
|
||||||
// .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0"),
|
|
||||||
// .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.0"),
|
|
||||||
// .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.18.0"),
|
|
||||||
// The Harper grammar core is bundled via a Rust static lib + C-FFI; see harper-ffi/.
|
|
||||||
],
|
|
||||||
targets: [
|
targets: [
|
||||||
|
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
|
||||||
|
.target(name: "CHarper", path: "Sources/CHarper"),
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "GrammaGem",
|
name: "GrammaGem",
|
||||||
path: "Sources/GrammaGem"
|
dependencies: ["CHarper"],
|
||||||
|
path: "Sources/GrammaGem",
|
||||||
|
linkerSettings: [
|
||||||
|
// Link the prebuilt universal Harper static library.
|
||||||
|
.unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"])
|
||||||
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "GrammaGemTests",
|
name: "GrammaGemTests",
|
||||||
|
|||||||
37
Sources/CHarper/include/harper.h
Normal file
37
Sources/CHarper/include/harper.h
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* harper.h — C ABI for the harper-ffi static library (harper-core 2.5.0).
|
||||||
|
*
|
||||||
|
* Symbols are provided by libharper_ffi.a (linked via -lharper_ffi). All
|
||||||
|
* strings are UTF-8. See harper-ffi/ for the Rust source.
|
||||||
|
*/
|
||||||
|
#ifndef HARPER_FFI_H
|
||||||
|
#define HARPER_FFI_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check `text` for grammar/spelling issues using harper-core.
|
||||||
|
*
|
||||||
|
* Returns a malloc'd, NUL-terminated UTF-8 JSON C string the caller MUST free
|
||||||
|
* with harper_free(). The JSON is an array of objects:
|
||||||
|
* [{ "start": <char offset>, "len": <char count>,
|
||||||
|
* "replacement": "<string>", "message": "<string>", "kind": "<string>" }]
|
||||||
|
*
|
||||||
|
* - "start"/"len" are CHAR (Unicode scalar) offsets, NOT bytes/UTF-16.
|
||||||
|
* - "kind" is one of: spelling, grammar, punctuation, repetition,
|
||||||
|
* capitalization, phrasing.
|
||||||
|
*
|
||||||
|
* On null/invalid input or any internal error, returns "[]". Thread-safe.
|
||||||
|
*/
|
||||||
|
char *harper_check(const char *text);
|
||||||
|
|
||||||
|
/* Free a string previously returned by harper_check(). NULL is a no-op. */
|
||||||
|
void harper_free(char *ptr);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* HARPER_FFI_H */
|
||||||
4
Sources/CHarper/include/module.modulemap
Normal file
4
Sources/CHarper/include/module.modulemap
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
module CHarper {
|
||||||
|
header "harper.h"
|
||||||
|
export *
|
||||||
|
}
|
||||||
4
Sources/CHarper/shim.c
Normal file
4
Sources/CHarper/shim.c
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// CHarper is a header-only shim that exposes libharper_ffi's C ABI (declared in
|
||||||
|
// include/harper.h) to Swift as the `CHarper` module. The actual symbols are
|
||||||
|
// provided by libharper_ffi.a, linked via the GrammaGem target's linkerSettings.
|
||||||
|
// This translation unit exists only so SwiftPM treats CHarper as a C target.
|
||||||
@ -19,9 +19,15 @@ protocol GrammarEngine: AnyObject {
|
|||||||
|
|
||||||
extension GrammarEngine {
|
extension GrammarEngine {
|
||||||
/// Default correction: apply suggestions right-to-left so offsets stay valid.
|
/// Default correction: apply suggestions right-to-left so offsets stay valid.
|
||||||
|
///
|
||||||
|
/// No-op flags (where the replacement equals the original — e.g. a grammar
|
||||||
|
/// issue Harper can describe but not auto-fix) are skipped, so "Fix all" /
|
||||||
|
/// the in-place hotkey only ever apply real, text-changing corrections and
|
||||||
|
/// never silently rewrite a span to itself.
|
||||||
func correct(_ text: String) -> String {
|
func correct(_ text: String) -> String {
|
||||||
let ns = NSMutableString(string: text)
|
let ns = NSMutableString(string: text)
|
||||||
for s in check(text).sorted(by: { $0.location > $1.location }) {
|
for s in check(text).sorted(by: { $0.location > $1.location }) {
|
||||||
|
guard s.replacement != s.original else { continue }
|
||||||
guard s.location + s.length <= ns.length else { continue }
|
guard s.location + s.length <= ns.length else { continue }
|
||||||
ns.replaceCharacters(in: s.range, with: s.replacement)
|
ns.replaceCharacters(in: s.range, with: s.replacement)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,75 +1,83 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CHarper
|
||||||
|
|
||||||
/// STUB standing in for the real Harper core.
|
/// Layer 1 — the real **Harper** grammar core (Apache-2.0), embedded via a Rust
|
||||||
|
/// C-FFI static lib (`harper-ffi/`, harper-core 2.5.0). Fully offline,
|
||||||
|
/// deterministic, and fast — real subject-verb agreement, verb forms,
|
||||||
|
/// punctuation, repetition, capitalization and spelling, not just a spell check.
|
||||||
///
|
///
|
||||||
/// TODO(real-integration): replace the rule set below with calls into the Harper
|
/// `harper_check` returns a JSON array of spans with **char (Unicode scalar)**
|
||||||
/// Rust crate over C-FFI (`harper_check(text) -> spans`). This stub implements a
|
/// offsets; we map those onto UTF-16 `NSRange` offsets so they compose with the
|
||||||
/// handful of Harper-style deterministic rules so the system loop, live
|
/// rest of the app (AX ranges, `NSMutableString`).
|
||||||
/// underlines, and tests have something real to exercise. It runs in well under
|
|
||||||
/// 10ms and is fully offline — matching Harper's performance profile.
|
|
||||||
final class HarperEngine: GrammarEngine {
|
final class HarperEngine: GrammarEngine {
|
||||||
var ignoreList: Set<String> = []
|
var ignoreList: Set<String> = []
|
||||||
|
|
||||||
func check(_ text: String) -> [Suggestion] {
|
func check(_ text: String) -> [Suggestion] {
|
||||||
var out: [Suggestion] = []
|
guard !text.isEmpty else { return [] }
|
||||||
let ns = text as NSString
|
|
||||||
|
|
||||||
// Rule 1 — repeated word ("the the" -> "the").
|
// Call into Rust. The returned C string is heap-allocated by Harper and
|
||||||
out += matches(in: text, pattern: #"\b(\w+)\s+\1\b"#) { match in
|
// must be freed with harper_free (it outlives the withCString buffer).
|
||||||
let full = ns.substring(with: match.range)
|
guard let raw = text.withCString({ harper_check($0) }) else { return [] }
|
||||||
let firstWord = ns.substring(with: match.range(at: 1))
|
defer { harper_free(raw) }
|
||||||
guard !ignoreList.contains(firstWord.lowercased()) else { return nil }
|
|
||||||
return Suggestion(
|
let json = String(cString: raw)
|
||||||
location: match.range.location, length: match.range.length,
|
guard let data = json.data(using: .utf8),
|
||||||
original: full, replacement: firstWord,
|
let lints = try? JSONDecoder().decode([HarperLint].self, from: data)
|
||||||
kind: .repetition, message: "Repeated word")
|
else { return [] }
|
||||||
|
|
||||||
|
// Harper can emit several lints over the SAME span (e.g. two phrasings of
|
||||||
|
// "checkin" -> "check in"). Keep the first (highest-priority) per span so
|
||||||
|
// the right-to-left bulk apply in `correct()` never double-writes a range.
|
||||||
|
var kept: [Suggestion] = []
|
||||||
|
for lint in lints {
|
||||||
|
guard lint.len > 0 || !lint.replacement.isEmpty else { continue }
|
||||||
|
guard let range = Self.utf16Range(scalarStart: lint.start, scalarLen: lint.len, in: text)
|
||||||
|
else { continue }
|
||||||
|
let original = (text as NSString).substring(with: range)
|
||||||
|
if !original.isEmpty, ignoreList.contains(original.lowercased()) { continue }
|
||||||
|
if kept.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) { continue }
|
||||||
|
kept.append(Suggestion(
|
||||||
|
location: range.location, length: range.length,
|
||||||
|
original: original, replacement: lint.replacement,
|
||||||
|
kind: GrammarKind(harperKind: lint.kind), message: lint.message))
|
||||||
|
}
|
||||||
|
return kept.sorted { $0.location < $1.location }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 2 — lowercase standalone "i" -> "I".
|
// MARK: - FFI decoding
|
||||||
out += matches(in: text, pattern: #"(?<=\s|^)i(?=\s|$|')"#) { match in
|
|
||||||
Suggestion(
|
private struct HarperLint: Decodable {
|
||||||
location: match.range.location, length: match.range.length,
|
let start: Int
|
||||||
original: "i", replacement: "I",
|
let len: Int
|
||||||
kind: .capitalization, message: "Capitalize the pronoun “I”")
|
let replacement: String
|
||||||
|
let message: String
|
||||||
|
let kind: String
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule 3 — a few common confusions / typos.
|
/// Map a Harper char-span (Unicode scalar offsets, end-exclusive) onto a
|
||||||
let confusions: [(String, String, GrammarKind, String)] = [
|
/// UTF-16 `NSRange`. Scalar boundaries are always UTF-16 boundaries, so the
|
||||||
(#"\btheir\b(?=\s+(any|are|is|was|were)\b)"#, "there", .grammar, "Did you mean “there”?"),
|
/// conversion is exact.
|
||||||
(#"\bteh\b"#, "the", .spelling, "Spelling"),
|
static func utf16Range(scalarStart: Int, scalarLen: Int, in text: String) -> NSRange? {
|
||||||
(#"\brecieve\b"#, "receive", .spelling, "Spelling (i before e)"),
|
let scalars = text.unicodeScalars
|
||||||
(#"\bcheckin\b"#, "check in", .phrasing, "“check in” is two words"),
|
guard let s = scalars.index(scalars.startIndex, offsetBy: scalarStart, limitedBy: scalars.endIndex),
|
||||||
(#"\blmk\b"#, "let me know", .phrasing, "Expand abbreviation"),
|
let e = scalars.index(s, offsetBy: scalarLen, limitedBy: scalars.endIndex)
|
||||||
(#"(?<=\s|^)u(?=\s|$)"#, "you", .phrasing, "Expand “u”"),
|
else { return nil }
|
||||||
]
|
let location = text.utf16.distance(from: text.utf16.startIndex, to: s)
|
||||||
for (pattern, replacement, kind, message) in confusions {
|
let length = text.utf16.distance(from: s, to: e)
|
||||||
out += matches(in: text, pattern: pattern) { match in
|
return NSRange(location: location, length: length)
|
||||||
let original = ns.substring(with: match.range)
|
}
|
||||||
guard !ignoreList.contains(original.lowercased()) else { return nil }
|
}
|
||||||
return Suggestion(
|
|
||||||
location: match.range.location, length: match.range.length,
|
extension GrammarKind {
|
||||||
original: original, replacement: replacement,
|
/// Map Harper's `kind` string (see harper.h) onto our category enum.
|
||||||
kind: kind, message: message)
|
init(harperKind kind: String) {
|
||||||
}
|
switch kind {
|
||||||
}
|
case "spelling": self = .spelling
|
||||||
|
case "punctuation": self = .punctuation
|
||||||
// Rule 4 — sentence should start uppercase (very light heuristic).
|
case "repetition": self = .repetition
|
||||||
// (Left to the real Harper integration; intentionally omitted to avoid
|
case "capitalization": self = .capitalization
|
||||||
// false positives in this stub.)
|
case "phrasing": self = .phrasing
|
||||||
|
default: self = .grammar
|
||||||
return out.sorted { $0.location < $1.location }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Regex helper
|
|
||||||
|
|
||||||
private func matches(
|
|
||||||
in text: String, pattern: String,
|
|
||||||
_ make: (NSTextCheckingResult) -> Suggestion?
|
|
||||||
) -> [Suggestion] {
|
|
||||||
guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
let full = NSRange(text.startIndex..., in: text)
|
|
||||||
return re.matches(in: text, range: full).compactMap(make)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,86 +1,83 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
/// Real, on-device grammar + spelling using macOS's built-in `NSSpellChecker`
|
/// Layer 1 orchestrator. The real **Harper** core is the primary engine
|
||||||
/// (the same engine TextEdit/Mail use). No network, no model — fully local,
|
/// (grammar, punctuation, repetition, capitalization, spelling); macOS's
|
||||||
/// matching the privacy promise. Augmented with a few deterministic Harper-style
|
/// `NSSpellChecker` is layered on top purely for *extra spelling coverage* from
|
||||||
/// rules (repeated words, lowercase "i", common expansions) the system checker
|
/// its large system dictionary.
|
||||||
/// doesn't surface.
|
///
|
||||||
|
/// 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 {
|
final class SystemGrammarEngine: GrammarEngine {
|
||||||
var ignoreList: Set<String> = []
|
var ignoreList: Set<String> = [] {
|
||||||
|
didSet { harper.ignoreList = ignoreList }
|
||||||
|
}
|
||||||
|
|
||||||
private let checker = NSSpellChecker.shared
|
private let checker = NSSpellChecker.shared
|
||||||
private let tag = NSSpellChecker.uniqueSpellDocumentTag()
|
private let tag = NSSpellChecker.uniqueSpellDocumentTag()
|
||||||
private let extras = HarperEngine()
|
private let harper = HarperEngine()
|
||||||
|
|
||||||
func check(_ text: String) -> [Suggestion] {
|
func check(_ text: String) -> [Suggestion] {
|
||||||
guard text.count >= 2 else { return [] }
|
guard text.count >= 2 else { return [] }
|
||||||
return onMain { self.checkOnMain(text) }
|
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 checkOnMain(_ text: String) -> [Suggestion] {
|
private func spellingSupplement(_ text: String) -> [Suggestion] {
|
||||||
let ns = text as NSString
|
let ns = text as NSString
|
||||||
let full = NSRange(location: 0, length: ns.length)
|
let full = NSRange(location: 0, length: ns.length)
|
||||||
let language = checker.language()
|
let language = checker.language()
|
||||||
var out: [Suggestion] = []
|
|
||||||
|
|
||||||
let types = NSTextCheckingResult.CheckingType([.spelling, .grammar]).rawValue
|
|
||||||
let results = checker.check(
|
let results = checker.check(
|
||||||
text, range: full, types: types, options: nil,
|
text, range: full, types: NSTextCheckingResult.CheckingType.spelling.rawValue,
|
||||||
inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
|
options: nil, inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
|
||||||
|
|
||||||
for r in results {
|
var out: [Suggestion] = []
|
||||||
switch r.resultType {
|
for r in results where r.resultType == .spelling {
|
||||||
case .spelling:
|
|
||||||
let word = ns.substring(with: r.range)
|
let word = ns.substring(with: r.range)
|
||||||
if ignoreList.contains(word.lowercased()) { continue }
|
if ignoreList.contains(word.lowercased()) { continue }
|
||||||
let fix = checker.correction(
|
guard let raw = checker.correction(
|
||||||
forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
|
forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
|
||||||
?? checker.guesses(
|
?? checker.guesses(
|
||||||
forWordRange: r.range, in: text, language: language,
|
forWordRange: r.range, in: text, language: language,
|
||||||
inSpellDocumentWithTag: tag)?.first
|
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(
|
out.append(Suggestion(
|
||||||
location: r.range.location, length: r.range.length,
|
location: r.range.location, length: r.range.length,
|
||||||
original: word, replacement: fix ?? word,
|
original: word, replacement: fix,
|
||||||
kind: .spelling, message: "Possible spelling mistake"))
|
kind: .spelling, message: "Possible spelling mistake"))
|
||||||
|
|
||||||
case .grammar:
|
|
||||||
let details = r.grammarDetails ?? []
|
|
||||||
if details.isEmpty {
|
|
||||||
let phrase = ns.substring(with: r.range)
|
|
||||||
out.append(Suggestion(
|
|
||||||
location: r.range.location, length: r.range.length,
|
|
||||||
original: phrase, replacement: phrase,
|
|
||||||
kind: .grammar, message: "Grammar issue"))
|
|
||||||
} else {
|
|
||||||
for d in details {
|
|
||||||
var loc = r.range.location, len = r.range.length
|
|
||||||
if let rv = d[NSGrammarRange] as? NSValue {
|
|
||||||
let sub = rv.rangeValue
|
|
||||||
loc = r.range.location + sub.location
|
|
||||||
len = sub.length
|
|
||||||
}
|
|
||||||
guard len > 0, loc + len <= ns.length else { continue }
|
|
||||||
let phrase = ns.substring(with: NSRange(location: loc, length: len))
|
|
||||||
let message = (d[NSGrammarUserDescription] as? String) ?? "Grammar issue"
|
|
||||||
let corrections = (d[NSGrammarCorrections] as? [String]) ?? []
|
|
||||||
out.append(Suggestion(
|
|
||||||
location: loc, length: len,
|
|
||||||
original: phrase, replacement: corrections.first ?? phrase,
|
|
||||||
kind: .grammar, message: message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic extras, skipping anything overlapping a system result.
|
/// Re-case `replacement` to follow `original`'s casing.
|
||||||
extras.ignoreList = ignoreList
|
static func matchingCase(of original: String, in replacement: String) -> String {
|
||||||
for h in extras.check(text) where !out.contains(where: { overlaps($0.range, h.range) }) {
|
guard let o = original.first, let r = replacement.first else { return replacement }
|
||||||
out.append(h)
|
// 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()
|
||||||
}
|
}
|
||||||
return out.sorted { $0.location < $1.location }
|
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 {
|
private func overlaps(_ a: NSRange, _ b: NSRange) -> Bool {
|
||||||
|
|||||||
5617
harper-ffi/Cargo.lock
generated
Normal file
5617
harper-ffi/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
harper-ffi/Cargo.toml
Normal file
21
harper-ffi/Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "harper-ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# Static library exposing a C ABI for linking into the Swift app.
|
||||||
|
name = "harper_ffi"
|
||||||
|
crate-type = ["staticlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# harper-core 2.5.0 — the grammar checker from the Automattic Harper project.
|
||||||
|
# Verified against https://docs.rs/harper-core/2.5.0/
|
||||||
|
harper-core = "2.5.0"
|
||||||
|
# Used to build the output JSON with correct string escaping.
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
26
harper-ffi/build.sh
Executable file
26
harper-ffi/build.sh
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the UNIVERSAL (Apple Silicon + Intel) Harper static library that the
|
||||||
|
# Swift app links against: harper-ffi/lib/libharper_ffi.a
|
||||||
|
#
|
||||||
|
# Requires the Rust toolchain (rustup) with both macOS targets:
|
||||||
|
# rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# Make cargo available even in non-login shells.
|
||||||
|
[ -f "$HOME/.cargo/env" ] && source "$HOME/.cargo/env"
|
||||||
|
|
||||||
|
echo "==> cargo build --release (aarch64 + x86_64)"
|
||||||
|
cargo build --release --target aarch64-apple-darwin
|
||||||
|
cargo build --release --target x86_64-apple-darwin
|
||||||
|
|
||||||
|
echo "==> lipo -> lib/libharper_ffi.a (universal)"
|
||||||
|
mkdir -p lib
|
||||||
|
lipo -create -output lib/libharper_ffi.a \
|
||||||
|
target/aarch64-apple-darwin/release/libharper_ffi.a \
|
||||||
|
target/x86_64-apple-darwin/release/libharper_ffi.a
|
||||||
|
|
||||||
|
lipo -info lib/libharper_ffi.a
|
||||||
|
echo "==> done"
|
||||||
56
harper-ffi/include/harper.h
Normal file
56
harper-ffi/include/harper.h
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* harper.h — C ABI for the harper-ffi static library (harper-core 2.5.0).
|
||||||
|
*
|
||||||
|
* Link against libharper_ffi.a. All strings are UTF-8.
|
||||||
|
*/
|
||||||
|
#ifndef HARPER_FFI_H
|
||||||
|
#define HARPER_FFI_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Check `text` for grammar/spelling issues using harper-core.
|
||||||
|
*
|
||||||
|
* Returns a malloc'd, NUL-terminated UTF-8 JSON C string that the caller MUST
|
||||||
|
* free with harper_free().
|
||||||
|
*
|
||||||
|
* The JSON is an array of objects:
|
||||||
|
* [
|
||||||
|
* {
|
||||||
|
* "start": <integer, char (Unicode scalar) offset into the input>,
|
||||||
|
* "len": <integer, char count of the offending span>,
|
||||||
|
* "replacement": "<string>",
|
||||||
|
* "message": "<string>",
|
||||||
|
* "kind": "<string>"
|
||||||
|
* },
|
||||||
|
* ...
|
||||||
|
* ]
|
||||||
|
*
|
||||||
|
* Notes on the contract:
|
||||||
|
* - "start"/"len" are CHAR (Unicode scalar) offsets, matching harper's native
|
||||||
|
* char indexing. They are NOT byte offsets and NOT UTF-16 code-unit offsets.
|
||||||
|
* "len" = span.end - span.start (end-exclusive).
|
||||||
|
* - "replacement" is the FIRST ReplaceWith suggestion's text. If the first
|
||||||
|
* actionable suggestion is a removal, "replacement" is "" (empty string).
|
||||||
|
* - Lints with NO actionable suggestion are SKIPPED.
|
||||||
|
* - "kind" is one of: "spelling", "grammar", "punctuation", "repetition",
|
||||||
|
* "capitalization", "phrasing". Best-effort; defaults to "grammar".
|
||||||
|
*
|
||||||
|
* On a null `text`, invalid UTF-8, or any internal error/panic, returns "[]".
|
||||||
|
* Never returns NULL on a normal return path. Thread-safe.
|
||||||
|
*/
|
||||||
|
char *harper_check(const char *text);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Free a string previously returned by harper_check().
|
||||||
|
* Passing NULL is a no-op. Do not free the same pointer twice.
|
||||||
|
*/
|
||||||
|
void harper_free(char *ptr);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /* extern "C" */
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* HARPER_FFI_H */
|
||||||
170
harper-ffi/src/lib.rs
Normal file
170
harper-ffi/src/lib.rs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
//! C-ABI FFI bridge exposing harper-core 2.5.0's grammar checker to Swift.
|
||||||
|
//!
|
||||||
|
//! Public surface:
|
||||||
|
//! char* harper_check(const char* text); // malloc'd UTF-8 JSON, free with harper_free
|
||||||
|
//! void harper_free(char* ptr);
|
||||||
|
//!
|
||||||
|
//! Every API call below was written against docs.rs for harper-core 2.5.0.
|
||||||
|
|
||||||
|
use std::ffi::{CStr, CString};
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
use std::panic::{catch_unwind, AssertUnwindSafe};
|
||||||
|
use std::sync::{Arc, OnceLock};
|
||||||
|
|
||||||
|
use harper_core::{Dialect, Document};
|
||||||
|
use harper_core::linting::{LintGroup, LintKind, Linter, Suggestion};
|
||||||
|
use harper_core::spell::FstDictionary;
|
||||||
|
|
||||||
|
/// Empty JSON array used for null/invalid input and on any panic.
|
||||||
|
const EMPTY_JSON: &str = "[]";
|
||||||
|
|
||||||
|
/// The curated FST dictionary is the expensive artifact to build, so build it
|
||||||
|
/// exactly once and share it across calls/threads via `Arc`. `FstDictionary`
|
||||||
|
/// is immutable and `Arc<FstDictionary>` is `Send + Sync`, so this is safe to
|
||||||
|
/// read from any thread.
|
||||||
|
///
|
||||||
|
/// We intentionally do NOT cache the `LintGroup` itself: `Linter::lint` takes
|
||||||
|
/// `&mut self`, which would require a `Mutex` and serialize all callers. The
|
||||||
|
/// `LintGroup` is cheap to reconstruct relative to the dictionary, so we build
|
||||||
|
/// a fresh one per call from the shared dictionary instead.
|
||||||
|
fn curated_dictionary() -> Arc<FstDictionary> {
|
||||||
|
static DICT: OnceLock<Arc<FstDictionary>> = OnceLock::new();
|
||||||
|
DICT.get_or_init(FstDictionary::curated).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a harper `LintKind` to the lowercase string contract:
|
||||||
|
/// one of spelling, grammar, punctuation, repetition, capitalization, phrasing.
|
||||||
|
/// Default is "grammar" for anything not specifically categorized.
|
||||||
|
fn kind_to_str(kind: LintKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
LintKind::Spelling | LintKind::Typo => "spelling",
|
||||||
|
LintKind::Punctuation | LintKind::Formatting => "punctuation",
|
||||||
|
LintKind::Repetition => "repetition",
|
||||||
|
LintKind::Capitalization => "capitalization",
|
||||||
|
// "phrasing"-flavored categories: word choice / style / readability / usage.
|
||||||
|
LintKind::WordChoice
|
||||||
|
| LintKind::Style
|
||||||
|
| LintKind::Readability
|
||||||
|
| LintKind::Usage
|
||||||
|
| LintKind::Redundancy
|
||||||
|
| LintKind::Enhancement
|
||||||
|
| LintKind::Eggcorn
|
||||||
|
| LintKind::Malapropism
|
||||||
|
| LintKind::Nonstandard
|
||||||
|
| LintKind::Regionalism => "phrasing",
|
||||||
|
// Grammar-ish / structural categories.
|
||||||
|
LintKind::Grammar
|
||||||
|
| LintKind::Agreement
|
||||||
|
| LintKind::BoundaryError
|
||||||
|
| LintKind::Miscellaneous => "grammar",
|
||||||
|
// LintKind may gain variants; default safely.
|
||||||
|
_ => "grammar",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run harper over `text` and produce the JSON contract string.
|
||||||
|
fn check_to_json(text: &str) -> String {
|
||||||
|
let dictionary = curated_dictionary();
|
||||||
|
|
||||||
|
// Build the document with the built-in PlainEnglish parser + curated dictionary.
|
||||||
|
let document = Document::new_plain_english(text, &dictionary);
|
||||||
|
|
||||||
|
// Fresh curated lint group from the shared dictionary; American dialect.
|
||||||
|
let mut group = LintGroup::new_curated(dictionary.clone(), Dialect::American);
|
||||||
|
|
||||||
|
// Linter::lint(&mut self, &Document) -> Vec<Lint>
|
||||||
|
let lints = group.lint(&document);
|
||||||
|
|
||||||
|
let mut out: Vec<serde_json::Value> = Vec::with_capacity(lints.len());
|
||||||
|
for lint in lints {
|
||||||
|
// span is Span<char>: start/end are CHAR (Unicode scalar) offsets, end-exclusive.
|
||||||
|
let start = lint.span.start;
|
||||||
|
let len = lint.span.end.saturating_sub(lint.span.start);
|
||||||
|
|
||||||
|
// Pick the first ReplaceWith suggestion as "replacement".
|
||||||
|
// If the first actionable suggestion is a Remove, replacement = "".
|
||||||
|
// InsertAfter is treated as non-replacement here. Lints with no
|
||||||
|
// actionable suggestion are skipped.
|
||||||
|
let mut replacement: Option<String> = None;
|
||||||
|
for suggestion in &lint.suggestions {
|
||||||
|
match suggestion {
|
||||||
|
Suggestion::ReplaceWith(chars) => {
|
||||||
|
replacement = Some(chars.iter().collect());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Suggestion::Remove => {
|
||||||
|
replacement = Some(String::new());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Suggestion::InsertAfter(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let replacement = match replacement {
|
||||||
|
Some(r) => r,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
out.push(serde_json::json!({
|
||||||
|
"start": start,
|
||||||
|
"len": len,
|
||||||
|
"replacement": replacement,
|
||||||
|
"message": lint.message,
|
||||||
|
"kind": kind_to_str(lint.lint_kind),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string(&out).unwrap_or_else(|_| EMPTY_JSON.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Allocate a C string for return to the caller. The caller owns it and must
|
||||||
|
/// free it with `harper_free`.
|
||||||
|
fn into_c_string(s: String) -> *mut c_char {
|
||||||
|
match CString::new(s) {
|
||||||
|
Ok(cstr) => cstr.into_raw(),
|
||||||
|
Err(_) => CString::new(EMPTY_JSON).unwrap().into_raw(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check `text` for grammar/spelling issues.
|
||||||
|
///
|
||||||
|
/// Returns a malloc'd, NUL-terminated UTF-8 JSON C string the caller MUST free
|
||||||
|
/// with `harper_free`. On null/invalid-UTF-8 input or on any internal panic,
|
||||||
|
/// returns the JSON `"[]"`. Never returns null on a normal return path.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `text` must be a valid NUL-terminated C string pointer or null.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn harper_check(text: *const c_char) -> *mut c_char {
|
||||||
|
let result = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
if text.is_null() {
|
||||||
|
return EMPTY_JSON.to_string();
|
||||||
|
}
|
||||||
|
// SAFETY: caller guarantees a valid NUL-terminated C string (non-null checked).
|
||||||
|
let c_str = unsafe { CStr::from_ptr(text) };
|
||||||
|
match c_str.to_str() {
|
||||||
|
Ok(s) => check_to_json(s),
|
||||||
|
Err(_) => EMPTY_JSON.to_string(),
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(json) => into_c_string(json),
|
||||||
|
Err(_) => into_c_string(EMPTY_JSON.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Free a string previously returned by `harper_check`.
|
||||||
|
///
|
||||||
|
/// # Safety
|
||||||
|
/// `ptr` must be a pointer returned by `harper_check` (or null), freed once.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "C" fn harper_free(ptr: *mut c_char) {
|
||||||
|
if ptr.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: ptr came from CString::into_raw in this module; reclaim and drop it.
|
||||||
|
unsafe {
|
||||||
|
drop(CString::from_raw(ptr));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,10 @@ CONFIG="release"
|
|||||||
APP="GrammaGem.app"
|
APP="GrammaGem.app"
|
||||||
DIST="dist"
|
DIST="dist"
|
||||||
|
|
||||||
|
echo "==> Building Harper static lib (universal)"
|
||||||
|
chmod +x harper-ffi/build.sh
|
||||||
|
./harper-ffi/build.sh
|
||||||
|
|
||||||
echo "==> Compiling universal (arm64 + x86_64, ${CONFIG})"
|
echo "==> Compiling universal (arm64 + x86_64, ${CONFIG})"
|
||||||
swift build -c "${CONFIG}" --arch arm64 --arch x86_64
|
swift build -c "${CONFIG}" --arch arm64 --arch x86_64
|
||||||
BIN="$(swift build -c "${CONFIG}" --arch arm64 --arch x86_64 --show-bin-path)"
|
BIN="$(swift build -c "${CONFIG}" --arch arm64 --arch x86_64 --show-bin-path)"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user