// The `ion` CLI: read and re-emit Ion, convert to and from JSON, hash values,
// and validate Ion values against an Ion Schema. Ion input may be text or
// binary; a leading version marker decides. Ion input is streamed: each value
// is read, handled, and dropped in turn, so memory stays proportional to the
// largest value rather than to the input.
//
// ion print [file|-]
// ion json [file|-]
// ion fromjson [file|-]
// ion hash [file|-]
// ion validate [file|-]
///|
fn version() -> String {
"moonrockz/ion 0.1.0"
}
///|
fn usage() -> String {
"usage: ion [args]\n" +
" ion print [file|-] Ion text or Ion binary in, Ion text out\n" +
" ion json [file|-] Ion text or Ion binary in, JSON out\n" +
" ion fromjson [file|-] JSON in, Ion text out\n" +
" ion hash [file|-]\n" +
" ion validate [file|-]"
}
///|
/// Write a line to stderr, falling back to stdout when stderr is unavailable.
fn eprintln(message : String) -> Unit {
@fs.write_string_to_file("/dev/stderr", message + "\n") catch {
_ => println(message)
}
}
///|
/// Read a file, or stdin when the path is `-`.
async fn read_input(path : String) -> String {
let data = if path == "-" {
@stdio.stdin.read_all() catch {
_ => raise CliError("stdin: cannot be read")
}
} else {
@async_fs.read_file(path) catch {
error => raise open_failure(path, error)
}
}
data.text() catch {
_ => raise CliError("\{describe_path(path)}: is not UTF-8 text")
}
}
///|
/// How error messages name an input path: `stdin` for `-`.
fn describe_path(path : String) -> String {
if path == "-" {
"stdin"
} else {
path
}
}
///|
/// An error the CLI reports in its own words, such as an input it cannot open.
suberror CliError {
CliError(String)
}
///|
/// The message of an error: the text of an `IonError` or a `CliError`, or its
/// description.
fn describe(error : Error) -> String {
match error {
@core.IonError::Syntax(message)
| @core.IonError::DataModel(message)
| @core.IonError::Unsupported(message)
| CliError(message) => message
_ => error.to_string()
}
}
///|
/// Why `path` could not be opened.
fn open_failure(path : String, error : Error) -> CliError {
match error {
@os_error.OSError(_, ..) as os_error if os_error.is_ENOENT() =>
CliError("\{path}: no such file or directory")
@os_error.OSError(_, ..) as os_error if os_error.is_EACCES() =>
CliError("\{path}: permission denied")
_ => CliError("\{path}: cannot be opened")
}
}
///|
/// Call `visit` on every value of a file, or of stdin when the path is `-`,
/// as each value arrives: Ion binary when the input begins with the version
/// marker, and Ion text otherwise.
async fn each_value(
path : String,
visit : (@core.IonValue) -> Unit raise,
) -> Unit {
if path == "-" {
each_value_of("stdin", @stdio.stdin, visit)
} else {
let file = @async_fs.open(path, mode=ReadOnly) catch {
error => raise open_failure(path, error)
}
defer file.close()
each_value_of(path, file, visit)
}
}
///|
/// Call `visit` on every value of `source`, which `name` names in errors. The
/// first octets decide the encoding, and then go to the reader ahead of the
/// rest of the source.
async fn each_value_of(
name : String,
source : &@io.Reader,
visit : (@core.IonValue) -> Unit raise,
) -> Unit {
let mut head = Bytes::new(0)
while head.length() < 4 {
let chunk = source.read_some() catch {
_ => raise CliError("\{name}: cannot be read")
}
match chunk {
Some(chunk) if chunk.length() > 0 => head = head + chunk
_ => break
}
}
let input = @io.MemoryReader(async fn(writer) {
writer.write(head)
// Large reads keep the number of hand-offs through the pipe small.
while source.read_some(max_len=65536) is Some(chunk) {
writer.write(chunk)
}
})
defer input.close()
if @binary.has_version_marker(head[:], 0) {
let reader = @binary_stream.BinaryReader::new(input)
while reader.next() is Some(value) {
visit(value)
}
} else {
let reader = @text_stream.TextReader::new(input)
while reader.next() is Some(value) {
visit(value)
}
}
}
///|
/// Read a document and print its canonical Ion text, one value per line.
async fn do_print(path : String) -> Result[Unit, String] {
each_value(path, value => println(@text.write_ion(value))) catch {
error => return Err(describe(error))
}
Ok(())
}
///|
/// Print the SHA-256 Ion Hash of every value in a document, one per line.
async fn do_hash(path : String) -> Result[Unit, String] {
each_value(path, value => println(@hash.ion_hash_hex(value))) catch {
error => return Err(describe(error))
}
Ok(())
}
///|
/// Validate every value in a document against a named schema type.
async fn do_validate(
schema_path : String,
type_name : String,
input : String,
) -> Result[Unit, String] {
let schema_text = read_input(schema_path) catch {
error => return Err(describe(error))
}
let schema = @schema.Schema::load_from_text(schema_text) catch {
error => return Err(error.message())
}
let mut index = 0
let mut failed = 0
each_value(input, fn(value) {
let violations = schema.validate(type_name, value)
for violation in violations {
eprintln("value \{index}: \{violation.path()}: \{violation.message()}")
}
if !violations.is_empty() {
failed += 1
}
index += 1
}) catch {
error => return Err(describe(error))
}
if failed == 0 {
Ok(())
} else {
Err("\{failed} of \{index} value(s) failed validation")
}
}
///|
/// Dispatch a parsed command line.
async fn run(args : Array[String]) -> Result[Unit, String] {
match args {
[_] => do_print("-")
[_, "print", path, ..] => do_print(path)
[_, "print"] => do_print("-")
[_, "json", path, ..] => do_json(path)
[_, "json"] => do_json("-")
[_, "fromjson", path, ..] => do_from_json(path)
[_, "fromjson"] => do_from_json("-")
[_, "hash", path, ..] => do_hash(path)
[_, "hash"] => do_hash("-")
[_, "validate", schema_path, type_name, input, ..] =>
do_validate(schema_path, type_name, input)
[_, "validate", schema_path, type_name] =>
do_validate(schema_path, type_name, "-")
[_, "version", ..] => {
println(version())
Ok(())
}
_ => Err(usage())
}
}
///|
async fn main {
match run(@env.args()) {
Ok(_) => ()
Err(message) => {
eprintln(message)
@sys.exit(1)
}
}
}
///|
/// Read a document and print each value as JSON text, one per line, following
/// the cookbook's down-conversion rules.
async fn do_json(path : String) -> Result[Unit, String] {
each_value(path, value => println(@json.write_json(value))) catch {
error => return Err(describe(error))
}
Ok(())
}
///|
/// Read a JSON document and print its canonical Ion text.
async fn do_from_json(path : String) -> Result[Unit, String] {
let text = read_input(path) catch { error => return Err(describe(error)) }
let value = @json.read_json(text) catch {
error => return Err(error.message())
}
let printed = @text.write_ion(value) catch {
error => return Err(error.message())
}
println(printed)
Ok(())
}