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:
BigMarc 2026-06-18 06:43:11 +02:00
parent e3ba7ff7e0
commit 3ea090ae4b
14 changed files with 6098 additions and 137 deletions

5
.gitignore vendored
View File

@ -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/

View File

@ -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",

View 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 */

View File

@ -0,0 +1,4 @@
module CHarper {
header "harper.h"
export *
}

4
Sources/CHarper/shim.c Normal file
View 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.

View File

@ -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)
}

View File

@ -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))
}
// 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”")
}
// 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)
}
}
// 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 }
return kept.sorted { $0.location < $1.location }
}
// MARK: - Regex helper
// MARK: - FFI decoding
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)
private struct HarperLint: Decodable {
let start: Int
let len: Int
let replacement: String
let message: String
let kind: String
}
/// 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)
}
}
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
}
}
}

View File

@ -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:
let word = ns.substring(with: r.range)
if ignoreList.contains(word.lowercased()) { continue }
let fix = checker.correction(
forWordRange: r.range, in: text, language: language, inSpellDocumentWithTag: tag)
?? checker.guesses(
forWordRange: r.range, in: text, language: language,
inSpellDocumentWithTag: tag)?.first
out.append(Suggestion(
location: r.range.location, length: r.range.length,
original: word, replacement: fix ?? word,
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
}
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
}
// 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

File diff suppressed because it is too large Load Diff

21
harper-ffi/Cargo.toml Normal file
View 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
View 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"

View 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
View 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));
}
}

View File

@ -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)"