ion

    A MoonBit implementation of the Amazon Ion data format and Ion Schema.

    ion
    serialization
    aws
    schema
    Download zip
    Author
    Version
    0.2.0
    License
    Apache-2.0
    Last updated
    5 days ago
    Downloads
    914

    Dependencies

    #moonrockz/ion

    A MoonBit implementation of Amazon Ion — a richly-typed, self-describing, hierarchical data serialization format — and Ion Schema.

    Status: early. The core data model, the Ion text and Ion binary encodings, Ion Hash (Ion Hash 1.0 over SHA-256), Ion Schema 1.0 and 2.0 validation, and JSON interoperability are implemented and tested, with asynchronous streaming readers and writers for both encodings over moonbitlang/async.

    #Installation

    moon add moonrockz/ion

    #Packages

    Each package has a README with checked examples, linked below.

    PackageSourceImport pathPurpose
    @ion (core)pkgs/ionmoonrockz/ion/ionCore data model: Ion types, values, annotations, decimals, timestamps, symbol tokens
    @textpkgs/textmoonrockz/ion/textIon text reader and writer
    @text/streampkgs/text/streammoonrockz/ion/text/streamIon text readers and writers over asynchronous IO (moonbitlang/async)
    @hashpkgs/hashmoonrockz/ion/hashIon Hash 1.0: an encoding-independent hash of an Ion value
    @binarypkgs/binarymoonrockz/ion/binaryIon binary codec: values, containers, annotations, and local symbol tables
    @binary/streampkgs/binary/streammoonrockz/ion/binary/streamThe Ion binary codec over asynchronous IO (moonbitlang/async)
    @schemapkgs/schemamoonrockz/ion/schemaIon Schema model, loader, and validator
    @jsonpkgs/jsonmoonrockz/ion/jsonJSON interoperability: IonValue ⇄ core Json
    @clipkgs/climoonrockz/ion/cliThe command line's commands, runnable with any streams
    ion CLIpkgsmoonrockz/ionion print, ion json, ion fromjson, ion hash, ion validate — the module root package is the executable

    #Repository layout

    The module's source directory is pkgs/ (source = "pkgs" in moon.mod), so the repository root holds only module metadata, tooling, and tests.

    • pkgs/ is the module root package, moonrockz/ion, and it is the ion executable — so the published package is runnable at the short coordinate moonx moonrockz/ion;
    • pkgs/ion/ is the @ion core data model (moonrockz/ion/ion);
    • pkgs/text (with the async pkgs/text/stream), pkgs/hash, pkgs/binary (with the async pkgs/binary/stream), pkgs/json, and pkgs/schema are the remaining library packages.

    The executable owns the module root (instead of living in a cmd/ package) so that moonx can run it as moonrockz/ion; library users import the core model as moonrockz/ion/ion.

    #Quick start

    Read and re-write Ion text:

    let value = @text.read_ion("{ name: \"ion\", tags: [a, b] }")
    println(@text.write_ion(value)) // {name: "ion", tags: [a, b]}

    Read and write Ion binary:

    let values = @text.read_ion_datagram("{ name: \"ion\" } 42")
    let bytes = @binary.write_binary(values) // Ion binary, with a local symbol table
    let decoded = @binary.read_binary(bytes) // back to the same values

    Convert between Ion and JSON:

    let value = @text.read_ion("{ name: \"ion\", tags: [a, b] }")
    let json = @json.to_json(value) // {"name":"ion","tags":["a","b"]}
    let ion = @json.to_ion(json) // back to the Ion data model

    Build a value programmatically:

    let value = @ion.IonValue::from_fields([
    @ion.Field::new("name", @ion.IonValue::string("ion")),
    @ion.Field::new("tags", @ion.IonValue::list([
    @ion.IonValue::symbol("a"),
    @ion.IonValue::symbol("b"),
    ])),
    ])

    Validate a value against an Ion Schema type:

    let schema = @schema.Schema::load_from_text(
    "type::{ name: person, fields: { name: { type: string, occurs: required } } }",
    )
    let value = @text.read_ion("{ name: \"Ada\" }")
    if schema.is_valid("person", value) {
    println("valid")
    }

    Command line (from the repository root; the CLI is the module root package):

    moon run pkgs -- print data.ion moon run pkgs -- print --binary data.ion > data.10n moon run pkgs -- hash data.ion moon run pkgs -- validate schema.isl person data.ion

    print, json, hash, and validate stream their Ion input (text or binary, from a file or from stdin with -) through @text/stream and @binary/stream: each value is read, handled, and dropped in turn, so memory stays proportional to the largest value rather than to the input. An error stops the command after the values before it are handled. The CLI builds for the native and wasm targets, which moonbitlang/async supports.

    Once published, the same commands run through moonx at the short coordinate:

    moonx moonrockz/ion version moonx moonrockz/ion print data.ion moonx moonrockz/ion hash data.ion moonx moonrockz/ion validate schema.isl person data.ion

    #Parsing APIs

    Like the moonrockz gherkin project, Ion offers four ways to consume a document, over both a CST (concrete syntax) and an AST (the IonValue data model):

    APIEntry pointsDescription
    CST@text.tokenize, @text.parse_cstA lossless token stream, and a syntax tree that keeps tokens and source spans
    DOM (AST)@text.read_ion, @text.read_ion_datagramBuild the IonValue tree for random access
    Visitor@ion.IonVisitor + IonValue::acceptDepth-first traversal; override only what you need
    Fold@ion.IonFold + IonValue::foldThread an accumulator with Continue / SkipChildren / Stop
    SAX@text.IonReader (pull) and @text.IonHandler + @text.parse_with_handler (push)A flat IonEvent stream without building the DOM
    Binary@binary.read_binary, @binary.write_binary, @binary/streamThe Ion binary codec, with local symbol tables and an async streaming reader and writer
    Streaming text@text/stream.TextReader (values), @text/stream.TextEventReader (events), @text/stream.TextWriterIon text over async byte sources and sinks, one top-level value at a time

    let value = @text.read_ion("{a: 1}") // DOM / AST
    value.accept(visitor) // visitor
    let total = value.fold(0, @ion.IonFold::default()) // fold
    let tokens = @text.tokenize("int32::12") // CST tokens
    @text.parse_with_handler("1 2 3", handler) // SAX (push)

    #Streaming Ion text

    @text/stream reads and writes Ion text over moonbitlang/async byte sources and sinks. The reader decodes the UTF-8 as it arrives and parses one top-level value at a time, so memory stays proportional to the largest value, not to the stream:

    let reader = @stream.TextReader::new(source) // moonrockz/ion/text/stream; any &@io.Reader
    while reader.next() is Some(value) {
    println(@text.write_ion(value))
    }
    let writer = @stream.TextWriter::new(sink) // any &@io.Writer
    writer.write(value) // one value per line
    @stream.write_all(sink, values, pretty=true)

    TextReader yields the values that @text.read_ion_datagram returns for the whole text, and TextEventReader yields the events that @text.IonReader returns for it. TextWriter and write_all write the UTF-8 octets of @text.write_all (or @text.write_all_pretty), one value at a time.

    Text has no length prefix, so a value is settled when it closes with ], ), }, or ". Any other value, such as a number or a symbol, is settled only when the text after it shows where it ends. The reader reads each code unit once to track containers, strings, comments, and lobs, and it parses only when the text is back at the top level between values. Thus a large value costs time linear in its length. Until the stream ends, a value that does not parse is taken to be cut short, and the reader reads on. Thus a malformed value is reported only at the end of the stream, and the reader holds the text from that value on until then. @text.read_ion_prefix is the parsing step on its own, for a caller that feeds text in some other way.

    #Ion Hash

    @hash implements Ion Hash 1.0 over SHA-256: a value is serialized to a canonical byte sequence that does not depend on the encoding or on symbol IDs, then hashed. Struct fields are unordered, so their hashes are sorted, and timestamps are normalized to UTC.

    let value = @text.read_ion("{ name: \"ion\", tags: [a, b] }")
    let digest = @hash.ion_hash_hex(value) // lowercase SHA-256
    let bytes = @hash.ion_hash(value) // 32 raw bytes

    The digest function is pluggable, as the specification requires: @hash.ion_hash_with(value, h) accepts any h : (Array[Byte]) -> Array[Byte] for both the final digest and the struct field hashes. Passing the identity function yields the serialization s(value) itself.

    The implementation is checked against the official conformance suite: pkgs/hash/conformance_test.mbt replays tests/fixtures/ion_hash_tests.ion and compares the serialization byte for byte, for both the text (ion) and the binary (10n) cases.

    #JSON interoperability

    @json converts between the Ion data model and MoonBit's core Json type, in both directions. JSON is a strict subset of Ion, so the two directions are not inverses:

    let value = @text.read_ion("{ data: annot::{time: 1969-07-20T20:18Z}, n: 1.50 }")
    let json = @json.to_json(value) // {"data":{"time":"1969-07-20T20:18Z"},"n":1.50}
    let ion = @json.to_ion(json) // back into the Ion data model

    • JSON to Ion is faithful in the sense that a JSON value converted to Ion and back is the same JSON value, and it follows the cookbook's JSON-to-Ion rules: null, booleans, strings, arrays, and objects map to the matching Ion values, and a number becomes an Ion int, decimal, or float according to its spelling. The spelling core kept in repr wins when present — core keeps an integer literal above 2^53 - 1 and any literal that overflows a Double — so large integers survive exactly; otherwise the number's shortest round-trip spelling decides, so 1.50 becomes the Ion decimal 1.5 and 1e-7 an Ion float.
    • Ion to JSON is lossy and follows the Ion cookbook's down-conversion process: a null of any type becomes null; integers and decimals keep their precision; nan and ±inf become null; timestamps and symbols become strings; a clob becomes a Latin-1 string and a blob a Base64 string; lists and s-expressions become arrays; a struct becomes an object; annotations are dropped. A symbol known only by symbol ID has no text, so converting it raises IonError::Unsupported rather than inventing one.

    @json.read_json and @json.write_json wrap the same rules for JSON text:

    let value = @json.read_json("{\"a\": [1, 2]}") // {a: [1, 2]}
    let text = @json.write_json(value) // {"a":[1,2]}

    The CLI exposes both directions: ion json [file] reads Ion (text or binary) and prints JSON, and ion fromjson [file] reads JSON and prints Ion text.

    #Design notes

    • Annotations live on the value. Every IonValue carries an ordered annotation list plus a kind, so readers, writers, and validators reach annotations the same way.
    • Absence is T?, never a sentinel. A SymbolToken has text : String? and sid : Int?; a decimal keeps an explicit negative-zero flag; a timestamp keeps an optional TimestampOffset.
    • Exact numbers. Integers and decimals are exact (BigInt coefficient/exponent), so precision and signed zero round-trip through text.
    • Decimal arithmetic is opt-in. add/subtract/multiply/compare/ to_double and conversion go through moonbitlang/x/decimal, whose decimal is normalized and caps the scale, so those helpers document what they drop; the value type itself keeps Ion's exact scale and signed zero.
    • Derived precision. A timestamp's precision is derived from which components are present, not stored separately.
    • One symbol table for both encodings. @ion.SymbolTable holds the symbols in effect in a stream. The text and binary readers apply a version marker ($ion_1_0 unquoted at the top level, or the binary marker) and a top-level $ion_symbol_table::{...} struct to it instead of returning them, and resolve each symbol ID against it. Imports of shared symbol tables resolve against an @ion.Catalog, which the readers and the CLI's --catalog option take. A symbol ID beyond the table is an error; one whose slot has no text keeps only its ID, and the writers keep that ID by reserving it in the symbol table they write.
    • Unsupported is an error. The schema loader raises IonError::Unsupported for what it does not implement (imports when no resolver is given), rather than silently ignoring it.

    #Roadmap

    Each item is a GitHub issue; test-suite work carries the testing label.

    • Shared symbol tables: a symbol whose text a shared table leaves unknown keeps its symbol ID but not its place in that table, so any two such symbols compare as equal, as symbols with unknown text do.
    • Ion 1.1 (#7): only Ion 1.0 is implemented.
    • Ion binary output from the CLI (#8).
    • An incremental writer that writes containers without building them first (#9).
    • An event reader for Ion binary (#10).

    #Building and testing

    Tooling is configured with mise:

    mise run setup # fetch the test-data submodules and install dependencies mise run test:check # moon check mise run test:unit # moon test (unit, doc, snapshot, conformance, QuickCheck) mise run test:all # check + test mise run build:native moon fmt # format moon info # regenerate package interfaces (*.mbti) moon test --update # refresh the golden fixtures' recorded output

    The official ion-tests suite is a git submodule at tests/ion-tests. mise run setup fetches it, and mise run test:unit fetches it first when it is missing; without mise, run git submodule update --init (or clone with --recurse-submodules). pkgs/conformance runs every Ion 1.0 file in it: each good file must read and survive a round trip through both writers, each bad file must fail, and the equivs and non-equivs sequences must compare as their directory says. It also runs each file through the stream readers (in one-octet and 4 KiB chunks), the streaming event reader, the tokenizer and syntax tree, the pretty writer, and the async writers, which must agree with the sync reader. And it runs the Ion 1.0 cases of ion-tests' conformance/ directory, written in its test language, which cover version markers, symbol tables, imports, and the data model. Every Ion 1.0 file passes. The conformance tests keep skip lists, each entry with its reason, and a skipped case that starts to pass fails the test, so the lists stay accurate; two DSL cases are skipped today, both over problems in ion-tests itself.

    The ion-schema-tests suite is a second submodule, at tests/ion-schema-tests, and pkgs/conformance runs its ISL 1.0 and ISL 2.0 files: each schema must load, each $test value must match its type or not, and each invalid schema or type must fail to load. Imports resolve to the suite's files. Every file passes, except two ISL 1.0 cases that contradict another file of the suite, which are listed as known failures.

    Besides the example and snapshot tests, several packages carry property tests (property_test.mbt) using the built-in QuickCheck: text round-trips, decimal and timestamp rendering, binary type descriptors, Ion Hash invariances, and the JSON conversions are checked over generated inputs, with counterexamples shrunk to a minimal case.

    #License

    Apache-2.0. See LICENSE.

    #The ion command-line tool (moonrockz/ion)

    The module's root package is the ion executable, so a published version runs as moonx moonrockz/ion. The commands themselves live in moonrockz/ion/cli, which this package runs with the process's arguments and standard streams. From a checkout, run it with moon run pkgs -- <command>, or build a native binary with mise run build:native.

    ion print [--binary | --pretty] [file|-] Ion text or binary in; Ion text, pretty text, or binary out ion json [file|-] Ion text or binary in, JSON out ion fromjson [file|-] JSON in, Ion text out ion hash [file|-] Ion Hash (SHA-256) of each value ion validate <schema-file> <type-name> [file|-] validate each value ion version also `ion --version` or `ion -V` ion help [command] also `ion --help` or `ion <command> --help`

    --catalog <file>, before or after the command and as many times as needed, names an Ion file of $ion_shared_symbol_table::{...} structs; imports of those shared tables in the input then resolve to their symbols' text.

    validate loads the schemas that the schema file imports. An import's id is a path relative to the schema file's directory, or to the directory that --schema-root <dir> names.

    A missing file argument, or -, reads stdin. Ion input may be text or binary; the Ion binary version marker at its start decides.

    #Examples

    $ cat people.ion { name: "Ada", born: 1815 } { name: 42 } $ ion print people.ion {name: "Ada", born: 1815} {name: 42} $ ion print --binary people.ion > people.10n $ ion print people.10n {name: "Ada", born: 1815} {name: 42} $ ion json people.ion {"name":"Ada","born":1815} {"name":42} $ echo '{"a": [1, 2.5]}' | ion fromjson {a: [1, 25d-1]} $ ion hash people.ion 5d2dbea37f03297ad83be8aa10f162c10e0c1e90fd950ce7b4972093774cdf2a be93d3daaf4fe159a03cfe8e4b0c02394de41c4b2a690257f4d194e0ba1fc21e $ cat person.isl $ion_schema_2_0 type::{ name: person, type: struct, fields: { name: { type: string, occurs: required }, born: int } } $ ion validate person.isl person people.ion value 1: $.name: expected one of [string] but found int 1 of 2 value(s) failed validation $ echo $? 1

    #Behavior

    • print, json, hash, and validate stream their Ion input through moonrockz/ion/text/stream and moonrockz/ion/binary/stream: each value is read, handled, and dropped in turn, so memory stays proportional to the largest value, not to the input.
    • print --binary writes each value as it arrives, declaring each new symbol text once, in a local symbol table that appends to the ones before.
    • An error stops a command after the values before it are handled, prints a message to stderr, and exits with status 1. So does a validation failure. Arguments that do not make a command print an error and the help of the command they concern to stderr, and exit with status 2.
    • moonbitlang/core/argparse parses the arguments.
    • fromjson reads its whole input, since a JSON document is one value.
    • The tool builds for the native and wasm targets, which moonbitlang/async supports for files and stdin.

    Source Files