///|
fn is_ascii_word_letter(character : Char) -> Bool {
  (character >= 'A' && character <= 'Z') ||
  (character >= 'a' && character <= 'z')
}

///|
fn is_scan_candidate_char(
  character : Char,
  mode : RomanMode,
  accept_unicode : Bool,
) -> Bool {
  if is_ascii_roman_letter(character) {
    return true
  }
  if mode == ParenthesizedThousands && (character == '(' || character == ')') {
    return true
  }
  accept_unicode && unicode_roman_expansion(character) is Some(_)
}

///|
fn scan_substring(chars : Array[Char], start : Int, end : Int) -> String {
  let selected : Array[Char] = []
  for index = start; index < end; index = index + 1 {
    selected.push(chars[index])
  }
  String::from_array(selected)
}

///|
fn has_scan_word_boundaries(
  chars : Array[Char],
  start : Int,
  end : Int,
) -> Bool {
  let left_is_word = start > 0 && is_ascii_word_letter(chars[start - 1])
  let right_is_word = end < chars.length() && is_ascii_word_letter(chars[end])
  !left_is_word && !right_is_word
}

///|
fn retain_scan_rejection(
  rejections : Array[RomanScanRejection],
  config : ScanConfig,
  span : SourceSpan,
  source_text : String,
  reason : ScanRejectionReason,
) -> Unit {
  if config.retain_rejected {
    rejections.push({ span, source_text, reason })
  }
}

///|
/// Scan complete lexical candidates and parse each with the selected profile.
pub fn scan_roman_text(
  text : String,
  config : ScanConfig,
) -> Result[RomanScanReport, ConfigError] {
  match validate_scan_config(config) {
    Err(error) => return Err(error)
    Ok(_) => ()
  }
  let chars = text.to_array()
  let matches : Array[RomanScanMatch] = []
  let rejections : Array[RomanScanRejection] = []
  let mut candidates_examined = 0
  let mut index = 0
  while index < chars.length() {
    if !is_scan_candidate_char(
        chars[index],
        config.parse_config.mode,
        config.parse_config.accept_unicode,
      ) {
      index = index + 1
      continue
    }
    let start = index
    while index < chars.length() &&
          is_scan_candidate_char(
            chars[index],
            config.parse_config.mode,
            config.parse_config.accept_unicode,
          ) {
      index = index + 1
    }
    let end = index
    if !has_scan_word_boundaries(chars, start, end) {
      continue
    }
    candidates_examined = candidates_examined + 1
    let span : SourceSpan = { start, end }
    let source_text = scan_substring(chars, start, end)
    let source_length = end - start
    if source_length > config.max_candidate_length {
      retain_scan_rejection(
        rejections,
        config,
        span,
        source_text,
        CandidateTooLong(source_length),
      )
      continue
    }
    match parse_with_config(source_text, config.parse_config) {
      Ok(report) =>
        if report.normalized.to_array().length() == 1 &&
          !config.include_single_symbol {
          retain_scan_rejection(
            rejections,
            config,
            span,
            source_text,
            SingleSymbolExcluded,
          )
        } else {
          matches.push({ span, source_text, report })
        }
      Err(error) =>
        retain_scan_rejection(
          rejections,
          config,
          span,
          source_text,
          CandidateParseFailed(error),
        )
    }
  }
  Ok({ matches, rejections, candidates_examined })
}