// Loading Ion Schema documents into the constraint model.

///|
/// Reject annotations other than those a type reference may have:
/// `$null_or::` in ISL 2.0, and `nullable::` in ISL 1.0, where an inline type
/// may also be `type::`.
fn reject_unknown_annotations(
  value : @ion.IonValue,
  context : String,
  ctx : Context,
) -> Unit raise @ion.IonError {
  let allowed = match (ctx.version, value.kind()) {
    (Isl10, @ion.IonValueKind::Struct(_)) => ["nullable", "type"]
    (Isl10, _) => ["nullable"]
    (Isl20, _) => ["$null_or"]
  }
  for token in value.annotations() {
    let name = token.require_text()
    if !allowed.contains(name) {
      raise @ion.IonError::DataModel(
        "the annotation '\{name}' is not allowed on " + context,
      )
    }
  }
}

///|
/// Load a schema from Ion Schema text.
pub fn Schema::load_from_text(
  text : StringView,
  resolver? : (String) -> Array[@ion.IonValue] raise @ion.IonError,
  fuel~ : @ion.Fuel = Unlimited,
) -> Schema raise @ion.IonError {
  Schema::load(@text.read_ion_datagram(text, fuel~), resolver?, fuel~)
}

///|
/// Whether a value carries the named annotation.
fn has_annotation(value : @ion.IonValue, name : String) -> Bool {
  for token in value.annotations() {
    if token.text() == Some(name) {
      return true
    }
  }
  false
}

///|
/// The value of the named struct field, when present.
fn field_value(fields : Array[@ion.Field], name : String) -> @ion.IonValue? {
  for field in fields {
    if field.name().text() == Some(name) {
      return Some(field.value())
    }
  }
  None
}

///|
/// The integer value of an Ion `int`.
fn int_of(value : @ion.IonValue) -> Int32 raise @ion.IonError {
  match value.kind() {
    @ion.IonValueKind::Int(n) => n.to_int()
    _ => raise @ion.IonError::DataModel("expected an integer")
  }
}

///|
/// Load a `type::{ ... }` definition in the scope of `ctx`.
async fn load_type_definition(
  fields : Array[@ion.Field],
  ctx : Context,
) -> TypeDefinition raise @ion.IonError + nocancel {
  { name: type_name(fields), constraints: load_type_fields(fields, ctx), }
}

///|
/// Load the constraints of a type definition, named or inline. An inline type
/// has no name. It may have an `occurs` when it is `variably_occurring`, in
/// `fields` or `ordered_elements`, and not `nullable`; ISL 1.0 allows a valid
/// `occurs` in any type, and ignores it where it does not apply.
///
/// In ISL 1.0, a type with no `type` constraint has `type: any`, and a field
/// that is not an ISL 1.0 keyword is open content. `content: closed` loads as
/// the names of the type's `fields`, which are the only fields allowed.
async fn load_type_fields(
  fields : Array[@ion.Field],
  ctx : Context,
  inline~ : Bool = false,
  variably_occurring~ : Bool = false,
  nullable~ : Bool = false,
) -> Array[Constraint] raise @ion.IonError + nocancel {
  let constraints : Array[Constraint] = []
  let mut closed = false
  for field in fields {
    let key = field.name().require_text()
    let value = field.value()
    if key == "name" {
      if inline {
        raise @ion.IonError::DataModel("an inline type must not have a name")
      }
      continue
    }
    match ctx.version {
      Isl10 =>
        match key {
          "occurs" => {
            let occurs = load_occurs(value)
            if occurs.upper == Some(0) {
              raise @ion.IonError::DataModel("occurs must allow at least 1")
            }
            if nullable {
              raise @ion.IonError::DataModel(
                "a type with 'occurs' cannot be 'nullable::'",
              )
            }
          }
          "content" => {
            if !(value.kind() is @ion.IonValueKind::Symbol(token) &&
              token.text() == Some("closed") &&
              !value.has_annotations()) {
              raise @ion.IonError::DataModel(
                "content must be the symbol closed",
              )
            }
            closed = true
          }
          _ =>
            if isl10_constraints.contains(key) {
              constraints.push(load_constraint(key, value, ctx))
            }
        }
      Isl20 => {
        if key == "occurs" {
          if !variably_occurring {
            raise @ion.IonError::DataModel(
              "'occurs' is allowed only in 'fields' and 'ordered_elements'",
            )
          }
          if nullable {
            raise @ion.IonError::DataModel(
              "a type with 'occurs' cannot be '$null_or::'",
            )
          }
          continue
        }
        if is_user_field(key, ctx.user_fields) {
          continue
        }
        constraints.push(load_constraint(key, value, ctx))
      }
    }
  }
  if ctx.version == Isl10 {
    if field_value(fields, "type") is None {
      constraints.insert(0, isl10_any())
    }
    if closed {
      let names = match
        field_value(fields, "fields").map(value => value.kind()) {
        Some(@ion.IonValueKind::Struct(declared)) =>
          declared.map(field => field.name().require_text())
        _ => []
      }
      constraints.push(ClosedFields(names))
    }
  }
  constraints
}

///|
/// The ISL 1.0 constraints, which `load_constraint` loads. `occurs` and
/// `content` are keywords too, which `load_type_fields` loads.
let isl10_constraints : Array[String] = [
  "all_of", "annotations", "any_of", "byte_length", "codepoint_length", "container_length",
  "contains", "element", "fields", "not", "one_of", "ordered_elements", "precision",
  "regex", "scale", "timestamp_offset", "timestamp_precision", "type", "utf8_byte_length",
  "valid_values",
]

///|
/// The ISL 1.0 core type `any`: any value that is not null, or a document.
fn isl10_any() -> Constraint {
  AnyOf([IsType(all_ion_types), IsDocument])
}

///|
/// The Ion types other than null.
let all_ion_types : Array[@ion.IonType] = [
  @ion.IonType::Bool,
  @ion.IonType::Int,
  @ion.IonType::Float,
  @ion.IonType::Decimal,
  @ion.IonType::Timestamp,
  @ion.IonType::Symbol,
  @ion.IonType::String,
  @ion.IonType::Clob,
  @ion.IonType::Blob,
  @ion.IonType::List,
  @ion.IonType::Sexp,
  @ion.IonType::Struct,
]

///|
/// Load one constraint by its field name.
async fn load_constraint(
  key : String,
  value : @ion.IonValue,
  ctx : Context,
) -> Constraint raise @ion.IonError + nocancel {
  if ctx.version == Isl20 && !implemented_constraints.contains(key) {
    if isl_keywords.contains(key) {
      raise @ion.IonError::Unsupported(
        "unsupported Ion Schema constraint: " + key,
      )
    }
    raise @ion.IonError::DataModel(
      "'\{key}' is not an Ion Schema 2.0 keyword; declare it in the header's user_reserved_fields to use it as a user field",
    )
  }
  // These take annotations of their own, which their loaders check.
  if !checks_own_annotations.contains(key) && value.has_annotations() {
    raise @ion.IonError::DataModel("constraint '\{key}' takes no annotation")
  }
  match key {
    "type" => load_type_constraint(value, ctx)
    "element" if ctx.version == Isl10 => Element(load_inline_type(value, ctx))
    "fields" if ctx.version == Isl10 => {
      if value.has_annotations() {
        raise @ion.IonError::DataModel("'fields' takes no annotation")
      }
      Fields(load_fields(value, ctx))
    }
    "annotations" if ctx.version == Isl10 => load_isl10_annotations(value)
    "scale" => {
      // The scale is the number of digits after the decimal point: the
      // negated exponent.
      let (lower, upper) = load_range(value)
      Exponent(
        match upper {
          Some(upper) => -upper
          None => -2_147_483_648
        },
        Some(-lower),
      )
    }
    "element" => {
      let (bare, distinct) = take_modifier(value, "distinct")
      let element = Element(load_inline_type(bare, ctx))
      if distinct {
        AllOf([element, DistinctElements])
      } else {
        element
      }
    }
    "field_names" => {
      let (bare, distinct) = take_modifier(value, "distinct")
      let names = FieldNames(load_inline_type(bare, ctx))
      if distinct {
        AllOf([names, DistinctFieldNames])
      } else {
        names
      }
    }
    "fields" => {
      let (bare, closed) = take_modifier(value, "closed")
      if bare.has_annotations() {
        raise @ion.IonError::DataModel(
          "'fields' takes no annotation but closed",
        )
      }
      let fields = load_fields(bare, ctx)
      if closed {
        AllOf([Fields(fields), ClosedFields(fields.map(field => field.name))])
      } else {
        Fields(fields)
      }
    }
    "annotations" => Annotations(load_annotations(value, ctx))
    "ordered_elements" => {
      let items = match value.kind() {
        @ion.IonValueKind::List(items) if !value.has_annotations() => items
        _ =>
          raise @ion.IonError::DataModel(
            "ordered_elements must be a non-null list of types",
          )
      }
      let slots : Array[(Constraint, Occurs)] = []
      for item in items {
        slots.push(
          (
            load_inline_type(item, ctx, variably_occurring=true),
            occurs_of(item).unwrap_or(Occurs::required()),
          ),
        )
      }
      OrderedElements(slots)
    }
    "valid_values" => load_valid_values(value, ctx.version)
    "container_length" => {
      let (lower, upper) = load_range(value)
      ContainerLength(lower, upper)
    }
    "codepoint_length" => {
      let (lower, upper) = load_range(value)
      CodepointLength(lower, upper)
    }
    "one_of" => OneOf(load_constraint_list(value, ctx))
    "any_of" => AnyOf(load_constraint_list(value, ctx))
    "all_of" => AllOf(load_constraint_list(value, ctx))
    "not" => Not(load_inline_type(value, ctx))
    "contains" => Contains(as_list(value))
    "regex" => Matches(load_regex(value, ctx))
    "byte_length" => {
      let (lower, upper) = load_range(value)
      ByteLength(lower, upper)
    }
    "utf8_byte_length" => {
      let (lower, upper) = load_range(value)
      Utf8ByteLength(lower, upper)
    }
    "precision" => {
      let (lower, upper) = load_ordered_range(value, Some(1), int_of)
      Precision(lower, upper)
    }
    "exponent" => {
      let (lower, upper) = load_ordered_range(value, None, int_of)
      Exponent(lower, upper)
    }
    "timestamp_precision" => {
      let (lower, upper) = load_ordered_range(value, None, precision_of)
      TimestampPrecision(lower, upper)
    }
    "timestamp_offset" => TimestampOffsets(load_offsets(value))
    "ieee754_float" => Ieee754Float(load_float_format(value))
    _ => panic()
  }
}

///|
/// The constraints `load_constraint` loads.
let implemented_constraints : Array[String] = [
  "type", "element", "fields", "valid_values", "container_length", "codepoint_length",
  "one_of", "any_of", "all_of", "not", "contains", "regex", "byte_length", "utf8_byte_length",
  "precision", "exponent", "timestamp_precision", "timestamp_offset", "ieee754_float",
  "field_names", "annotations", "ordered_elements",
]

///|
/// The constraints whose loaders check their own annotations.
let checks_own_annotations : Array[String] = [
  "type", "not", "container_length", "codepoint_length", "valid_values", "regex",
  "byte_length", "utf8_byte_length", "precision", "exponent", "timestamp_precision",
  "timestamp_offset", "ieee754_float", "element", "field_names", "fields", "annotations",
  "ordered_elements", "scale",
]

///|
/// A value without its first annotation, when that is the modifier `name`,
/// and whether it was there. The value's other annotations stay, for the
/// loader to check.
fn take_modifier(
  value : @ion.IonValue,
  name : String,
) -> (@ion.IonValue, Bool) raise @ion.IonError {
  match value.annotations() {
    [first, .. rest] if first.text() == Some(name) => {
      if rest.iter().any(token => token.text() == Some(name)) {
        raise @ion.IonError::DataModel("'\{name}::' appears twice")
      }
      (value.with_annotations(rest.to_owned()), true)
    }
    _ => (value, false)
  }
}

///|
/// Load an `annotations` constraint. The standard form is a type, which the
/// value's annotations, as a list of symbols, must satisfy. The simplified
/// form is a list of symbols annotated with `required::`, `closed::`, or
/// both: `required` means each symbol is an annotation of the value, and
/// `closed` means the value has no other annotation.
async fn load_annotations(
  value : @ion.IonValue,
  ctx : Context,
) -> Constraint raise @ion.IonError + nocancel {
  guard value.kind() is @ion.IonValueKind::List(items) else {
    return load_inline_type(value, ctx)
  }
  let modifiers = value.annotations().map(token => token.text())
  let required = modifiers.contains(Some("required"))
  let closed = modifiers.contains(Some("closed"))
  let expected = (if required { 1 } else { 0 }) + (if closed { 1 } else { 0 })
  if modifiers.is_empty() || modifiers.length() != expected {
    raise @ion.IonError::DataModel(
      "an annotations list must be annotated with required::, closed::, or both",
    )
  }
  let symbols : Array[@ion.IonValue] = []
  for item in items {
    match item.kind() {
      @ion.IonValueKind::Symbol(_) if !item.has_annotations() =>
        symbols.push(item)
      _ =>
        raise @ion.IonError::DataModel(
          "an annotations list holds symbols without annotations",
        )
    }
  }
  let parts : Array[Constraint] = []
  if required {
    parts.push(Contains(symbols))
  }
  if closed {
    parts.push(Element(ValidValues(symbols)))
  }
  if parts.length() == 1 {
    parts[0]
  } else {
    AllOf(parts)
  }
}

///|
/// Load an ISL 1.0 `annotations` constraint: a list of symbols, each
/// optionally `required::` or `optional::`, annotated with any of
/// `required::`, `ordered::`, and `closed::`. A symbol is required when it is
/// `required::`, or when the list is `required::` and the symbol is not
/// `optional::`.
///
/// - Without `ordered`, each required symbol is an annotation of the value,
///   and, when `closed`, the value has no annotation but these symbols.
/// - With `ordered` and `closed`, the value's annotations are the symbols in
///   order, each once, or not at all if it is not required.
/// - With `ordered` alone, the required symbols are among the value's
///   annotations in order, with any others before, between, or after them.
fn load_isl10_annotations(
  value : @ion.IonValue,
) -> Constraint raise @ion.IonError {
  let items = match value.kind() {
    @ion.IonValueKind::List(items) => items
    _ =>
      raise @ion.IonError::DataModel(
        "annotations must be a non-null list of symbols",
      )
  }
  let modifiers : Array[String] = []
  for token in value.annotations() {
    let name = token.require_text()
    if !["required", "ordered", "closed"].contains(name) ||
      modifiers.contains(name) {
      raise @ion.IonError::DataModel(
        "an annotations list takes required::, ordered::, and closed::, each at most once",
      )
    }
    modifiers.push(name)
  }
  let symbols : Array[(@ion.IonValue, Bool)] = []
  for item in items {
    let required = match item.annotations().map(token => token.text()) {
      [] => modifiers.contains("required")
      [Some("required")] => true
      [Some("optional")] => false
      _ =>
        raise @ion.IonError::DataModel(
          "an annotation may be only required:: or optional::",
        )
    }
    match item.kind() {
      @ion.IonValueKind::Symbol(_) =>
        symbols.push((item.with_annotations([]), required))
      _ =>
        raise @ion.IonError::DataModel(
          "an annotations list holds non-null symbols",
        )
    }
  }
  let one = (symbol : @ion.IonValue) => ValidValues([symbol])
  let any_number = (Constraint::any(), Occurs::new(0, None))
  let constraint = match
    (modifiers.contains("ordered"), modifiers.contains("closed")) {
    (true, true) =>
      OrderedElements(
        symbols.map(pair => {
          let (symbol, required) = pair
          (
            one(symbol),
            if required {
              Occurs::required()
            } else {
              Occurs::optional()
            },
          )
        }),
      )
    (true, false) => {
      let slots = [any_number]
      for pair in symbols {
        if pair.1 {
          slots.push((one(pair.0), Occurs::required()))
          slots.push(any_number)
        }
      }
      OrderedElements(slots)
    }
    (false, closed) => {
      let parts : Array[Constraint] = []
      let required = symbols.filter(pair => pair.1).map(pair => pair.0)
      if !required.is_empty() {
        parts.push(Contains(required))
      }
      if closed {
        parts.push(Element(ValidValues(symbols.map(pair => pair.0))))
      }
      if parts.length() == 1 {
        parts[0]
      } else {
        AllOf(parts)
      }
    }
  }
  Annotations(constraint)
}

///|
/// The integer for a named timestamp precision, as `TimestampPrecision`
/// counts it.
fn precision_of(value : @ion.IonValue) -> Int32 raise @ion.IonError {
  let name = match value.kind() {
    @ion.IonValueKind::Symbol(token) => token.text()
    _ => None
  }
  match name {
    Some("year") => -4
    Some("month") => -3
    Some("day") => -2
    Some("minute") => -1
    Some("second") => 0
    Some("millisecond") => 3
    Some("microsecond") => 6
    Some("nanosecond") => 9
    _ =>
      raise @ion.IonError::DataModel(
        "a timestamp precision must be year, month, day, minute, second, millisecond, microsecond, or nanosecond",
      )
  }
}

///|
/// Load a `timestamp_offset` list: one or more strings `[+|-]hh:mm`, where
/// `-00:00` is the unknown offset.
fn load_offsets(
  value : @ion.IonValue,
) -> Array[@ion.TimestampOffset] raise @ion.IonError {
  let invalid = @ion.IonError::DataModel(
    "timestamp_offset must be a non-empty list of offsets such as \"+01:30\" or \"-00:00\"",
  )
  let items = match value.kind() {
    @ion.IonValueKind::List(items) if !value.has_annotations() &&
      !items.is_empty() => items
    _ => raise invalid
  }
  items.map(item => {
    let text = match item.kind() {
      @ion.IonValueKind::String(text) if !item.has_annotations() => text
      _ => raise invalid
    }
    let chars : Array[Char] = text.iter().collect()
    let digit = (index : Int32) => {
      let c = chars[index]
      if c >= '0' && c <= '9' {
        c.to_int() - '0'.to_int()
      } else {
        raise invalid
      }
    }
    if chars.length() != 6 ||
      !(chars[0] == '+' || chars[0] == '-') ||
      chars[3] != ':' {
      raise invalid
    }
    let hours = digit(1) * 10 + digit(2)
    let minutes = digit(4) * 10 + digit(5)
    if hours > 23 || minutes > 59 {
      raise invalid
    }
    if text == "-00:00" {
      @ion.TimestampOffset::Unknown
    } else {
      let total = hours * 60 + minutes
      @ion.TimestampOffset::Known(if chars[0] == '-' { -total } else { total })
    }
  })
}

///|
/// Load an `ieee754_float` format: `binary16`, `binary32`, or `binary64`.
fn load_float_format(value : @ion.IonValue) -> FloatFormat raise @ion.IonError {
  let name = match value.kind() {
    @ion.IonValueKind::Symbol(token) if !value.has_annotations() => token.text()
    _ => None
  }
  match name {
    Some("binary16") => Binary16
    Some("binary32") => Binary32
    Some("binary64") => Binary64
    _ =>
      raise @ion.IonError::DataModel(
        "ieee754_float must be binary16, binary32, or binary64",
      )
  }
}

///|
/// Load a `type:` constraint: a type name, or an inline
/// type or import, with `$null_or::` if nullable.
async fn load_type_constraint(
  value : @ion.IonValue,
  ctx : Context,
) -> Constraint raise @ion.IonError + nocancel {
  load_inline_type(value, ctx)
}

///|
/// Load a `type:` constraint, ignoring annotations.
fn load_type_constraint_bare(
  value : @ion.IonValue,
  ctx : Context,
) -> Constraint raise @ion.IonError {
  match value.kind() {
    @ion.IonValueKind::Symbol(token) => {
      let name = token.require_text()
      match built_in_type(name) {
        _ if name == "any" && ctx.version == Isl10 => isl10_any()
        Some(constraint) => constraint
        None => ctx.resolve(name)
      }
    }
    _ =>
      raise @ion.IonError::DataModel(
        "a type must be a type name, an inline type, or an inline import",
      )
  }
}

///|
/// The Ion types that a built-in type name without a `$` covers.
fn built_in_ion_types(name : String) -> Array[@ion.IonType]? {
  match name {
    "text" => Some([@ion.IonType::String, @ion.IonType::Symbol])
    "lob" => Some([@ion.IonType::Blob, @ion.IonType::Clob])
    "number" =>
      Some([@ion.IonType::Int, @ion.IonType::Float, @ion.IonType::Decimal])
    "any" => Some(all_ion_types)
    "null" | "$null" => None
    _ =>
      match @ion.IonType::from_text(name) {
        Some(t) => Some([t])
        None => None
      }
  }
}

///|
/// The constraint of an ISL 2.0 built-in type. A name without a `$`, such as
/// `int` or `text`, covers the non-null values of its Ion types. A name with a
/// `$`, such as `$int` or `$text`, also covers the typed nulls of those types.
/// `$null` covers `null.null`, `$any` every value, and `nothing` no value.
fn built_in_type(name : String) -> Constraint? {
  match name {
    "$any" => Some(Constraint::any())
    "$null" =>
      Some(ValidValues([@ion.IonValue::typed_null(@ion.IonType::Null)]))
    "nothing" => Some(Nothing)
    "document" => Some(IsDocument)
    _ if name.has_prefix("$") =>
      match built_in_ion_types(name.view(start_offset=1).to_owned()) {
        Some(types) =>
          Some(
            AnyOf([
              IsType(types),
              ValidValues(types.map(t => @ion.IonValue::typed_null(t))),
            ]),
          )
        _ => None
      }
    _ => built_in_ion_types(name).map(types => IsType(types))
  }
}

///|
/// Load a `valid_values` constraint: a list of values and ranges, or one
/// range. No other value in the list may have annotations. In ISL 1.0, no
/// bound of a timestamp range may have the unknown offset.
fn load_valid_values(
  value : @ion.IonValue,
  version : IslVersion,
) -> Constraint raise @ion.IonError {
  let load = (range : @ion.IonValue) => {
    let loaded = load_value_range(range)
    if version == Isl10 && loaded.has_unknown_offset() {
      raise @ion.IonError::DataModel(
        "an Ion Schema 1.0 timestamp range bound must have a known offset",
      )
    }
    InRange(loaded)
  }
  if value.has_annotations() {
    return load(value)
  }
  let values : Array[@ion.IonValue] = []
  let ranges : Array[Constraint] = []
  for item in as_list(value) {
    if has_annotation(item, "range") {
      ranges.push(load(item))
    } else if item.has_annotations() {
      raise @ion.IonError::DataModel(
        "a value in 'valid_values' must not have annotations",
      )
    } else {
      values.push(item)
    }
  }
  if ranges.is_empty() {
    ValidValues(values)
  } else if values.is_empty() && ranges.length() == 1 {
    ranges[0]
  } else {
    AnyOf([ValidValues(values)] + ranges)
  }
}

///|
/// Load a nested type, which is either a symbol or an inline `{ ... }` type.
async fn load_inline_type(
  value : @ion.IonValue,
  ctx : Context,
  variably_occurring~ : Bool = false,
) -> Constraint raise @ion.IonError + nocancel {
  ctx.loader.runner.pause()
  defer ctx.loader.runner.pause()
  let outer = ctx.loader.fuel
  defer {
    ctx.loader.fuel = outer
  }
  ctx.loader.fuel = outer.enter(SchemaLoad, ValuePath("$"))
  reject_unknown_annotations(value, "inline type", ctx)
  let nullable = has_annotation(
    value,
    match ctx.version {
      Isl10 => "nullable"
      Isl20 => "$null_or"
    },
  )
  let inner = match value.kind() {
    @ion.IonValueKind::Struct(fields) if field_value(fields, "id") is Some(_) =>
      ctx.inline_import(fields)
    @ion.IonValueKind::Struct(fields) => {
      let constraints = load_type_fields(
        fields,
        ctx,
        inline=true,
        variably_occurring~,
        nullable~,
      )
      if constraints.length() == 1 {
        constraints[0]
      } else {
        AllOf(constraints)
      }
    }
    _ => load_type_constraint_bare(value, ctx)
  }
  match (nullable, ctx.version) {
    (false, _) => inner
    (true, Isl20) => NullOr(inner)
    (true, Isl10) => {
      let nulls = [@ion.IonValue::typed_null(@ion.IonType::Null)]
      for ion_type in ctx.base_types(value, Set([])) {
        nulls.push(@ion.IonValue::typed_null(ion_type))
      }
      AnyOf([ValidValues(nulls), inner])
    }
  }
}

///|
/// Load an `occurs` value, when present.
fn occurs_of(value : @ion.IonValue) -> Occurs? raise @ion.IonError {
  match value.kind() {
    @ion.IonValueKind::Struct(fields) =>
      match field_value(fields, "occurs") {
        Some(occurs) => Some(load_occurs(occurs))
        None => None
      }
    _ => None
  }
}

///|
/// Load an `occurs` value.
fn load_occurs(value : @ion.IonValue) -> Occurs raise @ion.IonError {
  match value.kind() {
    @ion.IonValueKind::Symbol(token) => {
      let name = token.require_text()
      match name {
        "required" => Occurs::required()
        "optional" => Occurs::optional()
        _ => raise @ion.IonError::DataModel("unknown occurs value: " + name)
      }
    }
    @ion.IonValueKind::Int(n) => {
      let count = n.to_int()
      if count < 0 {
        raise @ion.IonError::DataModel("occurs count must be non-negative")
      }
      Occurs::new(count, Some(count))
    }
    @ion.IonValueKind::List(_) => {
      let (lower, upper) = load_range(value)
      Occurs::new(lower, upper)
    }
    _ => raise @ion.IonError::DataModel("invalid occurs value")
  }
}

///|
/// Load a length or count: a non-negative integer, or a range of them.
fn load_range(value : @ion.IonValue) -> (Int32, Int32?) raise @ion.IonError {
  load_ordered_range(value, Some(0), int_of)
}

///|
/// Load an exact value or a `range::[lower, upper]` of values that `read`
/// turns into integers, where a bound may be `exclusive::`, `lower` may be
/// `min`, and `upper` may be `max`. The result is the inclusive range, with
/// `None` for no upper bound. No value may be below `floor`.
fn load_ordered_range(
  value : @ion.IonValue,
  floor : Int32?,
  read : (@ion.IonValue) -> Int32 raise @ion.IonError,
) -> (Int32, Int32?) raise @ion.IonError {
  let too_low = () => {
    @ion.IonError::DataModel(
      "the value is below the lowest allowed, \{floor.unwrap_or(0)}",
    )
  }
  if !value.has_annotations() {
    let single = read(value)
    if floor is Some(floor) && single < floor {
      raise too_low()
    }
    return (single, Some(single))
  }
  let (lower_value, upper_value) = range_bounds(value)
  let lower = bound_value(lower_value, "min")
  let upper = bound_value(upper_value, "max")
  if lower is None && upper is None {
    raise @ion.IonError::DataModel("a range needs at least one bound")
  }
  let lower = match lower {
    Some((bound, exclusive)) => read(bound) + (if exclusive { 1 } else { 0 })
    None => floor.unwrap_or(-2_147_483_648)
  }
  let upper = match upper {
    Some((bound, exclusive)) =>
      Some(read(bound) - (if exclusive { 1 } else { 0 }))
    None => None
  }
  if floor is Some(floor) && lower < floor {
    raise too_low()
  }
  if upper is Some(upper) && upper < lower {
    raise @ion.IonError::DataModel("the range contains no value")
  }
  (lower, upper)
}

///|
/// Load a `fields: { ... }` constraint.
async fn load_fields(
  value : @ion.IonValue,
  ctx : Context,
) -> Array[FieldConstraint] raise @ion.IonError + nocancel {
  match value.kind() {
    @ion.IonValueKind::Struct(fields) => {
      if fields.is_empty() {
        raise @ion.IonError::DataModel("'fields' must name at least one field")
      }
      let result : Array[FieldConstraint] = []
      for field in fields {
        let name = field.name().require_text()
        if result.iter().any(other => other.name == name) {
          raise @ion.IonError::DataModel(
            "'fields' names the field '\{name}' twice",
          )
        }
        let type_value = field.value()
        let ty = load_inline_type(type_value, ctx, variably_occurring=true)
        let occurs = match occurs_of(type_value) {
          Some(occurs) => occurs
          None => Occurs::optional()
        }
        if occurs.upper == Some(0) {
          raise @ion.IonError::DataModel(
            "the field '\{name}' cannot occur 0 times",
          )
        }
        result.push({ name, ty, occurs, })
      }
      result
    }
    _ => raise @ion.IonError::DataModel("a fields constraint must be a struct")
  }
}

///|
/// A value that must be a list, as an array.
fn as_list(value : @ion.IonValue) -> Array[@ion.IonValue] raise @ion.IonError {
  match value.kind() {
    @ion.IonValueKind::List(items) => items
    _ => raise @ion.IonError::DataModel("expected a list")
  }
}

///|
/// Load a list of nested constraints.
async fn load_constraint_list(
  value : @ion.IonValue,
  ctx : Context,
) -> Array[Constraint] raise @ion.IonError + nocancel {
  let items = as_list(value)
  let result : Array[Constraint] = []
  for item in items {
    result.push(load_inline_type(item, ctx))
  }
  result
}

///|
/// Load a `regex`: a non-empty string, annotated with the flags `i` and `m`
/// at most once each.
fn load_regex(
  value : @ion.IonValue,
  ctx : Context,
) -> Regex raise @ion.IonError {
  let mut ignore_case = false
  let mut multiline = false
  for token in value.annotations() {
    match token.text() {
      Some("i") if !ignore_case => ignore_case = true
      Some("m") if !multiline => multiline = true
      _ =>
        raise @ion.IonError::DataModel(
          "a regex flag must be 'i' or 'm', each at most once",
        )
    }
  }
  match value.kind() {
    @ion.IonValueKind::String(source) if source != "" =>
      make_regex(source, ignore_case, multiline, ctx.loader.fuel)
    _ => raise @ion.IonError::DataModel("a regex must be a non-empty string")
  }
}

///|
/// The Ion types whose typed nulls the ISL 1.0 `nullable::` of a type
/// reference allows: those of the type's base type, which is the type its
/// `type` constraint names, or `any` without one. `visiting` holds the type
/// keys on the path, so that a cycle of references ends.
async fn Context::base_types(
  self : Context,
  reference : @ion.IonValue,
  visiting : Set[String],
) -> Array[@ion.IonType] raise @ion.IonError + nocancel {
  self.loader.runner.pause()
  defer self.loader.runner.pause()
  let outer = self.loader.fuel
  defer {
    self.loader.fuel = outer
  }
  self.loader.fuel = outer.enter(SchemaLoad, ValuePath("$"))
  let definition = match reference.kind() {
    @ion.IonValueKind::Symbol(token) => {
      let name = token.require_text()
      match name {
        "document" =>
          raise @ion.IonError::DataModel("a document cannot be nullable")
        "any" | "$any" => return all_ion_types
        "nothing" | "$null" => return []
        _ => ()
      }
      let ion_name = if name.has_prefix("$") {
        name.view(start_offset=1).to_owned()
      } else {
        name
      }
      if built_in_ion_types(ion_name) is Some(types) {
        return types
      }
      match self.scope.get(name) {
        Some(key) => key
        None =>
          raise @ion.IonError::DataModel(
            "no type named '\{name}' is defined or imported",
          )
      }
    }
    @ion.IonValueKind::Struct(fields) =>
      match field_value(fields, "id") {
        Some(_) => {
          let (id, name, _) = import_fields(fields, ["id", "type", "as"])
          type_key(Some(id), name.unwrap_or(""))
        }
        None =>
          return match field_value(fields, "type") {
            Some(inner) => self.base_types(inner, visiting)
            None => all_ion_types
          }
      }
    _ => return all_ion_types
  }
  if visiting.contains(definition) {
    return []
  }
  visiting.add(definition)
  match self.loader.definitions.get(definition) {
    Some((fields, ctx)) =>
      match field_value(fields, "type") {
        Some(inner) => ctx.base_types(inner, visiting)
        None => all_ion_types
      }
    None => all_ion_types
  }
}