// The public dictionary type: parse, index once, then answer `check` queries.
//
// This is an independent, clean-room implementation of the judgement rules
// documented in the Hunspell manual. No Hunspell (LGPL-2.1) source code was
// consulted or copied; this project is Apache-2.0.
///|
/// The error raised when a dictionary cannot be built.
///
/// It wraps whichever parser failed, so the caller keeps the original line
/// number and message.
pub(all) suberror SpellError {
AffParse(@aff.AffParseError)
DicParse(@dic.DicParseError)
}
///|
/// `"aff: line N: message"` or `"dic: line N: message"`, naming the file that
/// failed.
pub impl Show for SpellError with fn to_string(self) {
match self {
AffParse(error) => ".aff: \{error.to_string()}"
DicParse(error) => ".dic: \{error.to_string()}"
}
}
///|
/// A parsed, indexed dictionary that can judge words.
///
/// Build one with `Dictionary::from_text`. Parsing and indexing happen once, so
/// `check` is cheap enough to call for every word of a document.
///
/// The type is opaque: the index representation is an implementation detail and
/// may change. Use the accessors below to inspect the loaded file.
pub struct Dictionary {
/// Every `.dic` word mapped to its homonyms' flag vectors. A word that occurs
/// several times keeps one flag vector per occurrence, because Hunspell treats
/// them as alternatives: `foo`, `foo/X` and `foo/Y` make `foo` correct even
/// though the `X` homonym alone would not be.
entries : Map[String, Array[Array[String]]]
prefix_rules : Array[SpellRule]
suffix_rules : Array[SpellRule]
forbiddenword : String?
keepcase : String?
needaffix : String?
onlyincompound : String?
/// `CIRCUMFIX`: an affix carrying this flag may only be applied together with
/// an affix of the opposite kind that carries it too.
circumfix : String?
compound_flag : String?
/// `COMPOUNDBEGIN` / `COMPOUNDEND`: the flags that licence a word as the
/// first or the last part of a compound (`check_compound`). `COMPOUNDMIDDLE`
/// is parsed but unused until compounds of three or more parts are handled.
compound_begin : String?
compound_end : String?
compound_middle : String?
/// `COMPOUNDPERMITFLAG` / `COMPOUNDFORBIDFLAG`: the flags that let an affix sit
/// inside a compound / forbid an affixed word from being a compound part.
compound_permit : String?
compound_forbid : String?
/// `FORCEUCASE`: a compound whose last part carries this flag is only correct
/// when the whole word is capitalised.
forceucase : String?
/// The `COMPOUNDRULE` patterns, verbatim. A compound is correct when its
/// parts' flags match one of them (`check_compound_rule` parses them).
compound_rules : Array[String]
/// The `CHECKCOMPOUNDPATTERN` lines, verbatim; parsed at match time.
check_compound_pattern : Array[String]
check_compound_dup : Bool
check_compound_triple : Bool
check_compound_simplified_triple : Bool
check_compound_case : Bool
/// `0` means unlimited.
compound_word_max : Int
/// `COMPOUNDSYLLABLE max vowels`; `0` means the directive was absent.
compound_syllable_max : Int
compound_syllable_vowels : String
/// `REP`-driven `CHECKCOMPOUNDREP`.
check_compound_rep : Bool
/// The space-free spellings of `.dic` word pairs (`compound word`). A
/// compound must not be recognised for one of these: the word pair is the
/// only correct spelling (`wordpair`, `ph2`).
blocked_compounds : Map[String, Bool]
/// `0` means the `.aff` had no `COMPOUNDMIN`; Hunspell's default of `3` is
/// applied at match time.
compound_min : Int
/// `WORDCHARS`: characters that may appear inside a word. Only the trailing
/// `.` (abbreviations such as `etc.`) is acted on today.
word_chars : String
/// `TRY`: the characters the suggestion engine may substitute or insert.
/// Empty when the `.aff` had no `TRY` line.
try_chars : String
/// `KEY`: the keyboard layout, one row per `|`-separated group. Empty when the
/// `.aff` had no `KEY` line. Used only to rank suggestions.
keyboard : String
/// `REP`: the replacement pairs, in source order. `_` still stands for a space
/// (the parser keeps it verbatim); the suggestion engine interprets it.
replacements : Array[@aff.Replacement]
/// `REP` plus the rules derived from the `.dic` `ph:` fields, in source order.
/// The `ph:` fields are the manual's "inner REP table": a `ph:` value names a
/// transliteration of the word, so `which ph:wich` suggests `which` for
/// `wich`. Built once at load time.
suggestion_replacements : Array[@aff.Replacement]
/// `MAP` groups, verbatim, in source order. The suggestion engine expands each
/// group into "replace any member by any other member".
map_groups : Array[String]
/// `PHONE` phonetic-transcription rules, in source order. Empty when the
/// `.aff` file has none, in which case the phonetic pass is skipped entirely.
phone_rules : Array[@aff.PhoneRule]
/// The parsed `PHONE` table and the phonetic key -> words index, built once at
/// load time. Empty when there are no `PHONE` rules (so a dictionary that does
/// not use them pays nothing). A word whose `.dic` entry carries a `ph:` field
/// is indexed under that field instead of a computed key.
phonet : PhonetTable
/// `ICONV`: the input-conversion table, already in matching order. Applied to
/// the word being checked (`check`) and never to dictionary entries.
iconv : Array[@aff.Conversion]
/// `OCONV`: the output-conversion table, already in matching order. Applied to
/// suggestion output only (`Dictionary::apply_oconv`).
oconv : Array[@aff.Conversion]
/// `NOSUGGEST`: a word carrying this flag is never offered as a suggestion.
nosuggest : String?
/// The characters listed by the `IGNORE` directive, as code points. They are
/// removed from dictionary entries, affix `strip`/`add` text and checked
/// words before any comparison (`remove_ignored`).
ignore_chars : Array[Char]
/// `CHECKSHARPS`: an uppercase word may spell a dictionary `ß` as `SS`, and a
/// `KEEPCASE` entry containing `ß` may be capitalised or uppercased.
check_sharps : Bool
/// `COMPLEXPREFIXES`: when set, `affix_hit` allows a second prefix whose flag
/// the inner prefix's continuation flags supply, and refuses twofold suffix
/// stacking (the manual's "twofold prefix matching, but only one suffix level
/// instead of the default two").
complex_prefixes : Bool
/// The `BREAK` patterns: where a word may be split into two separately
/// correct parts (`check_breaks`). When the `.aff` has no `BREAK` line this
/// holds Hunspell's default (`-`, `^-`, `-$`); `BREAK 0` leaves it empty.
break_patterns : Array[String]
encoding : String
flag_type : @aff.FlagType
/// `LANG tr` / `az` / `crh`: use the Turkic dotted/dotless `I` casing rules.
turkic : Bool
rule_count : Int
}
///|
/// Parse `.aff` and `.dic` text and build an indexed dictionary.
///
/// Raises `SpellError` when either file is malformed. A leading UTF-8 BOM
/// (`U+FEFF`) on either text is ignored, because real dictionaries sometimes
/// carry one.
///
/// The `.dic` flags are decoded with the `.aff` file's `FLAG` mode; when the
/// `.aff` declares an `AF` alias table the `.dic` holds alias numbers, so the
/// numbers are resolved to the same strings that `PFX`/`SFX` rule flags were
/// resolved to.
pub fn Dictionary::from_text(
aff_text : String,
dic_text : String,
) -> Dictionary raise SpellError {
let aff = @aff.parse_aff(strip_bom(aff_text)) catch {
error => raise AffParse(error)
}
// With an `AF` table the word list stores alias indices, not flags, so it has
// to be decoded as numbers even when the file's own `FLAG` mode differs.
let dic_flag_type = if aff.flag_aliases.is_empty() {
aff.flag_type
} else {
@aff.Num
}
let dic = @dic.parse_dic(strip_bom(dic_text), dic_flag_type) catch {
error => raise DicParse(error)
}
let entries : Map[String, Array[Array[String]]] = Map([])
let blocked_compounds : Map[String, Bool] = Map([])
let ignore_chars = aff.ignore_chars.to_array()
for entry in dic.entries {
let flags : Array[String] = []
for flag in entry.flags {
// An `AF` alias stands for a whole flag vector (`AF AB` is the flags `A`
// and `B`), so resolving one alias can add several flags.
for resolved in resolve_alias(aff.flag_aliases, flag, aff.flag_type) {
flags.push(resolved)
}
}
// `IGNORE` characters are dropped from dictionary words as well as from the
// word being checked, so `مُدَرِّس` and `مدرس` become the same entry.
let word = remove_ignored(entry.word, ignore_chars)
match entries.get(word) {
Some(existing) => existing.push(flags)
None => {
let homonyms : Array[Array[String]] = [flags]
entries[word] = homonyms
}
}
// A word pair such as `a lot ph:alot` or `forbidden root/A ph:...` is one
// dictionary word spelled with a space. `word` above stays the first field
// (the judgement index leans on it), but the whole spelling is registered
// too, with the flags written on its last token, so that a `ph:` suggestion
// can pass `check`.
let full = remove_ignored(entry.full_word, ignore_chars)
if full != word {
let full_flags : Array[String] = []
for flag in entry.full_flags {
for resolved in resolve_alias(aff.flag_aliases, flag, aff.flag_type) {
full_flags.push(resolved)
}
}
match entries.get(full) {
Some(existing) => existing.push(full_flags)
None => {
let homonyms : Array[Array[String]] = [full_flags]
entries[full] = homonyms
}
}
// The same word without its space must never be recognised as a compound:
// the word pair is the only accepted spelling.
let joined = remove_whitespace(full)
if !joined.is_empty() {
blocked_compounds[joined] = true
}
}
}
let prefix_rules : Array[SpellRule] = []
let suffix_rules : Array[SpellRule] = []
for rule in aff.affix_rules {
let cont_flags = decode_continuation(
rule.add,
aff.flag_aliases,
aff.flag_type,
)
let cleaned = rule_without_continuation_flags(rule)
// The strip and add text are cleaned too: for `IGNORE aeiou` the rule
// `PFX A 0 re .` adds `r`, so `reexpression` and `rxprssn` both normalise to
// the same accepted form.
let cleaned = {
..cleaned,
strip: remove_ignored(cleaned.strip, ignore_chars),
add: remove_ignored(cleaned.add, ignore_chars),
}
let spell_rule : SpellRule = { rule: cleaned, cont_flags, }
match cleaned.kind {
@aff.Prefix => prefix_rules.push(spell_rule)
@aff.Suffix => suffix_rules.push(spell_rule)
}
}
let special = aff.special_flags
{
entries,
prefix_rules,
suffix_rules,
forbiddenword: special.forbiddenword,
keepcase: special.keepcase,
needaffix: special.needaffix,
onlyincompound: special.onlyincompound,
circumfix: special.circumfix,
compound_flag: aff.compound_flag,
compound_begin: aff.compound_begin,
compound_end: aff.compound_end,
compound_middle: aff.compound_middle,
compound_permit: aff.compound_permit,
compound_forbid: aff.compound_forbid,
forceucase: aff.forceucase,
compound_rules: aff.compound_rules,
check_compound_pattern: aff.check_compound_pattern,
check_compound_dup: aff.check_compound_dup,
check_compound_triple: aff.check_compound_triple,
check_compound_simplified_triple: aff.check_compound_simplified_triple,
check_compound_case: aff.check_compound_case,
compound_word_max: aff.compound_word_max,
compound_syllable_max: aff.compound_syllable_max,
compound_syllable_vowels: aff.compound_syllable_vowels,
check_compound_rep: aff.check_compound_repeat,
blocked_compounds,
compound_min: aff.compound_min,
word_chars: aff.word_chars,
try_chars: aff.try_chars,
keyboard: aff.keyboard,
replacements: aff.replacements,
suggestion_replacements: suggest_replacements(
aff.replacements,
dic.entries,
ignore_chars,
),
map_groups: aff.map_groups,
phone_rules: aff.phone_rules,
phonet: build_phonetic_table(dic.entries, ignore_chars, aff.phone_rules),
iconv: prepare_conversions(aff.iconv),
oconv: prepare_conversions(aff.oconv),
nosuggest: special.nosuggest,
ignore_chars,
check_sharps: aff.check_sharps,
complex_prefixes: aff.complex_prefixes,
break_patterns: if aff.breaks_declared {
aff.break_patterns
} else {
["-", "^-", "-$"]
},
encoding: aff.encoding,
flag_type: aff.flag_type,
turkic: aff.lang == "tr" || aff.lang == "az" || aff.lang == "crh",
rule_count: aff.affix_rules.length(),
}
}
///|
/// Remove every character listed in the `.aff` `IGNORE` directive from `text`.
///
/// Hunspell treats those characters as invisible: they are dropped from
/// dictionary entries and from the word being checked before the two are
/// compared. `ignored` holds the characters as code points, so an entry may
/// list multi-byte characters (Arabic diacritics, the right-to-left mark).
fn remove_ignored(text : String, ignored : Array[Char]) -> String {
if ignored.is_empty() {
return text
}
let buffer = StringBuilder()
for c in text {
if !ignored.contains(c) {
buffer.write_char(c)
}
}
buffer.to_string()
}
///|
/// Remove every whitespace character from `text` (`"a lot"` -> `"alot"`).
fn remove_whitespace(text : String) -> String {
let buffer = StringBuilder()
for c in text {
if !c.is_whitespace() {
buffer.write_char(c)
}
}
buffer.to_string()
}
///|
/// A `PFX`/`SFX` rule as the judgement engine needs it: the rule with the
/// continuation-flag list cut off its `add` text, plus that list as flags.
///
/// `SFX A 0 s/123 .` adds `s` and gives the derived form the flags `1`, `2`
/// and `3`; chaining a second affix through those flags is what
/// `cont_flags` enables.
///
/// The type is an implementation detail of the index (`Dictionary` is opaque),
/// so it is never constructed by a caller.
pub struct SpellRule {
rule : @aff.AffixRule
cont_flags : Array[String]
}
///|
/// The flags listed after the `/` of a rule's `add` field. An `add` without a
/// `/` has no continuation flags.
///
/// Without an `AF` table the list is a plain flag list in the file's `FLAG`
/// mode (`s/123` is the flags `1`, `2`, `3`). With an `AF` table the entries are
/// alias indices instead (`y/2` is whatever flag vector alias `2` names), and
/// each one expands to the flags it stands for.
fn decode_continuation(
add : String,
aliases : Array[String],
flag_type : @aff.FlagType,
) -> Array[String] {
let chars = add.to_array()
let mut index = 0
while index < chars.length() && chars[index] != '/' {
index = index + 1
}
if index >= chars.length() {
return []
}
let buffer = StringBuilder()
let mut cursor = index + 1
while cursor < chars.length() {
buffer.write_char(chars[cursor])
cursor = cursor + 1
}
let raw = buffer.to_string()
if aliases.is_empty() {
return @dic.decode_flags(raw, flag_type)
}
// Alias indices are stored as decimal numbers, exactly as in the `.dic`.
let flags : Array[String] = []
for entry in @dic.decode_flags(raw, Num) {
for resolved in resolve_alias(aliases, entry, flag_type) {
flags.push(resolved)
}
}
flags
}
///|
/// Drop a leading UTF-8 byte-order mark, if the text starts with one.
fn strip_bom(text : String) -> String {
match text.strip_prefix("\u{FEFF}") {
Some(rest) => rest.to_owned()
None => text
}
}
///|
/// Copy `rule`, cutting the continuation-flag list off the `add` field.
///
/// Hunspell writes `SFX A 0 s/123 .` to mean "add `s` and give the result the
/// flags `123`", which is how a second affix can be chained onto the first.
/// Chaining is not implemented (see the README's limitations), but the *first*
/// affix is real and has to be applied with `add` = `s`, otherwise a word as
/// simple as `foos` would not be found. The flags after `/` are therefore
/// dropped rather than left glued to the addition.
fn rule_without_continuation_flags(rule : @aff.AffixRule) -> @aff.AffixRule {
{
kind: rule.kind,
flag: rule.flag,
cross_product: rule.cross_product,
strip: rule.strip,
add: add_without_flags(rule.add),
condition: rule.condition,
morph: rule.morph,
}
}
///|
/// The part of an `add` field before the first `/` (the continuation-flag
/// separator); `0` and other values pass through unchanged.
fn add_without_flags(add : String) -> String {
match add.find("/") {
None => add
Some(_) => {
let buffer = StringBuilder()
for c in add {
if c == '/' {
break
}
buffer.write_char(c)
}
buffer.to_string()
}
}
}
///|
/// Resolve one `.dic` flag through the `AF` alias table into the flags it
/// stands for.
///
/// With no `AF` table the flag is returned as-is. Otherwise a decimal flag is
/// an alias index (`1` is the first `AF` body line) and its value is a flag
/// *vector*, which is split with the file's `FLAG` mode: `AF AB` in
/// single-character mode is the two flags `A` and `B`. An out-of-range index or
/// a non-numeric flag is kept verbatim, so a `.dic` that mixes both still works.
fn resolve_alias(
aliases : Array[String],
flag : String,
flag_type : @aff.FlagType,
) -> Array[String] {
if aliases.is_empty() {
return [flag]
}
let parsed : Int? = Some(@string.parse_int(flag)) catch { _ => None }
match parsed {
Some(index) =>
if index >= 1 && index <= aliases.length() {
@dic.decode_flags(aliases[index - 1], flag_type)
} else {
[flag]
}
None => [flag]
}
}
///|
/// The `SET` encoding declared by the `.aff` file, or `""` when absent.
pub fn Dictionary::encoding(self : Dictionary) -> String {
self.encoding
}
///|
/// The `FLAG` mode declared by the `.aff` file (SingleChar when absent).
pub fn Dictionary::flag_type(self : Dictionary) -> @aff.FlagType {
self.flag_type
}
///|
/// How many `PFX`/`SFX` rule lines the `.aff` file declared.
pub fn Dictionary::rule_count(self : Dictionary) -> Int {
self.rule_count
}
///|
/// How many distinct words the word list holds.
pub fn Dictionary::entry_count(self : Dictionary) -> Int {
self.entries.length()
}
///|
/// The `TRY` characters the `.aff` file declared, or `""` when it had none.
///
/// The suggestion engine substitutes and inserts only these characters (falling
/// back to a plain ASCII alphabet when the list is empty).
pub fn Dictionary::try_chars(self : Dictionary) -> String {
self.try_chars
}
///|
/// The `KEY` keyboard layout, or `""` when the `.aff` file had none.
///
/// Rows are separated by `|`; the suggestion engine uses adjacency on this
/// layout to rank candidates.
pub fn Dictionary::keyboard(self : Dictionary) -> String {
self.keyboard
}
///|
/// The `WORDCHARS` characters the `.aff` file declared, or `""` when it had none.
pub fn Dictionary::word_chars(self : Dictionary) -> String {
self.word_chars
}
///|
/// The `REP` replacement pairs, in source order.
///
/// `_` stands for a space, exactly as it does in the file; the suggestion engine
/// is what interprets it.
pub fn Dictionary::replacements(self : Dictionary) -> Array[@aff.Replacement] {
self.replacements
}
///|
/// The full suggestion replacement table: `REP` followed by the rules derived
/// from the `.dic` `ph:` fields, in source order.
pub fn Dictionary::suggestion_replacements(
self : Dictionary,
) -> Array[@aff.Replacement] {
self.suggestion_replacements
}
///|
/// The `MAP` groups verbatim, in source order; empty when the `.aff` file has
/// none. Each entry is one `MAP` body line, e.g. `uü` or `ß(ss)`.
pub fn Dictionary::map_groups(self : Dictionary) -> Array[String] {
self.map_groups
}
///|
/// The `PHONE` rules in source order; empty when the `.aff` file has none.
pub fn Dictionary::phone_rules(self : Dictionary) -> Array[@aff.PhoneRule] {
self.phone_rules
}
///|
/// Is `word` itself marked `NOSUGGEST`, so that it must never be offered as a
/// suggestion? Only a direct `.dic` entry is considered.
pub fn Dictionary::is_no_suggest(self : Dictionary, word : String) -> Bool {
for flags in self.homonyms(word) {
if has_flag(self.nosuggest, flags) {
return true
}
}
false
}
///|
/// Apply the `.aff` `OCONV` output-conversion table to `text`.
///
/// Hunspell only ever converts *output* with this table — the spell-checking
/// judgement is unaffected — so the suggestion engine calls this on each
/// suggestion it is about to return.
pub fn Dictionary::apply_oconv(self : Dictionary, text : String) -> String {
apply_conversion(self.oconv, text)
}