diff --git a/examples/commandline/main.pony b/examples/commandline/main.pony new file mode 100644 index 0000000000..0496828ce9 --- /dev/null +++ b/examples/commandline/main.pony @@ -0,0 +1,47 @@ +use "cli" + +actor Main + new create(env: Env) => + try + let cmd = + match CommandParser(cli_spec()).parse(env.args, env.vars()) + | let c: Command => c + | let ch: CommandHelp => + ch.print_help(env.out) + env.exitcode(0) + return + | let se: SyntaxError => + env.out.print(se.string()) + env.exitcode(1) + return + end + + // cmd is a valid Command, now use it. + + end + + fun tag cli_spec(): CommandSpec box ? => + """ + Builds and returns the spec for a sample chat client's CLI. + """ + let cs = CommandSpec.parent("chat", "A sample chat program", [ + OptionSpec.bool("admin", "Chat as admin" where default' = false) + OptionSpec.string("name", "Your name" where short' = 'n') + OptionSpec.i64("volume", "Chat volume" where short' = 'v') + ], [ + CommandSpec.leaf("say", "Say something", Array[OptionSpec](), [ + ArgSpec.string("words", "The words to say") + ]) + CommandSpec.leaf("emote", "Send an emotion", [ + OptionSpec.f64("speed", "Emote play speed" where default' = F64(1.0)) + ], [ + ArgSpec.string("emotion", "Emote to send") + ]) + CommandSpec.parent("config", "Configuration commands", Array[OptionSpec](), [ + CommandSpec.leaf("server", "Server configuration", Array[OptionSpec](), [ + ArgSpec.string("address", "Address of the server") + ]) + ]) + ]) + cs.add_help() + cs diff --git a/examples/gups_basic/main.pony b/examples/gups_basic/main.pony index 90f6e15393..1fef2512bc 100755 --- a/examples/gups_basic/main.pony +++ b/examples/gups_basic/main.pony @@ -1,14 +1,44 @@ +use "cli" use "collections" -use "options" use "time" +class val Config + let logtable: USize + let iterate: USize + let chunk: USize + let streamer_count: USize + let updater_count: USize + + new val create(env: Env) ? => + let cs = CommandSpec.parent("gups_basic", "", [ + OptionSpec.i64("table", "Log2 of the total table size." where default' = 20) + OptionSpec.i64("iterate", "Number of iterations." where default' = 10000) + OptionSpec.i64("chunk", "Chunk size." where default' = 1024) + OptionSpec.i64("streamers", "Number of streamers." where default' = 4) + OptionSpec.i64("updaters", "Number of updaters." where default' = 8) + ]).>add_help() + let cmd = + match CommandParser(cs).parse(env.args, env.vars()) + | let c: Command => c + | let ch: CommandHelp => + ch.print_help(env.out) + env.exitcode(0) + error + | let se: SyntaxError => + env.out.print(se.string()) + env.exitcode(1) + error + end + logtable = cmd.option("table").i64().usize() + iterate = cmd.option("iterate").i64().usize() + chunk = cmd.option("chunk").i64().usize() + streamer_count = cmd.option("streamers").i64().usize() + updater_count = cmd.option("updaters").i64().usize() + actor Main let _env: Env - var _logtable: USize = 20 - var _iterate: USize = 10000 - var _chunk: USize = 1024 - var _streamer_count: USize = 4 - var _updater_count: USize = 8 + var _streamer_count: USize = 0 + var _updater_count: USize = 0 var _updates: USize = 0 var _to: Array[Updater] val var _start: U64 @@ -19,10 +49,12 @@ actor Main _start = Time.nanos() try - arguments() + let c = Config(env) + _streamer_count = c.streamer_count + _updater_count = c.updater_count - var size = (1 << _logtable) / _updater_count - _updates = _chunk * _iterate * _streamer_count + var size = (1 << c.logtable) / _updater_count + _updates = c.chunk * c.iterate * _streamer_count let count = _updater_count @@ -37,7 +69,7 @@ actor Main end for i in Range(0, _streamer_count) do - Streamer(this, _to, size, _chunk, _chunk * _iterate * i)(_iterate) + Streamer(this, _to, size, c.chunk, c.chunk * c.iterate * i)(c.iterate) end end @@ -57,38 +89,6 @@ actor Main ) end - fun ref arguments() ? => - var options = Options(_env.args) - - options - .add("table", "t", I64Argument) - .add("iterate", "i", I64Argument) - .add("chunk", "c", I64Argument) - .add("streamers", "s", I64Argument) - .add("updaters", "u", I64Argument) - - for option in options do - match option - | ("table", let arg: I64) => _logtable = arg.usize() - | ("iterate", let arg: I64) => _iterate = arg.usize() - | ("chunk", let arg: I64) => _chunk = arg.usize() - | ("streamers", let arg: I64) => _streamer_count = arg.usize() - | ("updaters", let arg: I64) => _updater_count = arg.usize() - | let err: ParseError => err.report(_env.out) ; usage() ; error - end - end - - fun ref usage() => - _env.out.print( - """ - gups_basic [OPTIONS] - --table N log2 of the total table size. Defaults to 20. - --iterate N number of iterations. Defaults to 10000. - --chunk N chunk size. Defaults to 1024. - --streamers N number of streamers. Defaults to 4. - --updaters N number of updaters. Defaults to 8. - """ - ) actor Streamer let main: Main diff --git a/examples/gups_opt/main.pony b/examples/gups_opt/main.pony index deed6a2774..1c2d47a075 100755 --- a/examples/gups_opt/main.pony +++ b/examples/gups_opt/main.pony @@ -1,88 +1,81 @@ -use "options" -use "time" +use "cli" use "collections" +use "time" -class Config - var logtable: USize = 20 - var iterate: USize = 10000 - var logchunk: USize = 10 - var logactors: USize = 2 - - fun ref apply(env: Env): Bool => - var options = Options(env.args) - - options - .add("table", "t", I64Argument) - .add("iterate", "i", I64Argument) - .add("chunk", "c", I64Argument) - .add("actors", "a", I64Argument) - - for option in options do - match option - | ("table", let arg: I64) => logtable = arg.usize() - | ("iterate", let arg: I64) => iterate = arg.usize() - | ("chunk", let arg: I64) => logchunk = arg.usize() - | ("actors", let arg: I64) => logactors = arg.usize() - | let err: ParseError => - err.report(env.out) - env.out.print( - """ - gups_opt [OPTIONS] - --table N log2 of the total table size. Defaults to 20. - --iterate N number of iterations. Defaults to 10000. - --chunk N log2 of the chunk size. Defaults to 10. - --actors N log2 of the actor count. Defaults to 2. - """ - ) - return false +class val Config + let logtable: USize + let iterate: USize + let logchunk: USize + let logactors: USize + + new val create(env: Env) ? => + let cs = CommandSpec.parent("gups_opt", "", [ + OptionSpec.i64("table", "Log2 of the total table size." + where default' = 20) + OptionSpec.i64("iterate", "Number of iterations." where default' = 10000) + OptionSpec.i64("chunk", "Log2 of the chunk size." where default' = 10) + OptionSpec.i64("actors", "Log2 of the actor count." where default' = 2) + ]).>add_help() + let cmd = + match CommandParser(cs).parse(env.args, env.vars()) + | let c: Command => c + | let ch: CommandHelp => + ch.print_help(env.out) + env.exitcode(0) + error + | let se: SyntaxError => + env.out.print(se.string()) + env.exitcode(1) + error end - end + logtable = cmd.option("table").i64().usize() + iterate = cmd.option("iterate").i64().usize() + logchunk = cmd.option("chunk").i64().usize() + logactors = cmd.option("actors").i64().usize() env.out.print( "logtable: " + logtable.string() + "\niterate: " + iterate.string() + "\nlogchunk: " + logchunk.string() + - "\nlogactors: " + logactors.string() - ) - true + "\nlogactors: " + logactors.string()) actor Main let _env: Env - let _config: Config = Config - var _updates: USize = 0 var _confirm: USize = 0 - let _start: U64 + var _start: U64 = 0 var _actors: Array[Updater] val new create(env: Env) => _env = env - if _config(env) then - let actor_count = 1 << _config.logactors - let loglocal = _config.logtable - _config.logactors - let chunk_size = 1 << _config.logchunk - let chunk_iterate = chunk_size * _config.iterate + let c = try + Config(env) + else + _actors = recover Array[Updater] end + return + end + + let actor_count = 1 << c.logactors + let loglocal = c.logtable - c.logactors + let chunk_size = 1 << c.logchunk + let chunk_iterate = chunk_size * c.iterate - _updates = chunk_iterate * actor_count - _confirm = actor_count + _updates = chunk_iterate * actor_count + _confirm = actor_count - var updaters = recover Array[Updater](actor_count) end + var updaters = recover Array[Updater](actor_count) end - for i in Range(0, actor_count) do - updaters.push(Updater(this, actor_count, i, loglocal, chunk_size, - chunk_iterate * i)) - end + for i in Range(0, actor_count) do + updaters.push(Updater(this, actor_count, i, loglocal, chunk_size, + chunk_iterate * i)) + end - _actors = consume updaters - _start = Time.nanos() + _actors = consume updaters + _start = Time.nanos() - for a in _actors.values() do - a.start(_actors, _config.iterate) - end - else - _start = 0 - _actors = recover Array[Updater] end + for a in _actors.values() do + a.start(_actors, c.iterate) end be done() => diff --git a/examples/httpget/httpget.pony b/examples/httpget/httpget.pony index f621ecf52e..be2c74cb2e 100644 --- a/examples/httpget/httpget.pony +++ b/examples/httpget/httpget.pony @@ -1,60 +1,62 @@ use "assert" +use "cli" use "collections" +use "encode/base64" +use "files" use "net/http" use "net/ssl" -use "files" -use "encode/base64" -use "options" + +class val Config + let user: String + let pass: String + let output: String + let url: String + + new val create(env: Env) ? => + let cs = CommandSpec.leaf("httpget", "", [ + OptionSpec.string("user", "Username for authenticated queries." + where short' = 'u', default' = "") + OptionSpec.string("pass", "Password for authenticated queries." + where short' = 'p', default' = "") + OptionSpec.string("output", "Name of file to write response body." + where short' = 'o', default' = "") + ],[ + ArgSpec.string("url", "Url to query." where default' = None) + ]).>add_help() + let cmd = + match CommandParser(cs).parse(env.args, env.vars()) + | let c: Command => c + | let ch: CommandHelp => + ch.print_help(env.out) + env.exitcode(0) + error + | let se: SyntaxError => + env.out.print(se.string()) + env.exitcode(1) + error + end + user = cmd.option("user").string() + pass = cmd.option("pass").string() + output = cmd.option("output").string() + url = cmd.arg("url").string() actor Main """ Fetch data from URLs on the command line. """ - var user: String = "" - var pass: String = "" - var output: String = "" - new create(env: Env) => // Get common command line options. - let opts = Options(env.args) - opts - .add("user", "u", StringArgument) - .add("pass", "p", StringArgument) - .add("output", "o", StringArgument) - - for option in opts do - match option - | ("user", let u: String) => user = u - | ("pass", let p: String) => pass = p - | ("output", let o: String) => output = o - | let err: ParseError => - err.report(env.out) - _usage(env.out) - return - end - end + let c = try Config(env) else return end - // The positional parameter is the URL to be fetched. - let urlref = try env.args(env.args.size() - 1) else "" end let url = try - URL.valid(urlref) + URL.valid(c.url) else - env.out.print("Invalid URL") + env.out.print("Invalid URL: " + c.url) return end // Start the actor that does the real work. - _GetWork.create(env, url, user, pass, output) - - fun tag _usage(out: StdStream) => - """ - Print command error message. - """ - out.print( - "httpget [OPTIONS] URL\n" + - " --user (-u) Username for authenticated queries\n" + - " --pass (-p) Password for authenticated queries\n" + - " --output (-o) Name of file to write response body\n") + _GetWork.create(env, url, c.user, c.pass, c.output) actor _GetWork """ @@ -212,6 +214,6 @@ class HttpNotify is HTTPHandler """ _main.finished() _session.dispose() - + fun ref cancelled() => _main.cancelled() diff --git a/examples/main.pony b/examples/main.pony index 1ea1c2a6ba..037d1700b8 100644 --- a/examples/main.pony +++ b/examples/main.pony @@ -13,6 +13,7 @@ use "ponytest" // Commented packages are broken at the moment. We have to fix them and // then keep the tests green. use ex_circle = "circle" +use ex_commandline = "commandline" use ex_counter = "counter" use ex_echo = "echo" // use ex_ffi_struct = "ffi-struct" diff --git a/examples/mandelbrot/mandelbrot.pony b/examples/mandelbrot/mandelbrot.pony index f15dd63d15..da1dcb9f72 100644 --- a/examples/mandelbrot/mandelbrot.pony +++ b/examples/mandelbrot/mandelbrot.pony @@ -1,6 +1,6 @@ -use "files" -use "options" +use "cli" use "collections" +use "files" actor Worker new mandelbrot(main: Main, x: USize, y: USize, width: USize, @@ -65,28 +65,92 @@ actor Worker main.draw(x * (width >> 3), consume view) end +class val Config + let iterations: USize + let limit: F32 + let chunks: USize + let width: USize + let outpath: (FilePath | None) + + new val create(env: Env) ? => + let cs = CommandSpec.parent("gups_opt", + """ + The binary output can be converted to a PNG with the following command + (ImageMagick Tools required): + + convert PNG:.png""", + [ + OptionSpec.i64("iterations", + "Maximum amount of iterations to be done for each pixel." + where short' = 'i', default' = 50) + OptionSpec.f64("limit", + "Square of the limit that pixels need to exceed in order to escape from the Mandelbrot set." + where short' = 'l', default' = 4.0) + OptionSpec.i64("chunks", + "Maximum line count of chunks the image should be divided into for divide & conquer processing." + where short' = 'c', default' = 16) + OptionSpec.i64("width", + "Lateral length of the resulting mandelbrot image." + where short' = 'w', default' = 16000) + OptionSpec.string("output", + "File to write the output to." + where short' = 'o', default' = "") + ]).>add_help() + let cmd = + match CommandParser(cs).parse(env.args, env.vars()) + | let c: Command => c + | let ch: CommandHelp => + ch.print_help(env.out) + env.exitcode(0) + error + | let se: SyntaxError => + env.out.print(se.string()) + env.exitcode(1) + error + end + iterations = cmd.option("iterations").i64().usize() + limit = cmd.option("limit").f64().f32() + chunks = cmd.option("chunks").i64().usize() + width = cmd.option("width").i64().usize() + outpath = + try + FilePath(env.root as AmbientAuth, cmd.option("output").string()) + else + None + end + + new val none() => + iterations = 0 + limit = 0 + chunks = 0 + width = 0 + outpath = None + actor Main - var iterations: USize = 50 - var limit: F32 = 4.0 - var chunks: USize = 16 - var width: USize = 16000 + let c: Config + var outfile: (File | None) = None var actors: USize = 0 var header: USize = 0 var real: Array[F32] val = recover Array[F32] end var imaginary: Array[F32] val = recover Array[F32] end - var outfile: (File | None) = None new create(env: Env) => try - arguments(env) + c = Config(env) + outfile = + match c.outpath + | let fp: FilePath => File(fp) + else + None + end - let length = width - let recip_width = 2.0 / width.f32() + let length = c.width + let recip_width = 2.0 / c.width.f32() var r = recover Array[F32](length) end var i = recover Array[F32](length) end - for j in Range(0, width) do + for j in Range(0, c.width) do r.push((recip_width * j.f32()) - 1.5) i.push((recip_width * j.f32()) - 1.0) end @@ -97,6 +161,7 @@ actor Main spawn_actors() create_outfile() end + c = Config.none() be draw(offset: USize, pixels: Array[U8] val) => match outfile @@ -111,79 +176,24 @@ actor Main fun ref create_outfile() => match outfile | let f: File => - f.print("P4\n " + width.string() + " " + width.string() + "\n") + f.print("P4\n " + c.width.string() + " " + c.width.string() + "\n") header = f.size() - f.set_length((width * (width >> 3)) + header) + f.set_length((c.width * (c.width >> 3)) + header) end fun ref spawn_actors() => - actors = ((width + (chunks - 1)) / chunks) - - var rest = width % chunks - - if rest == 0 then rest = chunks end + actors = ((c.width + (c.chunks - 1)) / c.chunks) + var rest = c.width % c.chunks + if rest == 0 then rest = c.chunks end var x: USize = 0 var y: USize = 0 for i in Range(0, actors - 1) do - x = i * chunks - y = x + chunks - Worker.mandelbrot(this, x, y, width, iterations, limit, real, imaginary) + x = i * c.chunks + y = x + c.chunks + Worker.mandelbrot(this, x, y, c.width, c.iterations, c.limit, real, imaginary) end - Worker.mandelbrot(this, y, y + rest, width, iterations, limit, real, + Worker.mandelbrot(this, y, y + rest, c.width, c.iterations, c.limit, real, imaginary) - - fun ref arguments(env: Env) ? => - let options = Options(env.args) - - options - .add("iterations", "i", I64Argument) - .add("limit", "l", F64Argument) - .add("chunks", "c", I64Argument) - .add("width", "w", I64Argument) - .add("output", "o", StringArgument) - - for option in options do - match option - | ("iterations", let arg: I64) => iterations = arg.usize() - | ("limit", let arg: F64) => limit = arg.f32() - | ("chunks", let arg: I64) => chunks = arg.usize() - | ("width", let arg: I64) => width = arg.usize() - | ("output", let arg: String) => - outfile = try File(FilePath(env.root as AmbientAuth, arg)) end - | let err: ParseError => err.report(env.out) ; usage(env) ; error - end - end - - fun tag usage(env: Env) => - env.out.print( - """ - mandelbrot [OPTIONS] - - The binary output can be converted to a BMP with the following command - (ImageMagick Tools required): - - convert JPEG:.jpg - - Available options: - - --iterations, -i Maximum amount of iterations to be done for each pixel. - Defaults to 50. - - --limit, -l Square of the limit that pixels need to exceed in order - to escape from the Mandelbrot set. - Defaults to 4.0. - - --chunks, -c Maximum line count of chunks the image should be - divided into for divide & conquer processing. - Defaults to 16. - - --width, -w Lateral length of the resulting mandelbrot image. - Defaults to 16000. - - --output, -o File to write the output to. - - """ - ) diff --git a/examples/printargs/printargs.pony b/examples/printargs/printargs.pony index 36341fc5cc..af606f190d 100644 --- a/examples/printargs/printargs.pony +++ b/examples/printargs/printargs.pony @@ -1,4 +1,4 @@ -use "options" +use "cli" actor Main new create(env: Env) => diff --git a/examples/yield/main.pony b/examples/yield/main.pony index d75df2ffd1..413d9fc641 100644 --- a/examples/yield/main.pony +++ b/examples/yield/main.pony @@ -1,47 +1,44 @@ """ - -An actor behaviour is intended for short lived finite interactions -executed asynchronously. Sometimes it is useful to be able to naturally -code behaviours of short lived finite signals punctuating over a -longer lived ( but finite ) behaviour. In actor implementations that -do not feature causal messaging this is fairly natural and idiomatic. -But in pony, without yield, this is impossible. - -The causal messaging guarantee, and asynchronous execution means that -the messages enqueued in the actor's mailbox will never be scheduled -for execution if the receiving behaviour is infinite, which it can be -in the worst case ( bad code ). - -By rediculo ad absurdum the simplest manifestation of this problem is -a signaling behaviour, say a 'kill' message, that sets a flag to conditionally +An actor behaviour is intended for short lived finite interactions executed +asynchronously. Sometimes it is useful to be able to naturally code behaviours +of short lived finite signals punctuating over a longer lived (but finite) +behaviour. In actor implementations that do not feature causal messaging this is +fairly natural and idiomatic. But in pony, without yield, this is impossible. + +The causal messaging guarantee, and asynchronous execution means that the +messages enqueued in the actor's mailbox will never be scheduled for execution +if the receiving behaviour is infinite, which it can be in the worst case (bad +code). + +By rediculo ad absurdum the simplest manifestation of this problem is a +signaling behaviour, say a 'kill' message, that sets a flag to conditionally stop accepting messages. The runtime will only detect an actor as GCable if it -reaches quiescence *and* there are no pending messages waiting to be enqueued -to the actor in its mailbox. But, our 'kill' message can never proceed -from the mailbox as the currently active behaviour ( infinite ) never completes. +reaches quiescence *and* there are no pending messages waiting to be enqueued to +the actor in its mailbox. But, our 'kill' message can never proceed from the +mailbox as the currently active behaviour (infinite) never completes. We call this the lonely pony problem. And, it can be solved in 0 lines of pony. -Yield in pony is a simple clever trick. By transforming loops in long -running behaviours to lazy tail-recursive behaviour calls composed, -we can yield conditionally whilst preserving causal messaging guarantees, -and enforcing at-most-once delivery semantics. +Yield in pony is a simple clever trick. By transforming loops in long running +behaviours to lazy tail-recursive behaviour calls composed, we can yield +conditionally whilst preserving causal messaging guarantees, and enforcing at- +most-once delivery semantics. The benefits of causal messaging, garbage collectible actors, and safe mutable actors far outweigh the small price manifested by the lonely pony problem. The solution, that uncovered the consume apply idiom and its application to enable -interruptible behaviours that are easy to use are far more valuable at the cost to -the actor implementor of only a few extra lines of code per behaviour to enable -interruptible semantics with strong causal guarantees. +interruptible behaviours that are easy to use are far more valuable at the cost +to the actor implementor of only a few extra lines of code per behaviour to +enable interruptible semantics with strong causal guarantees. In a nutshell, by avoiding for and while loops, and writing behaviours tail -recursively, the ability to compose long-lived with short-lived behaviours -is a builtin feature of pony. - +recursively, the ability to compose long-lived with short-lived behaviours is a +builtin feature of pony. """ +use "cli" use "collections" use "debug" -use "options" use "time" class StopWatch @@ -82,7 +79,7 @@ actor LonelyPony be forever() => """ - The trivial case of a badly written behaviour that eats a scheduler ( forever ) + The trivial case of a badly written behaviour that eats a scheduler (forever) """ while _alive do if _debug then @@ -188,73 +185,66 @@ actor Main new create(env: Env) => _env = env - var punk: Bool = false - var lonely: Bool = false - var perf: U64 = 0 - var debug: Bool = false - var err: Bool = false - - let options = Options(env.args) + - ("punk", "p", None) + - ("lonely", "l", None) + - ("bench", "b", I64Argument) + - ("debug", "d", None) - ("help", "h", None) - - for opt in options do - match opt - | ("punk", None) => punk = true - | ("lonely", None) => lonely = true - | ("bench", let arg: I64) => perf = arg.u64() - | ("debug", None) => debug = true - else - err = true - end + let cs = try + CommandSpec.parent("yield", + """ + Demonstrate use of the yield behaviour when writing tail recursive + behaviours in pony. + + By Default, the actor will run quiet and interruptibly.""", + [ + OptionSpec.bool("punk", + "Run a punctuated stream demonstration." + where short' = 'p', default' = false) + OptionSpec.i64("bench", + "Run an instrumented behaviour to guesstimate overhead of non/interruptive." + where short' = 'b', default' = 0) + OptionSpec.bool("lonely", + "Run a non-interruptible behaviour with logic that runs forever." + where short' = 'l', default' = false) + OptionSpec.bool("debug", "Run in debug mode with verbose output." + where short' = 'd', default' = false) + ]).>add_help() + else + _env.exitcode(-1) // some kind of coding error + return end - match err + let cmd = + match CommandParser(cs).parse(_env.args, _env.vars()) + | let c: Command => c + | let ch: CommandHelp => + ch.print_help(_env.out) + _env.exitcode(0) + return + | let se: SyntaxError => + _env.out.print(se.string()) + _env.exitcode(1) + return + end + + var punk: Bool = cmd.option("punk").bool() + var perf: U64 = cmd.option("bench").i64().u64() + var lonely: Bool = cmd.option("lonely").bool() + var debug: Bool = cmd.option("lonely").bool() + + match punk | true => - usage() - return + PunkDemo(env) + .>loop() + .>inc().>inc().>inc() + .>dec().>dec().>dec() + .>inc().>dec() + .>kill() else - match punk + match perf > 0 | true => - PunkDemo(env) - .>loop() - .>inc().>inc().>inc() - .>dec().>dec().>dec() - .>inc().>dec() - .>kill() + InterruptiblePony(env,debug,perf).perf() + LonelyPony(env,debug,perf).perf() else - match perf > 0 - | true => - InterruptiblePony(env,debug,perf).perf() - LonelyPony(env,debug,perf).perf() - else - match lonely - | false => InterruptiblePony(env,debug).>forever().>kill() - | true => LonelyPony(env,debug).>forever().>kill() - end + match lonely + | false => InterruptiblePony(env,debug).>forever().>kill() + | true => LonelyPony(env,debug).>forever().>kill() end end end - - fun usage() => - _env.out.print( - """ - yield ( --punk | --lonely | --bench NUM ) [ OPTIONS ] - --punk, -p Run a punctuated stream demonstration - --lonely, -l Run a non-interruptible behaviour with logic that runs forever - --bench, -b NUM Run an instrumented behaviour to guesstimate overhead of non/interruptive - - OPTIONS - --debug, -d Run in debug mode with verbose output - --help, -h This help - - DESCRIPTION - - Demonstrate use of the yield behaviour when writing tail recursive - behaviours in pony. - - By Default, the actor will run quiet and interruptibly. - """) diff --git a/packages/cli/_test.pony b/packages/cli/_test.pony new file mode 100644 index 0000000000..aed78a97ec --- /dev/null +++ b/packages/cli/_test.pony @@ -0,0 +1,381 @@ +use "ponytest" + +actor Main is TestList + new create(env: Env) => PonyTest(env, this) + new make() => None + + fun tag tests(test: PonyTest) => + test(_TestMinimal) + test(_TestBadName) + test(_TestHyphenArg) + test(_TestBools) + test(_TestDefaults) + test(_TestShortsAdj) + test(_TestShortsEq) + test(_TestShortsNext) + test(_TestLongsEq) + test(_TestLongsNext) + test(_TestEnvs) + test(_TestOptionStop) + test(_TestDuplicate) + test(_TestChatMin) + test(_TestChatAll) + test(_TestHelp) + +class iso _TestMinimal is UnitTest + fun name(): String => "ponycli/minimal" + + fun apply(h: TestHelper) ? => + let cs = CommandSpec.leaf("minimal", "", [ + OptionSpec.bool("aflag", "") + ]) + + h.assert_eq[String]("minimal", cs.name()) + + let args: Array[String] = ["ignored"; "--aflag=true"] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[Bool](true, cmd.option("aflag").bool()) + +class iso _TestBadName is UnitTest + fun name(): String => "ponycli/badname" + + fun apply(h: TestHelper) ? => + try + let cs = CommandSpec.leaf("min imal", "") + else + return // error was expected + end + error // lack of error is bad + +class iso _TestHyphenArg is UnitTest + fun name(): String => "ponycli/hyphen" + + // Rule 1 + fun apply(h: TestHelper) ? => + let cs = CommandSpec.leaf("minimal" where args' = [ + ArgSpec.string("name", "") + ]) + let args: Array[String] = ["ignored"; "-"] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[String]("-", cmd.arg("name").string()) + +class iso _TestBools is UnitTest + fun name(): String => "ponycli/bools" + + // Rules 2, 3, 5, 7 w/ Bools + fun apply(h: TestHelper) ? => + let cs = _Fixtures.bools_cli_spec() + + let args: Array[String] = ["ignored"; "-ab"; "-c=true"; "-d=false"] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[Bool](true, cmd.option("aaa").bool()) + h.assert_eq[Bool](true, cmd.option("bbb").bool()) + h.assert_eq[Bool](true, cmd.option("ccc").bool()) + h.assert_eq[Bool](false, cmd.option("ddd").bool()) + +class iso _TestDefaults is UnitTest + fun name(): String => "ponycli/defaults" + + // Rules 2, 3, 5, 6 + fun apply(h: TestHelper) ? => + let cs = _Fixtures.simple_cli_spec() + + let args: Array[String] = ["ignored"; "-B"; "-S--"; "-I42"; "-F42.0"] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[Bool](true, cmd.option("boolo").bool()) + h.assert_eq[String]("astring", cmd.option("stringo").string()) + h.assert_eq[I64](42, cmd.option("into").i64()) + h.assert_eq[F64](42.0, cmd.option("floato").f64()) + +class iso _TestShortsAdj is UnitTest + fun name(): String => "ponycli/shorts_adjacent" + + // Rules 2, 3, 5, 6, 8 + fun apply(h: TestHelper) ? => + let cs = _Fixtures.simple_cli_spec() + + let args: Array[String] = ["ignored"; "-BS--"; "-I42"; "-F42.0"] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[Bool](true, cmd.option("boolr").bool()) + h.assert_eq[String]("--", cmd.option("stringr").string()) + h.assert_eq[I64](42, cmd.option("intr").i64()) + h.assert_eq[F64](42.0, cmd.option("floatr").f64()) + +class iso _TestShortsEq is UnitTest + fun name(): String => "ponycli/shorts_eq" + + // Rules 2, 3, 5, 7 + fun apply(h: TestHelper) ? => + let cs = _Fixtures.simple_cli_spec() + + let args: Array[String] = ["ignored"; "-BS=astring"; "-I=42"; "-F=42.0"] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[Bool](true, cmd.option("boolr").bool()) + h.assert_eq[String]("astring", cmd.option("stringr").string()) + h.assert_eq[I64](42, cmd.option("intr").i64()) + h.assert_eq[F64](42.0, cmd.option("floatr").f64()) + +class iso _TestShortsNext is UnitTest + fun name(): String => "ponycli/shorts_next" + + // Rules 2, 3, 5, 8 + fun apply(h: TestHelper) ? => + let cs = _Fixtures.simple_cli_spec() + + let args: Array[String] = [ + "ignored"; "-BS"; "--"; "-I"; "42"; "-F"; "42.0" + ] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[Bool](true, cmd.option("boolr").bool()) + h.assert_eq[String]("--", cmd.option("stringr").string()) + h.assert_eq[I64](42, cmd.option("intr").i64()) + h.assert_eq[F64](42.0, cmd.option("floatr").f64()) + +class iso _TestLongsEq is UnitTest + fun name(): String => "ponycli/shorts_eq" + + // Rules 4, 5, 7 + fun apply(h: TestHelper) ? => + let cs = _Fixtures.simple_cli_spec() + + let args: Array[String] = [ + "ignored" + "--boolr=true"; "--stringr=astring"; "--intr=42"; "--floatr=42.0" + ] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[Bool](true, cmd.option("boolr").bool()) + h.assert_eq[String]("astring", cmd.option("stringr").string()) + h.assert_eq[I64](42, cmd.option("intr").i64()) + h.assert_eq[F64](42.0, cmd.option("floatr").f64()) + +class iso _TestLongsNext is UnitTest + fun name(): String => "ponycli/longs_next" + + // Rules 4, 5, 8 + fun apply(h: TestHelper) ? => + let cs = _Fixtures.simple_cli_spec() + + let args: Array[String] = [ + "ignored" + "--boolr"; "--stringr"; "--"; "--intr"; "42"; "--floatr"; "42.0" + ] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[String]("--", cmd.option("stringr").string()) + h.assert_eq[I64](42, cmd.option("intr").i64()) + h.assert_eq[F64](42.0, cmd.option("floatr").f64()) + +class iso _TestEnvs is UnitTest + fun name(): String => "ponycli/envs" + + // Rules + fun apply(h: TestHelper) ? => + let cs = _Fixtures.simple_cli_spec() + + let args: Array[String] = [ + "ignored" + ] + let envs: Array[String] = [ + "SHORTS_BOOLR=true" + "SHORTS_STRINGR=astring" + "SHORTS_INTR=42" + "SHORTS_FLOATR=42.0" + ] + let cmdErr = CommandParser(cs).parse(args, envs) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[Bool](true, cmd.option("boolr").bool()) + h.assert_eq[String]("astring", cmd.option("stringr").string()) + h.assert_eq[I64](42, cmd.option("intr").i64()) + h.assert_eq[F64](42.0, cmd.option("floatr").f64()) + +class iso _TestOptionStop is UnitTest + fun name(): String => "ponycli/option_stop" + + // Rules 2, 3, 5, 7, 9 + fun apply(h: TestHelper) ? => + let cs = _Fixtures.simple_cli_spec() + + let args: Array[String] = [ + "ignored" + "-BS=astring"; "-I=42"; "-F=42.0" + "--"; "-f=1.0" + ] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[String]("-f=1.0", cmd.arg("words").string()) + h.assert_eq[F64](42.0, cmd.option("floato").f64()) + +class iso _TestDuplicate is UnitTest + fun name(): String => "ponycli/duplicate" + + // Rules 4, 5, 7, 10 + fun apply(h: TestHelper) ? => + let cs = _Fixtures.simple_cli_spec() + + let args: Array[String] = [ + "ignored" + "--boolr=true"; "--stringr=astring"; "--intr=42"; "--floatr=42.0" + "--stringr=newstring" + ] + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[String]("newstring", cmd.option("stringr").string()) + +class iso _TestChatMin is UnitTest + fun name(): String => "ponycli/chat_min" + + fun apply(h: TestHelper) ? => + let cs = _Fixtures.chat_cli_spec() + + let args: Array[String] = ["ignored"; "--name=me"; "--volume=42"] + + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + h.assert_eq[String]("chat", cs.name()) + +class iso _TestChatAll is UnitTest + fun name(): String => "ponycli/chat_all" + + fun apply(h: TestHelper) ? => + let cs = _Fixtures.chat_cli_spec() + + let args: Array[String] = [ + "ignored" + "--admin"; "--name=carl"; "say"; "-v80"; "hello" + ] + + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + let cmd = match cmdErr | let c: Command => c else error end + + h.assert_eq[String]("say", cmd.spec().name()) + + let f1 = cmd.option("admin") + h.assert_eq[String]("admin", f1.spec().name()) + h.assert_eq[Bool](true, f1.bool()) + + let f2 = cmd.option("name") + h.assert_eq[String]("name", f2.spec().name()) + h.assert_eq[String]("carl", f2.string()) + + let f3 = cmd.option("volume") + h.assert_eq[String]("volume", f3.spec().name()) + h.assert_eq[F64](80.0, f3.f64()) + + let a1 = cmd.arg("words") + h.assert_eq[String]("words", a1.spec().name()) + h.assert_eq[String]("hello", a1.string()) + +class iso _TestHelp is UnitTest + fun name(): String => "ponycli/help" + + fun apply(h: TestHelper) ? => + let cs = _Fixtures.chat_cli_spec() + + let chErr = Help.for_command(cs, ["config"; "server"]) + let ch = match chErr | let c: CommandHelp => c else error end + + let help = ch.help_string() + h.log(help) + h.assert_true(help.contains("Address of the server")) + +primitive _Fixtures + fun bools_cli_spec(): CommandSpec box ? => + """ + Builds and returns the spec for a CLI with four bool options. + """ + CommandSpec.leaf("bools", "A sample CLI with four bool options", [ + OptionSpec.bool("aaa" where short' = 'a') + OptionSpec.bool("bbb" where short' = 'b') + OptionSpec.bool("ccc" where short' = 'c') + OptionSpec.bool("ddd" where short' = 'd') + ]) + + fun simple_cli_spec(): CommandSpec box ? => + """ + Builds and returns the spec for a CLI with short options of each type. + """ + CommandSpec.leaf("shorts", + "A sample program with various short options, optional and required", [ + OptionSpec.bool("boolr" where short' = 'B') + OptionSpec.bool("boolo" where short' = 'b', default' = true) + OptionSpec.string("stringr" where short' = 'S') + OptionSpec.string("stringo" where short' = 's', default' = "astring") + OptionSpec.i64("intr" where short' = 'I') + OptionSpec.i64("into" where short' = 'i', default' = I64(42)) + OptionSpec.f64("floatr" where short' = 'F') + OptionSpec.f64("floato" where short' = 'f', default' = F64(42.0)) + ], [ + ArgSpec.string("words" where default' = "hello") + ]) + + fun chat_cli_spec(): CommandSpec box ? => + """ + Builds and returns the spec for a sample chat client's CLI. + """ + CommandSpec.parent("chat", "A sample chat program", [ + OptionSpec.bool("admin", "Chat as admin" where default' = false) + OptionSpec.string("name", "Your name" where short' = 'n') + OptionSpec.f64("volume", "Chat volume" where short' = 'v') + ], [ + CommandSpec.leaf("say", "Say something", Array[OptionSpec](), [ + ArgSpec.string("words", "The words to say") + ]) + CommandSpec.leaf("emote", "Send an emotion", [ + OptionSpec.f64("speed", "Emote play speed" where default' = F64(1.0)) + ], [ + ArgSpec.string("emotion", "Emote to send") + ]) + CommandSpec.parent("config", "Configuration commands", Array[OptionSpec](), [ + CommandSpec.leaf("server", "Server configuration", Array[OptionSpec](), [ + ArgSpec.string("address", "Address of the server") + ]) + ]) + ]) diff --git a/packages/cli/command.pony b/packages/cli/command.pony new file mode 100644 index 0000000000..ee018aa221 --- /dev/null +++ b/packages/cli/command.pony @@ -0,0 +1,110 @@ +use "collections" + +class box Command + """ + Command contains all of the information describing a command with its spec + and effective options and arguments, ready to use. + """ + let _spec: CommandSpec box + let _options: Map[String, Option] box + let _args: Map[String, Arg] box + + new create( + spec': CommandSpec box, + options': Map[String, Option] box, + args': Map[String, Arg] box) + => + _spec = spec' + _options = options' + _args = args' + + fun string(): String => + let s: String iso = _spec.name().clone() + for o in _options.values() do + s.append(" ") + s.append(o.deb_string()) + end + for a in _args.values() do + s.append(" ") + s.append(a.deb_string()) + end + s + + fun box spec() : CommandSpec box => _spec + + fun box option(name: String): Option => + try _options(name) else Option(OptionSpec.bool(name), false) end + + fun box arg(name: String): Arg => + try _args(name) else Arg(ArgSpec.bool(name), false) end + +class val Option + """ + Option contains a spec and an effective value for a given option. + """ + let _spec: OptionSpec + let _value: _Value + + new val create(spec': OptionSpec, value': _Value) => + _spec = spec' + _value = value' + + fun spec() : OptionSpec => _spec + + fun bool(): Bool => + try _value as Bool else false end + + fun string(): String => + try _value as String else "" end + + fun i64(): I64 => + try _value as I64 else I64(0) end + + fun f64(): F64 => + try _value as F64 else F64(0) end + + fun deb_string(): String => + _spec.deb_string() + "=" + _value.string() + +class val Arg + """ + Arg contains a spec and an effective value for a given arg. + """ + let _spec: ArgSpec + let _value: _Value + + new val create(spec': ArgSpec, value': _Value) => + _spec = spec' + _value = value' + + fun spec(): ArgSpec => _spec + + fun bool(): Bool => + try _value as Bool else false end + + fun string(): String => + try _value as String else "" end + + fun i64(): I64 => + try _value as I64 else I64(0) end + + fun f64(): F64 => + try _value as F64 else F64(0) end + + fun deb_string(): String => + "(" + _spec.deb_string() + "=)" + _value.string() + +class val SyntaxError + """ + SyntaxError summarizes a syntax error in a given parsed command line. + """ + let _token: String + let _msg: String + + new val create(token': String, msg': String) => + _token = token' + _msg = msg' + + fun token(): String => _token + + fun string(): String => "Error: " + _msg + " at: '" + _token + "'" diff --git a/packages/cli/command_help.pony b/packages/cli/command_help.pony new file mode 100644 index 0000000000..b3ad2f81f7 --- /dev/null +++ b/packages/cli/command_help.pony @@ -0,0 +1,180 @@ +use "buffered" + +primitive Help + fun general(cs: CommandSpec box): CommandHelp => + CommandHelp._create(None, cs) + + fun for_command(cs: CommandSpec box, argv: Array[String] box) + : (CommandHelp | SyntaxError) + => + _parse(cs, CommandHelp._create(None, cs), argv) + + fun _parse(cs: CommandSpec box, ch: CommandHelp, argv: Array[String] box) + : (CommandHelp | SyntaxError) + => + if argv.size() > 0 then + try + let cname = argv(0) + if cs.commands().contains(cname) then + match cs.commands()(cname) + | let ccs: CommandSpec box => + let cch = CommandHelp._create(ch, ccs) + return _parse(ccs, cch, argv.slice(1)) + end + end + return SyntaxError(cname, "unknown command") + end + end + ch + +class box CommandHelp + """ + CommandHelp encapsulates the information needed to generate a user help + message for a given CommandSpec, optionally with a specific command + identified to get help on. Use `Help.general()` or `Help.for_command()` to + create a CommandHelp instance. + """ + let _parent: (CommandHelp box | None) + let _spec: CommandSpec box + + new _create(parent': (CommandHelp box | None), spec': CommandSpec box) => + _parent = parent' + _spec = spec' + + fun box fullname(): String => + match _parent + | let p: CommandHelp => p.fullname() + " " + _spec.name() + else + _spec.name() + end + + fun box string(): String => fullname() + + fun box help_string(): String => + let w: Writer = Writer + _write_help(w) + let str = recover trn String(w.size()) end + for bytes in w.done().values() do str.append(bytes) end + consume str + + fun box print_help(os: OutStream) => + let w: Writer = Writer + _write_help(w) + os.writev(w.done()) + + fun box _write_help(w: Writer) => + _write_usage(w) + if _spec.descr().size() > 0 then + w.write("\n") + w.write(_spec.descr() + "\n") + end + + let options = _all_options() + if options.size() > 0 then + w.write("\nOptions:\n") + _write_options(w, options) + end + if _spec.commands().size() > 0 then + w.write("\nCommands:\n") + _write_commands(w) + end + let args = _spec.args() + if args.size() > 0 then + w.write("\nArgs:\n") + _write_args(w, args) + end + + fun box _write_usage(w: Writer) => + w.write("usage: " + fullname()) + if _any_options() then + w.write(" []") + end + if _spec.commands().size() > 0 then + w.write(" ") + end + if _spec.args().size() > 0 then + for a in _spec.args().values() do + w.write(" " + a.help_string()) + end + else + w.write(" [ ...]") + end + w.write("\n") + + fun box _write_options(w: Writer, options: Array[OptionSpec box] box) => + let cols = Array[(USize,String,String)]() + for o in options.values() do + cols.push((2, o.help_string(), o.descr())) + end + _Columns.write(w, cols) + + fun box _write_commands(w: Writer) => + let cols = Array[(USize,String,String)]() + _list_commands(_spec, cols, 1) + _Columns.write(w, cols) + + fun box _list_commands( + cs: CommandSpec box, + cols: Array[(USize,String,String)], + level: USize) + => + for c in cs.commands().values() do + cols.push((level*2, c.help_string(), c.descr())) + _list_commands(c, cols, level + 1) + end + + fun box _write_args(w: Writer, args: Array[ArgSpec] box) => + let cols = Array[(USize,String,String)]() + for a in args.values() do + cols.push((2, a.help_string(), a.descr())) + end + _Columns.write(w, cols) + + fun box _any_options(): Bool => + if _spec.options().size() > 0 then + true + else + match _parent + | let p: CommandHelp => p._any_options() + else + false + end + end + + fun box _all_options(): Array[OptionSpec box] => + let options = Array[OptionSpec box]() + _all_options_fill(options) + options + + fun box _all_options_fill(options: Array[OptionSpec box]) => + match _parent + | let p: CommandHelp => p._all_options_fill(options) + end + for o in _spec.options().values() do + options.push(o) + end + +primitive _Columns + fun indent(w: Writer, n: USize) => + var i = n + while i > 0 do + w.write(" ") + i = i - 1 + end + + fun write(w: Writer, cols: Array[(USize,String,String)]) => + var widest: USize = 0 + for c in cols.values() do + (let c0, let c1, _) = c + let c1s = c0 + c1.size() + if c1s > widest then + widest = c1s + end + end + for c in cols.values() do + (let c0, let c1, let c2) = c + indent(w, 1 + c0) + w.write(c1) + indent(w, (widest - c1.size()) + 2) + w.write(c2 + "\n") + end diff --git a/packages/cli/command_parser.pony b/packages/cli/command_parser.pony new file mode 100644 index 0000000000..a925728d08 --- /dev/null +++ b/packages/cli/command_parser.pony @@ -0,0 +1,318 @@ +use "collections" + +class CommandParser + let _spec: CommandSpec box + let _parent: (CommandParser box | None) + + new box create(spec': CommandSpec box) => + _spec = spec' + _parent = None + + new box _sub(spec': CommandSpec box, parent': CommandParser box) => + _spec = spec' + _parent = parent' + + fun box parse( + argv: Array[String] box, + envs: (Array[String] box | None) = None) + : (Command | CommandHelp | SyntaxError) + => + """ + Parses all of the command line tokens and env vars and returns a Command, + or the first SyntaxError. + """ + let tokens = argv.clone() + try tokens.shift() end // argv[0] is the program name, so skip it + let options: Map[String,Option] ref = options.create() + let args: Map[String,Arg] ref = args.create() + _parse_command( + tokens, options, args, + EnvVars(envs, _spec.name().upper() + "_", true), + false) + + fun box _root_spec(): CommandSpec box => + match _parent + | let p: CommandParser box => p._root_spec() + else + _spec + end + + fun box _parse_command( + tokens: Array[String] ref, + options: Map[String,Option] ref, + args: Map[String,Arg] ref, + envsmap: Map[String, String] box, + ostop: Bool) + : (Command | CommandHelp | SyntaxError) + => + """ + Parses all of the command line tokens and env vars into the given options + and args maps. Returns the first SyntaxError, or the Command when OK. + """ + var opt_stop = ostop + var arg_pos: USize = 0 + + while tokens.size() > 0 do + let token = try tokens.shift() else "" end + if token == "--" then + opt_stop = true + + elseif not opt_stop and (token.compare_sub("--", 2, 0) == Equal) then + match _parse_long_option(token.substring(2), tokens) + | let o: Option => options.update(o.spec().name(), o) + | let se: SyntaxError => return se + end + + elseif not opt_stop and + ((token.compare_sub("-", 1, 0) == Equal) and (token.size() > 1)) then + match _parse_short_options(token.substring(1), tokens) + | let os: Array[Option] => + for o in os.values() do + options.update(o.spec().name(), o) + end + | let se: SyntaxError => + return se + end + + else // no dashes, must be a command or an arg + if _spec.commands().size() > 0 then + try + match _spec.commands()(token) + | let cs: CommandSpec box => + return CommandParser._sub(cs, this). + _parse_command(tokens, options, args, envsmap, opt_stop) + end + else + return SyntaxError(token, "unknown command") + end + else + match _parse_arg(token, arg_pos) + | let a: Arg => args.update(a.spec().name(), a) + arg_pos = arg_pos + 1 + | let se: SyntaxError => return se + end + end + end + end + + // If it's a help option, return a general or specific CommandHelp. + if options.contains(_help_name()) then + return + if _spec is _root_spec() then + Help.general(_root_spec()) + else + Help.for_command(_root_spec(), [_spec.name()]) + end + end + + // If it's a help command, return a general or specific CommandHelp. + if _spec.name() == _help_name() then + try + match args("command").string() + | "" => return Help.general(_root_spec()) + | let c: String => return Help.for_command(_root_spec(), [c]) + end + end + return Help.general(_root_spec()) + end + + // Fill in option values from env or from coded defaults. + for os in _spec.options().values() do + if not options.contains(os.name()) then + // Lookup and use env vars before code defaults + if envsmap.contains(os.name()) then + let vs = + try + envsmap(os.name()) + else // TODO(cq) why is else needed? we just checked + "" + end + let v: _Value = + match _ValueParser.parse(os._typ_p(), vs) + | let v: _Value => v + | let se: SyntaxError => return se + end + options.update(os.name(), Option(os, v)) + else + if not os.required() then + options.update(os.name(), Option(os, os._default_p())) + end + end + end + end + + // Check for missing options and error if any exist. + for os in _spec.options().values() do + if not options.contains(os.name()) then + if os.required() then + return SyntaxError(os.name(), "missing value for required option") + end + end + end + + // Check for missing args and error if found. + while arg_pos < _spec.args().size() do + try + let ars = _spec.args()(arg_pos) + if ars.required() then + return SyntaxError(ars.name(), "missing value for required argument") + end + args.update(ars.name(), Arg(ars, ars._default_p())) + end + arg_pos = arg_pos + 1 + end + + // A successfully parsed and populated leaf Command + Command(_spec, consume options, args) + + fun box _parse_long_option( + token: String, + args: Array[String] ref) + : (Option | SyntaxError) + => + """ + --opt=foo => --opt has argument foo + --opt foo => --opt has argument foo, iff arg is required + """ + let parts = token.split("=") + let name = try parts(0) else "???" end + let targ = try parts(1) else None end + match _option_with_name(name) + | let os: OptionSpec => _OptionParser.parse(os, targ, args) + | None => SyntaxError(name, "unknown long option") + end + + fun box _parse_short_options( + token: String, + args: Array[String] ref) + : (Array[Option] | SyntaxError) + => + """ + if 'O' requires an argument + -OFoo => -O has argument Foo + -O=Foo => -O has argument Foo + -O Foo => -O has argument Foo + else + -O=Foo => -O has argument foo + -abc => options a, b, c. + -abcFoo => options a, b, c. c has argument Foo iff its arg is required. + -abc=Foo => options a, b, c. c has argument Foo. + -abc Foo => options a, b, c. c has argument Foo iff its arg is required. + """ + let parts = token.split("=") + let shorts = (try parts(0) else "" end).clone() + var targ = try parts(1) else None end + + let options: Array[Option] ref = options.create() + while shorts.size() > 0 do + let c = + try + shorts.shift() + else + 0 // TODO(cq) Should never error since checked + end + match _option_with_short(c) + | let fs: OptionSpec => + if fs._requires_arg() and (shorts.size() > 0) then + // opt needs an arg, so consume the remainder of the shorts for targ + if targ is None then + targ = shorts.clone() + shorts.truncate(0) + else + return SyntaxError(_short_string(c), "ambiguous args for short option") + end + end + let arg = if shorts.size() == 0 then targ else None end + match _OptionParser.parse(fs, arg, args) + | let f: Option => options.push(f) + | let se: SyntaxError => return se + end + | None => SyntaxError(_short_string(c), "unknown short option") + end + end + options + + fun box _parse_arg(token: String, arg_pos: USize): (Arg | SyntaxError) => + try + let arg_spec = _spec.args()(arg_pos) + _ArgParser.parse(arg_spec, token) + else + return SyntaxError(token, "too many positional arguments") + end + + fun box _option_with_name(name: String): (OptionSpec | None) => + try + return _spec.options()(name) + end + match _parent + | let p: CommandParser box => p._option_with_name(name) + else + None + end + + fun box _option_with_short(short: U8): (OptionSpec | None) => + for o in _spec.options().values() do + if o._has_short(short) then + return o + end + end + match _parent + | let p: CommandParser box => p._option_with_short(short) + else + None + end + + fun tag _short_string(c: U8): String => + recover String.from_utf32(c.u32()) end + + fun box _help_name(): String => + _root_spec().help_name() + +primitive _OptionParser + fun parse( + spec: OptionSpec, + targ: (String|None), + args: Array[String] ref) + : (Option | SyntaxError) + => + // Grab the token-arg if provided, else consume an arg if one is required. + let arg = match targ + | (let fn: None) if spec._requires_arg() => + try args.shift() else None end + else + targ + end + // Now convert the arg to Type, detecting missing or mis-typed args + match arg + | let a: String => + match _ValueParser.parse(spec._typ_p(), a) + | let v: _Value => Option(spec, v) + | let se: SyntaxError => se + end + else + if not spec._requires_arg() then + Option(spec, spec._default_arg()) + else + SyntaxError(spec.name(), "missing arg for option") + end + end + +primitive _ArgParser + fun parse(spec: ArgSpec, arg: String): (Arg | SyntaxError) => + match _ValueParser.parse(spec._typ_p(), arg) + | let v: _Value => Arg(spec, v) + | let se: SyntaxError => se + end + +primitive _ValueParser + fun box parse(typ: _ValueType, arg: String): (_Value | SyntaxError) => + try + match typ + | let b: _BoolType => arg.bool() + | let s: _StringType => arg + | let f: _F64Type => arg.f64() + | let i: _I64Type => arg.i64() + end + else + SyntaxError(arg, "unable to convert '" + arg + "' to " + typ.string()) + end diff --git a/packages/cli/command_spec.pony b/packages/cli/command_spec.pony new file mode 100644 index 0000000000..ba46002f36 --- /dev/null +++ b/packages/cli/command_spec.pony @@ -0,0 +1,346 @@ +""" +This package implements command line parsing with the notion of commands that +are specified as a hierarchy. +See RFC-xxx for more details. + +The general EBNF of the command line looks like: + command_line ::= root_command (option | command)* (option | arg)* + command ::= alphanum_word + alphanum_word ::= alphachar(alphachar | numchar | '_' | '-')* + option ::= longoption | shortoptionset + longoption ::= '--'alphanum_word['='arg | ' 'arg] + shortoptionset := '-'alphachar[alphachar]...['='arg | ' 'arg] + arg := boolarg | intarg | floatarg | stringarg + boolarg := 'true' | 'false' + intarg> := ['-'] numchar... + floatarg ::= ['-'] numchar... ['.' numchar...] + stringarg ::= anychar + +Some Examples: + usage: chat [] [] [ ...] +""" +use "collections" + +class CommandSpec + """ + CommandSpec describes the specification of a parent or leaf command. Each + command has the following attributes: + + - a name: a simple string token that identifies the command. + - a description: used in the syntax message. + - a map of options: the valid options for this command. + - an optional help option+command name for help parsing + - one of: + - a Map of child commands. + - an Array of arguments. + """ + let _name: String + let _descr: String + let _options: Map[String, OptionSpec] = _options.create() + var _help_name: String = "" + + // A parent commands can have sub-commands; leaf commands can have args. + let _commands: Map[String, CommandSpec box] = _commands.create() + let _args: Array[ArgSpec] = _args.create() + + new parent( + name': String, + descr': String = "", + options': Array[OptionSpec] box = Array[OptionSpec](), + commands': Array[CommandSpec] box = Array[CommandSpec]()) ? + => + """ + Create a command spec that can accept options and child commands, but not + arguments. + """ + _name = _assertName(name') + _descr = descr' + for o in options'.values() do + _options.update(o.name(), o) + end + for c in commands'.values() do + _commands.update(c.name(), c) + end + + new leaf( + name': String, + descr': String = "", + options': Array[OptionSpec] box = Array[OptionSpec](), + args': Array[ArgSpec] box = Array[ArgSpec]()) ? + => + """ + Create a command spec that can accept options and arguments, but not child + commands. + """ + _name = _assertName(name') + _descr = descr' + for o in options'.values() do + _options.update(o.name(), o) + end + for a in args'.values() do + _args.push(a) + end + + fun tag _assertName(nm: String): String ? => + for b in nm.values() do + if (b != '-') and (b != '_') and + not ((b >= '0') and (b <= '9')) and + not ((b >= 'A') and (b <= 'Z')) and + not ((b >= 'a') and (b <= 'z')) then + error + end + end + nm + + fun ref add_command(cmd: CommandSpec box) ? => + """ + Add an additional child command to this parent command. + """ + if _args.size() > 0 then error end + _commands.update(cmd.name(), cmd) + + fun ref add_help(hname: String = "help") ? => + """ + Add a standard help option and, optionally, command to a root command. + """ + _help_name = hname + let help_option = OptionSpec.bool(_help_name, "", 'h', false) + _options.update(_help_name, help_option) + if _args.size() == 0 then + let help_cmd = CommandSpec.leaf(_help_name, "", Array[OptionSpec](), [ + ArgSpec.string("command" where default' = "") + ]) + _commands.update(_help_name, help_cmd) + end + + fun box name(): String => _name + + fun box descr(): String => _descr + + fun box options(): Map[String, OptionSpec] box => _options + + fun box commands(): Map[String, CommandSpec box] box => _commands + + fun box args(): Array[ArgSpec] box => _args + + fun box help_name(): String => _help_name + + fun box help_string(): String => + let s = _name.clone() + s.append(" ") + for a in _args.values() do + s.append(a.help_string()) + end + s + +class val OptionSpec + """ + OptionSpec describes the specification of a named Option. They have a name, + descr(iption), a short-name, a typ(e), and a default value when they are not + required. + + Options can be placed anywhere before or after commands, and can be thought of + as named arguments. + """ + let _name: String + let _descr: String + let _short: (U8 | None) + let _typ: _ValueType + let _default: _Value + let _required: Bool + + fun tag _init(typ': _ValueType, default': (_Value | None)) + : (_ValueType, _Value, Bool) + => + match default' + | None => (typ', false, true) + | let d: _Value => (typ', d, false) + else + // Ponyc limitation: else can't happen, but segfaults without it + (_BoolType, false, false) + end + + new val bool( + name': String, + descr': String = "", + short': (U8 | None) = None, + default': (Bool | None) = None) + => + _name = name' + _descr = descr' + _short = short' + (_typ, _default, _required) = _init(_BoolType, default') + + new val string( + name': String, + descr': String = "", + short': (U8 | None) = None, + default': (String | None) = None) + => + _name = name' + _descr = descr' + _short = short' + (_typ, _default, _required) = _init(_StringType, default') + + new val i64(name': String, + descr': String = "", + short': (U8 | None) = None, + default': (I64 | None) = None) + => + _name = name' + _descr = descr' + _short = short' + (_typ, _default, _required) = _init(_I64Type, default') + + new val f64(name': String, + descr': String = "", + short': (U8 | None) = None, + default': (F64 | None) = None) + => + _name = name' + _descr = descr' + _short = short' + (_typ, _default, _required) = _init(_F64Type, default') + + fun name(): String => _name + + fun descr(): String => _descr + + fun _typ_p(): _ValueType => _typ + + fun _default_p(): _Value => _default + + fun required(): Bool => _required + + // Other than bools, all options require args. + fun _requires_arg(): Bool => + match _typ + | let _: _BoolType => false + else + true + end + + // Used for bool options to get the true arg when option is present w/o arg + fun _default_arg(): _Value => + match _typ + | let _: _BoolType => true + else + false + end + + fun _has_short(sh: U8): Bool => + match _short + | let ss: U8 => sh == ss + else + false + end + + fun help_string(): String => + let s = + match _short + | let ss: U8 => "-" + String.from_utf32(ss.u32()) + ", " + else + " " + end + s + "--" + _name + + if not _required then "=" + _default.string() else "" end + + fun deb_string(): String => + "--" + _name + "[" + _typ.string() + "]" + + if not _required then "(=" + _default.string() + ")" else "" end + +class val ArgSpec + """ + ArgSpec describes the specification of a positional Arg(ument). They have a + name, descr(iption), a typ(e), and a default value when they are not required. + + Args always come after a leaf command, and are assigned in their positional + order. + """ + let _name: String + let _descr: String + let _typ: _ValueType + let _default: _Value + let _required: Bool + + fun tag _init(typ': _ValueType, default': (_Value | None)) + : (_ValueType, _Value, Bool) + => + match default' + | None => (typ', false, true) + | let d: _Value => (typ', d, false) + else + // Ponyc limitation: else can't happen, but segfaults without it + (_BoolType, false, false) + end + + new val bool( + name': String, + descr': String = "", + default': (Bool | None) = None) + => + _name = name' + _descr = descr' + (_typ, _default, _required) = _init(_BoolType, default') + + new val string( + name': String, + descr': String = "", + default': (String | None) = None) + => + _name = name' + _descr = descr' + (_typ, _default, _required) = _init(_StringType, default') + + new val i64(name': String, + descr': String = "", + default': (I64 | None) = None) + => + _name = name' + _descr = descr' + (_typ, _default, _required) = _init(_I64Type, default') + + new val f64(name': String, + descr': String = "", + default': (F64 | None) = None) + => + _name = name' + _descr = descr' + (_typ, _default, _required) = _init(_F64Type, default') + + fun name(): String => _name + + fun descr(): String => _descr + + fun _typ_p(): _ValueType => _typ + + fun _default_p(): _Value => _default + + fun required(): Bool => _required + + fun help_string(): String => + "<" + _name + ">" + + fun deb_string(): String => + _name + "[" + _typ.string() + "]" + + if not _required then "(=" + _default.string() + ")" else "" end + +type _Value is (Bool | String | I64 | F64) + +primitive _BoolType + fun string(): String => "Bool" + +primitive _StringType + fun string(): String => "String" + +primitive _I64Type + fun string(): String => "I64" + +primitive _F64Type + fun string(): String => "F64" + +type _ValueType is + ( _BoolType + | _StringType + | _I64Type + | _F64Type ) diff --git a/packages/cli/env_vars.pony b/packages/cli/env_vars.pony new file mode 100644 index 0000000000..1130cdc195 --- /dev/null +++ b/packages/cli/env_vars.pony @@ -0,0 +1,38 @@ +use "collections" + +primitive EnvVars + fun apply( + envs: (Array[String] box | None), + prefix: String = "", + squash: Bool = false): + Map[String, String] val + => + """ + Turns an array of strings that look like environment variables, ie. + key=value, into a map from string to string. Can optionally filter for + keys matching a 'prefix', and will squash resulting keys to lowercase + iff 'squash' is true. + + So: + = + becomes: + {KEY, VALUE} or {key, VALUE} + """ + let envsmap = recover Map[String, String]() end + match envs + | let envsarr: Array[String] box => + let prelen = prefix.size().isize() + for e in envsarr.values() do + let eqpos = try e.find("=") else ISize.max_value() end + let ek: String val = e.substring(0, eqpos) + let ev: String val = e.substring(eqpos + 1) + if (prelen == 0) or ek.at(prefix, 0) then + if squash then + envsmap.update(ek.substring(prelen).lower(), ev) + else + envsmap.update(ek.substring(prelen), ev) + end + end + end + end + envsmap