///|
priv struct Parser {
  mut mode : Mode
  gullet : MacroExpander
  settings : Settings
  function_registry : FunctionRegistry
  environment_registry : EnvironmentRegistry
  mut next_token : Token?
  mut leftright_depth : Int
}

///|
priv enum AtomResult {
  EmitAtom(ParseNode)
  SkipAtom
}

///|
fn Parser::make(
  input : String,
  settings : Settings,
  extra_specs? : Array[FunctionSpec] = [],
  extra_env_specs? : Array[EnvironmentSpec] = [],
) -> Parser {
  let registry = build_function_registry(extra_specs)
  {
    mode: Math,
    gullet: MacroExpander::make(
      input,
      settings,
      report_nonstrict=(error_code, error_message) => {
        settings.report_nonstrict(error_code, error_message, None)
      },
      command_status=name => {
        match registry.get(name) {
          Some(spec) =>
            if spec.is_expandable() {
              ExternalExpandable
            } else {
              ExternalUnexpandable
            }
          None =>
            if is_registered_symbol(name) {
              ExternalUnexpandable
            } else {
              ExternalUndefined
            }
        }
      },
    ),
    settings,
    function_registry: registry,
    environment_registry: build_environment_registry(extra_env_specs),
    next_token: None,
    leftright_depth: 0,
  }
}

///|
pub fn parse(
  input : String,
  settings? : Settings = Default::default(),
  extra_specs? : Array[FunctionSpec] = [],
  extra_env_specs? : Array[EnvironmentSpec] = [],
) -> Array[ParseNode] raise ParseFailure {
  let parser = Parser::make(input, settings, extra_specs~, extra_env_specs~)
  parser.gullet.macros.set("\\df@tag", None)
  let body = parser.parse()
  let body = if parser.gullet.macros.get("\\df@tag") is Some(_) {
    guard settings.display_mode else {
      raise InvalidArgument(
        message="\\tag works only in display equations",
        loc=None,
      )
    }
    [Tag(mode=Text, body~, tag=parser.subparse([Token::make("\\df@tag")]))]
  } else {
    body
  }
  parser.gullet.macros.set("\\current@color", None)
  parser.gullet.macros.set("\\color", None)

  guard settings.display_mode else { body }
  [Styling(mode=Math, body~, style=DisplayStyle, reset_font=true)]
}

///|
fn Parser::fetch(self : Parser) -> Token raise ParseFailure {
  match self.next_token {
    Some(token) => token
    None => {
      let token = self.gullet.expand_next_token()
      self.next_token = Some(token)
      token
    }
  }
}

///|
fn Parser::consume(self : Parser) -> Unit {
  self.next_token = None
}

///|
fn Parser::expect(
  self : Parser,
  text : String,
  consume? : Bool = true,
) -> Unit raise ParseFailure {
  let token = self.fetch()
  guard token.text == text else {
    raise ExpectedToken(expected=text, actual=Diagnostic::from_token(token))
  }
  if consume {
    self.consume()
  }
}

///|
fn Parser::parse(self : Parser) -> Array[ParseNode] raise ParseFailure {
  if !self.settings.global_group {
    self.gullet.begin_group()
  }
  if self.settings.color_is_text_color {
    self.gullet.macros.set(
      "\\color",
      Some(MacroDefinition::text("\\textcolor")),
    )
  }
  let result : Result[Array[ParseNode], ParseFailure] = capture_parse_result(() => {
    let body = self.parse_expression(false, None)
    self.expect("EOF")
    body
  })
  let close_result : Result[Unit, ParseFailure] = capture_parse_result(() => {
    guard !self.settings.global_group else { () }
    self.gullet.end_group()
  })
  self.gullet.end_groups()
  if self.settings.global_group {
    self.persist_user_macros()
  }
  unwrap_captured(close_result, result)
}

///|
/// Copies the macros defined during parsing back into the caller's macro
/// store, so `\newcommand` in a `global_group` parse survives the parse.
fn Parser::persist_user_macros(self : Parser) -> Unit {
  match self.settings.macro_store {
    Some(macros) => {
      let user_entries = self.gullet.macros.get_user_entries()
      for name, definition in user_entries {
        macros.0[name] = definition
      }
    }
    None => ()
  }
}

///|
fn Parser::subparse(
  self : Parser,
  tokens : Array[Token],
) -> Array[ParseNode] raise ParseFailure {
  let old_token = self.next_token
  self.consume()
  self.gullet.push_token(Token::make("}"))
  self.gullet.push_tokens(tokens)
  defer {
    self.next_token = old_token
  }
  let body = self.parse_expression(false, Some("}"))
  self.expect("}")
  body
}

///|
fn Parser::parse_math_mode(
  self : Parser,
  close : String,
) -> Array[ParseNode] raise ParseFailure {
  let outer_mode = self.mode
  self.switch_mode(Math)
  defer self.switch_mode(outer_mode)
  let body = self.parse_expression(false, Some(close))
  self.expect(close)
  body
}

///|
fn Parser::current_color(self : Parser) -> String? raise ParseFailure {
  match self.gullet.macros.get("\\current@color") {
    None => None
    Some(Text(color)) => Some(color)
    Some(Expansion(_)) =>
      raise InvalidArgument(
        message="\\current@color set to non-string in \\right",
        loc=None,
      )
  }
}

///|
fn Parser::parse_left_right(
  self : Parser,
  left : String,
) -> ParseNode raise ParseFailure {
  self.leftright_depth = self.leftright_depth + 1
  defer {
    self.leftright_depth = self.leftright_depth - 1
  }
  let body = self.parse_expression(false, None)
  self.expect("\\right", consume=false)
  guard self.parse_function(None, None)
    is Some(EmitAtom(LeftRightRight(delim=right, color~, ..))) else {
    raise InternalInvariant(
      message="\\right did not produce a closing delimiter",
    )
  }
  LeftRight(mode=self.mode, body~, left~, right~, right_color=color)
}

///|
fn Parser::parse_expression(
  self : Parser,
  break_on_infix : Bool,
  break_on_token_text : String?,
) -> Array[ParseNode] raise ParseFailure {
  let body : Array[ParseNode] = []
  for ;; {
    if self.mode == Math {
      self.consume_spaces()
    }
    let token = self.fetch()
    if self.should_break_expression(
        token.text,
        break_on_infix,
        break_on_token_text,
      ) {
      break self.finish_expression(body)
    }
    match self.parse_atom(break_on_token_text) {
      None => break self.finish_expression(body)
      Some(SkipAtom) => continue
      Some(EmitAtom(node)) => {
        body.push(node)
        continue
      }
    }
  }
}

///|
/// True when the token ends the current expression: a group- or
/// environment-ending token, the caller's explicit stop token, or an infix
/// function (`\over`, `\atop`, ...) when the caller breaks on those.
fn Parser::should_break_expression(
  self : Parser,
  text : String,
  break_on_infix : Bool,
  break_on_token_text : String?,
) -> Bool {
  is_end_of_expression(text) ||
  (break_on_token_text is Some(stop) && text == stop) ||
  (
    break_on_infix &&
    self.function_registry.get(text) is Some(spec) &&
    spec.infix
  )
}

///|
fn Parser::finish_expression(
  self : Parser,
  body : Array[ParseNode],
) -> Array[ParseNode] raise ParseFailure {
  let normalized = if self.mode == Text {
    form_text_ligatures(body)
  } else {
    body
  }
  self.handle_infix_nodes(normalized)
}

///|
fn is_end_of_expression(text : String) -> Bool {
  text == "}" ||
  text == "\\endgroup" ||
  text == "\\end" ||
  text == "\\right" ||
  text == "&"
}

///|
fn Parser::consume_spaces(self : Parser) -> Unit raise ParseFailure {
  for ;; {
    guard self.fetch().text == " " else { break }
    self.consume()
  }
}

///|
fn Parser::parse_atom(
  self : Parser,
  break_on_token_text : String?,
) -> AtomResult? raise ParseFailure {
  match self.parse_group("atom", break_on_token_text) {
    None => None
    Some(SkipAtom) => Some(SkipAtom)
    Some(EmitAtom(Internal(..))) => Some(SkipAtom)
    Some(EmitAtom(base)) if self.mode == Text => Some(EmitAtom(base))
    Some(EmitAtom(base)) => Some(EmitAtom(self.parse_scripts(base)))
  }
}

///|
fn Parser::parse_scripts(
  self : Parser,
  base : ParseNode,
) -> ParseNode raise ParseFailure {
  let mut base = base
  let mut sup : ParseNode? = None
  let mut sub : ParseNode? = None
  for ;; {
    self.consume_spaces()
    let token = self.fetch()
    if token.text == "\\limits" || token.text == "\\nolimits" {
      base = set_limits(base, token.text == "\\limits", token.loc)
      self.consume()
      continue
    } else if token.text == "^" {
      if sup is Some(_) {
        raise DoubleSuperscript(loc=token.loc)
      }
      sup = Some(self.handle_sup_subscript("superscript"))
      continue
    } else if token.text == "_" {
      if sub is Some(_) {
        raise DoubleSubscript(loc=token.loc)
      }
      sub = Some(self.handle_sup_subscript("subscript"))
      continue
    } else if token.text == "'" {
      if sup is Some(_) {
        raise DoubleSuperscript(loc=token.loc)
      }
      sup = Some(self.parse_prime_run())
      continue
    } else {
      match lookup_unicode_script(token.text) {
        None => break make_supsub_or_base(self.mode, base, sup, sub)
        Some(first_script) => {
          let (is_subscript, script_tokens) = self.consume_unicode_script_run(
            first_script,
          )
          let body = self.subparse(script_tokens)
          let group = OrdGroup(mode=Math, loc=None, body~, semisimple=false)
          if is_subscript {
            sub = Some(group)
          } else {
            sup = Some(group)
          }
          continue
        }
      }
    }
  }
}

///|
/// Rewrites an operator node so that its scripts are drawn as limits above
/// and below. Only `Op` and `\operatorname*` nodes accept limit controls.
fn set_limits(
  base : ParseNode,
  limits : Bool,
  loc : SourceLocation?,
) -> ParseNode raise ParseFailure {
  match base {
    Op(mode~, parent_is_sup_sub~, suppress_base_shift~, content~, ..) =>
      Op(
        mode~,
        limits~,
        always_handle_sup_sub=true,
        parent_is_sup_sub~,
        suppress_base_shift~,
        content~,
      )
    OperatorName(
      mode~,
      body~,
      always_handle_sup_sub=true,
      parent_is_sup_sub~,
      ..
    ) =>
      OperatorName(
        mode~,
        body~,
        always_handle_sup_sub=true,
        limits~,
        parent_is_sup_sub~,
      )
    _ =>
      raise InvalidArgument(
        message="Limit controls must follow a math operator",
        loc~,
      )
  }
}

///|
/// Consumes a run of consecutive `'` primes (followed by an optional `^`
/// script) and returns them as a single `OrdGroup` superscript.
fn Parser::parse_prime_run(self : Parser) -> ParseNode raise ParseFailure {
  let primes : Array[ParseNode] = []
  while self.fetch().text == "'" {
    let prime_token = self.fetch()
    primes.push(TextOrd(mode=self.mode, loc=prime_token.loc, text="\\prime"))
    self.consume()
  }
  if self.fetch().text == "^" {
    primes.push(self.handle_sup_subscript("superscript"))
  }
  OrdGroup(mode=self.mode, loc=None, body=primes, semisimple=false)
}

///|
fn Parser::consume_unicode_script_run(
  self : Parser,
  first : UnicodeScript,
) -> (Bool, Array[Token]) raise ParseFailure {
  let is_subscript = first.kind is UnicodeSubscript
  let tokens : Array[Token] = [Token::make(first.replacement)]
  self.consume()
  for ;; {
    let next = self.fetch()
    match lookup_unicode_script(next.text) {
      Some(script) if (script.kind is UnicodeSubscript) == is_subscript => {
        tokens.push(Token::make(script.replacement))
        self.consume()
        continue
      }
      _ => {
        tokens.rev_in_place()
        break (is_subscript, tokens)
      }
    }
  }
}

///|
fn make_supsub_or_base(
  mode : Mode,
  base : ParseNode,
  sup : ParseNode?,
  sub : ParseNode?,
) -> ParseNode {
  match (sup, sub) {
    (None, None) => base
    _ => SupSub(mode~, base=Some(base), sup~, sub~)
  }
}

///|
fn Parser::handle_sup_subscript(
  self : Parser,
  name : String,
) -> ParseNode raise ParseFailure {
  let token = self.fetch()
  self.consume()
  self.consume_spaces()
  for ;; {
    match self.parse_group(name, None) {
      Some(EmitAtom(Internal(..))) | Some(SkipAtom) => continue
      Some(EmitAtom(group)) => break group
      None => raise ExpectedGroupAfter(symbol=token.text, loc=token.loc)
    }
  }
}

///|
fn Parser::parse_group(
  self : Parser,
  name : String,
  break_on_token_text : String?,
) -> AtomResult? raise ParseFailure {
  let first_token = self.fetch()
  let text = first_token.text
  if text == "{" || text == "\\begingroup" {
    Some(EmitAtom(self.parse_group_body(first_token, text)))
  } else {
    match self.parse_function(break_on_token_text, Some(name)) {
      Some(result) => Some(result)
      None =>
        match self.parse_symbol() {
          Some(node) => Some(EmitAtom(node))
          None => self.handle_undefined_control(first_token)
        }
    }
  }
}

///|
/// Parses the body of a `{...}` or `\begingroup...\endgroup` group into an
/// `OrdGroup` node whose source span covers the whole group.
fn Parser::parse_group_body(
  self : Parser,
  first_token : Token,
  text : String,
) -> ParseNode raise ParseFailure {
  self.consume()
  let group_end = if text == "{" { "}" } else { "\\endgroup" }
  self.gullet.begin_group()
  let body = self.parse_expression(false, Some(group_end))
  let last = self.fetch()
  self.expect(group_end)
  self.gullet.end_group()
  let loc = if first_token.loc is Some(start_loc) && last.loc is Some(end_loc) {
    Some(SourceLocation::range(start_loc, end_loc))
  } else {
    None
  }
  OrdGroup(mode=self.mode, loc~, body~, semisimple=text == "\\begingroup")
}

///|
/// Reports a token that parsed neither as a function nor as a symbol: a
/// backslash command that is not implicitly recognized is an undefined
/// control sequence (raised when `throw_on_error`, otherwise rendered as an
/// `error_color` span); anything else is not a group at all.
fn Parser::handle_undefined_control(
  self : Parser,
  token : Token,
) -> AtomResult? raise ParseFailure {
  let text = token.text
  guard is_undefined_control_sequence(text) else { None }
  guard self.settings.throw_on_error else {
    self.consume()
    Some(EmitAtom(format_unsupported_command(self.mode, self.settings, text)))
  }
  raise UndefinedControlSequence(name=text, loc=token.loc)
}

///|
fn is_undefined_control_sequence(text : String) -> Bool {
  text.length() > 0 && text[0] == '\\' && !is_implicit_command(text)
}

///|
fn Parser::parse_function(
  self : Parser,
  break_on_token_text : String?,
  name : String?,
) -> AtomResult? raise ParseFailure {
  let token = self.fetch()
  match self.function_registry.get(token.text) {
    None => None
    Some(func_data) => {
      self.consume()
      if name is Some(context_name) &&
        context_name != "atom" &&
        !func_data.allowed_in_argument {
        raise FunctionNotAllowed(
          func_name=token.text,
          context=context_name,
          loc=token.loc,
        )
      } else if self.mode == Text && !func_data.allowed_in_text {
        raise FunctionNotAllowed(
          func_name=token.text,
          context="text mode",
          loc=token.loc,
        )
      } else if self.mode == Math && !func_data.allowed_in_math {
        raise FunctionNotAllowed(
          func_name=token.text,
          context="math mode",
          loc=token.loc,
        )
      } else {
        let (args, opt_args) = self.parse_arguments(token.text, func_data)
        Some(
          EmitAtom(
            self.call_function(
              token.text,
              args,
              opt_args,
              Some(token),
              break_on_token_text,
            ),
          ),
        )
      }
    }
  }
}

///|
fn Parser::call_function(
  self : Parser,
  func_name : String,
  args : Array[ParseNode],
  opt_args : Array[ParseNode?],
  token : Token?,
  break_on_token_text : String?,
) -> ParseNode raise ParseFailure {
  let context : FunctionContext = {
    func_name,
    mode: self.mode,
    token,
    break_on_token_text,
    set_macro: (name, definition) => self.gullet.macros.set(name, definition),
    report_nonstrict: (error_code, error_message) => {
      self.settings.report_nonstrict(error_code, error_message, token)
    },
    is_trusted: context => self.settings.is_trusted(context),
    parse_optional_size: () => {
      if self.gullet.future().text != "[" {
        None
      } else {
        match self.parse_size_group(true) {
          Some(Size(value~, ..)) => Some(value)
          _ => raise InternalInvariant(message="Expected optional size")
        }
      }
    },
    display_mode: self.settings.display_mode,
    use_strict_behavior: (error_code, error_message) => {
      self.settings.use_strict_behavior(error_code, error_message, token)
    },
    current_color: () => self.current_color(),
    in_left_right: () => self.leftright_depth > 0,
    parse_expression: (break_on_infix, break_on_token_text) => {
      self.parse_expression(break_on_infix, break_on_token_text)
    },
    parse_math_mode: close => self.parse_math_mode(close),
    parse_left_right: left => self.parse_left_right(left),
    pop_token: () => self.gullet.pop_token(),
    future_token: () => self.gullet.future(),
    push_token: value => self.gullet.push_token(value),
    consume_spaces: () => self.gullet.consume_spaces(),
    consume_macro_arg: () => self.gullet.consume_arg(None).tokens,
    expand_tokens: values => self.gullet.expand_tokens(values),
    get_macro: name => self.gullet.macros.get(name),
    set_macro_definition: (name, definition, global) => {
      self.gullet.macros.set(name, Some(definition), global~)
    },
    is_expandable: name => self.gullet.is_expandable(name),
    parse_prefixed_function: name => self.parse_prefixed_function(name),
    parse_environment: name => self.parse_environment(name),
  }
  guard self.function_registry.get(func_name) is Some(spec) else {
    raise MissingFunctionHandler(func_name~, loc=None)
  }
  guard spec.handler is Some(handler) else {
    raise MissingFunctionHandler(func_name~, loc=None)
  }
  handler(context, args, opt_args)
}

///|
fn Parser::parse_environment(
  self : Parser,
  name : String,
) -> ParseNode raise ParseFailure {
  guard self.environment_registry.get(name) is Some(spec) else {
    raise InvalidArgument(message="No such environment: \{name}", loc=None)
  }
  let arguments = FunctionSpec::make(
    [],
    spec.num_args,
    num_optional_args=spec.num_optional_args,
    arg_types=spec.arg_types,
  )
  let (args, opt_args) = self.parse_arguments("\\begin{\{name}}", arguments)
  let context : EnvironmentContext = {
    mode: self.mode,
    display_mode: self.settings.display_mode,
    leqno: self.settings.leqno,
    env_name: name,
    parse_array: options => self.parse_array_environment(options),
    parse_matrix_alignment: () => self.parse_matrix_alignment(),
    parse_cd: () => self.parse_cd_environment(),
  }
  let result = (spec.handler)(context, args, opt_args)
  self.expect("\\end", consume=false)
  match self.parse_function(None, None) {
    Some(EmitAtom(EnvironmentEnd(name=end_name, ..))) if end_name == name =>
      result
    Some(EmitAtom(EnvironmentEnd(name=end_name, ..))) =>
      raise InvalidArgument(
        message="Mismatch: \\begin{\{name}} matched by \\end{\{end_name}}",
        loc=None,
      )
    _ => raise InternalInvariant(message="Expected environment end")
  }
}

///|
fn Parser::parse_matrix_alignment(self : Parser) -> String? raise ParseFailure {
  self.consume_spaces()
  guard self.fetch().text == "[" else { None }
  self.consume()
  self.consume_spaces()
  let token = self.fetch()
  guard token.text == "l" || token.text == "c" || token.text == "r" else {
    raise InvalidArgument(message="Expected l or c or r", loc=token.loc)
  }
  self.consume()
  self.consume_spaces()
  self.expect("]")
  Some(token.text)
}

///|
fn Parser::parse_prefixed_function(
  self : Parser,
  name : String,
) -> ParseNode raise ParseFailure {
  self.gullet.push_token(Token::make(name))
  guard self.parse_function(None, None) is Some(EmitAtom(node)) else {
    raise InternalInvariant(message="Expected function after macro prefix")
  }
  node
}

///|
/// Parses a single token into a symbol node: structural tokens (`^`, `_`,
/// braces, `&`, EOF) are not symbols, verb tokens are parsed as `Verb` nodes,
/// and everything else goes through `parse_symbol_text`.
fn Parser::parse_symbol(self : Parser) -> ParseNode? raise ParseFailure {
  let token = self.fetch()
  let original_text = token.text
  if original_text == "EOF" ||
    original_text == "^" ||
    original_text == "_" ||
    original_text == "{" ||
    original_text == "}" ||
    original_text == "&" {
    None
  } else if is_verb_token(original_text) {
    self.consume()
    Some(parse_verb_token(original_text))
  } else {
    self.parse_symbol_text(
      token,
      original_text,
      normalize_unicode_symbol(self.mode, original_text),
    )
  }
}

///|
/// Parses a non-verb token as a symbol: normalizes it, splits off trailing
/// combining marks (with the `i`/`j` -> dotless forms), looks it up in the
/// symbol registry, and reports strict-mode warnings for text characters in
/// math mode. Unrecognized non-ASCII text becomes a `TextOrd` node.
fn Parser::parse_symbol_text(
  self : Parser,
  token : Token,
  original_text : String,
  normalized : String,
) -> ParseNode? raise ParseFailure {
  // KaTeX Parser.ts: accented Unicode text decomposition in math mode
  if self.mode == Math && normalized != original_text {
    self.settings.report_nonstrict(
      "unicodeTextInMathMode",
      "Accented Unicode text character \"\{original_text[0]}\" used in math mode",
      Some(token),
    )
  }
  let (text, marks) = split_combining_marks(normalized)
  match lookup_symbol(self.mode, text) {
    Some(spec) => {
      // KaTeX Parser.ts: Latin-1 fallback letters (Ð Þ þ) in math mode
      if self.mode == Math && is_extra_latin(text) {
        self.settings.report_nonstrict(
          "unicodeTextInMathMode",
          "Latin-1/Unicode text character \"\{text[0]}\" used in math mode",
          Some(token),
        )
      }
      self.consume()
      let base = make_symbol_node(self.mode, text, token.loc, spec)
      match marks {
        None => Some(base)
        Some(accents) =>
          Some(apply_unicode_accents(self.mode, token.loc, base, accents))
      }
    }
    None if is_non_ascii(text) => {
      // KaTeX Parser.ts: unrecognized Unicode characters
      if !supported_codepoint(text[0].to_int()) {
        self.settings.report_nonstrict(
          "unknownSymbol",
          "Unrecognized Unicode character \"\{text[0]}\" (\{text[0].to_int()})",
          Some(token),
        )
      } else if self.mode == Math {
        self.settings.report_nonstrict(
          "unicodeTextInMathMode",
          "Unicode text character \"\{text[0]}\" used in math mode",
          Some(token),
        )
      }
      self.consume()
      Some(TextOrd(mode=Text, loc=token.loc, text~))
    }
    None => None
  }
}

///|
/// Splits a normalized token text into a base without trailing combining
/// marks and the marks themselves. A base of `i`/`j` becomes its dotless form
/// (`ı`/`ȷ`) since the combining mark would otherwise render on the dot.
fn split_combining_marks(normalized : String) -> (String, String?) {
  match trailing_combining_mark_start(normalized) {
    None => (normalized, None)
    Some(start) => {
      let base = normalized.unsafe_substring(start=0, end=start)
      let base = if base == "i" {
        "ı"
      } else if base == "j" {
        "ȷ"
      } else {
        base
      }
      (base, Some(normalized.unsafe_substring(start~, end=normalized.length())))
    }
  }
}

///|
fn is_non_ascii(text : String) -> Bool {
  text.length() > 0 && text[0] >= 0x80
}

///|
fn is_verb_token(text : String) -> Bool {
  text.length() > 5 &&
  starts_with_at(text, 0, "\\verb") &&
  !is_ascii_alphabetic(text[5])
}

///|
fn parse_verb_token(text : String) -> ParseNode raise ParseFailure {
  let raw_argument = text.unsafe_substring(start=5, end=text.length())
  let star = raw_argument.length() > 0 && raw_argument[0] == '*'
  let argument = if star {
    raw_argument.unsafe_substring(start=1, end=raw_argument.length())
  } else {
    raw_argument
  }
  if argument.length() < 2 || argument[0] != argument[argument.length() - 1] {
    raise InternalInvariant(
      message="\\verb assertion failed -- please report what input caused this bug",
    )
  } else {
    Verb(
      mode=Text,
      loc=None,
      body=argument.unsafe_substring(start=1, end=argument.length() - 1),
      star~,
    )
  }
}

///|
/// True when the text is one of the Latin-1 letters KaTeX registers as
/// fallback symbols (Ð Þ þ, symbols.ts extraLatin).
fn is_extra_latin(text : String) -> Bool {
  match text.length() {
    0 => false
    _ => text[0] == 'Ð' || text[0] == 'Þ' || text[0] == 'þ'
  }
}

///|
fn apply_unicode_accents(
  mode : Mode,
  loc : SourceLocation?,
  base : ParseNode,
  accents : String,
) -> ParseNode raise ParseFailure {
  let mut result = base
  for accent in accents {
    let accent_text = String::from_array([accent])
    guard unicode_accent_command(mode, accent_text) is Some(label) else {
      raise InvalidArgument(message="Unknown accent ' \{accent_text}'", loc~)
    }
    result = Accent(
      mode~,
      loc~,
      label~,
      is_stretchy=false,
      is_shifty=true,
      base=result,
    )
  }
  result
}

///|
fn make_symbol_node(
  mode : Mode,
  text : String,
  loc : SourceLocation?,
  spec : SymbolSpec,
) -> ParseNode {
  match spec.group {
    AccentTokenGroup => AccentToken(mode~, loc~, text~)
    BinaryGroup => Atom(mode~, loc~, family=Mbin, text~)
    CloseGroup => Atom(mode~, loc~, family=Mclose, text~)
    InnerGroup => Atom(mode~, loc~, family=Minner, text~)
    MathOrdGroup => MathOrd(mode~, loc~, text~)
    OperatorTokenGroup => OperatorToken(mode~, loc~, text~)
    OpenGroup => Atom(mode~, loc~, family=Mopen, text~)
    PunctuationGroup => Atom(mode~, loc~, family=Mpunct, text~)
    RelationGroup => Atom(mode~, loc~, family=Mrel, text~)
    SpacingGroup => Spacing(mode~, loc~, text~)
    TextOrdGroup => TextOrd(mode~, loc~, text~)
  }
}

///|
fn format_unsupported_command(
  mode : Mode,
  settings : Settings,
  text : String,
) -> ParseNode {
  let body = text
    .to_array()
    .map(ch => TextOrd(mode=Text, loc=None, text=String::from_array([ch])))
  Color(mode~, color=settings.error_color, body~)
}