///|
fn Parser::parse_attribute(self : Parser, attrs : Map[String, String?]) -> Unit {
  let name_start = self.pos
  if self.current() is Some('=') {
    self.error_at("unexpected-equals-sign-before-attribute-name", self.pos)
    ignore(self.advance_char())
  }
  // Scan with direct code-unit indexing: the delimiters are ASCII, which
  // never matches inside a surrogate pair, so stepping one unit at a
  // time is safe and avoids the Char? boxing of get_char per character.
  let input = self.input
  let input_length = input.length()
  while self.pos < input_length {
    match input[self.pos] {
      '=' | '/' | '>' => break
      ' ' | '\t' | '\n' | '\u{000B}' | '\u{000C}' | '\r' => break
      '<' | '"' | '\'' => {
        self.error_at("unexpected-character-in-attribute-name", self.pos)
        self.pos += 1
      }
      _ => self.pos += 1
    }
  }
  let (name, name_errors) = @tok.parse_lower_name_value_replacing_nulls(
    self.input,
    name_start,
    self.pos,
  )
  for error in name_errors {
    self.error_at(error.code, error.offset)
  }
  if name == "" {
    ignore(self.advance_char())
    return
  }
  let duplicate = attrs.contains(name)
  if duplicate {
    self.error_at("duplicate-attribute", name_start)
  }
  self.skip_ascii_whitespace()
  if !(self.current() is Some('=')) {
    if !duplicate {
      attrs[name] = None
    }
    return
  }
  ignore(self.advance_char())
  self.skip_ascii_whitespace()
  let missing_value = self.current() is Some('>')
  if missing_value {
    self.error_at("missing-attribute-value", self.pos)
  }
  let value = match self.current() {
    Some('"') => self.parse_quoted_attribute_value('"')
    Some('\'') => self.parse_quoted_attribute_value('\'')
    _ => {
      let start = self.pos
      // Direct code-unit scan; see the attribute-name loop above.
      while self.pos < input_length {
        match input[self.pos] {
          '>' => break
          ' ' | '\t' | '\n' | '\u{000B}' | '\u{000C}' | '\r' => break
          '\u{0000}' => {
            self.error_at("unexpected-null-character", self.pos)
            self.pos += 1
          }
          '"' | '\'' | '<' | '=' | '`' => {
            self.error_at(
              "unexpected-character-in-unquoted-attribute-value",
              self.pos,
            )
            self.pos += 1
          }
          _ => self.pos += 1
        }
      }
      let view = self.input[start:self.pos]
      let decoded = @tok.decode_attribute_value_with_errors(view)
      for error in decoded.errors {
        self.error_at(error.code, self.pos)
      }
      decoded.data.to_owned()
    }
  }
  if !duplicate {
    attrs[name] = if missing_value { None } else { Some(value) }
  }
}

///|
fn Parser::parse_quoted_attribute_value(self : Parser, quote : Char) -> String {
  ignore(self.advance_char())
  let start = self.pos
  // Direct code-unit scan: the quote and NUL are ASCII, which never
  // matches inside a surrogate pair, so stepping one unit at a time is
  // safe and avoids the Char? boxing of get_char per character.
  let input = self.input
  let input_length = input.length()
  let quote_unit = quote.to_int()
  while self.pos < input_length {
    let ch = input[self.pos]
    if ch.to_int() == quote_unit {
      break
    }
    if ch is '\u{0000}' {
      self.error_at("unexpected-null-character", self.pos)
    }
    self.pos += 1
  }
  let raw = self.input[start:self.pos]
  if self.current() is Some(ch) && ch == quote {
    ignore(self.advance_char())
    match self.current() {
      Some(ch) if !ch.is_ascii_whitespace() && ch != '>' && ch != '/' =>
        self.error_at("missing-whitespace-between-attributes", self.pos)
      _ => ()
    }
  }
  let decoded = @tok.decode_attribute_value_with_errors(raw)
  for error in decoded.errors {
    self.error_at(error.code, self.pos)
  }
  decoded.data.to_owned()
}