// The top level of an Ion Schema document.
//
// A schema document is a sequence of Ion values: an optional version marker,
// an optional `schema_header::{...}`, the `type::{...}` definitions, and an
// optional `schema_footer::{...}`, after which nothing counts. The version
// marker `$ion_schema_2_0` makes the document ISL 2.0; `$ion_schema_1_0`, or
// no version marker, makes it ISL 1.0.
//
// In ISL 2.0, any other top-level value is open content, which the loader
// ignores, unless it is annotated with a reserved symbol. The reserved
// symbols are those that match `$ion_schema(_.*)?` and those in lower snake
// case, such as `type` or `foo_bar`. A header, a type definition, and a
// footer may have fields whose names are not reserved; they are user
// content. The header's `user_reserved_fields` can also declare reserved
// symbols, other than ISL keywords, as user fields.
//
// In ISL 1.0, there are no reserved symbols: any other top-level value, and
// any field that is not a keyword, is open content. A document has a header
// only if it has a footer.
///|
/// The version of the Ion Schema Language that a schema document uses.
priv enum IslVersion {
Isl10
Isl20
} derive(Eq)
///|
/// Load a schema from a parsed schema document, in ISL 1.0 or ISL 2.0 as its
/// version marker says. A construct that the version does not allow raises
/// `IonError::DataModel`, and one that this implementation does not
/// implement raises `IonError::Unsupported`.
///
/// The schema holds ISL 2.0 constraints: the loader translates each ISL 1.0
/// construct into the ISL 2.0 constraints with the same meaning. For
/// example, the ISL 1.0 `nullable::int` loads as `null.null`, `null.int`, or
/// `int`.
///
/// `resolver` returns the parsed values of the schema document that an
/// import names by its `id`, such as a file under a base directory. Without
/// it, a schema with imports raises `IonError::Unsupported`. An imported type
/// is in `types()` as `#`.
pub fn Schema::load(
values : Array[@ion.IonValue],
resolver? : (String) -> Array[@ion.IonValue] raise @ion.IonError,
fuel~ : @ion.Fuel = Unlimited,
) -> Schema raise @ion.IonError {
let loader : Loader = {
resolver,
runner: @work.Runner(),
fuel: fuel.start(),
documents: Map([]),
definitions: Map([]),
types: [],
}
let _ = loader.runner.run(() => loader.load_document(None, values))
Schema::from_types(loader.types)
}
///|
/// The parts of a schema document.
priv struct Parsed {
version : IslVersion
imports : Array[@ion.IonValue]
definitions : Array[Array[@ion.Field]]
user_fields : UserFields
}
///|
/// Split a schema document into its imports, its type definitions, and the
/// user fields its header declares, checking the order and form of its
/// top-level values.
fn parse_document(values : Array[@ion.IonValue]) -> Parsed raise @ion.IonError {
let definitions : Array[Array[@ion.Field]] = []
let mut imports : Array[@ion.IonValue] = []
let mut user_fields = UserFields::none()
let mut version = Isl10
let mut version_seen = false
let mut header_seen = false
let mut footer_seen = false
for value in values {
if version_marker(value) is Some(marker) {
if value.has_annotations() {
raise @ion.IonError::DataModel(
"a version marker must not have annotations",
)
}
if version_seen || header_seen || !definitions.is_empty() {
raise @ion.IonError::DataModel(
"a schema has one version marker, before its header and types",
)
}
version = match marker {
"$ion_schema_2_0" => Isl20
"$ion_schema_1_0" => Isl10
_ =>
raise @ion.IonError::DataModel(
"unknown Ion Schema version: " + marker,
)
}
version_seen = true
continue
}
let annotations = value.annotations().map(token => token.text())
let keyword = annotations
.iter()
.find_first(text => {
text == Some("type") ||
text == Some("schema_header") ||
text == Some("schema_footer")
})
match keyword {
Some(Some(keyword)) => {
if annotations.length() != 1 {
raise @ion.IonError::DataModel(
"a '\{keyword}' must have no other annotation",
)
}
let fields = match value.kind() {
@ion.IonValueKind::Struct(fields) => fields
_ =>
raise @ion.IonError::DataModel(
"a '\{keyword}' must be a non-null struct",
)
}
match keyword {
"schema_header" => {
if header_seen || !definitions.is_empty() {
raise @ion.IonError::DataModel(
"a schema has one header, before its types",
)
}
header_seen = true
let (declared, header_imports) = load_header(fields, version)
user_fields = declared
imports = header_imports
}
"type" => definitions.push(fields)
_ => {
if version == Isl20 {
check_user_fields(fields, user_fields.footer, "schema footer")
}
footer_seen = true
break
}
}
}
_ =>
// Open content.
for text in annotations {
if version == Isl20 && text is Some(text) && is_reserved(text) {
raise @ion.IonError::DataModel(
"open content must not be annotated with the reserved symbol '\{text}'",
)
}
}
}
}
if version == Isl10 && header_seen != footer_seen {
raise @ion.IonError::DataModel(
"an Ion Schema 1.0 schema has a header only if it has a footer",
)
}
{ version, imports, definitions, user_fields, }
}
///|
/// Schema documents being loaded, and the types loaded so far.
priv struct Loader {
resolver : ((String) -> Array[@ion.IonValue] raise @ion.IonError)?
/// The names of the types each document defines, by the document's id,
/// known before its imports load, so that documents can import each other.
documents : Map[String, Array[String]]
/// The type definitions of each document, by type key, with the scope
/// they are in, for what a type reference refers to before it loads.
definitions : Map[String, (Array[@ion.Field], Context)]
types : Array[TypeDefinition]
runner : @work.Runner
mut fuel : @ion.FuelState
}
///|
/// The name a type has in the loaded schema: its own name for the root
/// document, and `#` for an imported one.
fn type_key(id : String?, name : String) -> String {
match id {
Some(id) => "\{id}#\{name}"
None => name
}
}
///|
/// Load a schema document, `id` for an imported one, and return the names of
/// the types it defines.
async fn Loader::load_document(
self : Loader,
id : String?,
values : Array[@ion.IonValue],
) -> Array[String] raise @ion.IonError + nocancel {
self.runner.pause()
defer self.runner.pause()
let outer = self.fuel
defer {
self.fuel = outer
}
if id is Some(id) {
self.fuel = outer.enter(SchemaImport, SchemaId(id))
}
let parsed = parse_document(values)
let names : Array[String] = []
for fields in parsed.definitions {
let name = type_name(fields)
if names.contains(name) {
raise @ion.IonError::DataModel("the type name '\{name}' is defined twice")
}
names.push(name)
}
if id is Some(id) {
self.documents[id] = names
}
let scope : Map[String, String] = Map([])
for name in names {
scope[name] = type_key(id, name)
}
let ctx : Context = {
loader: self,
id,
version: parsed.version,
scope,
user_fields: parsed.user_fields.types,
}
for fields in parsed.definitions {
self.definitions[type_key(id, type_name(fields))] = (fields, ctx)
}
for import_ in parsed.imports {
ctx.header_import(import_)
}
for fields in parsed.definitions {
let definition = load_type_definition(fields, ctx)
self.types.push({
name: type_key(id, definition.name),
constraints: definition.constraints,
})
}
names
}
///|
/// The names of the types the document `id` defines, loading it first if
/// needed. `importer` is the id of the document that imports it.
async fn Loader::names_of(
self : Loader,
id : String,
importer : String?,
) -> Array[String] raise @ion.IonError + nocancel {
if importer == Some(id) {
raise @ion.IonError::DataModel("the schema '\{id}' imports itself")
}
if self.documents.get(id) is Some(names) {
return names
}
let values = match self.resolver {
Some(resolver) => resolver(id)
None =>
raise @ion.IonError::Unsupported(
"the schema imports '\{id}', but no resolver was given to load it",
)
}
self.load_document(Some(id), values)
}
///|
/// What a type definition in one schema document can see: the types it
/// defines and imports, by the names they have there, and the reserved
/// symbols the header declares as user fields of a type.
priv struct Context {
loader : Loader
id : String?
version : IslVersion
scope : Map[String, String]
user_fields : Array[String]
}
///|
/// The reference to the type named `name` in the document's scope.
fn Context::resolve(
self : Context,
name : String,
) -> Constraint raise @ion.IonError {
match self.scope.get(name) {
Some(key) => TypeRef(key)
None =>
raise @ion.IonError::DataModel(
"no type named '\{name}' is defined or imported",
)
}
}
///|
/// Bring an imported type into the document's scope as `name`.
fn Context::add(
self : Context,
name : String,
key : String,
) -> Unit raise @ion.IonError {
match self.scope.get(name) {
Some(existing) if existing != key =>
raise @ion.IonError::DataModel(
"the type name '\{name}' is defined or imported twice",
)
_ => self.scope[name] = key
}
}
///|
/// Apply a header import: all the types of a schema, or one type, by its own
/// name or the name after `as`.
async fn Context::header_import(
self : Context,
import_ : @ion.IonValue,
) -> Unit raise @ion.IonError + nocancel {
let fields = match import_.kind() {
@ion.IonValueKind::Struct(fields) if !import_.has_annotations() => fields
_ =>
raise @ion.IonError::DataModel(
"an import must be a non-null struct without annotations",
)
}
let (id, name, rename) = import_fields(fields, ["id", "type", "as"])
let names = self.loader.names_of(id, self.id)
match name {
None => {
if rename is Some(_) {
raise @ion.IonError::DataModel("an import with 'as' needs a 'type'")
}
for name in names {
self.add(name, type_key(Some(id), name))
}
}
Some(name) => {
if !names.contains(name) {
raise @ion.IonError::DataModel(
"the schema '\{id}' has no type '\{name}'",
)
}
self.add(rename.unwrap_or(name), type_key(Some(id), name))
}
}
}
///|
/// The reference an inline import `{ id: ..., type: ... }` makes. It does not
/// change the document's scope. ISL 1.0 also allows an `as`, which has no
/// effect.
async fn Context::inline_import(
self : Context,
fields : Array[@ion.Field],
) -> Constraint raise @ion.IonError + nocancel {
let allowed = match self.version {
Isl10 => ["id", "type", "as"]
Isl20 => ["id", "type"]
}
let (id, name, _) = import_fields(fields, allowed)
guard name is Some(name) else {
raise @ion.IonError::DataModel("an inline import needs a 'type'")
}
if !self.loader.names_of(id, self.id).contains(name) {
raise @ion.IonError::DataModel("the schema '\{id}' has no type '\{name}'")
}
TypeRef(type_key(Some(id), name))
}
///|
/// The `id` string, and the `type` and `as` symbols, of an import. Each field
/// appears at most once, without annotations, and only the `allowed` ones.
fn import_fields(
fields : Array[@ion.Field],
allowed : Array[String],
) -> (String, String?, String?) raise @ion.IonError {
let mut id : String? = None
let mut name : String? = None
let mut rename : String? = None
for field in fields {
let key = field.name().text().unwrap_or("")
let value = field.value()
if !allowed.contains(key) || value.has_annotations() {
raise @ion.IonError::DataModel(
"an import takes only \{allowed.join(", ")}, without annotations",
)
}
let twice = @ion.IonError::DataModel("an import has '\{key}' twice")
match (key, value.kind()) {
("id", @ion.IonValueKind::String(text)) =>
if id is None {
id = Some(text)
} else {
raise twice
}
("id", @ion.IonValueKind::Symbol(token)) =>
if id is None {
id = Some(token.require_text())
} else {
raise twice
}
("type", @ion.IonValueKind::Symbol(token)) =>
if name is None {
name = Some(token.require_text())
} else {
raise twice
}
("as", @ion.IonValueKind::Symbol(token)) =>
if rename is None {
rename = Some(token.require_text())
} else {
raise twice
}
_ =>
raise @ion.IonError::DataModel(
"an import's id is a string or symbol, and its type and as are symbols",
)
}
}
match id {
Some(id) => (id, name, rename)
None => raise @ion.IonError::DataModel("an import needs an 'id'")
}
}
///|
/// The name of a type definition: its one `name` field, a non-null symbol
/// without annotations.
fn type_name(fields : Array[@ion.Field]) -> String raise @ion.IonError {
let names = fields.filter(field => field.name().text() == Some("name"))
match names {
[field] =>
match field.value().kind() {
@ion.IonValueKind::Symbol(token) if !field.value().has_annotations() =>
token.require_text()
_ =>
raise @ion.IonError::DataModel(
"a type's name must be a non-null symbol without annotations",
)
}
_ => raise @ion.IonError::DataModel("a type definition needs one 'name'")
}
}
///|
/// The text of a version marker: a symbol that matches `$ion_schema_\d.*`,
/// with or without annotations.
fn version_marker(value : @ion.IonValue) -> String? {
match value.kind() {
@ion.IonValueKind::Symbol(token) =>
match token.text() {
Some(text) if text.has_prefix("$ion_schema_") &&
text.length() > 12 &&
text[12] >= '0' &&
text[12] <= '9' => Some(text)
_ => None
}
_ => None
}
}
///|
/// Whether a symbol is reserved: `$ion_schema`, `$ion_schema_...`, or lower
/// snake case (`[a-z][a-z0-9]*(_[a-z0-9]+)*`).
fn is_reserved(text : String) -> Bool {
if text == "$ion_schema" || text.has_prefix("$ion_schema_") {
return true
}
let chars : Array[Char] = text.iter().collect()
if chars.is_empty() || !(chars[0] >= 'a' && chars[0] <= 'z') {
return false
}
let mut previous = chars[0]
for c in chars[1:] {
let lower_or_digit = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
if !(lower_or_digit || (c == '_' && previous != '_')) {
return false
}
previous = c
}
previous != '_'
}
///|
/// The keywords of ISL 2.0, which cannot be declared as user fields.
let isl_keywords : Array[String] = [
"all_of", "annotations", "any_of", "as", "byte_length", "codepoint_length", "container_length",
"contains", "element", "exponent", "field_names", "fields", "id", "ieee754_float",
"imports", "name", "not", "occurs", "one_of", "ordered_elements", "precision",
"regex", "schema_footer", "schema_header", "timestamp_offset", "timestamp_precision",
"type", "user_reserved_fields", "utf8_byte_length", "valid_values",
]
///|
/// Whether a field is user content, given the reserved symbols declared as
/// user fields where it appears: its name is not reserved, or is declared.
fn is_user_field(name : String, declared : Array[String]) -> Bool {
!is_reserved(name) || declared.contains(name)
}
///|
/// The reserved symbols the schema header declares as user fields, for the
/// header, for type definitions, and for the footer.
priv struct UserFields {
header : Array[String]
types : Array[String]
footer : Array[String]
}
///|
fn UserFields::none() -> UserFields {
{ header: [], types: [], footer: [], }
}
///|
/// Load the schema header: the user fields its `user_reserved_fields`
/// declares, and its imports. In ISL 2.0, its other fields must be user
/// content. In ISL 1.0, they are open content, and `user_reserved_fields` is
/// one of them.
fn load_header(
fields : Array[@ion.Field],
version : IslVersion,
) -> (UserFields, Array[@ion.IonValue]) raise @ion.IonError {
let user_fields = match field_value(fields, "user_reserved_fields") {
Some(declaration) if version == Isl20 =>
load_user_reserved_fields(declaration)
_ => UserFields::none()
}
let keys = if version == Isl20 {
["user_reserved_fields", "imports"]
} else {
["imports"]
}
for key in keys {
if fields.filter(field => field.name().text() == Some(key)).length() > 1 {
raise @ion.IonError::DataModel("a schema header has at most one '\{key}'")
}
}
let imports = match field_value(fields, "imports") {
None => []
Some(value) =>
match value.kind() {
@ion.IonValueKind::List(items) if !value.has_annotations() => items
_ =>
raise @ion.IonError::DataModel(
"imports must be a non-null list without annotations",
)
}
}
if version == Isl10 {
return (user_fields, imports)
}
check_user_fields(
fields.filter(field => {
field.name().text() != Some("user_reserved_fields") &&
field.name().text() != Some("imports")
}),
user_fields.header,
"schema header",
)
(user_fields, imports)
}
///|
/// Check that every field is user content.
fn check_user_fields(
fields : Array[@ion.Field],
declared : Array[String],
context : String,
) -> Unit raise @ion.IonError {
for field in fields {
let name = field.name().require_text()
if !is_user_field(name, declared) {
raise @ion.IonError::DataModel(
"the reserved symbol '\{name}' is not a field of a \{context}",
)
}
}
}
///|
/// Load a `user_reserved_fields` struct: at most one each of `schema_header`,
/// `type`, and `schema_footer`, each a list of symbols that are not ISL
/// keywords.
fn load_user_reserved_fields(
value : @ion.IonValue,
) -> UserFields raise @ion.IonError {
let fields = match value.kind() {
@ion.IonValueKind::Struct(fields) if !value.has_annotations() => fields
_ =>
raise @ion.IonError::DataModel(
"user_reserved_fields must be a non-null struct without annotations",
)
}
let declared : Map[String, Array[String]] = Map([])
for field in fields {
let place = field.name().require_text()
if !["schema_header", "type", "schema_footer"].contains(place) ||
declared.contains(place) {
raise @ion.IonError::DataModel(
"user_reserved_fields takes at most one each of schema_header, type, and schema_footer",
)
}
let items = match field.value().kind() {
@ion.IonValueKind::List(items) if !field.value().has_annotations() =>
items
_ =>
raise @ion.IonError::DataModel(
"user_reserved_fields.\{place} must be a non-null list without annotations",
)
}
let names : Array[String] = []
for item in items {
match item.kind() {
@ion.IonValueKind::Symbol(token) if !item.has_annotations() => {
let name = token.require_text()
if isl_keywords.contains(name) {
raise @ion.IonError::DataModel(
"the ISL keyword '\{name}' cannot be a user field",
)
}
names.push(name)
}
_ =>
raise @ion.IonError::DataModel(
"a user field must be a non-null symbol without annotations",
)
}
}
declared[place] = names
}
{
header: declared.get("schema_header").unwrap_or([]),
types: declared.get("type").unwrap_or([]),
footer: declared.get("schema_footer").unwrap_or([]),
}
}