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/
|
||||
.DS_Store
|
||||
.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
|
||||
import PackageDescription
|
||||
import Foundation
|
||||
|
||||
// GrammaGem — native macOS menu-bar writing assistant.
|
||||
//
|
||||
// This package builds with ONLY Apple frameworks so it compiles cleanly out of
|
||||
// the box (`swift build`). The heavyweight third-party pieces from the spec —
|
||||
// Harper (Rust grammar core), MLX (local LLM), KeyboardShortcuts, Sparkle — are
|
||||
// abstracted behind protocols with working stub implementations and clearly
|
||||
// marked `TODO(real-integration)` seams. See README.md for wiring the real deps.
|
||||
// Layer-1 grammar is the real **Harper** core (Apache-2.0), embedded as a Rust
|
||||
// C-FFI static library (see `harper-ffi/`). Build the lib first with
|
||||
// `harper-ffi/build.sh` (universal arm64+x86_64 -> harper-ffi/lib/libharper_ffi.a);
|
||||
// `scripts/build.sh` does this automatically. The remaining heavyweight pieces
|
||||
// (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(
|
||||
name: "GrammaGem",
|
||||
platforms: [
|
||||
@ -16,17 +22,17 @@ let package = Package(
|
||||
products: [
|
||||
.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: [
|
||||
// C shim exposing libharper_ffi's C ABI (harper-ffi/include/harper.h) to Swift.
|
||||
.target(name: "CHarper", path: "Sources/CHarper"),
|
||||
.executableTarget(
|
||||
name: "GrammaGem",
|
||||
path: "Sources/GrammaGem"
|
||||
dependencies: ["CHarper"],
|
||||
path: "Sources/GrammaGem",
|
||||
linkerSettings: [
|
||||
// Link the prebuilt universal Harper static library.
|
||||
.unsafeFlags(["-L\(harperLibDir)", "-lharper_ffi"])
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
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 {
|
||||
/// 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 {
|
||||
let ns = NSMutableString(string: text)
|
||||
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 }
|
||||
ns.replaceCharacters(in: s.range, with: s.replacement)
|
||||
}
|
||||
|
||||
@ -1,75 +1,83 @@
|
||||
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
|
||||
/// Rust crate over C-FFI (`harper_check(text) -> spans`). This stub implements a
|
||||
/// handful of Harper-style deterministic rules so the system loop, live
|
||||
/// underlines, and tests have something real to exercise. It runs in well under
|
||||
/// 10ms and is fully offline — matching Harper's performance profile.
|
||||
/// `harper_check` returns a JSON array of spans with **char (Unicode scalar)**
|
||||
/// offsets; we map those onto UTF-16 `NSRange` offsets so they compose with the
|
||||
/// rest of the app (AX ranges, `NSMutableString`).
|
||||
final class HarperEngine: GrammarEngine {
|
||||
var ignoreList: Set<String> = []
|
||||
|
||||
func check(_ text: String) -> [Suggestion] {
|
||||
var out: [Suggestion] = []
|
||||
let ns = text as NSString
|
||||
guard !text.isEmpty else { return [] }
|
||||
|
||||
// Rule 1 — repeated word ("the the" -> "the").
|
||||
out += matches(in: text, pattern: #"\b(\w+)\s+\1\b"#) { match in
|
||||
let full = ns.substring(with: match.range)
|
||||
let firstWord = ns.substring(with: match.range(at: 1))
|
||||
guard !ignoreList.contains(firstWord.lowercased()) else { return nil }
|
||||
return Suggestion(
|
||||
location: match.range.location, length: match.range.length,
|
||||
original: full, replacement: firstWord,
|
||||
kind: .repetition, message: "Repeated word")
|
||||
// Call into Rust. The returned C string is heap-allocated by Harper and
|
||||
// must be freed with harper_free (it outlives the withCString buffer).
|
||||
guard let raw = text.withCString({ harper_check($0) }) else { return [] }
|
||||
defer { harper_free(raw) }
|
||||
|
||||
let json = String(cString: raw)
|
||||
guard let data = json.data(using: .utf8),
|
||||
let lints = try? JSONDecoder().decode([HarperLint].self, from: data)
|
||||
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".
|
||||
out += matches(in: text, pattern: #"(?<=\s|^)i(?=\s|$|')"#) { match in
|
||||
Suggestion(
|
||||
location: match.range.location, length: match.range.length,
|
||||
original: "i", replacement: "I",
|
||||
kind: .capitalization, message: "Capitalize the pronoun “I”")
|
||||
// MARK: - FFI decoding
|
||||
|
||||
private struct HarperLint: Decodable {
|
||||
let start: Int
|
||||
let len: Int
|
||||
let replacement: String
|
||||
let message: String
|
||||
let kind: String
|
||||
}
|
||||
|
||||
// Rule 3 — a few common confusions / typos.
|
||||
let confusions: [(String, String, GrammarKind, String)] = [
|
||||
(#"\btheir\b(?=\s+(any|are|is|was|were)\b)"#, "there", .grammar, "Did you mean “there”?"),
|
||||
(#"\bteh\b"#, "the", .spelling, "Spelling"),
|
||||
(#"\brecieve\b"#, "receive", .spelling, "Spelling (i before e)"),
|
||||
(#"\bcheckin\b"#, "check in", .phrasing, "“check in” is two words"),
|
||||
(#"\blmk\b"#, "let me know", .phrasing, "Expand abbreviation"),
|
||||
(#"(?<=\s|^)u(?=\s|$)"#, "you", .phrasing, "Expand “u”"),
|
||||
]
|
||||
for (pattern, replacement, kind, message) in confusions {
|
||||
out += matches(in: text, pattern: pattern) { match in
|
||||
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,
|
||||
original: original, replacement: replacement,
|
||||
kind: kind, message: message)
|
||||
/// Map a Harper char-span (Unicode scalar offsets, end-exclusive) onto a
|
||||
/// UTF-16 `NSRange`. Scalar boundaries are always UTF-16 boundaries, so the
|
||||
/// conversion is exact.
|
||||
static func utf16Range(scalarStart: Int, scalarLen: Int, in text: String) -> NSRange? {
|
||||
let scalars = text.unicodeScalars
|
||||
guard let s = scalars.index(scalars.startIndex, offsetBy: scalarStart, limitedBy: scalars.endIndex),
|
||||
let e = scalars.index(s, offsetBy: scalarLen, limitedBy: scalars.endIndex)
|
||||
else { return nil }
|
||||
let location = text.utf16.distance(from: text.utf16.startIndex, to: s)
|
||||
let length = text.utf16.distance(from: s, to: e)
|
||||
return NSRange(location: location, length: length)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4 — sentence should start uppercase (very light heuristic).
|
||||
// (Left to the real Harper integration; intentionally omitted to avoid
|
||||
// false positives in this stub.)
|
||||
|
||||
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)
|
||||
extension GrammarKind {
|
||||
/// Map Harper's `kind` string (see harper.h) onto our category enum.
|
||||
init(harperKind kind: String) {
|
||||
switch kind {
|
||||
case "spelling": self = .spelling
|
||||
case "punctuation": self = .punctuation
|
||||
case "repetition": self = .repetition
|
||||
case "capitalization": self = .capitalization
|
||||
case "phrasing": self = .phrasing
|
||||
default: self = .grammar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,86 +1,83 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Real, on-device grammar + spelling using macOS's built-in `NSSpellChecker`
|
||||
/// (the same engine TextEdit/Mail use). No network, no model — fully local,
|
||||
/// matching the privacy promise. Augmented with a few deterministic Harper-style
|
||||
/// rules (repeated words, lowercase "i", common expansions) the system checker
|
||||
/// doesn't surface.
|
||||
/// 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> = []
|
||||
var ignoreList: Set<String> = [] {
|
||||
didSet { harper.ignoreList = ignoreList }
|
||||
}
|
||||
|
||||
private let checker = NSSpellChecker.shared
|
||||
private let tag = NSSpellChecker.uniqueSpellDocumentTag()
|
||||
private let extras = HarperEngine()
|
||||
private let harper = HarperEngine()
|
||||
|
||||
func check(_ text: String) -> [Suggestion] {
|
||||
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 full = NSRange(location: 0, length: ns.length)
|
||||
let language = checker.language()
|
||||
var out: [Suggestion] = []
|
||||
|
||||
let types = NSTextCheckingResult.CheckingType([.spelling, .grammar]).rawValue
|
||||
let results = checker.check(
|
||||
text, range: full, types: types, options: nil,
|
||||
inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
|
||||
text, range: full, types: NSTextCheckingResult.CheckingType.spelling.rawValue,
|
||||
options: nil, inSpellDocumentWithTag: tag, orthography: nil, wordCount: nil)
|
||||
|
||||
for r in results {
|
||||
switch r.resultType {
|
||||
case .spelling:
|
||||
var out: [Suggestion] = []
|
||||
for r in results where r.resultType == .spelling {
|
||||
let word = ns.substring(with: r.range)
|
||||
if ignoreList.contains(word.lowercased()) { continue }
|
||||
let fix = checker.correction(
|
||||
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 ?? word,
|
||||
original: word, replacement: fix,
|
||||
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.
|
||||
extras.ignoreList = ignoreList
|
||||
for h in extras.check(text) where !out.contains(where: { overlaps($0.range, h.range) }) {
|
||||
out.append(h)
|
||||
/// 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()
|
||||
}
|
||||
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 {
|
||||
|
||||
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"
|
||||
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})"
|
||||
swift build -c "${CONFIG}" --arch arm64 --arch x86_64
|
||||
BIN="$(swift build -c "${CONFIG}" --arch arm64 --arch x86_64 --show-bin-path)"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user