///|
/// Parse tab-separated dictionary lines: keyword, label, optional weight.
pub fn parse_tsv(text : String) -> Array[Entry] {
  let entries : Array[Entry] = []
  for raw in text.split("\n") {
    let line = raw.trim()
    if line.length() > 0 && !line.has_prefix("#") {
      match line.split_once("\t") {
        Some((keyword, rest)) =>
          match rest.split_once("\t") {
            Some((label, weight_text)) =>
              entries.push({
                keyword: keyword.to_owned(),
                label: label.to_owned(),
                weight: parse_int_or(weight_text, 1),
              })
            None =>
              entries.push({
                keyword: keyword.to_owned(),
                label: rest.to_owned(),
                weight: 1,
              })
          }
        None => ()
      }
    }
  }
  entries
}

///|
/// Render matches as tab-separated rows.
pub fn matches_to_tsv(matches : ArrayView[LexiconMatch]) -> String {
  let buf = StringBuilder()
  for hit in matches {
    buf.write_string(
      "\{hit.keyword}\t\{hit.label}\t\{hit.start}\t\{hit.end}\t\{hit.weight}\n",
    )
  }
  buf.to_string()
}

///|
fn parse_int_or(text : StringView, fallback : Int) -> Int {
  let view = text.trim()
  if view.length() == 0 {
    return fallback
  }
  let mut sign = 1
  let mut seen = false
  let mut value = 0
  for index, ch in view {
    if index == 0 && ch == '-' {
      sign = -1
    } else if ch >= '0' && ch <= '9' {
      seen = true
      value = value * 10 + (ch.to_int() - '0'.to_int())
    } else {
      return fallback
    }
  }
  if seen {
    sign * value
  } else {
    fallback
  }
}