Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
sudo apt-get install -y \
libhunspell-dev \
hunspell-en-us \
hunspell-de-de \
hunspell-fr \
libclang-dev

- name: Test
Expand All @@ -46,10 +48,11 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Install hunspell
run: |
sudo apt-get update
sudo apt-get install -y libhunspell-dev hunspell-en-us libclang-dev
- run: cargo fmt --all -- --check
- run: cargo clippy --all-targets -- -D warnings
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ This project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Breaking
- `Error` is now `InvalidLocale` / `UnsupportedLocale` / `DictionaryNotFound` / `InitializationFailed` (removed `Unavailable`)
- macOS `with_locale` returns `UnsupportedLocale` if the language is not installed
- Added `Checker::locale` and `Checker::available_locales`
- Removed the crate binary (`src/main.rs`); use `examples/`

### Changed
- Document platform defaults for `Checker::new()`, locale failure, suggestions cap, and UTF-8 error ranges in rustdoc
- README now states that macOS `Checker::new()` uses the system language
Expand Down
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "spellkit"
description = "Bindings to your friendly neighborhood spellchecker."
keywords = ["spellcheck", "spellchecker", "hunspell"]
description = "Cross-platform native spell checking for Rust (NSSpellChecker, Windows Spell Checker, Hunspell)."
keywords = ["spellcheck", "spellchecker", "hunspell", "nsspellchecker"]
categories = ["os", "text-processing"]
documentation = "https://docs.rs/spellkit"
repository = "https://github.com/rtmongold/spellkit"
Expand Down
125 changes: 102 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
# spellkit
# Spellkit

Cross-platform native spell checking for Rust.

[![On crates.io](https://img.shields.io/crates/v/spellkit.svg)](https://crates.io/crates/spellkit)
![Downloads](https://img.shields.io/crates/d/spellkit?style=flat-square)
[![CI](https://github.com/rtmongold/spellkit/actions/workflows/ci.yml/badge.svg)](https://github.com/rtmongold/spellkit/actions/workflows/ci.yml)
[![Docs](https://docs.rs/spellkit/badge.svg)](https://docs.rs/spellkit)

Native spell checking with a small Rust API.
## Why Spellkit?

This project is **based on** [euclio/spellbound](https://github.com/euclio/spellbound)
(last upstream commit 2020).
Use the spell-checking facilities already on the user's system.

| Platform | API |
| -------- | ------------------ |
| Platform | Backend |
| -------- | ------- |
| macOS | [`NSSpellChecker`] |
| Windows | [`ISpellChecker`] |
| *nix | [`hunspell`] |
| Windows | [`ISpellChecker`] (Windows Spell Checker) |
| Linux / other Unix | [`Hunspell`] with system dictionaries |

[`ISpellChecker`]: https://docs.microsoft.com/en-us/windows/desktop/api/spellcheck/nn-spellcheck-ispellchecker
[`NSSpellChecker`]: https://developer.apple.com/documentation/appkit/nsspellchecker
[`hunspell`]: https://hunspell.github.io/
[`Hunspell`]: https://hunspell.github.io/

Applications should not reimplement macOS, Windows, and Hunspell separately. Spellkit is one small API over those backends.

This project is **based on** [euclio/spellbound](https://github.com/euclio/spellbound) (last upstream commit 2020).

## What Spellkit is not

Spellkit does **not** bundle dictionaries or implement its own spelling algorithm. It wraps the platform backend and uses system / installed dictionaries. Behavior can differ across operating systems where the APIs differ.

## Example
That is the distinction from crates that ship an engine and word lists (for example Spellbook).

## Quick start

cargo add spellkit

```rust
use spellkit::Checker;

fn main() -> Result<(), spellkit::Error> {
let checker = Checker::new()?;
// Or: Checker::with_locale("en-US")?;

for err in checker.check("I beleeve I can fly") {
for err in checker.check("I havv a spelling error.") {
println!("{} @ {}..{}", err.text(), err.start(), err.end());
for suggestion in checker.suggest(err.text()) {
println!(" → {suggestion}");
Expand All @@ -39,30 +51,97 @@ fn main() -> Result<(), spellkit::Error> {
}
```

`Checker::new()` uses a platform default: system language on macOS, the user locale on Windows (falling back to `en-US`), and `LC_ALL` / `LC_MESSAGES` / `LANG` on Linux when a dictionary exists (otherwise `en_US` / `en_GB`). Use `with_locale` for another language.
`Checker::locale()` is the language this instance is using. `Checker::available_locales()` lists what the OS can check.

Unknown or unsupported locales behave differently by platform:
```rust
use spellkit::Checker;

- **Linux:** missing dictionary / unknown locale → `Error::Unavailable`
- **macOS:** empty locale → `Error::Unavailable`; unknown tags may still create a checker (system fallback)
- **Windows:** unsupported language tag → `Error::Unavailable`
fn main() -> Result<(), spellkit::Error> {
println!("available: {:?}", Checker::available_locales());
let checker = Checker::new()?;
println!("using: {}", checker.locale());
Ok(())
}
```

## Threading
## Features

`Checker` is not `Send` or `Sync`. Do not share it across threads. macOS also serializes access to the shared `NSSpellChecker`.
- Cross-platform: macOS, Windows, Linux
- System dictionaries (no files shipped in the crate)
- Suggestions (up to 10)
- Locale via `with_locale` (`en_US` and `en-US` both work)
- Temporary ignored words (`ignore` is per checker, not global)
- UTF-8 byte ranges (`start` / `end` / `range`)
- Small API

## Platform support

| | Linux | macOS | Windows |
| --- | --- | --- | --- |
| Backend | Hunspell | NSSpellChecker | ISpellChecker |
| `Checker::new()` | `LC_ALL` / `LC_MESSAGES` / `LANG` if a dict exists, else `en_US` / `en_GB` | system language | user locale, else `en-US` |
| Unknown `with_locale` | `Error::DictionaryNotFound` (paths searched) | `Error::UnsupportedLocale` | `Error::UnsupportedLocale` |
| Empty locale | `Error::InvalidLocale` | `Error::InvalidLocale` | `Error::InvalidLocale` |
| Suggestions | yes | yes | yes |
| `ignore` | yes (this handle only) | yes (this document tag only) | yes (this checker only) |
| `available_locales` | `*.dic` stems on disk (`DICPATH` then system dirs) | `availableLanguages` | `SupportedLanguages` |
| `Send` / `Sync` | no | no | no |

Linux also honors `DICPATH` (colon-separated directories) before `/usr/share/hunspell` and the other built-in paths.

## Linux
Word breaks are **not** identical: Linux tokenizes alphanumeric / `'` runs; macOS and Windows use the OS checker.

Needs a hunspell dictionary on disk (default search includes `/usr/share/hunspell`). Example:
## How it works

`Checker` is a thin wrapper. On each OS it calls the native API, then converts misspelling ranges to UTF-8 byte offsets into the original `&str`.

## Spellkit vs other approaches

**Why not Spellbook?** Use Spellbook when you want a portable engine and bundled (or app-shipped) dictionaries. Use Spellkit when you want the OS dictionaries, native suggestions, and minimal integration.

**Why not Hunspell directly?** You would own dictionary discovery, FFI, and a second implementation for macOS and Windows. Spellkit is that integration.

**Why not ispell?** You would own an external process, its lifetime, and the command protocol. Spellkit stays in-process.

## Errors

- empty locale → `Error::InvalidLocale`
- Linux missing `.aff`/`.dic` → `Error::DictionaryNotFound` (includes search paths)
- macOS / Windows language not installed → `Error::UnsupportedLocale`
- backend failed to start (null Hunspell handle, COM factory, empty macOS language) → `Error::InitializationFailed`

## Linux packages

- Arch: `pacman -S hunspell hunspell-en_us`
- Debian/Ubuntu: `apt install libhunspell-dev hunspell-en-us`
- Extra languages used in CI: `hunspell-de-de`, `hunspell-fr`

Without a dictionary, `Checker::new()` returns `Error::DictionaryNotFound`.

## Examples

Without a dictionary, `Checker::new()` returns `Error::Unavailable`.
cargo run --example check -- "I havv a spelling error."
cargo run --example suggestions -- "I beleeve I can fly"
cargo run --example locale
cargo run --example highlight

## Threading

`Checker` is not `Send` or `Sync`. Do not share it across threads. macOS also serializes access to the shared `NSSpellChecker`.

## Documentation

- [docs.rs/spellkit](https://docs.rs/spellkit)
- [CHANGELOG.md](CHANGELOG.md)

## Contributing

Issues and PRs: [github.com/rtmongold/spellkit](https://github.com/rtmongold/spellkit)

## License

MIT OR Apache-2.0

## Credits

Originally by [Andy Russell](https://github.com/euclio). Maintained as `spellkit` by Robert Mongold.
Originally by [Andy Russell](https://github.com/euclio). Maintained as `spellkit` by Robert Mongold.
11 changes: 11 additions & 0 deletions examples/check.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
use spellkit::Checker;
use std::env;

fn main() -> Result<(), spellkit::Error> {
let text = env::args().skip(1).collect::<Vec<_>>().join(" ");
let checker = Checker::new()?;
for error in checker.check(&text) {
println!("{}", error.text());
}
Ok(())
}
12 changes: 12 additions & 0 deletions examples/highlight.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
use spellkit::Checker;

fn main() -> Result<(), spellkit::Error> {
let text = "I beleeve I can fly";
let checker = Checker::new()?;
for err in checker.check(text) {
let range = err.range();
println!("{} @ {range:?}", err.text());
println!(" slice: {}", &text[range]);
}
Ok(())
}
6 changes: 6 additions & 0 deletions examples/locale.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
fn main() -> Result<(), spellkit::Error> {
println!("available: {:?}", spellkit::Checker::available_locales());
let c = spellkit::Checker::new()?;
println!("locale: {}", c.locale());
Ok(())
}
14 changes: 14 additions & 0 deletions examples/suggestions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
use spellkit::Checker;
use std::env;

fn main() -> Result<(), spellkit::Error> {
let text = env::args().skip(1).collect::<Vec<_>>().join(" ");
let checker = Checker::new()?;
for error in checker.check(&text) {
println!("{}", error.text());
for s in checker.suggest(error.text()) {
println!(" {s}");
}
}
Ok(())
}
Loading
Loading