ion

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

    ion
    serialization
    aws
    schema
    Download zip
    Author
    Version
    0.3.1
    License
    Apache-2.0
    Last updated
    4 hours ago
    Downloads
    237

    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

    Install the library, run the CLI with moonx, or download a release binary. See Installation.

    moon add moonrockz/ion

    #User documentation

    Getting started prints one Ion value from the command line and from MoonBit. The cookbook covers text and binary conversion, JSON interoperability, schema validation, and modeling algebraic data types with Ion Schema. Ion and the repository covers the data model, package map, and building a checkout. See the documentation index for all guides.

    #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. moonx runs the published module. An installed release binary is the same command with the moonx moonrockz/ion prefix removed. Omit the input file to read stdin. See Installation and Getting started.

    printf '%s\n' '{name: "Ada"}' | moonx moonrockz/ion print moonx moonrockz/ion print data.ion moonx moonrockz/ion print --binary data.ion > data.10n moonx moonrockz/ion hash data.ion moonx moonrockz/ion validate schema.isl person data.ion

    printf '%s\n' '{name: "Ada"}' | ion print ion print data.ion ion validate schema.isl person data.ion

    PowerShell pipes a single-quoted string. > would rewrite binary output, so that redirect goes through cmd /c:

    '{name: "Ada"}' | moonx moonrockz/ion print moonx moonrockz/ion print data.ion cmd /c "moonx moonrockz/ion print --binary data.ion > data.10n" moonx moonrockz/ion hash data.ion moonx moonrockz/ion validate schema.isl person data.ion

    '{name: "Ada"}' | ion print ion print data.ion ion validate schema.isl person data.ion

    print, json, hash, and validate stream their Ion input (text or binary, from a file or from stdin) 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.

    #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. Decimals use positional notation such as 1.50 when the adjusted exponent is at least -6 and the exponent is non-positive. Other decimals retain d notation, so positive exponents and very small values preserve their scale.
    • 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 retains its shared-table name and slot when imported. Equality uses that location, independently of stream IDs and table versions. Writers preserve shared imports and reserve local unknown IDs as null slots. A shared table's imports field is informational metadata and is ignored.
    • 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.

    • Ion 1.1 (#7): only Ion 1.0 is implemented.

    #Building and testing

    Tooling is configured with mise:

    mise run setup # fetch the test-data submodules and install dependencies mise run hooks:install # install the git hooks (lefthook) 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 test:coverage # instrumented wasm tests, HTML and machine-readable coverage mise run build:native mise run bench # native release benchmarks and CLI peak RSS 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. Incremental writers also rebuild every good value through step-in/out calls, checking text equivalence and identical binary bytes. It runs both binary event readers against the text/DOM events, including binary encodings of every good text file and rejection of every bad binary file. 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.

    Every CI and release-validation run publishes a corpus conformance table in the GitHub Actions run summary. It reports files, checks, or cases for each suite, with passes, skipped cases, known failures, inapplicable cases, and unexpected failures counted separately. Expand a suite's details to see its exclusion reasons. The conformance-results artifacts contain the Markdown summary, machine-readable conformance.json, and the full test log, along with the tested commit and corpus revisions. Failed runs publish the report too; a suite that never finished is marked Not reported.

    CI runs the tests on Linux for wasm, native, JavaScript, and wasm-gc, and on Windows for native. Each matrix job publishes its own summary and a conformance-results-<os>-<target> artifact. The wasm-gc report marks the conformance package's suites as Excluded, with the reason linked to #13. Other wasm-gc tests, including Ion Hash, still run.

    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.

    Both @text/stream.IncrementalWriter and @binary/stream.IncrementalWriter write list, sexp, and struct containers through step_in / step_out calls. Set field names and annotations before values, write scalars as they arrive, then call finish. Text emits immediately; binary buffers each open container's encoded body until its length is known. See the package guides for examples and symbol-context requirements.

    Conformance summaries compare counts with an earlier successful main CI run for the same OS and target. They link that run and commit, show per-suite count deltas and missing/excluded suite transitions, and flag changed corpus revisions. conformance.json stores the comparison. The lookup considers the latest 20 successful main runs; missing, expired, incompatible, or inaccessible artifacts produce a baseline-unavailable note without changing current results.

    The native performance CI job samples sync and async text/binary readers and writers, Ion Hash, and incremental list writers. Each runs against many small values and one large value, with the incremental writers measured on the large list. A separate CLI test records peak process RSS while printing 300,000 small values, about 13 MiB of input. Builds, fixture setup, and warmup are outside the operation samples. Reports contain medians, throughput, and raw samples, with timing and memory deltas against a compatible main report. Changes are informational; command failures still fail the job. Runner or compiler changes can affect measurements, so the report records the OS, architecture, toolchain, workload settings, and CPU identifier.

    Run mise run bench locally. Reports go to _build/performance/; in CI they appear in the job summary and the performance-results-ubuntu-latest-native artifact as performance.md and performance.json. The first run has no performance baseline; later runs compare identical workload settings.

    The wasm CI job instruments its existing test run for coverage and adds a package and focused-file table to the run summary. The coverage-results-ubuntu-latest-wasm artifact includes coverage.json with per-file counts and uncovered line numbers, the original Coveralls JSON, Cobertura XML, and html/index.html with annotated source views. Download and extract the artifact to browse the HTML locally. Reports distinguish executable line coverage from Moon's execution-point totals, since several points can share a line. Coverage percentages are informational; failed tests or missing reports are shown as partial or unavailable and fail the reporting step.

    Run mise run test:coverage locally to generate the same reports in _build/coverage-results/. The task clears old traces before testing, so source changes cannot mix incompatible instrumentation. Coverage is for packages compiled on wasm; the native-only benchmark driver is not included.

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

    The module's root package is the ion executable. A published version runs as moonx moonrockz/ion, and a release binary runs as 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.

    Omit the file argument, or pass -, to read stdin. A pipe supplies that input. Ion input may be text or binary; the Ion binary version marker at its start decides. The transcript below is a POSIX shell. On PowerShell, pipe a single-quoted string, read $LASTEXITCODE instead of $?, and write binary with cmd /c so the redirect keeps the bytes intact:

    '{name: "Ada"}' | ion print '{"a": [1, 2.5]}' | ion fromjson cmd /c "ion print --binary people.ion > people.10n" ion print people.10n

    #Examples

    $ cat people.ion { name: "Ada", born: 1815 } { name: 42 } $ ion print people.ion {name: "Ada", born: 1815} {name: 42} $ printf '%s\n' '{name: "Ada"}' | ion print {name: "Ada"} $ 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