///|
suberror CliError {
CliError(String, Int32)
} derive(Debug)
///|
fn version_text() -> String {
"smithy \{@smithy.version()} (Smithy \{@smithy.SMITHY_VERSION})"
}
///|
fn input_command(
name : String,
about : String,
options~ : Array[@argparse.OptionArg] = [],
flags~ : Array[@argparse.FlagArg] = [],
subcommands~ : Array[@argparse.Command] = [],
files_about~ : String = "Input files; use '-' once to read stdin with --from. Use '--' before paths beginning with '-'.",
) -> @argparse.Command {
@argparse.Command(
name,
about~,
flags~,
options=[
@argparse.OptionArg(
"from",
about="Input format: idl or json. Required for stdin; otherwise inferred from .smithy or .json.",
),
] +
options,
positionals=[
@argparse.PositionArg(
"file",
about=files_about,
// A nested command does not satisfy its parent's inputs, so a parent
// with subcommands checks its own inputs after parsing.
num_args=@argparse.ValueRange(
lower=if subcommands.is_empty() { 1 } else { 0 },
),
),
],
subcommands~,
)
}
///|
fn command_line() -> @argparse.Command {
let output = @argparse.OptionArg(
"output",
short='o',
about="Write output to a file instead of stdout.",
)
@argparse.Command(
"smithy",
about="A Smithy toolchain for MoonBit: assemble, validate, select, diff, format and convert models.",
version=version_text(),
arg_required_else_help=true,
subcommand_required=true,
subcommands=[
input_command(
"ast",
"Assemble IDL/JSON files and print the Smithy JSON AST.",
options=[output],
),
@argparse.Command(
"validate",
about="Validate model definitions, JSON or Ion values, or JSON-RPC messages.",
flags=[
@argparse.FlagArg(
"protocol-tests",
about="Load the bundled smithy.test protocol-test traits.",
),
],
options=[
@argparse.OptionArg("from", about="Model format: idl or json."),
],
positionals=[
@argparse.PositionArg("file", num_args=@argparse.ValueRange(lower=0)),
],
subcommands=[
input_command(
"value",
"Validate a JSON or Ion value against a selected Smithy data shape.",
options=[
@argparse.OptionArg(
"shape",
about="Absolute Smithy data shape or member ID.",
required=true,
),
@argparse.OptionArg(
"value",
about="Value file, or '-' for stdin.",
required=true,
),
@argparse.OptionArg(
"input-format",
about="Value encoding: json, ion-text or ion-binary; default json.",
),
@argparse.OptionArg(
"ion-index",
about="Select a zero-based Ion datagram value; otherwise require exactly one value.",
),
@argparse.OptionArg(
"diagnostics",
about="Findings format: text or json; default text.",
),
@argparse.OptionArg(
"unknown-members",
about="Unknown structure member policy: reject or ignore; default reject.",
),
@argparse.OptionArg(
"member-names",
about="Member naming: smithy or json; default smithy.",
),
],
),
input_command(
"rpc",
"Validate JSON-RPC requests, notifications, responses or explicit 2.0 batches.",
subcommands=[transcript_command()],
options=[
// argparse checks a parent's required options even when a
// subcommand is named, so options() checks these two instead.
@argparse.OptionArg(
"version",
about="Explicit JSON-RPC version: 1.0 or 2.0. Required.",
),
@argparse.OptionArg(
"service",
about="Absolute Smithy service ID. Required.",
),
@argparse.OptionArg(
"message",
about="Request/notification JSON file, or '-' for stdin.",
),
@argparse.OptionArg(
"response",
about="Response JSON file, or '-' for stdin; replaces --message.",
),
@argparse.OptionArg(
"request",
about="Original request JSON file, or '-' for stdin; response context.",
),
@argparse.OptionArg(
"method",
about="Explicit wire method for response payload validation.",
),
@argparse.OptionArg(
"operation",
about="Explicit Smithy operation ID for response payload validation.",
),
@argparse.OptionArg(
"legacy-errors",
about="1.0 response error mapping: opaque or code-message-data; default opaque.",
),
@argparse.OptionArg(
"diagnostics",
about="Findings format: text or json; default text.",
),
@argparse.OptionArg(
"unknown-members",
about="Payload unknown members: reject or ignore; default reject.",
),
@argparse.OptionArg(
"member-names",
about="Payload member names: smithy or json; default smithy.",
),
],
flags=[
@argparse.FlagArg(
"batch",
about="Validate a complete JSON-RPC 2.0 request or response batch.",
),
@argparse.FlagArg(
"no-response",
about="Validate absent batch response; requires --batch and --request.",
),
@argparse.FlagArg(
"envelope-only",
about="Check response envelope only; no modeled payload or ID correlation.",
),
],
),
],
),
@argparse.Command(
"diff",
about="Compare two model files or directories and report compatibility changes.",
options=[
@argparse.OptionArg(
"old",
about="Old Smithy model file or directory.",
required=true,
),
@argparse.OptionArg(
"new",
about="New Smithy model file or directory.",
required=true,
),
@argparse.OptionArg(
"format",
about="Output format: text (default) or jsonl. JSONL flushes each stdout record after comparison.",
),
output,
],
),
@argparse.Command(
"build",
about="Run projections from smithy-build.json.",
flags=[
@argparse.FlagArg(
"offline",
about="Use only verified cached remote artifacts.",
),
],
options=[
@argparse.OptionArg(
"cache-dir",
about="Artifact cache directory, relative to the configuration file.",
),
@argparse.OptionArg(
"config",
short='c',
about="Build configuration file.",
),
],
),
@argparse.Command(
"clean",
about="Remove the configured build output directory.",
options=[
@argparse.OptionArg(
"config",
short='c',
about="Build configuration file.",
),
],
),
input_command(
"select",
"Print matching absolute shape IDs, one per line.",
options=[
@argparse.OptionArg(
"selector",
about="Smithy selector expression.",
required=true,
),
],
),
input_command(
"format",
"Format IDL/JSON files. By default, print one file to stdout.",
options=[output],
flags=[
@argparse.FlagArg(
"check",
about="Check formatting without writing; exit 1 if changed.",
conflicts_with=["write", "output"],
),
@argparse.FlagArg("write", about="Format files in place.", conflicts_with=[
"output",
]),
],
),
input_command(
"convert",
"Convert a model to JSON or flattened Smithy 2 IDL. IDL conversion does not retain comments.",
options=[
@argparse.OptionArg(
"to",
about="Output format: idl or json.",
required=true,
),
output,
@argparse.OptionArg(
"output-dir",
about="Write IDL files named .smithy; requires --to idl.",
conflicts_with=["output"],
),
],
),
@argparse.Command("version", about="Print the version."),
],
)
}
///|
struct Options {
command : String
files : Array[String]
from : String?
to : String?
selector : String?
old_model : String?
new_model : String?
diff_format : DiffFormat
value_validation : ValueOptions?
rpc_version : @rpc.ProtocolVersion?
protocol_tests : Bool
rpc_options : RpcOptions?
transcript : TranscriptOptions?
offline : Bool
cache_dir : String?
config : String?
output : String?
output_dir : String?
check : Bool
write : Bool
}
///|
fn options(args : Array[String]) -> Options raise CliError {
let parsed = command_line().parse(argv=args) catch {
error => raise CliError(error.to_string(), 2)
}
guard parsed.subcommand is Some((command, matches)) else {
raise CliError("expected a command; use --help", 2)
}
let validation_command = if command == "validate" {
matches.subcommand
} else {
None
}
let transcript_mode = validation_command
is Some(("rpc", { subcommand: Some(("transcript", _)), .. }))
let rpc_mode = validation_command is Some(("rpc", _)) && !transcript_mode
let value_mode = validation_command is Some(("value", _))
// Model inputs belong to the selected validation command's argparse scope.
if validation_command is Some((name, _)) &&
(
!matches.values.get("file").unwrap_or([]).is_empty() ||
matches.values.contains("from") ||
matches.flags.get("protocol-tests") == Some(true)
) {
raise CliError("place model inputs and options after validate " + name, 2)
}
let matches = match validation_command {
Some((_, nested)) => nested
None => matches
}
let matches = match matches.subcommand {
Some(("transcript", nested)) if transcript_mode => {
if !matches.values.is_empty() || matches.flags.values().any(set => set) {
raise CliError(
"place model inputs and options after validate rpc transcript", 2,
)
}
nested
}
_ => matches
}
fn value(name : String) -> String? {
match matches.values.get(name) {
Some([value, ..]) => Some(value)
_ => None
}
}
let (files, transcript) = if transcript_mode {
let inputs = matches.values.get("file").unwrap_or([])
guard inputs.length() >= 2 else {
raise CliError(
"validate rpc transcript requires model inputs and then a transcript file or '-'",
2,
)
}
let file = inputs[inputs.length() - 1]
(
inputs[:inputs.length() - 1].to_owned(),
Some(
transcript_options(
file,
value,
matches.flags,
directions=matches.values.get("direction").unwrap_or([]),
),
),
)
} else {
(matches.values.get("file").unwrap_or([]), None)
}
if command == "validate" && files.is_empty() {
raise CliError("validate requires model inputs; use validate --help", 2)
}
let from = value("from")
let to = value("to")
let selector = value("selector")
let config = value("config")
let output = value("output")
let output_dir = value("output-dir")
let diff_format = DiffFormat::parse(value("format").unwrap_or("text"))
let check = matches.flags.get("check") == Some(true)
let write = matches.flags.get("write") == Some(true)
if rpc_mode {
for name in ["version", "service"] {
if value(name) is None {
raise CliError("validate rpc requires --" + name, 2)
}
}
}
let rpc_version = if rpc_mode {
guard @rpc.ProtocolVersion::parse(value("version").unwrap())
is Some(version) else {
raise CliError("expected 1.0 or 2.0 after --version", 2)
}
Some(version)
} else {
None
}
let rpc_options = if rpc_mode {
let response = value("response")
let request = value("request")
let method_ = value("method")
let operation = value("operation")
let envelope = matches.flags.get("envelope-only") == Some(true)
let batch = matches.flags.get("batch") == Some(true)
let no_response = matches.flags.get("no-response") == Some(true)
if no_response && (!batch || request is None) {
raise CliError("--no-response requires --batch and --request", 2)
}
let inputs = (if value("message") is Some(_) { 1 } else { 0 }) +
(if response is Some(_) { 1 } else { 0 }) +
(if no_response { 1 } else { 0 })
if inputs != 1 {
raise CliError(
"use exactly one of --message, --response and --no-response", 2,
)
}
let contexts = (if request is Some(_) { 1 } else { 0 }) +
(if method_ is Some(_) { 1 } else { 0 }) +
(if operation is Some(_) { 1 } else { 0 }) +
(if envelope { 1 } else { 0 })
if contexts > 1 {
raise CliError(
"use only one response context: --request, --method, --operation or --envelope-only",
2,
)
}
if response is None &&
!no_response &&
(contexts > 0 || value("legacy-errors") is Some(_)) {
raise CliError(
"response context and --legacy-errors require --response", 2,
)
}
let legacy_errors = legacy_errors_mode(
value("legacy-errors").unwrap_or("opaque"),
)
if value("legacy-errors") is Some(_) {
require_legacy_version(rpc_version.unwrap())
}
let context = if method_ is Some(name) {
guard @rpc.MethodName::parse(name) is Some(name) else {
raise CliError("expected an application method name after --method", 2)
}
Some(@rpc.Method(name))
} else if operation is Some(id) {
guard @smithy.ShapeId::parse(id) is Some(id) && id.member_name() is None else {
raise CliError("expected an absolute operation ID after --operation", 2)
}
Some(@rpc.Operation(id))
} else if envelope {
Some(@rpc.EnvelopeOnly)
} else {
None
}
if request == Some("-") && (response == Some("-") || files.contains("-")) {
raise CliError(
"stdin '-' may only be read once across model, response and request inputs",
2,
)
}
Some(RpcOptions::{
response: response is Some(_) || no_response,
batch,
no_response,
request,
context,
legacy_errors,
})
} else {
None
}
let value_validation = if value_mode || rpc_mode || transcript_mode {
let selector = if rpc_mode {
"service"
} else if transcript_mode {
"protocol"
} else {
"shape"
}
let raw_shape = value(selector).unwrap()
guard @smithy.ShapeId::parse(raw_shape) is Some(target) &&
(!transcript_mode || target.member_name() is None) else {
raise CliError(
"expected an absolute Smithy shape ID after --" + selector,
2,
)
}
let file = if rpc_mode {
match value("response") {
Some(file) => file
None => value("message").unwrap_or("")
}
} else if transcript is Some(transcript) {
transcript.file
} else {
value("value").unwrap()
}
let diagnostics = match value("diagnostics").unwrap_or("text") {
"text" => TextFindings
"json" => JsonFindings
_ => raise CliError("expected text or json after --diagnostics", 2)
}
let unknown_members = match value("unknown-members").unwrap_or("reject") {
"reject" => @validation.Reject
"ignore" => @validation.Ignore
_ =>
raise CliError("expected reject or ignore after --unknown-members", 2)
}
let member_names = match value("member-names").unwrap_or("smithy") {
"smithy" => @validation.Smithy
"json" => @validation.JsonName
_ => raise CliError("expected smithy or json after --member-names", 2)
}
if file == "-" && files.contains("-") {
raise CliError(
"stdin '-' may only be read once across model and value inputs", 2,
)
}
// The transcript command reads --input-format in transcript_options.
let value_format = if transcript_mode {
None
} else {
value("input-format")
}
let input_format = match value_format.unwrap_or("json") {
"json" => JsonInput
"ion-text" => IonTextInput
"ion-binary" => IonBinaryInput
_ =>
raise CliError(
"expected json, ion-text or ion-binary after --input-format", 2,
)
}
let ion_index = match value("ion-index") {
None => None
Some(text) => {
guard input_format != JsonInput else {
raise CliError("--ion-index requires an Ion --input-format", 2)
}
guard !text.is_empty() && text.iter().all(c => c >= '0' && c <= '9') else {
raise CliError("expected a nonnegative integer after --ion-index", 2)
}
let index = @string.parse_int(text) catch {
_ =>
raise CliError(
"expected a nonnegative integer after --ion-index", 2,
)
}
Some(index)
}
}
Some(ValueOptions::{
target,
file,
diagnostics,
input_format,
ion_index,
config: @validation.Config::new(unknown_members~, member_names~),
})
} else {
None
}
for (name, format) in [("--from", from), ("--to", to)] {
if format is Some(format) && format != "idl" && format != "json" {
raise CliError("expected idl or json after \{name}, found '\{format}'", 2)
}
}
guard files.iter().filter(f => f == "-").count() <= 1 else {
raise CliError("stdin '-' may only be read once", 2)
}
if files.contains("-") && from is None {
raise CliError("use --from idl or --from json with stdin '-'", 2)
}
if write && files.contains("-") {
raise CliError("--write requires file inputs", 2)
}
if output_dir is Some(_) && to != Some("idl") {
raise CliError("--output-dir requires convert --to idl", 2)
}
if command == "format" && files.length() > 1 && !check && !write {
raise CliError("use --check or --write when formatting multiple files", 2)
}
{
offline: matches.flags.get("offline") == Some(true),
cache_dir: value("cache-dir"),
old_model: value("old"),
new_model: value("new"),
diff_format,
value_validation,
rpc_version,
protocol_tests: matches.flags.get("protocol-tests") == Some(true),
rpc_options,
transcript,
command,
files,
from,
to,
selector,
config,
output,
output_dir,
check,
write,
}
}
///|
/// Reads the --legacy-errors value of validate rpc and of its transcript
/// command.
fn legacy_errors_mode(text : String) -> @rpc.LegacyErrorMode raise CliError {
match text {
"opaque" => @rpc.Opaque
"code-message-data" => @rpc.CodeMessageData
_ =>
raise CliError(
"expected opaque or code-message-data after --legacy-errors", 2,
)
}
}
///|
/// --legacy-errors maps JSON-RPC 1.0 errors only.
fn require_legacy_version(
version : @rpc.ProtocolVersion,
) -> Unit raise CliError {
if version != @rpc.V1_0 {
raise CliError("--legacy-errors requires version 1.0", 2)
}
}
///|
fn input_format(name : String, override_ : String?) -> String raise CliError {
if override_ is Some(format) {
return format
}
if name.has_suffix(".smithy") {
"idl"
} else if name.has_suffix(".json") {
"json"
} else {
raise CliError(
"cannot infer the format of '\{name}'; use --from idl or --from json",
2,
)
}
}
///|
fn diagnostic(
d : @smithy.ModelDiagnostic,
inputs : Map[String, String],
) -> String {
let severity = if d.severity == Error { "error" } else { "warning" }
let source = d.source
let location = if source.span is Some(span) &&
inputs.get(source.file) is Some(text) {
let position = @syntax.SourceText::new(text).position(span.start)
"\{source.file}:\{position.line.0}:\{position.column.0}"
} else {
source.file + source.path
}
"\{location}: \{severity}[\{d.code}]: \{d.message}"
}
///|
fn validation_event_text(event : @smithy.ValidationEvent) -> String {
let shape = match event.shape {
Some(id) => id.to_string()
None => "-"
}
"[\{event.severity}] \{shape}: \{event.message} | \{event.id}"
}
///|
fn json_output(model : @smithy.Model) -> String raise {
@syntax.encode_json_ast(model.to_json_ast()).stringify(indent=4) + "\n"
}
///|
fn emit(text : String, path : String?) -> Unit raise {
match path {
Some(path) => @fs.write_string_to_file(path, text)
None =>
if !text.is_empty() {
println(
if text.has_suffix("\n") {
text[:text.length() - 1]
} else {
text[:]
},
)
}
}
}
///|
fn run(args : Array[String]) -> Int32 raise {
let opts = options(args)
if opts.command == "version" {
println(version_text())
return 0
}
if opts.command == "diff" {
return run_diff(
opts.old_model.unwrap(),
opts.new_model.unwrap(),
opts.output,
opts.diff_format,
)
}
if opts.command == "build" || opts.command == "clean" {
return run_build(
opts.config.unwrap_or("smithy-build.json"),
opts.command == "clean",
offline=opts.offline,
cache_dir=opts.cache_dir,
) catch {
@build.InvalidBuildConfig(message) => raise CliError(message, 1)
error => raise error
}
}
// Every model command resolves the bundled smithy.rpc traits.
let loader = @rpc.loader(protocol_tests=opts.protocol_tests)
let inputs : Map[String, String] = Map([])
let formatted : Array[(String, String)] = []
let mut failed = false
for name in opts.files {
let format = input_format(name, opts.from)
let text = if opts.value_validation is Some(value_opts) {
try {
if name == "-" {
read_stdin()
} else {
@fs.read_file_to_string(name)
}
} catch {
_ =>
return value_problem(
value_opts,
"InputRead",
"I could not read the model input.",
1,
source=Some(name),
)
}
} else if name == "-" {
read_stdin()
} else {
@fs.read_file_to_string(name)
}
inputs[name] = text
if opts.command == "format" {
let result = format_text(text, name, format) catch {
CliError(message, _) => {
eprintln(message)
failed = true
continue
}
}
formatted.push((name, result))
} else if format == "idl" {
ignore(loader.add_idl(name, text))
} else {
ignore(loader.add_json(name, text))
}
}
if opts.command == "format" {
// Validate every input before changing any file.
if failed {
return 1
}
for (name, result) in formatted {
if opts.check {
if inputs.get(name) != Some(result) {
eprintln("\{name}: formatting differs")
failed = true
}
} else if opts.write {
@fs.write_string_to_file(name, result)
} else {
emit(result, opts.output)
}
}
return if failed { 1 } else { 0 }
}
let result = loader.assemble()
if opts.value_validation is Some(value_opts) {
if opts.transcript is Some(transcript) {
return run_transcript_validation(value_opts, transcript, result)
}
if opts.rpc_version is Some(version) {
return run_rpc_validation(
value_opts,
version,
opts.rpc_options.unwrap(),
result,
)
}
return run_value_validation(value_opts, result)
}
if opts.command == "validate" {
let mut failed = result.model is None
for event in result.validation_events() {
if event.severity == "DANGER" || event.severity == "ERROR" {
failed = true
}
if event.severity == "WARNING" ||
event.severity == "DANGER" ||
event.severity == "ERROR" {
eprintln(validation_event_text(event))
}
}
return if failed { 1 } else { 0 }
}
for d in result.diagnostics {
eprintln(diagnostic(d, inputs))
}
guard result.model is Some(model) else { return 1 }
if opts.command == "select" {
guard opts.selector is Some(expression) else {
raise CliError("select requires --selector", 2)
}
let selector = @smithy.Selector::parse(expression) catch {
@smithy.InvalidSelector(message) => raise CliError(message, 2)
}
let ids = selector.select_ids(model)
emit(if ids.is_empty() { "" } else { ids.join("\n") + "\n" }, None)
return 0
}
if opts.to != Some("idl") {
emit(json_output(model), opts.output)
return 0
}
let files = model.to_idl()
if opts.output_dir is Some(dir) {
if !@fs.path_exists(dir) {
@fs.create_dir(dir)
}
let namespaces = files.keys().to_array()
namespaces.sort()
for ns in namespaces {
let filename = if ns == "" { "model" } else { ns }
@fs.write_string_to_file(
"\{dir}/\{filename}.smithy",
files.get(ns).unwrap(),
)
}
} else {
guard files.length() == 1 else {
raise CliError("model has multiple namespaces; use --output-dir DIR", 2)
}
for _, text in files {
emit(text, opts.output)
}
}
0
}
///|
fn read_stdin() -> String raise {
@utf8.decode(read_input_bytes("-"))
}
///|
/// Decodes UTF-8 that arrives in chunks. An incomplete sequence at the end of a
/// chunk waits for the next chunk, so a character split between two reads
/// decodes once. Invalid bytes become U+FFFD; nothing panics.
priv struct Utf8Chunks {
/// The start of a sequence that the last chunk did not finish: 0 to 3 bytes.
mut held : Bytes
}
///|
fn Utf8Chunks::new() -> Utf8Chunks {
{ held: b"", }
}
///|
/// Gives the text of every sequence that is complete after `chunk`.
fn Utf8Chunks::push(self : Utf8Chunks, chunk : Bytes) -> String {
let bytes = if self.held.length() == 0 { chunk } else { self.held + chunk }
let keep = incomplete_utf8_tail(bytes)
let end = bytes.length() - keep
self.held = bytes[end:].to_owned()
@utf8.decode_lossy(bytes[:end])
}
///|
/// Gives the held bytes at end of input, as U+FFFD.
fn Utf8Chunks::end(self : Utf8Chunks) -> String {
let held = self.held
self.held = b""
@utf8.decode_lossy(held)
}
///|
/// The number of bytes at the end of `bytes` that start a UTF-8 sequence but do
/// not finish it. Earlier bytes end on a sequence boundary, so they decode the
/// same alone as with the bytes that follow.
fn incomplete_utf8_tail(bytes : Bytes) -> Int32 {
let n = bytes.length()
for back in 1..<=3 {
if back > n {
return 0
}
let lead = bytes[n - back].to_int()
if (lead & 0xC0) == 0x80 {
// A continuation byte: look further back for its lead byte.
continue
}
let length = if lead >= 0xC2 && lead <= 0xDF {
2
} else if lead >= 0xE0 && lead <= 0xEF {
3
} else if lead >= 0xF0 && lead <= 0xF4 {
4
} else {
1
}
return if back < length { back } else { 0 }
}
0
}