///|
fn binder(
  o : Map[String, Value],
  prefix : String,
  min_args : Int32,
  max_args : Int32,
) -> (String, Array[String]) raise TemplateError {
  let keys = o.keys().filter(k => k.has_prefix(prefix + "(")).collect()
  if keys.length() != 1 {
    raise Invalid("exactly one binding property required")
  }
  let k = keys[0]
  if !k.has_suffix(")") {
    raise Invalid("malformed binding property")
  }
  let names = k[prefix.length() + 1:k.length() - 1]
    .to_owned()
    .split(",")
    .map(x => x.trim().to_owned())
    .collect()
  if names.length() < min_args || names.length() > max_args {
    raise Invalid("invalid binding argument count")
  }
  for name in names {
    if !identifier(name) {
      raise Invalid("invalid variable name")
    }
  }
  (k, names)
}

///|
fn render_collection(
  op : String,
  o : Map[String, Value],
  env : Map[String, Value],
  b : Budget,
  depth : Int32,
) -> Value raise TemplateError {
  let is_reduce = op == "$reduce"
  let (key, names) = binder(
    o,
    "each",
    if is_reduce {
      2
    } else {
      1
    },
    if is_reduce {
      3
    } else {
      2
    },
  )
  allowed(o, if is_reduce { [op, key, "initial"] } else { [op, key] })
  let body = required(o, key)
  if op == "$find" && !(body is Text(_)) {
    raise Invalid("find predicate must be expression string")
  }
  let value = render_value(required(o, op), env, b, depth + 1)
  let mut object_input = false
  let values = match value {
    Sequence(xs) => xs.map(x => (Nil, x))
    Record(obj) => {
      if op != "$map" {
        raise Invalid("operator requires array")
      }
      object_input = true
      obj.iter().map(pair => (Text(pair.0), pair.1)).collect()
    }
    _ => raise Invalid("map requires array or object")
  }
  let out = []
  let mapped = Map([])
  let mut acc = if is_reduce { required(o, "initial") } else { Nil }
  for i, pair in values {
    b.tick(depth)
    let child = env.copy()
    let item = pair.1
    if is_reduce {
      child[names[0]] = acc
      child[names[1]] = item
      if names.length() == 3 {
        child[names[2]] = Numeric(i.to_double())
      }
    } else if object_input {
      if names.length() == 1 {
        child[names[0]] = Record(Map([("key", pair.0), ("val", item)]))
      } else {
        child[names[0]] = item
        child[names[1]] = pair.0
      }
    } else {
      child[names[0]] = item
      if names.length() == 2 {
        child[names[1]] = Numeric(i.to_double())
      }
    }
    if op == "$find" {
      if truth(eval_source(string_value(body), child, b)) {
        return render_value(item, child, b, depth + 1)
      }
      continue
    }
    let result = render_value(body, child, b, depth + 1)
    if result == Omit {
      continue
    }
    if is_reduce {
      acc = result
    } else if object_input {
      match result {
        Record(obj) =>
          for k, v in obj {
            mapped[k] = v
          }
        _ => raise Invalid("object map body must return object")
      }
    } else {
      out.push(result)
    }
  }
  if is_reduce {
    acc
  } else if op == "$find" {
    Omit
  } else if object_input {
    Record(mapped)
  } else {
    Sequence(out)
  }
}