///|
/// One lexed tag occurrence.
///
/// `name` is the upper-cased tag name, with a leading `/` for a closing tag and
/// without attributes, so `` and `` are told apart the same
/// way every time. `value` is the text between this tag and the next one, still
/// entity-encoded. `line` is the 1-based line of the tag's `<`. `self_closing`
/// marks an empty element such as ``: it has no value and no closing tag,
/// so it must not open a scope that nothing will ever close.
priv struct TagToken {
  name : String
  value : String
  line : Int
  self_closing : Bool
}

///|
/// Whether a raw tag body ends with the self-closing marker, as in `` or
/// ``.
///
/// The check looks at the last non-space character, so an attribute value that
/// merely contains a slash (``) is not mistaken for an empty
/// element.
fn is_self_closing(raw : String) -> Bool {
  let trimmed = raw.trim_end()
  if trimmed.length() == 0 {
    false
  } else {
    trimmed.get_char(trimmed.length() - 1) is Some('/')
  }
}

///|
fn is_space(c : Char) -> Bool {
  c == ' ' || c == '\t' || c == '\n' || c == '\r'
}

///|
/// Whether `literal` occurs in `chars` at `index`.
fn match_chars(chars : Array[Char], index : Int, literal : Array[Char]) -> Bool {
  if index + literal.length() > chars.length() {
    return false
  }
  for offset in 0.. String {
  let chars = raw.iter().to_array()
  let name = StringBuilder()
  let mut index = 0
  while index < chars.length() && is_space(chars[index]) {
    index += 1
  }
  if index < chars.length() && chars[index] == '/' {
    name.write_char('/')
    index += 1
    while index < chars.length() && is_space(chars[index]) {
      index += 1
    }
  }
  while index < chars.length() && !is_space(chars[index]) && chars[index] != '/' {
    name.write_char(chars[index].to_ascii_uppercase())
    index += 1
  }
  name.to_string()
}

///|
/// Whether `value` is exactly `count` ASCII digits.
fn is_digits(value : String, count : Int) -> Bool {
  let chars = value.iter().to_array()
  if chars.length() != count {
    return false
  }
  for c in chars {
    if c < '0' || c > '9' {
      return false
    }
  }
  true
}

///|
/// Parse a decimal integer with an optional sign, or return `None` when the
/// text is not one or does not fit in an `Int`.
///
/// OFX writes small integers such as a status code as text, and a status code
/// may be negative, so the sign is part of the value rather than an error.
fn parse_int(text : String) -> Int? {
  let chars = text.iter().to_array()
  if chars.length() == 0 {
    return None
  }
  let mut index = 0
  let mut negative = false
  if chars[0] == '-' || chars[0] == '+' {
    negative = chars[0] == '-'
    index = 1
  }
  if index >= chars.length() {
    return None
  }
  let mut value = 0
  while index < chars.length() {
    let c = chars[index]
    if c < '0' || c > '9' {
      return None
    }
    let digit = c.to_int() - 48
    if value > (2147483647 - digit) / 10 {
      return None
    }
    value = value * 10 + digit
    index += 1
  }
  Some(if negative { -value } else { value })
}

///|
/// The aggregate a closing tag refers to, or `None` when the tag is not a
/// closing tag.
fn close_target(tag : String) -> String? {
  let chars = tag.iter().to_array()
  if chars.length() == 0 || chars[0] != '/' {
    return None
  }
  let out = StringBuilder()
  for position in 1..`
/// is reported and scanning stops, because every later offset would be a guess.
fn lex(source : String, diagnostics : Array[Diagnostic]) -> Array[TagToken] {
  let chars = source.iter().to_array()
  let comment_open = "".iter().to_array()
  let cdata_open = "".iter().to_array()
  let pi_open = "".iter().to_array()
  let declaration_open = "' {
        if chars[index] == '\n' {
          line += 1
        }
        index += 1
      }
      if index >= chars.length() {
        diagnostics.push(error_at("unclosed-tag", "@" + open_line.to_string()))
        break
      }
      index += 1
      continue
    }
    let tag_line = line
    index += 1
    let raw = StringBuilder()
    let mut in_double_quote = false
    let mut in_single_quote = false
    let mut closed = false
    while index < chars.length() {
      let c = chars[index]
      if in_double_quote {
        if c == '"' {
          in_double_quote = false
        }
        raw.write_char(c)
        index += 1
        continue
      }
      if in_single_quote {
        if c == '\'' {
          in_single_quote = false
        }
        raw.write_char(c)
        index += 1
        continue
      }
      if c == '>' {
        closed = true
        index += 1
        break
      }
      if c == '<' {
        // A `<` that is not inside quotes ends the tag body: report the tag as
        // unclosed and let the outer loop start over at this `<`.
        break
      }
      if c == '"' {
        in_double_quote = true
      } else if c == '\'' {
        in_single_quote = true
      } else if c == '\n' {
        line += 1
      }
      raw.write_char(c)
      index += 1
    }
    if !closed {
      diagnostics.push(error_at("unclosed-tag", "@" + tag_line.to_string()))
      if index >= chars.length() {
        break
      }
      continue
    }
    let raw_text = raw.to_string()
    let name = tag_name(raw_text)
    if name == "" {
      diagnostics.push(error_at("empty-tag", "@" + tag_line.to_string()))
      continue
    }
    let value = StringBuilder()
    let run = StringBuilder()
    let run_line = line
    while index < chars.length() && chars[index] != '<' {
      if chars[index] == '\n' {
        line += 1
      }
      run.write_char(chars[index])
      index += 1
    }
    decode_into(value, run.to_string(), run_line, diagnostics)
    while index < chars.length() && match_chars(chars, index, cdata_open) {
      index += cdata_open.length()
      let mut cdata_closed = false
      while index < chars.length() {
        if match_chars(chars, index, cdata_close) {
          index += cdata_close.length()
          cdata_closed = true
          break
        }
        if chars[index] == '\n' {
          line += 1
        }
        value.write_char(chars[index])
        index += 1
      }
      if !cdata_closed {
        diagnostics.push(
          error_at("unclosed-cdata", "@" + open_line.to_string()),
        )
        break
      }
      let tail = StringBuilder()
      let tail_line = line
      while index < chars.length() && chars[index] != '<' {
        if chars[index] == '\n' {
          line += 1
        }
        tail.write_char(chars[index])
        index += 1
      }
      decode_into(value, tail.to_string(), tail_line, diagnostics)
    }
    tokens.push({
      name,
      value: value.to_string().trim().to_owned(),
      line: tag_line,
      self_closing: is_self_closing(raw_text),
    })
  }
  tokens
}