///|
suberror CliError {
CliError(String, Int32)
} derive(Debug)
///|
let usage : String =
#|smithy: a Smithy toolchain for MoonBit
#|
#|Usage: smithy [options] ...
#|
#|Commands:
#| ast Assemble IDL/JSON files and print the Smithy JSON AST
#| validate Assemble files and report model diagnostics
#| format Format one IDL or JSON file to stdout
#| convert Convert a model with --to json or --to idl
#| version Print the version
#|
#|Options:
#| --from idl|json Override input format (required for stdin '-')
#| --to idl|json Output format for convert
#| -o, --output FILE Write output to a file instead of stdout
#| --output-dir DIR Write IDL files named .smithy
#| --check Check formatting without writing; exit 1 if changed
#| --write Format files in place
#| -h, --help Print help
#| -- End options
#|
#|Inputs may mix .smithy and .json files. Use '-' once to read stdin.
#|IDL conversion emits flattened Smithy 2 models; comments are not retained.
///|
struct Options {
command : String
files : Array[String]
from : String?
to : String?
output : String?
output_dir : String?
check : Bool
write : Bool
}
///|
fn options(args : Array[String]) -> Options raise CliError {
guard args.length() > 0 else {
raise CliError("expected a command; use --help", 2)
}
let command = args[0]
guard ["ast", "validate", "format", "convert"].contains(command) else {
raise CliError("unknown command '\{command}'; use --help", 2)
}
let files = []
let mut from = None
let mut to = None
let mut output = None
let mut output_dir = None
let mut check = false
let mut write = false
let mut positional = false
let mut i = 1
while i < args.length() {
let arg = args[i]
if !positional && arg == "--" {
positional = true
} else if !positional &&
["--from", "--to", "-o", "--output", "--output-dir"].contains(arg) {
i += 1
guard i < args.length() else {
raise CliError("expected a value after \{arg}", 2)
}
let value = args[i]
match arg {
"--from" | "--to" => {
guard value == "idl" || value == "json" else {
raise CliError(
"expected idl or json after \{arg}, found '\{value}'",
2,
)
}
if arg == "--from" {
from = Some(value)
} else {
to = Some(value)
}
}
"--output-dir" => output_dir = Some(value)
_ => output = Some(value)
}
} else if !positional && (arg == "--help" || arg == "-h") {
return {
command: "help",
files: [],
from: None,
to: None,
output: None,
output_dir: None,
check: false,
write: false,
}
} else if !positional && arg == "--check" {
check = true
} else if !positional && arg == "--write" {
write = true
} else if !positional && arg.has_prefix("-") && arg != "-" {
raise CliError("unknown option '\{arg}'", 2)
} else {
files.push(arg)
}
i += 1
}
guard !files.is_empty() else {
raise CliError("expected at least one input file", 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 command == "convert" && to is None {
raise CliError("convert requires --to idl or --to json", 2)
}
if command != "convert" && to is Some(_) {
raise CliError("--to requires convert", 2)
}
if (check || write) && command != "format" {
raise CliError("--check and --write require format", 2)
}
if check && write {
raise CliError("--check and --write cannot be combined", 2)
}
if (check || write) && output is Some(_) {
raise CliError("--output cannot be combined with --check or --write", 2)
}
if write && files.contains("-") {
raise CliError("--write requires file inputs", 2)
}
if command == "validate" && output is Some(_) {
raise CliError("validate does not write output files", 2)
}
if output_dir is Some(_) &&
(command != "convert" || to != Some("idl") || output is Some(_)) {
raise CliError(
"--output-dir requires convert --to idl and cannot be combined with --output",
2,
)
}
if command == "format" && files.length() > 1 && !check && !write {
raise CliError("use --check or --write when formatting multiple files", 2)
}
{ command, files, from, to, output, output_dir, check, write, }
}
///|
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 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 {
if args.is_empty() || args == ["help"] || args == ["--help"] || args == ["-h"] {
println(usage)
return 0
}
if args == ["version"] || args == ["--version"] {
println("smithy \{@smithy.version()} (Smithy \{@smithy.SMITHY_VERSION})")
return 0
}
let opts = options(args)
if opts.command == "help" {
println(usage)
return 0
}
let loader = @smithy.Loader::new()
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 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()
for d in result.diagnostics {
eprintln(diagnostic(d, inputs))
}
guard result.model is Some(model) else { return 1 }
if opts.command == "validate" {
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(stdin_bytes())
}