// Parser for Hunspell `.dic` word lists.
//
// This is an independent, clean-room implementation of the format documented in
// the Hunspell manual. No Hunspell (LGPL-2.1) source code was consulted or
// copied; this project is Apache-2.0.
//
// The format is line-oriented: the first non-empty line is the declared entry
// count and every following non-empty line is one `word[/FLAGS] [morph...]`
// entry. Parsing is single-pass; the declared count is stored but never
// enforced against the number of entries actually present.
///|
/// A non-empty source line, kept with its original 1-based line number.
priv struct DicLine {
number : Int
text : String
}
///|
/// The error raised when the `.dic` text cannot be parsed.
///
/// `line` is the 1-based line number in the original text.
pub(all) suberror DicParseError {
DicParseError(line~ : Int, message~ : String)
} derive(Eq, @debug.Debug)
///|
/// `"line N: message"`, the form used by error messages and logs.
pub impl Show for DicParseError with fn to_string(self) {
match self {
DicParseError(line~, message~) => "line \{line}: \{message}"
}
}
///|
/// Parse the text of a Hunspell `.dic` file.
///
/// `flag_type` comes from the matching `.aff` file (`FLAG long` / `num` /
/// `UTF-8`, anything else being single-character flags) and decides how the text
/// after `/` is split into flags.
///
/// Blank lines are ignored and `\r\n`, `\n` and lone `\r` all end a line. The
/// first non-empty line must be the declared entry count: it is stored as-is
/// even when the file holds a different number of entries, because stale counts
/// are common and harmless. A malformed count line raises `DicParseError`;
/// malformed *entry* lines do not, since the format gives them no syntax to
/// violate.
///
/// On an entry line the first whitespace-separated field is the word (with any
/// `\/` unescaped) optionally followed by `/` and its flags; the remaining
/// fields are morphological fields. A literal `/` in a word may be escaped as
/// `\/`.
pub fn parse_dic(
text : String,
flag_type : @aff.FlagType,
) -> DicFile raise DicParseError {
let lines = preprocess(text)
if lines.length() == 0 {
raise DicParseError(line=1, message="missing entry count line")
}
let header = lines[0]
let declared_count = parse_count(header)
let entries : Array[DicEntry] = []
let mut index = 1
while index < lines.length() {
entries.push(parse_entry(lines[index], flag_type))
index = index + 1
}
{ declared_count, entries, }
}
///|
/// Parse the count line into a non-negative `Int`, or report why it is not one.
///
/// Hunspell reads the count with a numeric scan and ignores whatever follows it
/// on the same line, and real dictionaries use that for a trailing comment
/// (`4 # Old Persian numbers`). Only the first whitespace-separated field is
/// therefore parsed; the error message still quotes the whole line so a
/// malformed count reports exactly what was there.
fn parse_count(line : DicLine) -> Int raise DicParseError {
let fields = split_whitespace(line.text)
let number = if fields.length() > 0 { fields[0] } else { line.text }
let parsed : Int? = Some(@string.parse_int(number)) catch { _ => None }
match parsed {
Some(count) =>
if count < 0 {
raise DicParseError(
line=line.number,
message="entry count must not be negative, found \"\{line.text}\"",
)
} else {
count
}
None =>
raise DicParseError(
line=line.number,
message="expected an entry count, found \"\{line.text}\"",
)
}
}
///|
/// Split `text` into trimmed non-empty lines, remembering source line numbers.
fn preprocess(text : String) -> Array[DicLine] {
let normalized = text
.replace_all(old="\r\n", new="\n")
.replace_all(old="\r", new="\n")
let lines : Array[DicLine] = []
let mut number = 1
for raw in normalized.split("\n") {
let trimmed = raw.trim()
if !trimmed.is_empty() {
lines.push({ number, text: trimmed.to_owned(), })
}
number = number + 1
}
lines
}
///|
/// Parse one entry line into a `DicEntry`.
fn parse_entry(line : DicLine, flag_type : @aff.FlagType) -> DicEntry {
let tokens = split_whitespace(line.text)
let (word, raw_flags) = split_word_flags(tokens[0])
let flags = match raw_flags {
Some(raw) => decode_flags(raw, flag_type)
None => ([] : Array[String])
}
// The morphological fields start at the first colon-bearing token (Hunspell
// writes them as `po:`, `ph:`, `st:`, ...); everything before it belongs to
// the word, so `a lot ph:alot` is the word `a lot` with one morph field.
let mut field_start = tokens.length()
let mut index = 1
while index < tokens.length() {
if field_start == tokens.length() && tokens[index].contains(":") {
field_start = index
}
index = index + 1
}
let morph : Array[String] = []
index = field_start
while index < tokens.length() {
morph.push(tokens[index])
index = index + 1
}
let full_word = if field_start <= 1 {
word
} else {
full_word_of(tokens, field_start)
}
// A word pair carries its flags on the last word token (`black bird/S`), so
// the whole-spelling alias needs those, not the first field's flags.
let full_flags = if field_start <= 1 {
flags
} else {
match split_word_flags(tokens[field_start - 1]) {
(_, Some(raw)) => decode_flags(raw, flag_type)
(_, None) => ([] : Array[String])
}
}
{ word, flags, morph, full_word, full_flags, }
}
///|
/// The word part of an entry line: the `count` tokens before the first
/// morphological field, joined by single spaces, with the `/FLAGS` suffix cut
/// off the last one (`forbidden root/A` -> `forbidden root`).
fn full_word_of(tokens : Array[String], count : Int) -> String {
let buffer = StringBuilder()
let mut index = 0
while index < count {
if index > 0 {
buffer.write_char(' ')
}
if index + 1 == count {
let (word, _) = split_word_flags(tokens[index])
buffer.write_string(word)
} else {
buffer.write_string(tokens[index])
}
index = index + 1
}
buffer.to_string()
}
///|
/// Split the first field of an entry into its word and its raw flag text.
///
/// The word ends at the first unescaped `/` that is not the very first
/// character; `\/` is emitted as a literal `/`. A leading `/` belongs to the
/// word (`/foo/AB` is the word `/foo` with the flags `AB`, and `/AB` is just the
/// word `/AB`). Returns `None` for the flag text when the field has no
/// separator.
fn split_word_flags(field : String) -> (String, String?) {
let chars = field.to_array()
let word = StringBuilder()
let mut raw_flags : String? = None
let mut index = 0
while index < chars.length() {
let c = chars[index]
if c == '\\' && index + 1 < chars.length() && chars[index + 1] == '/' {
word.write_char('/')
index = index + 2
} else if c == '/' && index > 0 {
let flags = StringBuilder()
let mut rest = index + 1
while rest < chars.length() {
flags.write_char(chars[rest])
rest = rest + 1
}
raw_flags = Some(flags.to_string())
index = chars.length()
} else {
word.write_char(c)
index = index + 1
}
}
(word.to_string(), raw_flags)
}
///|
/// Decode the flag text after `/` according to the file's flag mode.
///
/// * `SingleChar` and `Utf8`: one character (Unicode scalar value) per flag.
/// * `Long`: two characters per flag; a trailing odd character is ignored,
/// because it cannot form a flag.
/// * `Num`: comma-separated decimal numbers, kept verbatim as strings; empty
/// fields between commas are skipped.
///
/// This is also how the continuation flags after the `/` of an affix rule's
/// `add` field are decoded (`SFX A 0 s/123 .`), so it is part of the package
/// interface rather than a private helper.
pub fn decode_flags(raw : String, flag_type : @aff.FlagType) -> Array[String] {
match flag_type {
@aff.SingleChar | @aff.Utf8 => {
let flags : Array[String] = []
for c in raw {
flags.push(c.to_string())
}
flags
}
@aff.Long => {
let chars = raw.to_array()
let flags : Array[String] = []
let mut index = 0
while index + 1 < chars.length() {
flags.push(chars[index].to_string() + chars[index + 1].to_string())
index = index + 2
}
flags
}
@aff.Num => {
let flags : Array[String] = []
for part in raw.split(",") {
let trimmed = part.trim()
if !trimmed.is_empty() {
flags.push(trimmed.to_owned())
}
}
flags
}
}
}
///|
/// Split a trimmed line on runs of Unicode whitespace.
fn split_whitespace(text : String) -> Array[String] {
let tokens : Array[String] = []
let buffer = StringBuilder()
let mut pending = false
for c in text {
if c.is_whitespace() {
if pending {
tokens.push(buffer.to_string())
buffer.reset()
pending = false
}
} else {
buffer.write_char(c)
pending = true
}
}
if pending {
tokens.push(buffer.to_string())
}
tokens
}