Spelling correction & fuzzy search for the Novident suite, based on the symmetric delete spelling correction algorithm.
Dart port of symspell-ex (MIT) with some extra things for convenience.
- Symmetric delete index: fast lookup (~6 orders of magnitude faster than
linear scan) with tunable
maxDistance. - Damerau-Levenshtein edit distance (supports transpositions).
- Context-aware tokenizer (url / number / word / punctuation) for latin and arabic alphabets.
- Multi-language dictionaries with isolated namespaces.
- In-memory store with a synchronous API — no
Futureoverhead on the per-keystroke hot path. correctionfor full sentences,lookup/searchfor fuzzy suggestions,trainfor bulk dictionaries.- Optional Hunspell
.affsupport — expand stems to full word forms when the affix file is present (es_ES: 56k stems → 652k forms, 11.7x).
API note: the original symspell-ex API is
asynconly because of its Redis store. This port keeps aDataStoreabstraction, but the defaultMemoryStore(and the wholeSymSpellExfacade) is synchronous.
Deeper guides live in doc/:
| Guide | Contents |
|---|---|
| Training & persistence | How to train dictionaries, measured timings, and how to keep the trained state alive (avoiding full rebuilds) |
| Dictionary formats | Every accepted format: plain lists, frequency lists, CSV, Hunspell .dic and personal dictionaries |
| Available dictionaries | Where to get real dictionaries (LibreOffice, Norvig, Wolf Garbe, word lists) and their licenses |
| Correction algorithms | How the tokenizer, exact membership, symmetric delete and Damerau-Levenshtein work, with known limitations |
| Editor integration | Global state management, tokenizing paragraphs and marking misspelled TextSpans in a WYSIWYG editor |
| Testing | What each test suite covers and how to run it |
Add the dependency to pubspec.yaml:
dependencies:
novident_spell_checker: <latest> import 'package:novident_spell_checker/novident_spell_checker.dart';
void main() {
final symSpell = SymSpellEx(MemoryStore())..initialize();
// Bulk training: each item is 'term,frequency'.
symSpell.train(
const [
'albert,5',
'argument,2',
'academically,8',
'groundbreaking,4',
],
Languages.english,
);
// Add terms one by one.
symSpell.add('relativity', 3, Languages.english);
// Fuzzy lookup.
final suggestions = symSpell.lookup('acaddemicaly');
print(suggestions.first.suggestion); // academically
// Full sentence correction.
final correction = symSpell.correct(
'albrt did groundbrekin academically work.',
Languages.english,
);
print(correction!.output); // albert did groundbreaking academically work.
for (final s in correction.suggestions) {
print('${s.term} -> ${s.suggestion} (distance ${s.distance})');
}
}final symSpell = SymSpellEx(
MemoryStore(),
editDistance: DamerauLevenshteinDistance(),
tokenizer: CoreTokenizer(),
maxDistance: 3,
maxSuggestions: 10,
)..initialize();- Custom stores: implement
DataStore. - Custom edit distance: implement
EditDistance. - Custom tokenizers: implement
Tokenizer.
There are two complementary problems:
- Is this word valid? (exact membership) —
SymSpellEx.hasTerm()answers in O(1) via the hash store. A standaloneTriedoes the same when you don't need fuzzy suggestions and want a smaller footprint. - What should this typo be? (fuzzy correction) — solved by the symmetric
delete index, which is the memory-heavy part: a term of length L with
maxDistance2 generates ~C(L,1)+C(L,2) delete keys.
Dictionary accepts all common formats, so a user can pass a plain word
list, a Wolf Garbe frequency file or symspell-ex CSV records:
// Plain .txt word list ("word" per line) — frequency defaults to 1.
final dict = Dictionary.fromLines(File('words.txt').readAsLinesSync());
symSpell.trainDictionary(dict, Languages.english);
// Bundled Flutter asset.
// pubspec.yaml:
// flutter:
// assets:
// - assets/dictionaries/en-80k.txt
final dict = await AssetDictionaryLoader.load('assets/dictionaries/en-80k.txt');
symSpell.trainDictionary(dict, Languages.english);
// Exact membership ("is this word misspelled?").
symSpell.hasTerm('albert'); // true
symSpell.hasTerm('albrt'); // false (it's a delete key, not a term)A plain word list can also be compiled into a Trie for compact exact
lookups and autocomplete — useful when the app only needs to flag unknown
words (no suggestions):
final trie = Trie.fromDictionary(dict);
trie.contains('albert'); // true
trie.withPrefix('alb', limit: 5); // ['albert', ...]Trie uses packed child/sibling arrays (4 ints per node, no per-node
objects or maps) instead of the naive node-per-char design, which in Dart
loses to Set<String> on memory due to object overhead.
Measured on 20k synthetic words (see the stress suite):
| Structure | Build | 50k exact probes | Size proxy |
|---|---|---|---|
| SymSpell delete index | 1,515 ms | 158 ms | 837k entries (41.9x) |
Set<String> |
11 ms | 16 ms | 20k strings (1x) |
Trie |
29 ms | 61 ms | 97k nodes (4.9x) |
Rule of thumb: use Set/hasTerm for validity-only checks; add Trie
when you need prefix queries; pay the ~40x delete-index cost only when you
need fuzzy suggestions (lookup/correct).
The Hunspell .dic format (LibreOffice, Firefox, Chrome, macOS
dictionaries) is plain text: one word per line, optionally word/FLAGS.
A plain word list is already a valid Hunspell dictionary. Use
HunspellDictionary to consume that ecosystem directly:
// Main dictionary: parses the count header, strips /FLAGS,
// handles escaped slashes and multi-word pairs.
final dict = HunspellDictionary.fromString(
await rootBundle.loadString('assets/dictionaries/es_ANY.dic'),
);
symSpell.trainDictionary(dict.terms, Languages.spanish);
// Personal dictionary: word lists with *forbidden entries.
final personal = HunspellDictionary.fromString(
await rootBundle.loadString('assets/dictionaries/es_personal.dic'),
);
symSpell.trainDictionary(personal.terms, Languages.spanish);
// Check a word: forbidden entries are never trained, so check them first.
bool isValid(String word) =>
!personal.forbidden.contains(word.toLowerCase()) &&
symSpell.hasTerm(word, Languages.spanish);Or via assets: AssetDictionaryLoader.loadHunspell('assets/dictionaries/es.dic').
Notes:
- Optional
.affsupport. Flags are parsed and preserved, and when the companion.afffile is present,HunspellDictionary.expand(AffixRules)generates the full word forms (trabajar→trabajamos,work→worked,rework,reworked). Without it, only stems are trained. See doc/dictionary-formats.md. - LibreOffice dictionary files ship in cgit.freedesktop.org/libreoffice/dictionaries; check each dictionary's license (many are GPL/LGPL/MPL).
test/real_world_test.dart validates the engine against standard public
data sets (test/assets/):
| Asset | Content |
|---|---|
norvig/spell-errors.txt |
7,841 lines of real misspellings from Wikipedia + Roger Mitton's corpus (Peter Norvig's standard test set) → 38,865 cases |
dictionaries/en_count_big.txt |
29,136 real English words with counts (from Norvig's big.txt corpus) |
dictionaries/sgb_words.txt |
5,757 five-letter words from Knuth's Stanford GraphBase (plain list, no frequencies) |
dictionaries/es_common.txt |
Spanish frequency list with accented words |
dictionaries/es_personal.txt |
Hunspell-style personal dictionary |
Measured results (deterministic):
[REAL] spell-errors: 38865 cases, coverage 89.1%,
top-1 accuracy 32.4% overall / 69.1% fair / 78.3% ranking
[REAL] misses: 3250 contextual (typed word valid), 13137 out-of-range,
2160 closer-word, 3483 lost ties, 0 engine misses
[REAL] typo recall: 2000 sampled words, 0.3% misses (typo is another valid word)
The suite also guarantees: every dictionary word is recognized exactly
(self-validation), distance-1 typos of real words are recalled, and
engine misses == 0 on the standard set (every covered case within
maxDistance is found). Remaining top-1 misses are ranking ties — they
improve with a larger frequency dictionary (e.g. Norvig's 333k-word
count_1w), not with engine changes.
test/stress/stress_test.dart contains deterministic stress tests (fixed RNG
seeds) with large strings: a 20k-term dictionary, 200-char words
(~20,100 delete keys), 10k-word sentences (~90 KB) with 500 injected typos,
and lookup throughput benchmarks.
It also benchmarks the Hunspell pipeline against the real
es_ES.dic from LibreOffice (58,221 lines / 700 KB, test asset):
[STRESS] parse es_ES.dic: 253 ms (55,820 terms, 47,332 flagged)
[STRESS] train es_ES: 3,741 ms (2,068,565 entries, 37.1x amplification)
[STRESS] 5,000 typo lookups: 190 ms (26,316 lookups/sec, recall 97.9%)
[STRESS] loadHunspell: 270 ms (700 KB asset)
They are excluded from the default test suite. Run them with:
flutter test --dart-define=STRESS=true test/stress/stress_test.dart \
--reporter expanded- Original: m-elbably/symspell-ex
- Algorithm: symmetric delete spelling correction (SymSpell by Wolf Garbe).
- License: MIT.