///|
/// The `pure-py` command line tool.
///
/// It is the thing the conformance harness drives, so its exit codes are part
/// of the specification (`implementation-plan.md` ยง2.1) and not a convenience:
///
/// 0 accepted, or a run that finished
/// 1 a prohibited form, or a Python syntax error, or an abort
/// 3 an ill-formed module
/// 4 an ill-formed program
/// 5 an operation the semantics leaves undefined
fn main {
let (profile, asked, args) = take_profile(@env.args()[1:])
match args {
[] | ["-h"] | ["--help"] => println(usage)
["--version"] => println("pure-py 0.12.0")
["profiles"] => println(profiles_listing())
["tokens", path] => @sys.exit(cmd_tokens(path))
["dump", path] => @sys.exit(cmd_dump(path, pos=false))
["unparse", path] => @sys.exit(cmd_unparse(path))
["parse", .. paths] => @sys.exit(cmd_parse(paths, profile~, asked~))
["check", .. rest] => {
let (format, color, paths) = parse_error_options(rest)
@sys.exit(cmd_check(paths, format~, color~, profile~, asked~))
}
["check-program", path] =>
@sys.exit(cmd_check_program(path, profile~, asked~))
["run", path, .. rest] => @sys.exit(cmd_run(path, rest, profile~, asked~))
["dump", path, "--pos"] | ["dump", "--pos", path] =>
@sys.exit(cmd_dump(path, pos=true))
[cmd, ..] => {
println("pure-py: unknown command '\{cmd}'")
println(usage)
@sys.exit(2)
}
}
}
///|
/// `--profile NAME`, which must come BEFORE the command.
///
/// Before, and not after, because of `run`: `cmd_run` forwards everything past
/// the entry file to the guest's `sys.argv`, so `run main.py --profile pending`
/// would hand the GUEST the flag and run under `core` -- the wrong language,
/// silently, which is the worst way to be wrong. Rather than guess which
/// occurrence was meant, one position is legal and a stray one is refused
/// below.
///
/// Answers the profile, whether one was asked for at all, and what is left.
/// A run that asked for nothing must print exactly what it printed before
/// profiles existed, which is what `asked` is for.
fn take_profile(
argv : ArrayView[String],
) -> (@profile.Profile, Bool, Array[String]) {
let mut profile = @profile.core
let mut asked = false
let mut i = 0
while i + 1 < argv.length() && argv[i] == "--profile" {
match @profile.by_name(argv[i + 1]) {
Some(p) => {
profile = p
asked = true
}
None => {
println("pure-py: unknown profile '\{argv[i + 1]}'")
println(profiles_listing())
@sys.exit(2)
}
}
i += 2
}
let rest = argv[i:].to_owned()
// A `--profile` anywhere else is a mistake worth naming. Nothing legitimate
// passes the literal string to a guest, and running under the wrong profile
// because the flag landed in `sys.argv` is not a failure anyone would spot.
if rest.contains("--profile") {
println("pure-py: --profile must come before the command")
println(" pure-py --profile \{@profile.names[1]} run FILE")
@sys.exit(2)
}
(profile, asked, rest)
}
///|
/// `profiles`: what each name means, since a profile is a set and the set
/// grows.
fn profiles_listing() -> String {
let mut width = 0
for name in @profile.names {
if name.length() > width {
width = name.length()
}
}
let lines = ["profiles, each a superset of the one before it:"]
for name in @profile.names {
let p = match @profile.by_name(name) {
Some(p) => p
None => continue
}
let features = p.features().map(@profile.Feature::name)
let pad = " ".repeat(width - name.length())
lines.push(
" " +
name +
pad +
" " +
(if features.is_empty() {
"PurePy as specified"
} else {
features.join(", ")
}),
)
}
lines.join("\n")
}
///|
/// A line naming the profile that would have accepted this, or nothing.
///
/// Printed only when the caller asked about profiles at all. A rejection under
/// the default is a rejection PurePy makes, and it says what it always said --
/// `test/golden/` compares those messages against the reference's, and this is
/// how that stays true by construction rather than by a comparison that
/// happens not to look here.
fn profile_note(d : @err.Diagnostic, asked : Bool) -> String? {
guard asked else { return None }
guard d.kind is Prohibited(msg~) else { return None }
match @profile.accepted_by(msg) {
Some(p) => Some(" accepted under --profile \{p}")
None => None
}
}
///|
let usage : String =
#|usage: pure-py [--profile NAME] [options] FILE...
#|
#| --profile NAME opt in to a superset of PurePy; `profiles` lists
#| them. It must come BEFORE the command, because
#| `run` gives everything after the file to the guest.
#|
#| profiles the profiles and what each adds
#| tokens FILE one token per line
#| dump FILE [--pos] the abstract syntax tree
#| unparse FILE Python source regenerated from the tree
#| parse FILE... reject what PurePy excludes syntactically
#| check FILE... decide module well-formedness
#| check-program MAIN decide program well-formedness
#| run MAIN [ARGS...] evaluate a program
///|
/// Read a source file, or report why not and stop.
fn read_source(path : String) -> @basic.Source {
try @fs.read_file_to_string(path) catch {
_ => {
println("pure-py: cannot read \{path}")
@sys.exit(1)
panic()
}
} noraise {
text => @basic.Source::new(text, name=path)
}
}
///|
/// `tokens FILE`: one token per line, `L:C-L:C KIND text`, the format
/// `tools/pytokens.py` prints for CPython's `tokenize`.
fn cmd_tokens(path : String) -> Int32 {
let src = read_source(path)
try @lexer.lex(src) catch {
@err.PurePyError(d) => {
println("!error \{d.short(path, source=src)}")
1
}
e => {
println("!error \{e}")
1
}
} noraise {
tokens => {
let out = StringBuilder()
for t in tokens {
out.write_string(
"\{t.span.to_display()} \{t.kind.name()} \{@basic.py_repr(t.text)}\n",
)
}
write_stdout(out.to_string())
0
}
}
}
///|
/// `dump FILE [--pos]`: the tree, in the format `tools/pyast_dump.py` prints
/// for CPython's.
fn cmd_dump(path : String, pos~ : Bool) -> Int32 {
let src = read_source(path)
try @parser.parse(src) catch {
@err.PurePyError(d) => {
println("!error \{d.short(path, source=src)}")
1
}
e => {
println("!error \{e}")
1
}
} noraise {
m => {
write_stdout(@ast.dump(m, pos~))
0
}
}
}
///|
/// `unparse FILE`: the source rebuilt from the tree.
fn cmd_unparse(path : String) -> Int32 {
let src = read_source(path)
try @parser.parse(src) catch {
@err.PurePyError(d) => {
println("!error \{d.short(path, source=src)}")
1
}
e => {
println("!error \{e}")
1
}
} noraise {
m => {
write_stdout(@write.unparse(m))
0
}
}
}
///|
/// `parse FILE...`: what the sieve says about each file.
///
/// The format and the exit codes are `syntax.py`'s: `FILE: ok`, or
/// `FILE:line:col: message` with the column in UTF-8 BYTES, which is what
/// CPython's `col_offset` counts and what the reference therefore prints.
/// Several files in one invocation, and the LAST non-zero code wins.
fn cmd_parse(
paths : ArrayView[String],
profile~ : @profile.Profile = @profile.core,
asked~ : Bool = false,
) -> Int32 {
let mut code = 0
for path in paths {
let src = read_source(path)
let d = try @parser.parse(src) catch {
@err.PurePyError(d) => Some(d.as_parse_error())
e => Some(@err.Diagnostic::new(PythonSyntax(msg="parse error: \{e}")))
} noraise {
m => @sieve.result(m, profile~)
}
match d {
None => println("\{path}: ok")
Some(diag) => {
println(diag.short(path, source=src))
match profile_note(diag, asked) {
Some(note) => println(note)
None => ()
}
code = diag.exit_code()
}
}
}
code
}
///|
/// `check FILE...`: module well-formedness.
///
/// The sieve runs first, and its verdict is this command's when it rejects --
/// `check_module.py` does not run it and walks a node it has no case for
/// instead, so the reference's own runner never calls check on a file parse
/// rejected. The golden records the sieve's answer for those files.
fn cmd_check(
paths : Array[String],
format~ : @render.Format,
color~ : @style.ColorMode,
profile~ : @profile.Profile = @profile.core,
asked~ : Bool = false,
) -> Int32 {
let mut code = 0
for path in paths {
let src = read_source(path)
let d = try @parser.parse(src) catch {
@err.PurePyError(d) => Some(d.as_parse_error())
e => Some(@err.Diagnostic::new(PythonSyntax(msg="parse error: \{e}")))
} noraise {
m =>
match @sieve.result(m, profile~) {
Some(d) => Some(d)
None => @check.check_single(m, profile~)
}
}
match d {
None => if format is Short { println("\{path}: ok") }
Some(diag) => {
if format is Short {
println(diag.short(path, source=src))
} else {
println(render_diagnostic(diag, path, src, format~, color~))
}
match profile_note(diag, asked) {
Some(note) => println(note)
None => ()
}
code = diag.exit_code()
}
}
}
code
}
///|
/// Render a rejection with its source, through `error-report`.
fn render_diagnostic(
d : @err.Diagnostic,
path : String,
src : @basic.Source,
format~ : @render.Format,
color~ : @style.ColorMode,
) -> String {
let sources = @report.Sources::new()
let id = sources.add(path, src.text)
let config = { ..@render.default_config, format, color, }
@render.render_string(d.to_report(id), sources, config, is_tty=false)
}
///|
/// `--error-format short|human|json` and `--color auto|always|never`.
///
/// `short` is the reference's own format and the default, because the
/// conformance harness compares against the reference; the other two are for
/// people.
fn parse_error_options(
args : ArrayView[String],
) -> (@render.Format, @style.ColorMode, Array[String]) {
let mut format = @render.Format::Short
let mut color = @style.ColorMode::Auto
let paths : Array[String] = []
let mut i = 0
while i < args.length() {
let a = args[i]
if a == "--error-format" && i + 1 < args.length() {
format = match args[i + 1] {
"human" | "rich" => Rich
"compact" => Compact
"json" => Json
_ => Short
}
i += 2
} else if a == "--color" && i + 1 < args.length() {
color = match args[i + 1] {
"always" => Always
"never" => Never
_ => Auto
}
i += 2
} else {
paths.push(a)
i += 1
}
}
(format, color, paths)
}
///|
/// `check-program MAIN`: discover the program from its entry file and decide
/// whether the whole of it is well formed.
///
/// The format is the reference's: `MAIN: ok`, or `FILE:LINE:COL: message`
/// against the module at fault, or `MAIN: message` for a rejection of the
/// program as a whole.
fn cmd_check_program(
path : String,
profile~ : @profile.Profile = @profile.core,
asked~ : Bool = false,
) -> Int32 {
match @check.check_entry(path, profile~) {
None => {
println("\{path}: ok")
0
}
Some(d) => {
println(program_message(d, path))
match profile_note(d, asked) {
Some(note) => println(note)
None => ()
}
d.exit_code()
}
}
}
///|
/// A rejection in a program, against its file: the one it names, or the
/// entry. The column counts bytes in that file's text, as CPython's does.
fn program_message(d : @err.Diagnostic, entry : String) -> String {
let file = match d.file {
Some(f) => f
None => entry
}
try @fs.read_file_to_string(file) catch {
_ => d.short(file)
} noraise {
text => d.short(file, source=@basic.Source::new(text, name=file))
}
}
///|
/// `run MAIN [ARGS...]`: evaluate a program.
///
/// `sys.argv` is `[MAIN, ARGS...]`, so `argv[0]` is the path as it was
/// given, exactly as Python has it.
///
/// Output is buffered and flushed before an abort message, so a program that
/// prints and then fails shows both in the order Python shows them.
fn cmd_run(
path : String,
args : ArrayView[String],
profile~ : @profile.Profile = @profile.core,
asked~ : Bool = false,
) -> Int32 {
let argv = [path]
for a in args {
argv.push(a)
}
let tree = @program.SourceTree::from_entry(
path,
predefined=@check.predefined_module_names(),
sweep=false,
profile~,
) catch {
@err.PurePyError(d) => {
println(program_message(d, path))
match profile_note(d, asked) {
Some(note) => println(note)
None => ()
}
return d.exit_code()
}
e => {
println("pure-py: \{e}")
return 1
}
}
// A program that is not well formed does not run. The conformance harness
// never asks for one, but a person might, and the semantics is defined for
// well-formed programs only.
match @check.check_program(tree, profile~) {
Some(d) => {
println(program_message(d, path))
return d.exit_code()
}
None => ()
}
let (output, result) = @eval.run_program(tree, argv~, profile~)
write_stdout(output)
match result {
Finished => 0
// `sys.exit(n)` leaves with status n and prints nothing.
Terminated(SystemExit(n), _) => n.to_int()
Terminated(k, site) => {
// Python's traceback ends with the exception's name and message, and
// that last line is what the suite's `.exception.expected` holds. The
// frame line between them is Python's shape too, and it is a line and
// not a column because that is what CPython prints; the column is in
// the `Site` for a caller that wants it.
let frame = match site {
Some(s) =>
" File \"\{tree.path_of(s.in_module)}\", line \{s.span.start.line}\n"
None => ""
}
write_stderr("Traceback (most recent call last):\n" + frame + k.to_text())
1
}
// An operation the semantics leaves undefined. Chapter 1 allows an
// implementation to abort or to produce Python's answer; this one aborts,
// so that the suite's dynamically excluded tests are checked.
Undefined(op) => {
write_stderr("stuck: " + op)
5
}
// `run_program` builds the host itself, and that host answers no foreign
// call, so a run started here cannot park. The arm exists because the
// type allows what this caller does not.
Suspended => {
write_stderr("stuck: a host call suspended in a run that has no host")
5
}
}
}
///|
/// Write a line to standard error.
///
/// MoonBit's core offers no standard error, so this writes to `/dev/stderr`,
/// which every POSIX system provides. A system that does not have it falls
/// back to standard output: the message is then in the wrong stream, but it
/// is not lost, and the conformance harness compares the two together.
fn write_stderr(text : String) -> Unit {
// One call per run: `write_string_to_file` opens for TRUNCATION, so two
// calls would leave only the second when standard error is a file.
@fs.write_string_to_file("/dev/stderr", text + "\n") catch {
_ => println(text)
}
}
///|
/// Write to standard output with no newline of our own.
///
/// MoonBit's core offers `println` and nothing else, so the last newline of
/// the text is the one `println` adds. Every buffer this tool writes either is
/// empty or ends in a newline -- a token dump does, and so does the output of
/// a PurePy program, whose only output primitive is `print` -- so nothing is
/// lost. A buffer that does not end in one is printed with a newline added,
/// and that case is unreachable rather than tolerated.
fn write_stdout(text : String) -> Unit {
if text.is_empty() {
return
}
if text.has_suffix("\n") {
println(text[:text.length() - 1])
} else {
println(text)
}
}