///|
fn expand_tokens_at(
  value : String,
  specification : DirectiveSpec,
  context : ResolveContext,
  source : @syntax.SourceLocation,
) -> String raise ResolveError {
  let output = StringBuilder()
  let chars : Array[Char] = []
  for char in value {
    chars.push(char)
  }
  let mut index = 0
  while index < chars.length() {
    let char = chars[index]
    if char != '%' {
      output.write_char(char)
      index += 1
      continue
    }
    if index + 1 >= chars.length() {
      raise UnsupportedToken(token="%", keyword=specification.keyword, source~)
    }
    let token = chars[index + 1].to_string()
    if token == "%" {
      output.write_char('%')
      index += 2
      continue
    }
    if !is_member(token, specification.allowed_tokens) {
      raise UnsupportedToken(
        token="%\{token}",
        keyword=specification.keyword,
        source~,
      )
    }
    let replacement = match token {
      "h" => context.host
      "n" => context.original_host
      "p" => context.port.to_string()
      "r" =>
        match context.remote_user {
          Some(value) => value
          None =>
            raise MissingTokenContext(
              token="%r",
              keyword=specification.keyword,
              source~,
            )
        }
      "u" =>
        match context.local_user {
          Some(value) => value
          None =>
            raise MissingTokenContext(
              token="%u",
              keyword=specification.keyword,
              source~,
            )
        }
      "d" =>
        match context.home {
          Some(value) => value
          None =>
            raise MissingTokenContext(
              token="%d",
              keyword=specification.keyword,
              source~,
            )
        }
      _ =>
        raise UnsupportedToken(
          token="%\{token}",
          keyword=specification.keyword,
          source~,
        )
    }
    output.write_string(replacement)
    index += 2
  }
  output.to_string()
}

///|
/// Expand only the documented P0 `%` tokens. This is a single scan: a token
/// introduced by an expansion is data, not another expansion request.
///
/// Direct callers do not have a configuration source location, so errors use
/// the stable synthetic `:1:1` location. The resolver's internal call
/// path uses the directive's real source location.
pub fn expand_tokens(
  value : String,
  specification : DirectiveSpec,
  context : ResolveContext,
) -> String raise ResolveError {
  expand_tokens_at(value, specification, context, {
    path: "",
    line: 1,
    column: 1,
  })
}