// `COMPOUNDRULE` support.
//
// Clean-room implementation of the compound pattern language documented in the
// Hunspell manual. No Hunspell (LGPL-2.1) source code was consulted or copied;
// this project is Apache-2.0.
//
// `COMPOUNDRULE n*1t` declares a pattern over the *compound flags* of the words
// a compound is built from, and a compound is correct when the sequence of its
// parts matches one of the patterns:
//
//   `F`    exactly one part carrying flag `F`
//   `F*`   zero or more parts carrying `F`
//   `F?`   zero or one part carrying `F`
//   `(F)`  the flag `F` when it is multi-character (`FLAG long` / `FLAG num`)
//
// Every part has to be accounted for by exactly one atom, so the pattern must
// match the whole word. A compound needs at least two parts.

///|
/// How often the flag before a repeating marker may occur.
priv enum CpdRepeat {
  /// The bare flag: exactly one part.
  Once
  /// `F*`: zero or more parts.
  Star
  /// `F?`: zero or one part.
  Question
}

///|
/// One atom of a compound pattern: a flag and how often it may repeat.
priv struct CpdAtom {
  flag : String
  repeat : CpdRepeat
}

///|
/// Parse one `COMPOUNDRULE` pattern into atoms, left to right.
///
/// A flag is one character, or everything between `(` and `)` for the
/// multi-character flag modes. A trailing `*` or `?` is the repeat marker. An
/// unterminated `(` is treated as a literal `(` so that a malformed pattern
/// cannot make matching fail with an error.
fn parse_compound_rule(pattern : String) -> Array[CpdAtom] {
  let chars = pattern.to_array()
  let atoms : Array[CpdAtom] = []
  let mut index = 0
  while index < chars.length() {
    let (flag, next) = if chars[index] == '(' {
      let buffer = StringBuilder()
      let mut cursor = index + 1
      let mut closed = false
      while cursor < chars.length() && !closed {
        if chars[cursor] == ')' {
          closed = true
        } else {
          buffer.write_char(chars[cursor])
          cursor = cursor + 1
        }
      }
      if closed {
        (buffer.to_string(), cursor + 1)
      } else {
        ("(", index + 1)
      }
    } else {
      (chars[index].to_string(), index + 1)
    }
    let repeat = if next < chars.length() && chars[next] == '*' {
      Star
    } else if next < chars.length() && chars[next] == '?' {
      Question
    } else {
      Once
    }
    let following = match repeat {
      Once => next
      Star | Question => next + 1
    }
    atoms.push({ flag, repeat, })
    index = following
  }
  atoms
}

///|
/// May this repeat marker match more than one part?
fn repeats(repeat : CpdRepeat) -> Bool {
  match repeat {
    Once => false
    Star | Question => true
  }
}

///|
/// Does `word` decompose into at least two dictionary words whose flags match
/// one of the `.aff` file's `COMPOUNDRULE` patterns?
///
/// The parts must be plain dictionary entries (affixed parts are not tried).
/// `NEEDAFFIX` and `FORBIDDENWORD` disqualify a part; `ONLYINCOMPOUND` does not,
/// because a compound is exactly where such an entry is allowed.
fn Dictionary::check_compound_rule(
  self : Dictionary,
  word : String,
  mode : MatchCase,
) -> Bool {
  if self.compound_rules.length() == 0 {
    return false
  }
  let chars = word.to_array()
  if chars.length() < 2 {
    return false
  }
  for text in self.compound_rules {
    let pattern = parse_compound_rule(text)
    if self.matches_compound_pattern(chars, 0, pattern, 0, 0, mode) {
      return true
    }
  }
  false
}

///|
/// Try to match `pattern` from `atom_index` against `chars` from `position`.
///
/// `parts` counts the parts consumed so far, so the caller can insist on a real
/// compound (two or more parts) rather than a single dictionary word.
fn Dictionary::matches_compound_pattern(
  self : Dictionary,
  chars : Array[Char],
  position : Int,
  pattern : Array[CpdAtom],
  atom_index : Int,
  parts : Int,
  mode : MatchCase,
) -> Bool {
  if atom_index >= pattern.length() {
    return position >= chars.length() && parts >= 2
  }
  let atom = pattern[atom_index]
  // A repeating atom may always match nothing.
  if repeats(atom.repeat) &&
    self.matches_compound_pattern(
      chars,
      position,
      pattern,
      atom_index + 1,
      parts,
      mode,
    ) {
    return true
  }
  let minimum = if self.compound_min <= 0 { 3 } else { self.compound_min }
  let mut end = position + minimum
  while end <= chars.length() {
    let part = chars_to_string(chars, position, end)
    if self.compound_part_has_flag(part, atom.flag, mode) {
      // `F*` stays on the same atom so it can consume further parts; `F` and
      // `F?` move on.
      let next_atom = match atom.repeat {
        Star => atom_index
        Once | Question => atom_index + 1
      }
      if self.matches_compound_pattern(
          chars,
          end,
          pattern,
          next_atom,
          parts + 1,
          mode,
        ) {
        return true
      }
    }
    end = end + 1
  }
  false
}

///|
/// May `part` stand as one constituent of a `COMPOUNDRULE` compound, and does it
/// carry the compound flag `flag`? A `KEEPCASE` part only matches in an
/// exact-case lookup.
fn Dictionary::compound_part_has_flag(
  self : Dictionary,
  part : String,
  flag : String,
  mode : MatchCase,
) -> Bool {
  if self.is_forbidden_word(part) {
    return false
  }
  for flags in self.homonyms(part) {
    if flags.contains(flag) &&
      !has_flag(self.needaffix, flags) &&
      !keepcase_blocks(mode, part, self.keepcase, flags) {
      return true
    }
  }
  false
}

// ---------------------------------------------------------------------------
// General compounding: `COMPOUNDFLAG` / `COMPOUNDBEGIN` / `COMPOUNDMIDDLE` /
// `COMPOUNDEND` with affixed parts, `COMPOUNDPERMITFLAG`,
// `COMPOUNDFORBIDFLAG`, `CHECKCOMPOUNDPATTERN`, `CHECKCOMPOUNDDUP`,
// `CHECKCOMPOUNDTRIPLE`, `SIMPLIFIEDTRIPLE`, `CHECKCOMPOUNDCASE`,
// `COMPOUNDWORDMAX`, `FORCEUCASE` and `COMPOUNDSYLLABLE`.
//
// Clean-room implementation of the behaviour documented in the Hunspell
// manual; no Hunspell source code was consulted or copied.

///|
/// The position a compound part occupies, which decides the flag it needs.
priv enum CpdRole {
  First
  Middle
  Last
} derive(Eq)

///|
/// One parsed `CHECKCOMPOUNDPATTERN` line.
///
/// `end_chars`/`begin_chars` are the literal boundary text (`""` matches
/// anything); `zero_end` is the manual's special `0` pattern, which limits the
/// rule to unmodified stems. `replacement` is the optional simplified form.
priv struct CpdPattern {
  end_chars : String
  end_flag : String?
  begin_chars : String
  begin_flag : String?
  replacement : String?
  zero_end : Bool
}

///|
/// How a `CHECKCOMPOUNDPATTERN` rule reacts to one boundary.
priv enum CpdBoundary {
  /// The rule does not apply.
  NoMatch
  /// The rule forbids the plain join.
  Forbid
  /// The rule forbids the plain join but allows the replacement form.
  Replace
}

///|
/// Split one `endchars[/flag]` field of a `CHECKCOMPOUNDPATTERN` line.
fn split_pattern_field(token : String) -> (String, String?) {
  let body = StringBuilder()
  let flag = StringBuilder()
  let mut in_flag = false
  for c in token {
    if in_flag {
      flag.write_char(c)
    } else if c == '/' {
      in_flag = true
    } else {
      body.write_char(c)
    }
  }
  (body.to_string(), if in_flag { Some(flag.to_string()) } else { None })
}

///|
/// Parse a `CHECKCOMPOUNDPATTERN` line (the three fields joined by spaces).
fn parse_compound_pattern(text : String) -> CpdPattern {
  let fields : Array[String] = []
  let buffer = StringBuilder()
  let mut pending = false
  for c in text {
    if c.is_whitespace() {
      if pending {
        fields.push(buffer.to_string())
        buffer.reset()
        pending = false
      }
    } else {
      buffer.write_char(c)
      pending = true
    }
  }
  if pending {
    fields.push(buffer.to_string())
  }
  let end_field = if fields.length() > 0 { fields[0] } else { "" }
  let begin_field = if fields.length() > 1 { fields[1] } else { "" }
  let (end_raw, end_flag) = split_pattern_field(end_field)
  let (begin_chars, begin_flag) = split_pattern_field(begin_field)
  let replacement = if fields.length() > 2 {
    Some(apply_compound_underscore(fields[2]))
  } else {
    None
  }
  // The manual reserves the `0` endchars pattern for "unmodified stems".
  let zero_end = end_raw == "0"
  {
    end_chars: if zero_end {
      ""
    } else {
      end_raw
    },
    end_flag,
    begin_chars,
    begin_flag,
    replacement,
    zero_end,
  }
}

///|
/// `_` in a `CHECKCOMPOUNDPATTERN` replacement stands for a space (as in `REP`).
fn apply_compound_underscore(text : String) -> String {
  let buffer = StringBuilder()
  for c in text {
    if c == '_' {
      buffer.write_char(' ')
    } else {
      buffer.write_char(c)
    }
  }
  buffer.to_string()
}

///|
/// The compound flags that licence a part in `role`: `COMPOUNDFLAG` always, plus
/// the position-specific flag.
fn Dictionary::role_flags(self : Dictionary, role : CpdRole) -> Array[String?] {
  let flags : Array[String?] = match role {
    First => [self.compound_flag, self.compound_begin]
    Middle => [self.compound_flag, self.compound_middle]
    Last => [self.compound_flag, self.compound_end]
  }
  flags
}

///|
/// Does `flags` carry a flag that licences `role`?
fn Dictionary::flags_have_role(
  self : Dictionary,
  flags : Array[String],
  role : CpdRole,
) -> Bool {
  for flag in self.role_flags(role) {
    if has_flag(flag, flags) {
      return true
    }
  }
  false
}

///|
/// May `part` stand as one part of an ordinary compound in `role`? Either it is
/// a plain dictionary entry, or an affixed form of one.
fn Dictionary::compound_part_ok(
  self : Dictionary,
  part : String,
  role : CpdRole,
  mode : MatchCase,
) -> Bool {
  if self.is_forbidden_word(part) {
    return false
  }
  // `COMPOUNDFORBIDFLAG` on a dictionary word removes that *spelling* from the
  // first and middle positions, even when another homonym could also be derived
  // by an affix.
  if role != Last && has_flag(self.compound_forbid, self.flags_of_word(part)) {
    return false
  }
  if self.plain_compound_part_ok(part, role, mode) {
    return true
  }
  self.affixed_compound_part_ok(part, role, mode)
}

///|
/// A plain dictionary entry used as a compound part.
fn Dictionary::plain_compound_part_ok(
  self : Dictionary,
  part : String,
  role : CpdRole,
  mode : MatchCase,
) -> Bool {
  for flags in self.homonyms(part) {
    if has_flag(self.needaffix, flags) {
      continue
    }
    if keepcase_blocks(mode, part, self.keepcase, flags) {
      continue
    }
    // A plain word carrying COMPOUNDFORBIDFLAG is removed from the first and
    // middle positions (it may still be the last part).
    if role != Last && has_flag(self.compound_forbid, flags) {
      continue
    }
    if self.flags_have_role(flags, role) {
      return true
    }
  }
  false
}

///|
/// Is `stem` a dictionary entry carrying `flag` that may take an affix inside a
/// compound? `ONLYINCOMPOUND` and `NEEDAFFIX` are allowed here: this *is* the
/// inside of a compound, and a virtual stem is completed by the affix.
fn Dictionary::compound_stem_ok(
  self : Dictionary,
  stem : String,
  flag : String,
  mode : MatchCase,
) -> Bool {
  for flags in self.homonyms(stem) {
    if flags.contains(flag) &&
      !keepcase_blocks(mode, stem, self.keepcase, flags) {
      return true
    }
  }
  false
}

///|
/// May an affix with these continuation flags appear on a compound part in
/// `role`?
///
/// `COMPOUNDFORBIDFLAG` forbids it outright. An affix at its natural edge
/// (prefix on the first part, suffix on the last) needs nothing else, but an
/// affix "inside" the compound needs `COMPOUNDPERMITFLAG`. A suffix whose
/// continuation class carries `ONLYINCOMPOUND` is a Fuge-element: it must be
/// followed by another part, so it can never sit on the last part.
fn Dictionary::compound_affix_allowed(
  self : Dictionary,
  cont_flags : Array[String],
  role : CpdRole,
  is_suffix : Bool,
  check_fuge : Bool,
) -> Bool {
  if has_flag(self.compound_forbid, cont_flags) {
    return false
  }
  if check_fuge &&
    is_suffix &&
    role == Last &&
    has_flag(self.onlyincompound, cont_flags) {
    return false
  }
  let at_edge = if is_suffix { role == Last } else { role == First }
  if !at_edge && !has_flag(self.compound_permit, cont_flags) {
    return false
  }
  true
}

///|
/// Does a form built from `stem` plus these affix continuation classes carry a
/// flag that licences `role`? Either the stem itself carries it, or one of the
/// affixes hands it out (`compoundaffix2`'s permit flag is separate).
fn Dictionary::compound_role_from(
  self : Dictionary,
  stem : String,
  cont_flags : Array[Array[String]],
  role : CpdRole,
) -> Bool {
  for flags in self.homonyms(stem) {
    if self.flags_have_role(flags, role) {
      return true
    }
  }
  for cont in cont_flags {
    if self.flags_have_role(cont, role) {
      return true
    }
  }
  false
}

///|
/// Is a chain of affix continuation classes a complete surface form? The last
/// affix applied must not hand out `NEEDAFFIX`; `ONLYINCOMPOUND` is fine because
/// the form is used inside a compound.
fn Dictionary::compound_chain_ok(
  self : Dictionary,
  cont_flags : Array[Array[String]],
) -> Bool {
  let mut pending = false
  for cont in cont_flags {
    if pending {
      pending = false
    }
    if has_flag(self.needaffix, cont) {
      pending = true
    }
  }
  !pending
}

///|
/// Can one affix have produced `part` from a stem that is itself a valid
/// compound-part stem?
fn Dictionary::affixed_compound_part_ok(
  self : Dictionary,
  part : String,
  role : CpdRole,
  mode : MatchCase,
) -> Bool {
  // A single suffix.
  for rule in self.suffix_rules {
    if has_flag(self.circumfix, rule.cont_flags) {
      continue
    }
    let rest = match part.strip_suffix(normalise_affix(rule.rule.add)) {
      Some(view) => view.to_owned()
      None => continue
    }
    let stem = rest + normalise_affix(rule.rule.strip)
    if !self.compound_stem_ok(stem, rule.rule.flag, mode) {
      continue
    }
    if !self.compound_affix_allowed(rule.cont_flags, role, true, true) {
      continue
    }
    let conts : Array[Array[String]] = [rule.cont_flags]
    if !self.compound_role_from(stem, conts, role) {
      continue
    }
    if !self.compound_chain_ok(conts) {
      continue
    }
    match @affix.apply_rule(rule.rule, stem) {
      Some(produced) => if produced == part { return true }
      None => ()
    }
  }
  // A single prefix.
  for rule in self.prefix_rules {
    if has_flag(self.circumfix, rule.cont_flags) {
      continue
    }
    let rest = match part.strip_prefix(normalise_affix(rule.rule.add)) {
      Some(view) => view.to_owned()
      None => continue
    }
    let stem = normalise_affix(rule.rule.strip) + rest
    if !self.compound_stem_ok(stem, rule.rule.flag, mode) {
      continue
    }
    if !self.compound_affix_allowed(rule.cont_flags, role, false, true) {
      continue
    }
    let conts : Array[Array[String]] = [rule.cont_flags]
    if !self.compound_role_from(stem, conts, role) {
      continue
    }
    if !self.compound_chain_ok(conts) {
      continue
    }
    match @affix.apply_rule(rule.rule, stem) {
      Some(produced) => if produced == part { return true }
      None => ()
    }
  }
  // A prefix and a suffix on the same stem.
  for prefix_rule in self.prefix_rules {
    if !prefix_rule.rule.cross_product {
      continue
    }
    let after_prefix = match
      part.strip_prefix(normalise_affix(prefix_rule.rule.add)) {
      Some(view) => view.to_owned()
      None => continue
    }
    for suffix_rule in self.suffix_rules {
      if !suffix_rule.rule.cross_product {
        continue
      }
      let core = match
        after_prefix.strip_suffix(normalise_affix(suffix_rule.rule.add)) {
        Some(view) => view.to_owned()
        None => continue
      }
      let stem = normalise_affix(prefix_rule.rule.strip) +
        core +
        normalise_affix(suffix_rule.rule.strip)
      if self.is_forbidden_word(stem) {
        continue
      }
      if !@affix.matches_condition(
          prefix_rule.rule.condition,
          stem,
          @aff.Prefix,
        ) {
        continue
      }
      if !@affix.matches_condition(
          suffix_rule.rule.condition,
          stem,
          @aff.Suffix,
        ) {
        continue
      }
      if reconstruct_cross(prefix_rule.rule, suffix_rule.rule, stem) !=
        Some(part) {
        continue
      }
      if !self.circumfix_ok(
          has_flag(self.circumfix, prefix_rule.cont_flags),
          has_flag(self.circumfix, suffix_rule.cont_flags),
        ) {
        continue
      }
      // Mirror `derives_cross`: each rule's flag may come from the stem or from
      // the other rule's continuation class. The German decapitalising prefix
      // gets its flag from the zero suffix's continuation class this way.
      let stem_has_prefix = self.compound_stem_ok(
        stem,
        prefix_rule.rule.flag,
        mode,
      )
      let stem_has_suffix = self.compound_stem_ok(
        stem,
        suffix_rule.rule.flag,
        mode,
      )
      let prefix_first = stem_has_prefix &&
        (
          stem_has_suffix ||
          prefix_rule.cont_flags.contains(suffix_rule.rule.flag)
        )
      let suffix_first = stem_has_suffix &&
        (
          stem_has_prefix ||
          suffix_rule.cont_flags.contains(prefix_rule.rule.flag)
        )
      if !(prefix_first || suffix_first) {
        continue
      }
      if !self.compound_affix_allowed(prefix_rule.cont_flags, role, false, true) {
        continue
      }
      // When a prefix is applied as well, it can be the outermost affix, so the
      // suffix's `ONLYINCOMPOUND` does not force another part to follow.
      if !self.compound_affix_allowed(suffix_rule.cont_flags, role, true, false) {
        continue
      }
      let conts : Array[Array[String]] = [
        prefix_rule.cont_flags,
        suffix_rule.cont_flags,
      ]
      if !self.compound_role_from(stem, conts, role) {
        continue
      }
      if !self.compound_chain_ok(conts) {
        continue
      }
      return true
    }
  }
  false
}

///|
/// Does `part` carry `flag` after being used as a compound part in `role`?
///
/// This is the flag set `CHECKCOMPOUNDPATTERN` tests: the stem's own flags, the
/// affix rules' flags, and their continuation classes (the master corpus has
/// dedicated suites for a pattern flag arriving through an affix continuation
/// class).
fn Dictionary::part_has_pattern_flag(
  self : Dictionary,
  part : String,
  flag : String,
  role : CpdRole,
  mode : MatchCase,
) -> Bool {
  for flags in self.homonyms(part) {
    let usable = !has_flag(self.needaffix, flags) &&
      !keepcase_blocks(mode, part, self.keepcase, flags) &&
      !(role != Last && has_flag(self.compound_forbid, flags))
    if usable && flags.contains(flag) {
      return true
    }
  }
  if self.affixed_pattern_flag(part, flag, mode) {
    return true
  }
  false
}

///|
/// Could an affixed form of `part` carry `flag` for a `CHECKCOMPOUNDPATTERN`
/// test?
fn Dictionary::affixed_pattern_flag(
  self : Dictionary,
  part : String,
  flag : String,
  mode : MatchCase,
) -> Bool {
  for rule in self.suffix_rules {
    let rest = match part.strip_suffix(normalise_affix(rule.rule.add)) {
      Some(view) => view.to_owned()
      None => continue
    }
    let stem = rest + normalise_affix(rule.rule.strip)
    if !self.compound_stem_ok(stem, rule.rule.flag, mode) {
      continue
    }
    if rule.rule.flag == flag ||
      rule.cont_flags.contains(flag) ||
      self.entry_has_flag(stem, flag) {
      match @affix.apply_rule(rule.rule, stem) {
        Some(produced) => if produced == part { return true }
        None => ()
      }
    }
  }
  for rule in self.prefix_rules {
    let rest = match part.strip_prefix(normalise_affix(rule.rule.add)) {
      Some(view) => view.to_owned()
      None => continue
    }
    let stem = normalise_affix(rule.rule.strip) + rest
    if !self.compound_stem_ok(stem, rule.rule.flag, mode) {
      continue
    }
    if rule.rule.flag == flag ||
      rule.cont_flags.contains(flag) ||
      self.entry_has_flag(stem, flag) {
      match @affix.apply_rule(rule.rule, stem) {
        Some(produced) => if produced == part { return true }
        None => ()
      }
    }
  }
  for prefix_rule in self.prefix_rules {
    if !prefix_rule.rule.cross_product {
      continue
    }
    let after_prefix = match
      part.strip_prefix(normalise_affix(prefix_rule.rule.add)) {
      Some(view) => view.to_owned()
      None => continue
    }
    for suffix_rule in self.suffix_rules {
      if !suffix_rule.rule.cross_product {
        continue
      }
      let core = match
        after_prefix.strip_suffix(normalise_affix(suffix_rule.rule.add)) {
        Some(view) => view.to_owned()
        None => continue
      }
      let stem = normalise_affix(prefix_rule.rule.strip) +
        core +
        normalise_affix(suffix_rule.rule.strip)
      if reconstruct_cross(prefix_rule.rule, suffix_rule.rule, stem) !=
        Some(part) {
        continue
      }
      if prefix_rule.rule.flag == flag ||
        suffix_rule.rule.flag == flag ||
        prefix_rule.cont_flags.contains(flag) ||
        suffix_rule.cont_flags.contains(flag) ||
        self.entry_has_flag(stem, flag) {
        return true
      }
    }
  }
  false
}

///|
/// Apply one `CHECKCOMPOUNDPATTERN` line to the boundary between `left` and
/// `right`.
fn Dictionary::match_compound_pattern(
  self : Dictionary,
  pattern : CpdPattern,
  left : String,
  left_role : CpdRole,
  right : String,
  right_role : CpdRole,
  mode : MatchCase,
) -> CpdBoundary {
  // The optional flags must be present on the parts.
  match pattern.end_flag {
    Some(flag) =>
      if !self.part_has_pattern_flag(left, flag, left_role, mode) {
        return NoMatch
      }
    None => ()
  }
  match pattern.begin_flag {
    Some(flag) =>
      if !self.part_has_pattern_flag(right, flag, right_role, mode) {
        return NoMatch
      }
    None => ()
  }
  if pattern.zero_end {
    // The special `0` pattern only matches unmodified stems.
    if !self.is_unmodified_stem(left, pattern.end_flag) {
      return NoMatch
    }
  } else if !pattern.end_chars.is_empty() {
    if !ends_with_text(left, pattern.end_chars) {
      return NoMatch
    }
  }
  if !pattern.begin_chars.is_empty() {
    if !starts_with_text(right, pattern.begin_chars) {
      return NoMatch
    }
  }
  match pattern.replacement {
    None => Forbid
    Some(_) => Replace
  }
}

///|
/// Is `part` an unmodified dictionary stem (possibly through a zero affix)?
/// This is what the manual's special `CHECKCOMPOUNDPATTERN 0` endchars pattern
/// restricts a rule to.
fn Dictionary::is_unmodified_stem(
  self : Dictionary,
  part : String,
  flag : String?,
) -> Bool {
  if self.homonyms(part).length() == 0 {
    return false
  }
  match flag {
    None => true
    Some(flag) => self.entry_has_flag(part, flag)
  }
}

///|
/// Does `text` end with `suffix`? (`String::strip_suffix` returns `None` for the
/// empty suffix, which is why the callers test emptiness first.)
fn ends_with_text(text : String, suffix : String) -> Bool {
  match text.strip_suffix(suffix) {
    Some(_) => true
    None => false
  }
}

///|
/// Does `text` start with `prefix`?
fn starts_with_text(text : String, prefix : String) -> Bool {
  match text.strip_prefix(prefix) {
    Some(_) => true
    None => false
  }
}

///|
/// Does a `CHECKCOMPOUNDPATTERN` rule forbid this boundary?
fn Dictionary::boundary_forbidden(
  self : Dictionary,
  left : String,
  left_role : CpdRole,
  right : String,
  right_role : CpdRole,
  mode : MatchCase,
) -> Bool {
  for text in self.check_compound_pattern {
    let pattern = parse_compound_pattern(text)
    match
      self.match_compound_pattern(
        pattern, left, left_role, right, right_role, mode,
      ) {
      Forbid | Replace => return true
      NoMatch => ()
    }
  }
  false
}

///|
/// `CHECKCOMPOUNDCASE`: an upper-case character adjacent to the boundary forbids
/// the compound.
fn compound_case_forbidden(left : String, right : String) -> Bool {
  if left.is_empty() || right.is_empty() {
    return false
  }
  is_upper_char(last_char(left)) || is_upper_char(first_char(right))
}

///|
/// The last code point of `text` (the caller guarantees a non-empty string).
fn last_char(text : String) -> Char {
  let chars = text.to_array()
  chars[chars.length() - 1]
}

///|
/// The first code point of `text`.
fn first_char(text : String) -> Char {
  let chars = text.to_array()
  chars[0]
}

///|
/// `CHECKCOMPOUNDTRIPLE`: does the boundary between `left` and `right` join a
/// run of three or more identical characters? (Only ASCII characters are
/// checked, exactly as the manual warns.)
fn compound_triple_forbidden(left : String, right : String) -> Bool {
  if left.is_empty() || right.is_empty() {
    return false
  }
  let lc = last_char(left)
  let rc = first_char(right)
  if lc != rc {
    return false
  }
  trailing_run(left) + leading_run(right) >= 3
}

///|
/// How many identical characters `text` ends with.
fn trailing_run(text : String) -> Int {
  let chars = text.to_array()
  let last = chars[chars.length() - 1]
  let mut count = 0
  let mut index = chars.length() - 1
  while index >= 0 && chars[index] == last {
    count = count + 1
    index = index - 1
  }
  count
}

///|
/// How many identical characters `text` starts with.
fn leading_run(text : String) -> Int {
  let chars = text.to_array()
  let first = chars[0]
  let mut count = 0
  while count < chars.length() && chars[count] == first {
    count = count + 1
  }
  count
}

///|
/// May the two adjacent parts `left` (already chosen) and `right` be joined?
fn Dictionary::boundary_ok(
  self : Dictionary,
  left : String,
  left_role : CpdRole,
  right : String,
  right_role : CpdRole,
  mode : MatchCase,
) -> Bool {
  if self.check_compound_case && compound_case_forbidden(left, right) {
    return false
  }
  if self.check_compound_triple && compound_triple_forbidden(left, right) {
    return false
  }
  if self.boundary_forbidden(left, left_role, right, right_role, mode) {
    return false
  }
  if self.blocked_compounds.contains(left + right) {
    return false
  }
  if self.compound_rep_blocks(left, right, mode) {
    return false
  }
  true
}

///|
/// `CHECKCOMPOUNDREP`: forbid the boundary when a `REP` replacement turns the
/// joined pair into a plain dictionary word.
///
/// This is the manual's "the compound word may be a non-compound word with a
/// REP fault": `szer|víz` is rejected because `szerviz` is in the dictionary.
fn Dictionary::compound_rep_blocks(
  self : Dictionary,
  left : String,
  right : String,
  mode : MatchCase,
) -> Bool {
  if !self.check_compound_rep {
    return false
  }
  let joined = left + right
  for replacement in self.suggestion_replacements {
    let from = rep_text(replacement.from)
    if from.is_empty() {
      continue
    }
    let to = rep_text(replacement.to)
    let replaced = replace_text(joined, from, to)
    if replaced != joined &&
      (self.direct_hit(replaced, mode) || self.check_word_group(replaced)) {
      return true
    }
  }
  false
}

///|
/// Interpret one side of a `REP` pair for `CHECKCOMPOUNDREP`: `_` stands for a
/// space, and the `^`/`$` anchors are dropped (the plain substring case is what
/// the check needs).
fn rep_text(text : String) -> String {
  let buffer = StringBuilder()
  let chars = text.to_array()
  let mut index = 0
  while index < chars.length() {
    let c = chars[index]
    if c == '^' && index == 0 {
      index = index + 1
      continue
    }
    if c == '$' && index == chars.length() - 1 {
      index = index + 1
      continue
    }
    if c == '_' {
      buffer.write_char(' ')
    } else {
      buffer.write_char(c)
    }
    index = index + 1
  }
  buffer.to_string()
}

///|
/// Replace every occurrence of `from` in `text` with `to`.
fn replace_text(text : String, from : String, to : String) -> String {
  let chars = text.to_array()
  let needle = from.to_array()
  if needle.length() == 0 {
    return text
  }
  let buffer = StringBuilder()
  let mut index = 0
  while index < chars.length() {
    if index + needle.length() <= chars.length() &&
      matches_chars_at(chars, needle, index) {
      buffer.write_string(to)
      index = index + needle.length()
    } else {
      buffer.write_char(chars[index])
      index = index + 1
    }
  }
  buffer.to_string()
}

///|
/// Does the last compound part force capitalisation of the whole compound?
fn Dictionary::forceucase_part(
  self : Dictionary,
  part : String,
  mode : MatchCase,
) -> Bool {
  if self.forceucase is None {
    return false
  }
  if has_flag(self.forceucase, self.flags_of_word(part)) {
    return true
  }
  self.affixed_forceucase(part, mode)
}

///|
/// The union of all homonyms' flags for a word.
fn Dictionary::flags_of_word(self : Dictionary, word : String) -> Array[String] {
  let out : Array[String] = []
  for flags in self.homonyms(word) {
    for flag in flags {
      out.push(flag)
    }
  }
  out
}

///|
/// Does an affixed form of `part` carry the `FORCEUCASE` flag (on its stem or in
/// an affix's continuation class)?
fn Dictionary::affixed_forceucase(
  self : Dictionary,
  part : String,
  mode : MatchCase,
) -> Bool {
  let flag = match self.forceucase {
    None => return false
    Some(flag) => flag
  }
  for rule in self.suffix_rules {
    let rest = match part.strip_suffix(normalise_affix(rule.rule.add)) {
      Some(view) => view.to_owned()
      None => continue
    }
    let stem = rest + normalise_affix(rule.rule.strip)
    if !self.compound_stem_ok(stem, rule.rule.flag, mode) {
      continue
    }
    if rule.cont_flags.contains(flag) || self.entry_has_flag(stem, flag) {
      match @affix.apply_rule(rule.rule, stem) {
        Some(produced) => if produced == part { return true }
        None => ()
      }
    }
  }
  for rule in self.prefix_rules {
    let rest = match part.strip_prefix(normalise_affix(rule.rule.add)) {
      Some(view) => view.to_owned()
      None => continue
    }
    let stem = normalise_affix(rule.rule.strip) + rest
    if !self.compound_stem_ok(stem, rule.rule.flag, mode) {
      continue
    }
    if rule.cont_flags.contains(flag) || self.entry_has_flag(stem, flag) {
      match @affix.apply_rule(rule.rule, stem) {
        Some(produced) => if produced == part { return true }
        None => ()
      }
    }
  }
  false
}

///|
/// `COMPOUNDWORDMAX` / `COMPOUNDSYLLABLE`: how many parts a compound of this
/// spelling may have.
///
/// The manual's Hungarian rule: the syllable-count limit is the exception that
/// allows *more* words than `COMPOUNDWORDMAX` ("allow more words in a compound,
/// than COMPOUNDWORDMAX, if syllable count of the compound is 6 or less").
/// The syllable count is the number of vowels in the whole word.
fn Dictionary::compound_part_limit(self : Dictionary, word : String) -> Int {
  let word_max = if self.compound_word_max > 0 {
    self.compound_word_max
  } else {
    8
  }
  if self.compound_syllable_max <= 0 || self.compound_syllable_vowels.is_empty() {
    return word_max
  }
  let vowels = self.compound_syllable_vowels.to_array()
  let mut count = 0
  for c in word {
    if vowels.contains(c) {
      count = count + 1
    }
  }
  if count <= self.compound_syllable_max {
    8
  } else {
    word_max
  }
}

///|
/// Recursively split `chars` from `position` into compound parts.
///
/// `part_index` is the number of parts already accepted, `prev` the part that
/// ends at `position` (for the boundary rules), and `orig_cap` whether the word
/// as typed had a capital letter (`FORCEUCASE`).
fn Dictionary::compound_search(
  self : Dictionary,
  chars : Array[Char],
  position : Int,
  part_index : Int,
  max_parts : Int,
  mode : MatchCase,
  orig_cap : Bool,
  prev : String?,
) -> Bool {
  let minimum = if self.compound_min <= 0 { 3 } else { self.compound_min }
  let total = chars.length()
  // More parts (each at least `minimum` long) must remain possible.
  let mut end = position + minimum
  while end <= total - minimum && part_index + 1 < max_parts {
    let part = chars_to_string(chars, position, end)
    let role = if part_index == 0 { First } else { Middle }
    let left_role = if part_index == 0 { First } else { Middle }
    if self.compound_part_ok(part, role, mode) &&
      self.prev_boundary_ok(prev, left_role, part, role, mode) &&
      self.compound_search(
        chars,
        end,
        part_index + 1,
        max_parts,
        mode,
        orig_cap,
        Some(part),
      ) {
      return true
    }
    end = end + 1
  }
  if part_index >= 1 &&
    total - position >= minimum &&
    part_index + 1 <= max_parts {
    let part = chars_to_string(chars, position, total)
    let left_role = if part_index == 0 { First } else { Middle }
    if self.compound_part_ok(part, Last, mode) &&
      self.prev_boundary_ok(prev, left_role, part, Last, mode) &&
      self.dup_ok(prev, part) &&
      self.forceucase_ok(part, mode, orig_cap) {
      return true
    }
  }
  false
}

///|
/// Check the boundary before the part being added; `prev` is the part on the
/// left (absent for the first part).
fn Dictionary::prev_boundary_ok(
  self : Dictionary,
  prev : String?,
  left_role : CpdRole,
  right : String,
  right_role : CpdRole,
  mode : MatchCase,
) -> Bool {
  match prev {
    None => true
    Some(left) => self.boundary_ok(left, left_role, right, right_role, mode)
  }
}

///|
/// `CHECKCOMPOUNDDUP`: the last two parts must not be identical.
fn Dictionary::dup_ok(self : Dictionary, prev : String?, part : String) -> Bool {
  if !self.check_compound_dup {
    return true
  }
  match prev {
    None => true
    Some(left) => left != part
  }
}

///|
/// `FORCEUCASE`: a compound whose last part carries the flag is only correct
/// when the word as typed was capitalised.
fn Dictionary::forceucase_ok(
  self : Dictionary,
  part : String,
  mode : MatchCase,
  orig_cap : Bool,
) -> Bool {
  if orig_cap {
    return true
  }
  !self.forceucase_part(part, mode)
}

///|
/// Accept a word that splits into two dictionary words, each at least
/// `COMPOUNDMIN` characters long, with the roles `COMPOUNDFLAG` /
/// `COMPOUNDBEGIN` / `COMPOUNDMIDDLE` / `COMPOUNDEND` describe.
fn Dictionary::check_compound(
  self : Dictionary,
  word : String,
  mode : MatchCase,
  orig_cap : Bool,
) -> Bool {
  if self.compound_flag is None &&
    self.compound_begin is None &&
    self.compound_end is None {
    return false
  }
  let minimum = if self.compound_min <= 0 { 3 } else { self.compound_min }
  let chars = word.to_array()
  let total = chars.length()
  if total < minimum * 2 {
    return false
  }
  // A `.dic` word pair (`compound word`) forbids the space-free compound.
  if self.blocked_compounds.contains(word) {
    return false
  }
  if self.compound_search(
      chars,
      0,
      0,
      self.compound_part_limit(word),
      mode,
      orig_cap,
      None,
    ) {
    return true
  }
  self.compound_simplified(chars, mode, orig_cap)
}

///|
/// The `CHECKCOMPOUNDPATTERN` replacement forms and `SIMPLIFIEDTRIPLE`: a word
/// whose boundary is a forbidden pattern (or a triple letter) may still be
/// correct as the *simplified* spelling of a valid two-part compound.
fn Dictionary::compound_simplified(
  self : Dictionary,
  chars : Array[Char],
  mode : MatchCase,
  orig_cap : Bool,
) -> Bool {
  let total = chars.length()
  let minimum = if self.compound_min <= 0 { 3 } else { self.compound_min }
  let mut pos = 1
  while pos < total {
    let left = chars_to_string(chars, 0, pos)
    let right = chars_to_string(chars, pos, total)
    // CHECKCOMPOUNDPATTERN replacement.
    for text in self.check_compound_pattern {
      let pattern = parse_compound_pattern(text)
      match pattern.replacement {
        None => ()
        Some(replacement) =>
          if !replacement.is_empty() && ends_with_text(left, replacement) {
            let base_left = chars_to_string(
                chars,
                0,
                pos - replacement.to_array().length(),
              ) +
              pattern.end_chars
            let base_right = pattern.begin_chars + right
            if self.simplified_pair_ok(
                base_left, base_right, pattern, minimum, mode, orig_cap,
              ) {
              return true
            }
          }
      }
    }
    // SIMPLIFIEDTRIPLE: re-insert one letter at the boundary. The directive is
    // useful on its own (`gh151`), because it also relaxes `COMPOUNDMIN` for a
    // second part that is one letter shorter on the surface.
    if self.check_compound_simplified_triple && !left.is_empty() {
      let c = last_char(left)
      let restored = c.to_string() + right
      if compound_triple_forbidden(left, restored) {
        let base_right = restored
        if self.simplified_pair_ok(
            left,
            base_right,
            no_pattern(),
            minimum,
            mode,
            orig_cap,
          ) {
          return true
        }
      }
    }
    pos = pos + 1
  }
  false
}

///|
/// A neutral pattern used by the `SIMPLIFIEDTRIPLE` path (no flags, no
/// replacement).
fn no_pattern() -> CpdPattern {
  {
    end_chars: "",
    end_flag: None,
    begin_chars: "",
    begin_flag: None,
    replacement: None,
    zero_end: false,
  }
}

///|
/// Validate the two original parts of a simplified compound.
fn Dictionary::simplified_pair_ok(
  self : Dictionary,
  left : String,
  right : String,
  pattern : CpdPattern,
  minimum : Int,
  mode : MatchCase,
  orig_cap : Bool,
) -> Bool {
  if left.to_array().length() < minimum || right.to_array().length() < minimum {
    return false
  }
  if !self.compound_part_ok(left, First, mode) {
    return false
  }
  if !self.compound_part_ok(right, Last, mode) {
    return false
  }
  let left_role = First
  match
    self.match_compound_pattern(pattern, left, left_role, right, Last, mode) {
    Forbid | Replace => ()
    NoMatch => return false
  }
  if self.check_compound_case && compound_case_forbidden(left, right) {
    return false
  }
  self.forceucase_ok(right, mode, orig_cap)
}