///|
fn expr_span(expr : @ast.Expr) -> @ast.Span {
  match expr {
    NoneLiteral(span) => span
    BoolLiteral(_, span) => span
    IntLiteral(_, span) => span
    StringLiteral(_, span) => span
    Identifier(_, span) => span
    UnaryOp(span~, ..) => span
    BinaryOp(span~, ..) => span
    IfExpr(span~, ..) => span
    Call(span~, ..) => span
    Index(span~, ..) => span
    Slice(span~, ..) => span
    Dot(span~, ..) => span
    ListExpr(span~, ..) => span
    TupleExpr(span~, ..) => span
    DictExpr(span~, ..) => span
    ListComp(span~, ..) => span
    DictComp(span~, ..) => span
  }
}

///|
fn values_equal(a : @value.Value, b : @value.Value) -> Bool {
  match (a, b) {
    (None, None) => true
    (Bool(x), Bool(y)) => x == y
    (Int(x), Int(y)) => x == y
    (String(x), String(y)) => x == y
    (List(xs), List(ys)) => {
      if xs.val.length() != ys.val.length() {
        return false
      }
      for i = 0; i < xs.val.length(); i = i + 1 {
        if not(values_equal(xs.val[i], ys.val[i])) {
          return false
        }
      }
      true
    }
    (Tuple(xs), Tuple(ys)) => {
      if xs.length() != ys.length() {
        return false
      }
      for i = 0; i < xs.length(); i = i + 1 {
        if not(values_equal(xs[i], ys[i])) {
          return false
        }
      }
      true
    }
    (Dict(xs), Dict(ys)) => {
      if xs.val.length() != ys.val.length() {
        return false
      }
      for k, v in xs.val {
        match ys.val.get(k) {
          Some(v2) => if not(values_equal(v, v2)) { return false }
          None => return false
        }
      }
      true
    }
    _ => false
  }
}

///|
/// Compare two values. Returns -1, 0, or 1. Raises TypeError for incomparable types.
fn values_compare(
  a : @value.Value,
  b : @value.Value,
  span : @ast.Span,
) -> Int raise StarlarkError {
  match (a, b) {
    (Int(x), Int(y)) => if x < y { -1 } else if x > y { 1 } else { 0 }
    (String(x), String(y)) => x.lexical_compare(y)
    (Bool(x), Bool(y)) => {
      let xi : Int64 = if x { 1L } else { 0L }
      let yi : Int64 = if y { 1L } else { 0L }
      if xi < yi {
        -1
      } else if xi > yi {
        1
      } else {
        0
      }
    }
    (List(xs), List(ys)) => {
      let min_len = if xs.val.length() < ys.val.length() {
        xs.val.length()
      } else {
        ys.val.length()
      }
      for i = 0; i < min_len; i = i + 1 {
        let c = values_compare(xs.val[i], ys.val[i], span)
        if c != 0 {
          return c
        }
      }
      if xs.val.length() < ys.val.length() {
        -1
      } else if xs.val.length() > ys.val.length() {
        1
      } else {
        0
      }
    }
    (Tuple(xs), Tuple(ys)) => {
      let min_len = if xs.length() < ys.length() {
        xs.length()
      } else {
        ys.length()
      }
      for i = 0; i < min_len; i = i + 1 {
        let c = values_compare(xs[i], ys[i], span)
        if c != 0 {
          return c
        }
      }
      if xs.length() < ys.length() {
        -1
      } else if xs.length() > ys.length() {
        1
      } else {
        0
      }
    }
    _ =>
      raise StarlarkError::TypeError(
        message="not supported between instances of '\{a.type_name()}' and '\{b.type_name()}'",
        span~,
      )
  }
}

///|
/// Compute slice indices (start, stop, step) given optional values and length.
fn compute_slice(
  start : @value.Value?,
  stop : @value.Value?,
  step : @value.Value?,
  length : Int,
  span : @ast.Span,
) -> (Int, Int, Int) raise StarlarkError {
  let step_val : Int = match step {
    Some(Int(n)) =>
      if n == 0L {
        raise StarlarkError::ValueError(
          message="slice step cannot be zero",
          span~,
        )
      } else {
        n.to_int()
      }
    Some(None) => 1
    None => 1
    Some(other) =>
      raise StarlarkError::TypeError(
        message="slice indices must be integers or None, not '\{other.type_name()}'",
        span~,
      )
  }
  let default_start = if step_val > 0 { 0 } else { length - 1 }
  let default_stop = if step_val > 0 { length } else { -1 }
  let start_val : Int = match start {
    Some(Int(n)) => {
      let i = if n < 0L { n.to_int() + length } else { n.to_int() }
      if step_val > 0 {
        if i < 0 {
          0
        } else if i > length {
          length
        } else {
          i
        }
      } else if i < -1 {
        -1
      } else if i >= length {
        length - 1
      } else {
        i
      }
    }
    Some(None) => default_start
    None => default_start
    Some(other) =>
      raise StarlarkError::TypeError(
        message="slice indices must be integers or None, not '\{other.type_name()}'",
        span~,
      )
  }
  let stop_val : Int = match stop {
    Some(Int(n)) => {
      let i = if n < 0L { n.to_int() + length } else { n.to_int() }
      if step_val > 0 {
        if i < 0 {
          0
        } else if i > length {
          length
        } else {
          i
        }
      } else if i < -1 {
        -1
      } else if i >= length {
        length - 1
      } else {
        i
      }
    }
    Some(None) => default_stop
    None => default_stop
    Some(other) =>
      raise StarlarkError::TypeError(
        message="slice indices must be integers or None, not '\{other.type_name()}'",
        span~,
      )
  }
  (start_val, stop_val, step_val)
}

///|
/// Collect elements from a slice range.
fn slice_collect_values(
  get : (Int) -> @value.Value,
  start : Int,
  stop : Int,
  step : Int,
) -> Array[@value.Value] {
  let result : Array[@value.Value] = []
  if step > 0 {
    for i = start; i < stop; i = i + step {
      result.push(get(i))
    }
  } else {
    for i = start; i > stop; i = i + step {
      result.push(get(i))
    }
  }
  result
}

///|
/// Check if a string contains a substring.
fn string_contains(haystack : String, needle : String) -> Bool {
  if needle.length() == 0 {
    return true
  }
  if needle.length() > haystack.length() {
    return false
  }
  for i = 0; i <= haystack.length() - needle.length(); i = i + 1 {
    let mut found = true
    for j = 0; j < needle.length(); j = j + 1 {
      if haystack[i + j] != needle[j] {
        found = false
        break
      }
    }
    if found {
      return true
    }
  }
  false
}

///|
/// Repeat a string n times.
fn string_repeat(s : String, n : Int) -> String {
  if n <= 0 {
    return ""
  }
  let buf = StringBuilder::new()
  for i = 0; i < n; i = i + 1 {
    buf.write_string(s)
  }
  buf.to_string()
}

///|
/// Get a single character from a string as a string.
fn string_char_at(s : String, idx : Int) -> String {
  let buf = StringBuilder::new()
  buf.write_char(s[idx].to_int().unsafe_to_char())
  buf.to_string()
}

///|
/// Slice a string using start, stop, step indices.
fn string_slice(s : String, start : Int, stop : Int, step : Int) -> String {
  let buf = StringBuilder::new()
  if step > 0 {
    for i = start; i < stop; i = i + step {
      buf.write_char(s[i].to_int().unsafe_to_char())
    }
  } else {
    for i = start; i > stop; i = i + step {
      buf.write_char(s[i].to_int().unsafe_to_char())
    }
  }
  buf.to_string()
}

///|
pub fn eval_expr(
  scope : Scope,
  expr : @ast.Expr,
) -> @value.Value raise StarlarkError {
  match expr {
    NoneLiteral(_) => @value.Value::None
    BoolLiteral(b, _) => @value.Value::Bool(b)
    IntLiteral(n, _) => @value.Value::Int(n)
    StringLiteral(s, _) => @value.Value::String(s)
    Identifier(name, span) =>
      match scope.get(name) {
        Some(v) => v
        None => raise StarlarkError::NameError(name~, span~)
      }
    UnaryOp(op~, operand~, span~) => eval_unary_op(scope, op, operand, span)
    BinaryOp(left~, op~, right~, span~) =>
      eval_binary_op(scope, left, op, right, span)
    IfExpr(condition~, then_expr~, else_expr~, ..) => {
      let cond = eval_expr(scope, condition)
      if cond.is_truthy() {
        eval_expr(scope, then_expr)
      } else {
        eval_expr(scope, else_expr)
      }
    }
    ListExpr(elements~, ..) => {
      let arr : Array[@value.Value] = []
      for e in elements {
        arr.push(eval_expr(scope, e))
      }
      @value.Value::List(Ref::new(arr))
    }
    TupleExpr(elements~, ..) => {
      let arr : Array[@value.Value] = []
      for e in elements {
        arr.push(eval_expr(scope, e))
      }
      @value.Value::Tuple(FixedArray::from_array(arr))
    }
    DictExpr(entries~, ..) => {
      let map : Map[String, @value.Value] = {}
      for entry in entries {
        let (key_expr, val_expr) = entry
        let key = eval_expr(scope, key_expr)
        let val = eval_expr(scope, val_expr)
        match key {
          String(s) => map.set(s, val)
          _ =>
            raise StarlarkError::TypeError(
              message="dictionary keys must be strings, got '\{key.type_name()}'",
              span=expr_span(key_expr),
            )
        }
      }
      @value.Value::Dict(Ref::new(map))
    }
    Index(object~, index~, span~) => eval_index(scope, object, index, span)
    Slice(object~, start~, stop~, step~, span~) =>
      eval_slice(scope, object, start, stop, step, span)
    Call(func~, args~, span~) => eval_call(scope, func, args, span)
    Dot(object~, field~, span~) => {
      let obj = eval_expr(scope, object)
      match obj {
        String(s) =>
          match string_methods().get(field) {
            Some(m) =>
              @value.Value::BuiltinFunction("str." + field, fn(pos, kw) raise {
                m(s, pos, kw, span)
              })
            None =>
              raise StarlarkError::TypeError(
                message="'string' object has no attribute '\{field}'",
                span~,
              )
          }
        List(items) =>
          match list_methods().get(field) {
            Some(m) =>
              @value.Value::BuiltinFunction("list." + field, fn(pos, kw) raise {
                m(items, pos, kw, span)
              })
            None =>
              raise StarlarkError::TypeError(
                message="'list' object has no attribute '\{field}'",
                span~,
              )
          }
        Dict(map) =>
          match dict_methods().get(field) {
            Some(m) =>
              @value.Value::BuiltinFunction("dict." + field, fn(pos, kw) raise {
                m(map, pos, kw, span)
              })
            None =>
              raise StarlarkError::TypeError(
                message="'dict' object has no attribute '\{field}'",
                span~,
              )
          }
        _ =>
          raise StarlarkError::TypeError(
            message="'\{obj.type_name()}' object has no attribute '\{field}'",
            span~,
          )
      }
    }
    ListComp(expr~, clauses~, span~) =>
      eval_list_comp(scope, expr, clauses, span)
    DictComp(key~, value~, clauses~, span~) =>
      eval_dict_comp(scope, key, value, clauses, span)
  }
}

///|
fn eval_unary_op(
  scope : Scope,
  op : @ast.UnaryOp,
  operand : @ast.Expr,
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  let val = eval_expr(scope, operand)
  match op {
    Negate =>
      match val {
        Int(n) => @value.Value::Int(-n)
        _ =>
          raise StarlarkError::TypeError(
            message="bad operand type for unary -: '\{val.type_name()}'",
            span~,
          )
      }
    Pos =>
      match val {
        Int(n) => @value.Value::Int(n)
        _ =>
          raise StarlarkError::TypeError(
            message="bad operand type for unary +: '\{val.type_name()}'",
            span~,
          )
      }
    BitNot =>
      match val {
        Int(n) => @value.Value::Int(-(n + 1L))
        _ =>
          raise StarlarkError::TypeError(
            message="bad operand type for unary ~: '\{val.type_name()}'",
            span~,
          )
      }
    LogNot => @value.Value::Bool(not(val.is_truthy()))
  }
}

///|
fn eval_binary_op(
  scope : Scope,
  left_expr : @ast.Expr,
  op : @ast.BinaryOp,
  right_expr : @ast.Expr,
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  // Short-circuit for And/Or
  match op {
    And => {
      let left = eval_expr(scope, left_expr)
      if not(left.is_truthy()) {
        return left
      }
      return eval_expr(scope, right_expr)
    }
    Or => {
      let left = eval_expr(scope, left_expr)
      if left.is_truthy() {
        return left
      }
      return eval_expr(scope, right_expr)
    }
    _ => ()
  }
  let left = eval_expr(scope, left_expr)
  let right = eval_expr(scope, right_expr)
  match op {
    Add => eval_add(left, right, span)
    Sub => eval_arith(left, right, span, "-", fn(a, b) { a - b })
    Mul => eval_mul(left, right, span)
    Div =>
      raise StarlarkError::TypeError(
        message="unsupported binary operator: / (use // for integer division)",
        span~,
      )
    FloorDiv =>
      match (left, right) {
        (Int(a), Int(b)) =>
          if b == 0L {
            raise StarlarkError::ValueError(
              message="integer division by zero",
              span~,
            )
          } else {
            // Starlark floor division: rounds toward negative infinity
            let q = a / b
            // Adjust if signs differ and there's a remainder
            if (a ^ b) < 0L && q * b != a {
              @value.Value::Int(q - 1L)
            } else {
              @value.Value::Int(q)
            }
          }
        _ =>
          raise StarlarkError::TypeError(
            message="unsupported operand type(s) for //: '\{left.type_name()}' and '\{right.type_name()}'",
            span~,
          )
      }
    Mod =>
      match (left, right) {
        (String(fmt), _) => {
          let args : Array[@value.Value] = match right {
            Tuple(items) => {
              let arr : Array[@value.Value] = []
              for i = 0; i < items.length(); i = i + 1 {
                arr.push(items[i])
              }
              arr
            }
            _ => [right]
          }
          @value.Value::String(format_string(fmt, args, span))
        }
        (Int(a), Int(b)) =>
          if b == 0L {
            raise StarlarkError::ValueError(
              message="integer modulo by zero",
              span~,
            )
          } else {
            // Starlark modulo: result has same sign as divisor
            let r = a % b
            if r != 0L && (r ^ b) < 0L {
              @value.Value::Int(r + b)
            } else {
              @value.Value::Int(r)
            }
          }
        _ =>
          raise StarlarkError::TypeError(
            message="unsupported operand type(s) for %: '\{left.type_name()}' and '\{right.type_name()}'",
            span~,
          )
      }
    // Comparison
    Eq => @value.Value::Bool(values_equal(left, right))
    NotEq => @value.Value::Bool(not(values_equal(left, right)))
    Lt => @value.Value::Bool(values_compare(left, right, span) < 0)
    Gt => @value.Value::Bool(values_compare(left, right, span) > 0)
    LtEq => @value.Value::Bool(values_compare(left, right, span) <= 0)
    GtEq => @value.Value::Bool(values_compare(left, right, span) >= 0)
    // Membership
    In => @value.Value::Bool(eval_membership(left, right, span))
    NotIn => @value.Value::Bool(not(eval_membership(left, right, span)))
    // Bitwise
    BitAnd => eval_bitwise(left, right, span, "&", fn(a, b) { a & b })
    BitOr => eval_bitwise(left, right, span, "|", fn(a, b) { a | b })
    BitXor => eval_bitwise(left, right, span, "^", fn(a, b) { a ^ b })
    LShift =>
      eval_bitwise(left, right, span, "<<", fn(a, b) { a << b.to_int() })
    RShift =>
      eval_bitwise(left, right, span, ">>", fn(a, b) { a >> b.to_int() })
    // And/Or handled above
    And | Or => abort("unreachable: And/Or handled above")
  }
}

///|
fn eval_add(
  left : @value.Value,
  right : @value.Value,
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  match (left, right) {
    (Int(a), Int(b)) => @value.Value::Int(a + b)
    (String(a), String(b)) => @value.Value::String(a + b)
    (List(a), List(b)) => {
      let result : Array[@value.Value] = []
      for item in a.val {
        result.push(item)
      }
      for item in b.val {
        result.push(item)
      }
      @value.Value::List(Ref::new(result))
    }
    (Tuple(a), Tuple(b)) => {
      let result : Array[@value.Value] = []
      for i = 0; i < a.length(); i = i + 1 {
        result.push(a[i])
      }
      for i = 0; i < b.length(); i = i + 1 {
        result.push(b[i])
      }
      @value.Value::Tuple(FixedArray::from_array(result))
    }
    _ =>
      raise StarlarkError::TypeError(
        message="unsupported operand type(s) for +: '\{left.type_name()}' and '\{right.type_name()}'",
        span~,
      )
  }
}

///|
fn eval_arith(
  left : @value.Value,
  right : @value.Value,
  span : @ast.Span,
  op_str : String,
  op : (Int64, Int64) -> Int64,
) -> @value.Value raise StarlarkError {
  match (left, right) {
    (Int(a), Int(b)) => @value.Value::Int(op(a, b))
    _ =>
      raise StarlarkError::TypeError(
        message="unsupported operand type(s) for \{op_str}: '\{left.type_name()}' and '\{right.type_name()}'",
        span~,
      )
  }
}

///|
fn eval_mul(
  left : @value.Value,
  right : @value.Value,
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  match (left, right) {
    (Int(a), Int(b)) => @value.Value::Int(a * b)
    (String(s), Int(n)) => @value.Value::String(string_repeat(s, n.to_int()))
    (Int(n), String(s)) => @value.Value::String(string_repeat(s, n.to_int()))
    (List(items), Int(n)) => {
      let result : Array[@value.Value] = []
      for i = 0; i < n.to_int(); i = i + 1 {
        for item in items.val {
          result.push(item)
        }
      }
      @value.Value::List(Ref::new(result))
    }
    (Int(n), List(items)) => {
      let result : Array[@value.Value] = []
      for i = 0; i < n.to_int(); i = i + 1 {
        for item in items.val {
          result.push(item)
        }
      }
      @value.Value::List(Ref::new(result))
    }
    (Tuple(items), Int(n)) => {
      let result : Array[@value.Value] = []
      for i = 0; i < n.to_int(); i = i + 1 {
        for j = 0; j < items.length(); j = j + 1 {
          result.push(items[j])
        }
      }
      @value.Value::Tuple(FixedArray::from_array(result))
    }
    (Int(n), Tuple(items)) => {
      let result : Array[@value.Value] = []
      for i = 0; i < n.to_int(); i = i + 1 {
        for j = 0; j < items.length(); j = j + 1 {
          result.push(items[j])
        }
      }
      @value.Value::Tuple(FixedArray::from_array(result))
    }
    _ =>
      raise StarlarkError::TypeError(
        message="unsupported operand type(s) for *: '\{left.type_name()}' and '\{right.type_name()}'",
        span~,
      )
  }
}

///|
fn eval_bitwise(
  left : @value.Value,
  right : @value.Value,
  span : @ast.Span,
  op_str : String,
  op : (Int64, Int64) -> Int64,
) -> @value.Value raise StarlarkError {
  match (left, right) {
    (Int(a), Int(b)) => @value.Value::Int(op(a, b))
    _ =>
      raise StarlarkError::TypeError(
        message="unsupported operand type(s) for \{op_str}: '\{left.type_name()}' and '\{right.type_name()}'",
        span~,
      )
  }
}

///|
fn eval_membership(
  element : @value.Value,
  container : @value.Value,
  span : @ast.Span,
) -> Bool raise StarlarkError {
  match container {
    List(items) => {
      for item in items.val {
        if values_equal(element, item) {
          return true
        }
      }
      false
    }
    Tuple(items) => {
      for i = 0; i < items.length(); i = i + 1 {
        if values_equal(element, items[i]) {
          return true
        }
      }
      false
    }
    Dict(map) =>
      match element {
        String(key) => map.val.contains(key)
        _ =>
          raise StarlarkError::TypeError(
            message="dict membership test requires string key, got '\{element.type_name()}'",
            span~,
          )
      }
    String(haystack) =>
      match element {
        String(needle) => string_contains(haystack, needle)
        _ =>
          raise StarlarkError::TypeError(
            message="'in ' requires string as left operand, not '\{element.type_name()}'",
            span~,
          )
      }
    _ =>
      raise StarlarkError::TypeError(
        message="argument of type '\{container.type_name()}' is not iterable",
        span~,
      )
  }
}

///|
fn eval_index(
  scope : Scope,
  object_expr : @ast.Expr,
  index_expr : @ast.Expr,
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  let obj = eval_expr(scope, object_expr)
  let idx = eval_expr(scope, index_expr)
  match (obj, idx) {
    (List(items), Int(n)) => {
      let len = items.val.length()
      let i = if n < 0L { n.to_int() + len } else { n.to_int() }
      if i < 0 || i >= len {
        raise StarlarkError::IndexError(
          message="list index out of range",
          span~,
        )
      }
      items.val[i]
    }
    (Tuple(items), Int(n)) => {
      let len = items.length()
      let i = if n < 0L { n.to_int() + len } else { n.to_int() }
      if i < 0 || i >= len {
        raise StarlarkError::IndexError(
          message="tuple index out of range",
          span~,
        )
      }
      items[i]
    }
    (String(s), Int(n)) => {
      let len = s.length()
      let i = if n < 0L { n.to_int() + len } else { n.to_int() }
      if i < 0 || i >= len {
        raise StarlarkError::IndexError(
          message="string index out of range",
          span~,
        )
      }
      @value.Value::String(string_char_at(s, i))
    }
    (Dict(map), String(key)) =>
      match map.val.get(key) {
        Some(v) => v
        None => raise StarlarkError::KeyError(key~, span~)
      }
    (Dict(_), _) =>
      raise StarlarkError::TypeError(
        message="dictionary key must be a string, got '\{idx.type_name()}'",
        span~,
      )
    _ =>
      raise StarlarkError::TypeError(
        message="'\{obj.type_name()}' object is not subscriptable",
        span~,
      )
  }
}

///|
fn eval_slice(
  scope : Scope,
  object_expr : @ast.Expr,
  start_expr : @ast.Expr?,
  stop_expr : @ast.Expr?,
  step_expr : @ast.Expr?,
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  let obj = eval_expr(scope, object_expr)
  let start = match start_expr {
    Some(e) => Some(eval_expr(scope, e))
    None => None
  }
  let stop = match stop_expr {
    Some(e) => Some(eval_expr(scope, e))
    None => None
  }
  let step = match step_expr {
    Some(e) => Some(eval_expr(scope, e))
    None => None
  }
  match obj {
    List(items) => {
      let (s, e, st) = compute_slice(
        start,
        stop,
        step,
        items.val.length(),
        span,
      )
      let result = slice_collect_values(fn(i) { items.val[i] }, s, e, st)
      @value.Value::List(Ref::new(result))
    }
    Tuple(items) => {
      let (s, e, st) = compute_slice(start, stop, step, items.length(), span)
      let result = slice_collect_values(fn(i) { items[i] }, s, e, st)
      @value.Value::Tuple(FixedArray::from_array(result))
    }
    String(str) => {
      let (s, e, st) = compute_slice(start, stop, step, str.length(), span)
      @value.Value::String(string_slice(str, s, e, st))
    }
    _ =>
      raise StarlarkError::TypeError(
        message="'\{obj.type_name()}' object is not subscriptable",
        span~,
      )
  }
}

///|
fn eval_call_args(
  scope : Scope,
  args : Array[@ast.Argument],
  span : @ast.Span,
) -> (Array[@value.Value], Map[String, @value.Value]) raise StarlarkError {
  let pos_args : Array[@value.Value] = []
  let kw_args : Map[String, @value.Value] = {}
  for arg in args {
    match arg {
      Positional(expr) => pos_args.push(eval_expr(scope, expr))
      Keyword(name, expr) => {
        let value = eval_expr(scope, expr)
        if kw_args.contains(name) {
          raise StarlarkError::TypeError(
            message="keyword argument '\{name}' repeated",
            span~,
          )
        }
        kw_args.set(name, value)
      }
      Star(expr) =>
        // Unpack iterable into positional args
        match eval_expr(scope, expr) {
          List(items) =>
            for item in items.val {
              pos_args.push(item)
            }
          Tuple(items) =>
            for i = 0; i < items.length(); i = i + 1 {
              pos_args.push(items[i])
            }
          _ =>
            raise StarlarkError::TypeError(
              message="argument after * must be an iterable",
              span~,
            )
        }
      DoubleStar(expr) =>
        match eval_expr(scope, expr) {
          Dict(map) =>
            for k, v in map.val {
              if kw_args.contains(k) {
                raise StarlarkError::TypeError(
                  message="keyword argument '\{k}' repeated",
                  span~,
                )
              }
              kw_args.set(k, v)
            }
          _ =>
            raise StarlarkError::TypeError(
              message="argument after ** must be a dict",
              span~,
            )
        }
    }
  }
  (pos_args, kw_args)
}

///|
fn call_method(
  obj : @value.Value,
  field : String,
  pos_args : Array[@value.Value],
  kw_args : Map[String, @value.Value],
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  match obj {
    String(s) =>
      match string_methods().get(field) {
        Some(m) => m(s, pos_args, kw_args, span)
        None =>
          raise StarlarkError::TypeError(
            message="'string' object has no attribute '\{field}'",
            span~,
          )
      }
    List(items) =>
      match list_methods().get(field) {
        Some(m) => m(items, pos_args, kw_args, span)
        None =>
          raise StarlarkError::TypeError(
            message="'list' object has no attribute '\{field}'",
            span~,
          )
      }
    Dict(map) =>
      match dict_methods().get(field) {
        Some(m) => m(map, pos_args, kw_args, span)
        None =>
          raise StarlarkError::TypeError(
            message="'dict' object has no attribute '\{field}'",
            span~,
          )
      }
    _ =>
      raise StarlarkError::TypeError(
        message="'\{obj.type_name()}' object has no attribute '\{field}'",
        span~,
      )
  }
}

///|
fn eval_call(
  scope : Scope,
  func_expr : @ast.Expr,
  args : Array[@ast.Argument],
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  match func_expr {
    Dot(object~, field~, span=dot_span) => {
      // Fast path: method call — skip closure creation
      let obj = eval_expr(scope, object)
      let (pos_args, kw_args) = eval_call_args(scope, args, span)
      call_method(obj, field, pos_args, kw_args, dot_span)
    }
    _ => {
      // Regular call path
      let func = eval_expr(scope, func_expr)
      let (pos_args, kw_args) = eval_call_args(scope, args, span)
      match func {
        Function(fn_name, callable) =>
          call_function(fn_name, callable, pos_args, kw_args, span)
        BuiltinFunction(_name, builtin_fn) =>
          builtin_fn(pos_args, kw_args) catch {
            TypeError(_) as e => raise e
            NameError(_) as e => raise e
            ValueError(_) as e => raise e
            IndexError(_) as e => raise e
            KeyError(_) as e => raise e
            e => raise StarlarkError::TypeError(message=e.to_string(), span~)
          }
        _ =>
          raise StarlarkError::TypeError(
            message="'\{func.type_name()}' object is not callable",
            span~,
          )
      }
    }
  }
}

///|
fn call_function(
  _name : String,
  callable : (Array[@value.Value], Map[String, @value.Value]) -> @value.Value raise Error,
  pos_args : Array[@value.Value],
  kw_args : Map[String, @value.Value],
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  callable(pos_args, kw_args) catch {
    TypeError(_) as e => raise e
    NameError(_) as e => raise e
    ValueError(_) as e => raise e
    IndexError(_) as e => raise e
    KeyError(_) as e => raise e
    e => raise StarlarkError::TypeError(message=e.to_string(), span~)
  }
}

///|
fn eval_list_comp(
  scope : Scope,
  expr : @ast.Expr,
  clauses : Array[@ast.CompClause],
  _span : @ast.Span,
) -> @value.Value raise StarlarkError {
  let result : Array[@value.Value] = []
  let comp_scope = Scope::new(parent=scope)
  eval_comp_clauses(comp_scope, clauses, 0, () => {
    result.push(eval_expr(comp_scope, expr))
  })
  @value.Value::List(Ref::new(result))
}

///|
fn eval_dict_comp(
  scope : Scope,
  key_expr : @ast.Expr,
  value_expr : @ast.Expr,
  clauses : Array[@ast.CompClause],
  _span : @ast.Span,
) -> @value.Value raise StarlarkError {
  let result : Map[String, @value.Value] = {}
  let comp_scope = Scope::new(parent=scope)
  eval_comp_clauses(comp_scope, clauses, 0, () => {
    let key = eval_expr(comp_scope, key_expr)
    let value = eval_expr(comp_scope, value_expr)
    match key {
      String(s) => result.set(s, value)
      _ =>
        raise StarlarkError::TypeError(
          message="dictionary keys must be strings, got '\{key.type_name()}'",
          span=expr_span(key_expr),
        )
    }
  })
  @value.Value::Dict(Ref::new(result))
}

///|
fn eval_comp_clauses(
  scope : Scope,
  clauses : Array[@ast.CompClause],
  index : Int,
  body : () -> Unit raise StarlarkError,
) -> Unit raise StarlarkError {
  if index >= clauses.length() {
    body()
    return
  }
  match clauses[index] {
    For(vars~, iterable~) => {
      let iter_val = eval_expr(scope, iterable)
      let items = iterable_to_array(iter_val, expr_span(iterable))
      for item in items {
        assign_target(scope, vars, item, expr_span(vars))
        eval_comp_clauses(scope, clauses, index + 1, body)
      }
    }
    If(condition) => {
      let cond = eval_expr(scope, condition)
      if cond.is_truthy() {
        eval_comp_clauses(scope, clauses, index + 1, body)
      }
    }
  }
}

///|
fn iterable_to_array(
  value : @value.Value,
  span : @ast.Span,
) -> Array[@value.Value] raise StarlarkError {
  match value {
    List(items) => {
      let result : Array[@value.Value] = []
      for item in items.val {
        result.push(item)
      }
      result
    }
    Tuple(items) => {
      let result : Array[@value.Value] = []
      for i = 0; i < items.length(); i = i + 1 {
        result.push(items[i])
      }
      result
    }
    Dict(map) => {
      let result : Array[@value.Value] = []
      for k, _ in map.val {
        result.push(@value.Value::String(k))
      }
      result
    }
    String(s) => {
      let result : Array[@value.Value] = []
      for i = 0; i < s.length(); i = i + 1 {
        result.push(@value.Value::String(string_char_at(s, i)))
      }
      result
    }
    _ =>
      raise StarlarkError::TypeError(
        message="'\{value.type_name()}' object is not iterable",
        span~,
      )
  }
}

///|
/// Convert a value to an array of its elements, using a function name for error messages.
fn iterable_to_array_by_name(
  value : @value.Value,
  fname : String,
) -> Array[@value.Value] raise StarlarkError {
  let span : @ast.Span = {
    start: { file: "", line: 0, column: 0 },
    end: { file: "", line: 0, column: 0 },
  }
  match value {
    List(items) => {
      let result : Array[@value.Value] = []
      for item in items.val {
        result.push(item)
      }
      result
    }
    Tuple(items) => {
      let result : Array[@value.Value] = []
      for i = 0; i < items.length(); i = i + 1 {
        result.push(items[i])
      }
      result
    }
    Dict(map) => {
      let result : Array[@value.Value] = []
      for k, _ in map.val {
        result.push(@value.Value::String(k))
      }
      result
    }
    String(s) => {
      let result : Array[@value.Value] = []
      for i = 0; i < s.length(); i = i + 1 {
        result.push(@value.Value::String(string_char_at(s, i)))
      }
      result
    }
    _ =>
      raise StarlarkError::TypeError(
        message=fname +
          "() argument must be an iterable, not '" +
          value.type_name() +
          "'",
        span~,
      )
  }
}

///|
fn eval_index_assign(
  obj : @value.Value,
  idx : @value.Value,
  value : @value.Value,
  span : @ast.Span,
) -> Unit raise StarlarkError {
  match (obj, idx) {
    (List(items), Int(n)) => {
      let len = items.val.length()
      let i = if n < 0L { n.to_int() + len } else { n.to_int() }
      if i < 0 || i >= len {
        raise StarlarkError::IndexError(
          message="list assignment index out of range",
          span~,
        )
      }
      items.val[i] = value
    }
    (Dict(map), String(key)) => map.val.set(key, value)
    (Dict(_), _) =>
      raise StarlarkError::TypeError(
        message="dictionary key must be a string, got '\{idx.type_name()}'",
        span~,
      )
    _ =>
      raise StarlarkError::TypeError(
        message="'\{obj.type_name()}' object does not support item assignment",
        span~,
      )
  }
}

///|
fn assign_target(
  scope : Scope,
  target : @ast.Expr,
  value : @value.Value,
  span : @ast.Span,
) -> Unit raise StarlarkError {
  match target {
    Identifier(name, _) => scope.set(name, value)
    TupleExpr(elements~, ..) => {
      // Unpack value into the tuple elements
      let items = match value {
        List(items) => {
          let result : Array[@value.Value] = []
          for item in items.val {
            result.push(item)
          }
          result
        }
        Tuple(items) => {
          let result : Array[@value.Value] = []
          for i = 0; i < items.length(); i = i + 1 {
            result.push(items[i])
          }
          result
        }
        _ =>
          raise StarlarkError::TypeError(
            message="cannot unpack non-sequence '\{value.type_name()}'",
            span~,
          )
      }
      if items.length() != elements.length() {
        raise StarlarkError::ValueError(
          message="too many values to unpack (expected \{elements.length()}, got \{items.length()})",
          span~,
        )
      }
      for i = 0; i < elements.length(); i = i + 1 {
        assign_target(scope, elements[i], items[i], span)
      }
    }
    ListExpr(elements~, ..) => {
      // Same as tuple unpacking
      let items = match value {
        List(items) => {
          let result : Array[@value.Value] = []
          for item in items.val {
            result.push(item)
          }
          result
        }
        Tuple(items) => {
          let result : Array[@value.Value] = []
          for i = 0; i < items.length(); i = i + 1 {
            result.push(items[i])
          }
          result
        }
        _ =>
          raise StarlarkError::TypeError(
            message="cannot unpack non-sequence '\{value.type_name()}'",
            span~,
          )
      }
      if items.length() != elements.length() {
        raise StarlarkError::ValueError(
          message="too many values to unpack (expected \{elements.length()}, got \{items.length()})",
          span~,
        )
      }
      for i = 0; i < elements.length(); i = i + 1 {
        assign_target(scope, elements[i], items[i], span)
      }
    }
    _ =>
      raise StarlarkError::TypeError(
        message="cannot assign to \{target.to_string()}",
        span~,
      )
  }
}

///|
fn assign_op_to_binary_op(op : @ast.AssignOp) -> @ast.BinaryOp {
  match op {
    PlusEq => Add
    MinusEq => Sub
    StarEq => Mul
    SlashEq => Div
    FloorDivEq => FloorDiv
    PercentEq => Mod
    AmpEq => BitAnd
    PipeEq => BitOr
    CaretEq => BitXor
    LShiftEq => LShift
    RShiftEq => RShift
  }
}

///|
pub fn eval_stmts(scope : Scope, stmts : Array[@ast.Stmt]) -> Unit raise Error {
  for stmt in stmts {
    eval_stmt(scope, stmt)
  }
}

///|
fn eval_stmt(scope : Scope, stmt : @ast.Stmt) -> Unit raise Error {
  match stmt {
    Pass(_) => ()
    Break(_) => raise BreakSignal::BreakSignal
    Continue(_) => raise ContinueSignal::ContinueSignal
    Expr(expr~, ..) => {
      let _ = eval_expr(scope, expr)
    }
    Return(value~, ..) => {
      let val = match value {
        Some(expr) => eval_expr(scope, expr)
        None => @value.Value::None
      }
      raise ReturnSignal::ReturnSignal(val)
    }
    Assign(target~, op~, value~, span~) =>
      eval_assign(scope, target, op, value, span)
    If(clauses~, else_body~, ..) => eval_if(scope, clauses, else_body)
    For(vars~, iterable~, body~, span~) =>
      eval_for(scope, vars, iterable, body, span)
    Def(name~, params~, body~, span~) =>
      eval_def(scope, name, params, body, span)
    Load(mod=mod_name, bindings~, span~) =>
      eval_load(scope, mod_name, bindings, span)
  }
}

///|
fn eval_assign(
  scope : Scope,
  target : @ast.Expr,
  op : @ast.AssignOp?,
  value_expr : @ast.Expr,
  span : @ast.Span,
) -> Unit raise StarlarkError {
  let value = eval_expr(scope, value_expr)
  match op {
    None =>
      // Plain assignment
      match target {
        Identifier(name, _) => scope.set(name, value)
        TupleExpr(..) | ListExpr(..) =>
          assign_target(scope, target, value, span)
        Index(object~, index~, ..) => {
          let idx_span = expr_span(target)
          let obj = eval_expr(scope, object)
          let idx = eval_expr(scope, index)
          eval_index_assign(obj, idx, value, idx_span)
        }
        Dot(..) =>
          raise StarlarkError::TypeError(
            message="attribute assignment not supported",
            span~,
          )
        _ =>
          raise StarlarkError::TypeError(
            message="cannot assign to expression",
            span~,
          )
      }
    Some(assign_op) =>
      // Augmented assignment
      match target {
        Identifier(name, id_span) => {
          let current = match scope.get(name) {
            Some(v) => v
            None => raise StarlarkError::NameError(name~, span=id_span)
          }
          // Special case: list += iterable extends the list in place
          match (assign_op, current) {
            (PlusEq, List(items)) => {
              let new_items = iterable_to_array(value, span)
              for item in new_items {
                items.val.push(item)
              }
            }
            _ => {
              let bin_op = assign_op_to_binary_op(assign_op)
              let result = eval_binary_op_values(current, bin_op, value, span)
              scope.set(name, result)
            }
          }
        }
        Index(object~, index~, ..) => {
          let idx_span = expr_span(target)
          let obj = eval_expr(scope, object)
          let idx = eval_expr(scope, index)
          match (obj, idx) {
            (List(items), Int(n)) => {
              let len = items.val.length()
              let i = if n < 0L { n.to_int() + len } else { n.to_int() }
              if i < 0 || i >= len {
                raise StarlarkError::IndexError(
                  message="list assignment index out of range",
                  span=idx_span,
                )
              }
              let bin_op = assign_op_to_binary_op(assign_op)
              let current = items.val[i]
              let result = eval_binary_op_values(current, bin_op, value, span)
              items.val[i] = result
            }
            (Dict(map), String(key)) => {
              let current = match map.val.get(key) {
                Some(v) => v
                None => raise StarlarkError::KeyError(key~, span=idx_span)
              }
              let bin_op = assign_op_to_binary_op(assign_op)
              let result = eval_binary_op_values(current, bin_op, value, span)
              map.val.set(key, result)
            }
            _ =>
              raise StarlarkError::TypeError(
                message="'\{obj.type_name()}' object does not support item assignment",
                span=idx_span,
              )
          }
        }
        _ =>
          raise StarlarkError::TypeError(
            message="cannot use augmented assignment with this target",
            span~,
          )
      }
  }
}

///|
fn eval_binary_op_values(
  left : @value.Value,
  op : @ast.BinaryOp,
  right : @value.Value,
  span : @ast.Span,
) -> @value.Value raise StarlarkError {
  match op {
    Add => eval_add(left, right, span)
    Sub => eval_arith(left, right, span, "-", fn(a, b) { a - b })
    Mul => eval_mul(left, right, span)
    Div =>
      raise StarlarkError::TypeError(
        message="unsupported binary operator: / (use // for integer division)",
        span~,
      )
    FloorDiv =>
      match (left, right) {
        (Int(a), Int(b)) =>
          if b == 0L {
            raise StarlarkError::ValueError(
              message="integer division by zero",
              span~,
            )
          } else {
            let q = a / b
            if (a ^ b) < 0L && q * b != a {
              @value.Value::Int(q - 1L)
            } else {
              @value.Value::Int(q)
            }
          }
        _ =>
          raise StarlarkError::TypeError(
            message="unsupported operand type(s) for //: '\{left.type_name()}' and '\{right.type_name()}'",
            span~,
          )
      }
    Mod =>
      match (left, right) {
        (String(fmt), _) => {
          let args : Array[@value.Value] = match right {
            Tuple(items) => {
              let arr : Array[@value.Value] = []
              for i = 0; i < items.length(); i = i + 1 {
                arr.push(items[i])
              }
              arr
            }
            _ => [right]
          }
          @value.Value::String(format_string(fmt, args, span))
        }
        (Int(a), Int(b)) =>
          if b == 0L {
            raise StarlarkError::ValueError(
              message="integer modulo by zero",
              span~,
            )
          } else {
            let r = a % b
            if r != 0L && (r ^ b) < 0L {
              @value.Value::Int(r + b)
            } else {
              @value.Value::Int(r)
            }
          }
        _ =>
          raise StarlarkError::TypeError(
            message="unsupported operand type(s) for %: '\{left.type_name()}' and '\{right.type_name()}'",
            span~,
          )
      }
    Eq => @value.Value::Bool(values_equal(left, right))
    NotEq => @value.Value::Bool(not(values_equal(left, right)))
    Lt => @value.Value::Bool(values_compare(left, right, span) < 0)
    Gt => @value.Value::Bool(values_compare(left, right, span) > 0)
    LtEq => @value.Value::Bool(values_compare(left, right, span) <= 0)
    GtEq => @value.Value::Bool(values_compare(left, right, span) >= 0)
    In => @value.Value::Bool(eval_membership(left, right, span))
    NotIn => @value.Value::Bool(not(eval_membership(left, right, span)))
    BitAnd => eval_bitwise(left, right, span, "&", fn(a, b) { a & b })
    BitOr => eval_bitwise(left, right, span, "|", fn(a, b) { a | b })
    BitXor => eval_bitwise(left, right, span, "^", fn(a, b) { a ^ b })
    LShift =>
      eval_bitwise(left, right, span, "<<", fn(a, b) { a << b.to_int() })
    RShift =>
      eval_bitwise(left, right, span, ">>", fn(a, b) { a >> b.to_int() })
    And => if not(left.is_truthy()) { left } else { right }
    Or => if left.is_truthy() { left } else { right }
  }
}

///|
fn eval_if(
  scope : Scope,
  clauses : Array[(@ast.Expr, Array[@ast.Stmt])],
  else_body : Array[@ast.Stmt]?,
) -> Unit raise Error {
  for clause in clauses {
    let (condition, body) = clause
    let cond = eval_expr(scope, condition)
    if cond.is_truthy() {
      eval_stmts(scope, body)
      return
    }
  }
  match else_body {
    Some(body) => eval_stmts(scope, body)
    None => ()
  }
}

///|
fn eval_for(
  scope : Scope,
  vars : @ast.Expr,
  iterable_expr : @ast.Expr,
  body : Array[@ast.Stmt],
  span : @ast.Span,
) -> Unit raise Error {
  let iter_val = eval_expr(scope, iterable_expr)
  let items = iterable_to_array(iter_val, span)
  for item in items {
    assign_target(scope, vars, item, span)
    eval_stmts(scope, body) catch {
      ContinueSignal::ContinueSignal => continue
      BreakSignal::BreakSignal => break
      other => raise other
    }
  }
}

///|
fn eval_def(
  scope : Scope,
  name : String,
  params : Array[@ast.Parameter],
  body : Array[@ast.Stmt],
  span : @ast.Span,
) -> Unit raise StarlarkError {
  // Evaluate default parameter values at definition time
  let defaults : Array[@value.Value?] = []
  for param in params {
    match param.default {
      Some(expr) => defaults.push(Some(eval_expr(scope, expr)))
      None => defaults.push(None)
    }
  }
  // Capture the current scope (closure)
  let closure_scope = scope
  let param_names : Array[String] = []
  for param in params {
    param_names.push(param.name)
  }
  let callable : (Array[@value.Value], Map[String, @value.Value]) -> @value.Value raise Error = fn(
    pos_args,
    kw_args,
  ) raise {
    // Create a new child scope of the closure scope
    let call_scope = Scope::new(parent=closure_scope)
    // Bind arguments to parameter names
    for i = 0; i < param_names.length(); i = i + 1 {
      let param_name = param_names[i]
      if i < pos_args.length() {
        // Check if also given as keyword
        if kw_args.contains(param_name) {
          raise StarlarkError::TypeError(
            message="\{name}() got multiple values for argument '\{param_name}'",
            span~,
          )
        }
        call_scope.set(param_name, pos_args[i])
      } else {
        // Check keyword args
        match kw_args.get(param_name) {
          Some(v) => call_scope.set(param_name, v)
          None =>
            // Use default if available
            match defaults[i] {
              Some(default_val) => call_scope.set(param_name, default_val)
              None =>
                raise StarlarkError::TypeError(
                  message="\{name}() missing required argument: '\{param_name}'",
                  span~,
                )
            }
        }
      }
    }
    // Check for too many positional args
    if pos_args.length() > param_names.length() {
      raise StarlarkError::TypeError(
        message="\{name}() takes \{param_names.length()} positional argument(s) but \{pos_args.length()} were given",
        span~,
      )
    }
    // Check for unknown keyword args
    for k, _ in kw_args {
      let mut found = false
      for pname in param_names {
        if pname == k {
          found = true
          break
        }
      }
      if not(found) {
        raise StarlarkError::TypeError(
          message="\{name}() got an unexpected keyword argument '\{k}'",
          span~,
        )
      }
    }
    // Execute the body
    eval_stmts(call_scope, body) catch {
      ReturnSignal::ReturnSignal(val) => return val
      other => raise other
    }
    @value.Value::None
  }
  scope.set(name, @value.Value::Function(name, callable))
}

///|
fn eval_load(
  scope : Scope,
  mod_name : String,
  bindings : Array[(String, String)],
  span : @ast.Span,
) -> Unit raise Error {
  // 1. Get the environment
  let env = match scope.get_env() {
    Some(e) => e
    None =>
      raise StarlarkError::TypeError(
        message="no module loader configured",
        span~,
      )
  }
  // 2. Check module cache
  let exports = match env.module_cache.get(mod_name) {
    Some(cached) => cached
    None => {
      // 3. Get the loader
      let loader = match env.loader {
        Some(l) => l
        None =>
          raise StarlarkError::TypeError(
            message="no module loader configured",
            span~,
          )
      }
      // 4. Call loader to get source
      let source = loader(mod_name)
      // 5. Lex, parse, and evaluate the module in a fresh scope
      let mod_scope = Scope::new(env~)
      for name, val in env.predeclared {
        mod_scope.set(name, val)
      }
      let tokens = @lexer.tokenize(source)
      let stmts = @parser.parse(tokens)
      eval_stmts(mod_scope, stmts)
      // 6. Extract exported globals (names not starting with '_')
      let exported : Map[String, @value.Value] = {}
      for name, val in mod_scope.bindings {
        if not(env.predeclared.contains(name)) {
          exported.set(name, val)
        }
      }
      // 7. Cache the exports
      env.module_cache.set(mod_name, exported)
      exported
    }
  }
  // 8. Bind each requested name into the current scope
  for binding in bindings {
    let (local_name, remote_name) = binding
    // Check for private names
    if remote_name.length() > 0 && remote_name[0] == '_' {
      raise StarlarkError::TypeError(
        message="cannot load private name '\{remote_name}' from '\{mod_name}'",
        span~,
      )
    }
    match exports.get(remote_name) {
      Some(val) => scope.set(local_name, val)
      None =>
        raise StarlarkError::TypeError(
          message="module '\{mod_name}' has no symbol '\{remote_name}'",
          span~,
        )
    }
  }
}

///|
pub fn exec_module(scope : Scope, stmts : Array[@ast.Stmt]) -> Unit raise Error {
  eval_stmts(scope, stmts)
}

///|
fn format_string(
  fmt : String,
  args : Array[@value.Value],
  span : @ast.Span,
) -> String raise StarlarkError {
  let buf = StringBuilder::new()
  let mut arg_index = 0
  let len = fmt.length()
  let mut i = 0
  while i < len {
    let code = fmt[i].to_int()
    if code == '%'.to_int() {
      i = i + 1
      if i >= len {
        raise StarlarkError::ValueError(message="incomplete format", span~)
      }
      let verb = fmt[i].to_int()
      if verb == '%'.to_int() {
        buf.write_char('%')
      } else if verb == 's'.to_int() {
        if arg_index >= args.length() {
          raise StarlarkError::ValueError(
            message="not enough arguments for format string",
            span~,
          )
        }
        buf.write_string(value_to_str(args[arg_index]))
        arg_index = arg_index + 1
      } else if verb == 'r'.to_int() {
        if arg_index >= args.length() {
          raise StarlarkError::ValueError(
            message="not enough arguments for format string",
            span~,
          )
        }
        buf.write_string(value_to_repr(args[arg_index]))
        arg_index = arg_index + 1
      } else if verb == 'd'.to_int() {
        if arg_index >= args.length() {
          raise StarlarkError::ValueError(
            message="not enough arguments for format string",
            span~,
          )
        }
        match args[arg_index] {
          Int(n) => buf.write_string(n.to_string())
          _ =>
            raise StarlarkError::TypeError(
              message="%d format: a number is required, not \{args[arg_index].type_name()}",
              span~,
            )
        }
        arg_index = arg_index + 1
      } else {
        raise StarlarkError::ValueError(
          message="unsupported format character '\{verb.unsafe_to_char()}'",
          span~,
        )
      }
    } else {
      buf.write_char(code.unsafe_to_char())
    }
    i = i + 1
  }
  if arg_index != args.length() {
    raise StarlarkError::ValueError(
      message="not all arguments converted during string formatting",
      span~,
    )
  }
  buf.to_string()
}