// The wax-mb entry point: convert, format, check.
//
// What is HERE is everything that is about being a command-line tool: reading
// files, deciding whether a stream is a terminal, choosing a colour theme,
// picking an exit status. The compilation itself is `marianoguerra/wax` --
// `@wax.parse_string`, `@wax.Session`, `@wax.render` -- so that an embedder
// runs the same pipeline this binary does rather than a reconstruction of it.
///|
/// A sink writing to stderr, where every machine-readable format goes.
fn stderr_sink() -> @diagnostic.Sink {
{ write: s => @io.eprint(s), flush: () => (), }
}
///|
/// Parse one file and render whatever it complains about.
///
/// Returns the module when it parsed. Diagnostics are rendered here rather
/// than returned, so each file reports as it is processed instead of the whole
/// run going quiet until the end.
fn process(
path : String,
src : String,
opts : Options,
) -> (@ast.LocModule?, @trivia.Context, Bool) {
// `--all-errors` swaps the parser for the recovering one, which reports every
// syntax error it can resynchronize past instead of stopping at the first.
// Only `check` offers it: a recovered tree is a guess, and printing a guess
// back over the user's file is not a thing to do by accident.
let r = if opts.all_errors && opts.command is Check {
@wax.parse_recover(src, fname=path)
} else {
@wax.parse_string(src, fname=path)
}
let mut failed = false
if r.errors.length() > 0 {
// Colour is decided by the DESTINATION: diagnostics go to stderr, so it
// is stderr's tty-ness that matters, not stdout's.
let theme = @diagnostic.get_theme(
color=opts.color,
palette=@colors.wax_theme,
is_tty=@io.stderr_is_tty(),
)
for e in r.errors {
@diagnostic.output_error(
stderr_sink(),
theme,
opts.error_format,
Some(src),
@diagnostic.of_report(e),
)
if e.severity is Error {
failed = true
}
}
}
(r.module_, r.trivia, failed)
}
///|
/// Type-check one module and render what it reports.
///
/// `true` when anything was rejected. The diagnostics are collected rather than
/// streamed because the checker makes them out of order -- a global initializer
/// is checked before the function that reads it -- and a report that jumps
/// backwards through the file is harder to read than one that does not.
fn check(
path : String,
src : String,
m : @ast.LocModule,
opts : Options,
) -> Bool {
let session = @wax.Session::new(source=src, features=opts.features)
let _ = session.check(m, warn_unused=true, suggest=true)
ignore(path)
render_diagnostics(src, opts, session.reports())
}
///|
/// Render a run's diagnostics, returning whether anything was rejected.
///
/// The collector buffers everything RAW: the policy belongs to whoever finally
/// re-reports it, which is here. `@wax.apply_policy` is what hides, keeps or
/// promotes a named warning -- so a lint that is off by default costs nothing to
/// have implemented. The policy is the one the RUN was given: the defaults,
/// refined by `WAX_WARN` and then by `-W`. Reaching for the defaults here
/// instead made both of those parse, validate, and do nothing.
fn render_diagnostics(
src : String,
opts : Options,
collected : Array[@diagnostic.Diagnostic],
first_error~ : Bool = false,
) -> Bool {
let resolved = @wax.apply_policy(opts.policy, collected)
guard !resolved.is_empty() else { return false }
// A conversion stops at its first error, as the reference's does: its error
// queue holds one and flushes, ending the run, when it fills. The warnings
// before that error have been printed as they came.
let resolved = if first_error {
let upto : Array[@diagnostic.Diagnostic] = []
for d in resolved {
upto.push(d)
if d.severity is Error {
break
}
}
upto
} else {
resolved
}
let theme = @diagnostic.get_theme(
color=opts.color,
palette=@colors.wax_theme,
is_tty=@io.stderr_is_tty(),
)
for d in resolved {
@diagnostic.output_error(
stderr_sink(),
theme,
opts.error_format,
Some(src),
d,
)
}
@wax.rejected(resolved)
}
///|
/// Whether an output format has no implementation.
fn not_implemented_format(f : String) -> Bool {
!(f == "wax" || f == "wasm" || f == "wat")
}
///|
/// Check and lower one module to wasm bytes.
///
/// The diagnostics are rendered as `check` renders them, because a compile that
/// reports nothing and a check that reports nothing are the same claim about the
/// same module -- and a compile that reports an ERROR produces no bytes at all
/// rather than bytes derived from a module known to be wrong.
///
/// `Err(None)` is a module the diagnostics have already spoken about: the
/// rendered error IS the message, and a second line naming the file again says
/// nothing the caret did not.
fn compile(
path : String,
src : String,
m : @ast.LocModule,
opts : Options,
) -> Result[Bytes, String?] {
let session = @wax.Session::new(source=src, features=opts.features)
// No lints on the conversion path: a compile reports what stops it producing
// a module, and a style warning does not. The reference draws the same line --
// `wax f.wax -f wasm` is silent on a module `wax check` would comment on --
// unless `-v` asks for them.
let checked = session.check(m, lowering=true, warn_unused=opts.validate)
if render_diagnostics(src, opts, session.reports(), first_error=true) {
return Err(None)
}
Ok(checked.to_bytes(try_table=opts.try_table)) catch {
// What the binary format cannot hold is the module's to fix, and is
// reported as the reference reports it; anything else is a gap here.
e =>
match e.diagnostic() {
Some(d) => {
let _ = render_diagnostics(src, opts, [d])
Err(None)
}
None => Err(Some("\{path}: \{e}"))
}
}
}
///|
/// The module with its legacy tries rewritten into the structured `try`, or
/// `None` when checking it or rewriting it was rejected.
fn rewrite_legacy_try(
src : String,
m : @ast.LocModule,
opts : Options,
) -> @ast.LocModule? {
let session = @wax.Session::new(source=src, features=opts.features)
let checked = session.check(m, warn_unused=opts.validate)
if render_diagnostics(src, opts, session.reports(), first_error=true) {
return None
}
let rewritten = checked.rewrite_legacy_try()
if render_diagnostics(src, opts, session.reports(), first_error=true) {
return None
}
Some(rewritten)
}
///|
/// Check and print one module in the text format.
///
/// The same path the binary takes as far as the lowered module; only the last
/// step differs, which is what makes the two forms the same module.
fn compile_text(
path : String,
src : String,
m : @ast.LocModule,
opts : Options,
trivia : @trivia.Context,
) -> Result[String, String?] {
let session = @wax.Session::new(source=src, features=opts.features)
let checked = session.check(m, lowering=true, warn_unused=opts.validate)
if render_diagnostics(src, opts, session.reports(), first_error=true) {
return Err(None)
}
// `to_wat` keeps the conditionals rather than resolving them -- a module with
// an unresolved conditional has no single binary form, which is what a
// conditional is for -- and re-delimits the comments the parse collected.
Ok(checked.to_wat(trivia~, try_table=opts.try_table)) catch {
e => Err(Some("\{path}: \{e}"))
}
}
///|
/// The formatted form of a parsed module.
///
/// Colour is decided by the OUTPUT destination, not the diagnostic stream: a
/// file redirect must never receive escape codes.
fn formatted(
m : @ast.LocModule,
trivia : @trivia.Context,
opts : Options,
to_terminal : Bool,
) -> String {
// `to_terminal` is the destination's tty-ness, NOT stdout's: writing to a
// file or to a pipe must never emit escape codes even when stdout is a
// terminal.
let theme = if @colors.should_use_color(opts.color, is_tty=to_terminal) {
@colors.wax_theme
} else {
@colors.no_color
}
// `render` drops the trailing break; the reference ends its output with a
// newline, so one is added here.
@wax.render(m, trivia, theme~) + "\n"
}
///|
fn run(argv : Array[String]) -> Status {
let opts = match parse_args(argv) {
// A value an option does not accept is reported as the reference's
// command-line parser reports it: the usage line, then the option.
Err((status, msg)) if msg.has_prefix("option '") => {
@io.eprintln(usage_line(argv))
@io.eprintln("wax: " + msg)
return status
}
Err((status, msg)) => {
@io.eprintln(prog() + msg)
return status
}
Ok(o) => o
}
// No input file reads stdin, named `-` in what it reports, as the
// reference does.
let opts = if opts.files.is_empty() {
{ ..opts, files: ["-"], }
} else {
opts
}
// Only the Wax front end exists, so anything else is a usage error rather
// than a silent no-op that would make a differential comparison meaningless.
if opts.input_format != "wax" {
@io.eprintln(
prog() +
"input format '\{opts.input_format}' is not implemented (only 'wax')",
)
return Usage
}
if opts.command is Convert && not_implemented_format(opts.output_format) {
@io.eprintln(
prog() +
"output format '\{opts.output_format}' is not implemented (only 'wax', 'wasm' and 'wat')",
)
return Usage
}
if opts.command is Convert && opts.files.length() > 1 {
@io.eprintln(prog() + "convert takes a single input file")
return Usage
}
let mut rejected = false
let mut needs_format = false
for path in opts.files {
// An input that cannot be read rejects the run, as the reference's does,
// and says why in its words.
let src = if path == "-" {
@utf8.decode_lossy(@io.read_stdin())
} else {
@fs.read_file_to_string(path) catch {
_ => {
let why = if @fs.path_exists(path) {
"cannot be read"
} else {
"No such file or directory"
}
@io.eprintln("Error: \{path}: \{why}")
return Rejected
}
}
}
let (module_, trivia, failed) = process(path, src, opts)
if failed {
rejected = true
}
// `-D` resolves the conditionals it decides before anything else reads the
// module, and drops the comments of the branches it removes.
let module_ = match module_ {
Some(m) if !opts.defines.is_empty() => {
let d = @diagnostic.collector()
let (m, dropped) = @wax.specialize_defines(d, opts.defines, m)
trivia.drop_in_ranges(dropped)
if render_diagnostics(src, opts, d.collected()) {
return Rejected
}
Some(m)
}
other => other
}
match (module_, opts.command) {
(None, _) => ()
(Some(m), Check) =>
// A recovered tree is a guess, so checking it would report on
// instructions the author never wrote. The syntax errors are the ones
// to fix first.
if !failed && check(path, src, m, opts) {
rejected = true
}
// The TEXT format is the same module the binary one is, written folded
// and named rather than flat and numbered -- so it comes from the same
// lowering, and the two cannot drift.
(Some(m), Convert) if opts.output_format == "wat" => {
if failed {
return Rejected
}
match compile_text(path, src, m, opts, trivia) {
Err(msg) => {
if msg is Some(msg) {
@io.eprintln(prog() + msg)
}
return Rejected
}
Ok(text) =>
match opts.output_path {
None | Some("-") => @io.print(text)
Some(out) =>
@fs.write_bytes_to_file(out, @utf8.encode(text)) catch {
_ => {
@io.eprintln(prog() + "cannot write \{out}")
return Usage
}
}
}
}
}
(Some(m), Convert) if opts.output_format == "wasm" => {
// The checker has to run first: the lowering reads the types it left on
// every node, which is the whole interface between the two.
if failed {
return Rejected
}
match compile(path, src, m, opts) {
Err(msg) => {
if msg is Some(msg) {
@io.eprintln(prog() + msg)
}
return Rejected
}
Ok(bytes) =>
match opts.output_path {
// `-o -` and no `-o` both mean stdout, which for a binary is the
// raw bytes and nothing else.
None => @io.write_stdout_bytes(bytes)
Some("-") => @io.write_stdout_bytes(bytes)
Some(out) =>
@fs.write_bytes_to_file(out, bytes) catch {
_ => {
@io.eprintln(prog() + "cannot write \{out}")
return Usage
}
}
}
}
}
(Some(m), Convert) => {
// `--try-table` rewrites a legacy try into the structured `try` in the
// source itself. That rewrite is semantic -- it needs the result type
// the checker infers for an unannotated try -- so the module is
// type-checked, as a conversion to another format would check it.
// `-v` checks a same-format conversion too, which otherwise only
// re-prints.
if opts.validate && !opts.try_table {
if failed {
return Rejected
}
if check(path, src, m, opts) {
return Rejected
}
}
let m = if opts.try_table {
if failed {
return Rejected
}
match rewrite_legacy_try(src, m, opts) {
Some(r) => r
None => return Rejected
}
} else {
m
}
match opts.output_path {
// Writing to stdout, which may be a terminal.
None => @io.print(formatted(m, trivia, opts, @io.stdout_is_tty()))
Some(out) => {
let text = formatted(m, trivia, opts, false)
@fs.write_bytes_to_file(out, @utf8.encode(text)) catch {
_ => {
@io.eprintln(prog() + "cannot write \{out}")
return Usage
}
}
}
}
}
(Some(m), Format) =>
if opts.check_only {
// A file already formatted is left alone and unreported; only the
// ones that would change are listed.
if formatted(m, trivia, opts, false) != src {
@io.println(path)
needs_format = true
}
} else if opts.inplace {
let text = formatted(m, trivia, opts, false)
if text != src {
@fs.write_bytes_to_file(path, @utf8.encode(text)) catch {
_ => {
@io.eprintln(prog() + "cannot write \{path}")
return Usage
}
}
}
} else {
// WITHOUT `-i`, format writes to stdout and leaves the file alone.
// The opposite default would silently edit the user's files.
@io.print(formatted(m, trivia, opts, @io.stdout_is_tty()))
}
}
}
if rejected {
Rejected
} else if needs_format {
// `--check` found work to do: a usage-class status, not a rejected input.
Usage
} else {
Ok
}
}
///|
fn main {
let argv = @env.args()
// argv[0] is the program name, and this program reports under it.
if argv.length() > 0 {
set_prog_name(argv[0])
}
let args = if argv.length() > 1 { argv[1:].to_owned() } else { [] }
let status = run(args)
if status != Ok {
@sys.exit(status.code())
}
}