From 3fb8eec16ba2297e5096cd6c1e413fffd57c2363 Mon Sep 17 00:00:00 2001 From: Carl Quinn Date: Tue, 18 Jul 2017 16:26:01 -0700 Subject: [PATCH] CLI package update 1 (#2019) Features added: - string_seq option and arg value type for repeated strings collected into a sequence. - Command fullname() to make it easier to match on command name. - Checking commands are leaves so that partial commands return syntax errors. Other changes: - Tests for the above. - _ValueType is now a trait instead of a union. This makes it easier to delegate type specific work to one place, and makes value type more easily extended. - A few other cleanups, like the removal of redundant box modifiers on methods. --- CHANGELOG.md | 14 +- examples/commandline/main.pony | 69 ++++---- packages/cli/_test.pony | 234 +++++++++++++++++++++----- packages/cli/cli.pony | 122 ++++++++++++++ packages/cli/command.pony | 80 ++++++++- packages/cli/command_help.pony | 19 ++- packages/cli/command_parser.pony | 93 +++++++---- packages/cli/command_spec.pony | 274 ++++++++++++++++++++++++------- packages/cli/env_vars.pony | 2 +- 9 files changed, 726 insertions(+), 181 deletions(-) create mode 100644 packages/cli/cli.pony diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2391102e..52f1b3e67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ All notable changes to the Pony compiler and standard library will be documented ### Added +- Add cli package implementing the CLI syntax ([RFC #38](https://github.com/ponylang/rfcs/blob/master/text/0038-cli-format.md)) + - Initial ([PR #1897](https://github.com/ponylang/ponyc/pull/1897)) implemented the full RFC and contained: + - Enhanced Posix / GNU program argument syntax. + - Commands and sub-commands. + - Bool, String, I64 and F64 option / arg types. + - Help command and syntax errors with formatted output. + - Update ([PR #2019](https://github.com/ponylang/ponyc/pull/2019)) added: + - String-seq (ReadSeq[String]) types for repeated string options / args. + - Command fullname() to make it easier to match on unique command names. + - Checking that commands are leaves so that partial commands return syntax errors. ### Changed @@ -75,12 +85,12 @@ All notable changes to the Pony compiler and standard library will be documented - Compiler error instead of crash for invalid this-dot reference in a trait. ([PR #1879](https://github.com/ponylang/ponyc/pull/1879)) - Compiler error instead of crash for too few args to constructor in case pattern. ([PR #1880](https://github.com/ponylang/ponyc/pull/1880)) -- Pony runtime hashmap bug that resulted in issues [#1483](https://github.com/ponylang/ponyc/issues/1483), [#1781](https://github.com/ponylang/ponyc/issues/1781), and [#1872](https://github.com/ponylang/ponyc/issues/1872). ([PR #1886](https://github.com/ponylang/ponyc/pull/1886)) +- Pony runtime hashmap bug that resulted in issues [#1483](https://github.com/ponylang/ponyc/issues/1483), [#1781](https://github.com/ponylang/ponyc/issues/1781), and [#1872](https://github.com/ponylang/ponyc/issues/1872). ([PR #1886](https://github.com/ponylang/ponyc/pull/1886)) - Compiler crash when compiling to a library ([Issue #1881](https://github.com/ponylang/ponyc/issues/1881))([PR #1890](https://github.com/ponylang/ponyc/pull/1890)) ### Changed -- TCPConnection.connect_failed, UDPNotify.not_listening, TCPListenNotify.not_listening no longer have default implementation. The programmer is now required to implement error handling or consciously choose to ignore. ([PR #1853](https://github.com/ponylang/ponyc/pull/1853) +- TCPConnection.connect_failed, UDPNotify.not_listening, TCPListenNotify.not_listening no longer have default implementation. The programmer is now required to implement error handling or consciously choose to ignore. ([PR #1853](https://github.com/ponylang/ponyc/pull/1853) ## [0.13.2] - 2017-04-29 diff --git a/examples/commandline/main.pony b/examples/commandline/main.pony index 0496828ce9..e0b651403c 100644 --- a/examples/commandline/main.pony +++ b/examples/commandline/main.pony @@ -2,46 +2,35 @@ 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 + let cs = + try + CommandSpec.leaf("echo", "A sample echo program", [ + OptionSpec.bool("upper", "Uppercase words" + where short' = 'U', default' = false) + ], [ + ArgSpec.string_seq("words", "The words to echo") + ]).>add_help() + else + env.exitcode(-1) // some kind of coding error + return + end - // cmd is a valid Command, now use it. + 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 + let upper = cmd.option("upper").bool() + let words = cmd.arg("words").string_seq() + for word in words.values() do + env.out.write(if upper then word.upper() else word end + " ") 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 + env.out.print("") diff --git a/packages/cli/_test.pony b/packages/cli/_test.pony index aed78a97ec..9ecfe97185 100644 --- a/packages/cli/_test.pony +++ b/packages/cli/_test.pony @@ -7,6 +7,10 @@ actor Main is TestList fun tag tests(test: PonyTest) => test(_TestMinimal) test(_TestBadName) + test(_TestUnknownCommand) + test(_TestUnexpectedArg) + test(_TestUnknownShort) + test(_TestUnknownLong) test(_TestHyphenArg) test(_TestBools) test(_TestDefaults) @@ -18,8 +22,8 @@ actor Main is TestList test(_TestEnvs) test(_TestOptionStop) test(_TestDuplicate) - test(_TestChatMin) - test(_TestChatAll) + test(_TestChat) + test(_TestMustBeLeaf) test(_TestHelp) class iso _TestMinimal is UnitTest @@ -27,7 +31,7 @@ class iso _TestMinimal is UnitTest fun apply(h: TestHelper) ? => let cs = CommandSpec.leaf("minimal", "", [ - OptionSpec.bool("aflag", "") + OptionSpec.bool("aflag", "") ]) h.assert_eq[String]("minimal", cs.name()) @@ -38,18 +42,110 @@ class iso _TestMinimal is UnitTest let cmd = match cmdErr | let c: Command => c else error end + h.assert_eq[String]("minimal", cmd.fullname()) h.assert_eq[Bool](true, cmd.option("aflag").bool()) class iso _TestBadName is UnitTest fun name(): String => "ponycli/badname" - fun apply(h: TestHelper) ? => + // Negative test: command names must be alphanum tokens + fun apply(h: TestHelper) => try - let cs = CommandSpec.leaf("min imal", "") + let cs = CommandSpec.leaf("min imal", "") + h.fail("expected error on bad command name: " + cs.name()) + end + +class iso _TestUnknownCommand is UnitTest + fun name(): String => "ponycli/unknown_command" + + // Negative test: unknown command should report + fun apply(h: TestHelper) ? => + let cs = _Fixtures.chat_cli_spec() + + let args: Array[String] = [ + "ignored" + "unknown" + ] + + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + match cmdErr + | let se: SyntaxError => None + h.assert_eq[String]("Error: unknown command at: 'unknown'", se.string()) + else + h.fail("expected syntax error for unknown command: " + cmdErr.string()) + end + +class iso _TestUnexpectedArg is UnitTest + fun name(): String => "ponycli/unknown_command" + + // Negative test: unexpected arg/command token should report + fun apply(h: TestHelper) ? => + let cs = _Fixtures.bools_cli_spec() + + let args: Array[String] = [ + "ignored" + "unknown" + ] + + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + match cmdErr + | let se: SyntaxError => None + h.assert_eq[String]( + "Error: too many positional arguments at: 'unknown'", se.string()) + else + h.fail("expected syntax error for unknown command: " + cmdErr.string()) + end + +class iso _TestUnknownShort is UnitTest + fun name(): String => "ponycli/unknown_short" + + // Negative test: unknown short option should report + fun apply(h: TestHelper) ? => + let cs = _Fixtures.bools_cli_spec() + + let args: Array[String] = [ + "ignored" + "-Z" + ] + + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + match cmdErr + | let se: SyntaxError => None + h.assert_eq[String]("Error: unknown short option at: 'Z'", se.string()) + else + h.fail( + "expected syntax error for unknown short option: " + cmdErr.string()) + end + +class iso _TestUnknownLong is UnitTest + fun name(): String => "ponycli/unknown_long" + + // Negative test: unknown long option should report + fun apply(h: TestHelper) ? => + let cs = _Fixtures.bools_cli_spec() + + let args: Array[String] = [ + "ignored" + "--unknown" + ] + + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + match cmdErr + | let se: SyntaxError => None + h.assert_eq[String]( + "Error: unknown long option at: 'unknown'", se.string()) else - return // error was expected + h.fail( + "expected syntax error for unknown long option: " + cmdErr.string()) end - error // lack of error is bad class iso _TestHyphenArg is UnitTest fun name(): String => "ponycli/hyphen" @@ -57,7 +153,7 @@ class iso _TestHyphenArg is UnitTest // Rule 1 fun apply(h: TestHelper) ? => let cs = CommandSpec.leaf("minimal" where args' = [ - ArgSpec.string("name", "") + ArgSpec.string("name", "") ]) let args: Array[String] = ["ignored"; "-"] let cmdErr = CommandParser(cs).parse(args) @@ -65,6 +161,7 @@ class iso _TestHyphenArg is UnitTest let cmd = match cmdErr | let c: Command => c else error end + h.assert_eq[String]("minimal", cmd.fullname()) h.assert_eq[String]("-", cmd.arg("name").string()) class iso _TestBools is UnitTest @@ -80,6 +177,7 @@ class iso _TestBools is UnitTest let cmd = match cmdErr | let c: Command => c else error end + h.assert_eq[String]("bools", cmd.fullname()) 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()) @@ -102,6 +200,7 @@ class iso _TestDefaults is UnitTest 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()) + h.assert_eq[USize](0, cmd.option("stringso").string_seq().size()) class iso _TestShortsAdj is UnitTest fun name(): String => "ponycli/shorts_adjacent" @@ -110,7 +209,10 @@ class iso _TestShortsAdj is UnitTest fun apply(h: TestHelper) ? => let cs = _Fixtures.simple_cli_spec() - let args: Array[String] = ["ignored"; "-BS--"; "-I42"; "-F42.0"] + let args: Array[String] = [ + "ignored" + "-BS--"; "-I42"; "-F42.0"; "-zaaa"; "-zbbb" + ] let cmdErr = CommandParser(cs).parse(args) h.log("Parsed: " + cmdErr.string()) @@ -120,6 +222,10 @@ class iso _TestShortsAdj is UnitTest 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()) + let stringso = cmd.option("stringso") + h.assert_eq[USize](2, stringso.string_seq().size()) + h.assert_eq[String]("aaa", stringso.string_seq()(0)) + h.assert_eq[String]("bbb", stringso.string_seq()(1)) class iso _TestShortsEq is UnitTest fun name(): String => "ponycli/shorts_eq" @@ -128,7 +234,10 @@ class iso _TestShortsEq is UnitTest fun apply(h: TestHelper) ? => let cs = _Fixtures.simple_cli_spec() - let args: Array[String] = ["ignored"; "-BS=astring"; "-I=42"; "-F=42.0"] + let args: Array[String] = [ + "ignored" + "-BS=astring"; "-I=42"; "-F=42.0"; "-z=aaa"; "-z=bbb" + ] let cmdErr = CommandParser(cs).parse(args) h.log("Parsed: " + cmdErr.string()) @@ -138,6 +247,10 @@ class iso _TestShortsEq is UnitTest 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()) + let stringso = cmd.option("stringso") + h.assert_eq[USize](2, stringso.string_seq().size()) + h.assert_eq[String]("aaa", stringso.string_seq()(0)) + h.assert_eq[String]("bbb", stringso.string_seq()(1)) class iso _TestShortsNext is UnitTest fun name(): String => "ponycli/shorts_next" @@ -147,7 +260,8 @@ class iso _TestShortsNext is UnitTest let cs = _Fixtures.simple_cli_spec() let args: Array[String] = [ - "ignored"; "-BS"; "--"; "-I"; "42"; "-F"; "42.0" + "ignored" + "-BS"; "--"; "-I"; "42"; "-F"; "42.0"; "-z"; "aaa"; "-z"; "bbb" ] let cmdErr = CommandParser(cs).parse(args) h.log("Parsed: " + cmdErr.string()) @@ -158,6 +272,10 @@ class iso _TestShortsNext is UnitTest 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()) + let stringso = cmd.option("stringso") + h.assert_eq[USize](2, stringso.string_seq().size()) + h.assert_eq[String]("aaa", stringso.string_seq()(0)) + h.assert_eq[String]("bbb", stringso.string_seq()(1)) class iso _TestLongsEq is UnitTest fun name(): String => "ponycli/shorts_eq" @@ -167,8 +285,9 @@ class iso _TestLongsEq is UnitTest let cs = _Fixtures.simple_cli_spec() let args: Array[String] = [ - "ignored" - "--boolr=true"; "--stringr=astring"; "--intr=42"; "--floatr=42.0" + "ignored" + "--boolr=true"; "--stringr=astring"; "--intr=42"; "--floatr=42.0" + "--stringso=aaa"; "--stringso=bbb" ] let cmdErr = CommandParser(cs).parse(args) h.log("Parsed: " + cmdErr.string()) @@ -179,6 +298,10 @@ class iso _TestLongsEq is UnitTest 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()) + let stringso = cmd.option("stringso") + h.assert_eq[USize](2, stringso.string_seq().size()) + h.assert_eq[String]("aaa", stringso.string_seq()(0)) + h.assert_eq[String]("bbb", stringso.string_seq()(1)) class iso _TestLongsNext is UnitTest fun name(): String => "ponycli/longs_next" @@ -188,8 +311,9 @@ class iso _TestLongsNext is UnitTest let cs = _Fixtures.simple_cli_spec() let args: Array[String] = [ - "ignored" - "--boolr"; "--stringr"; "--"; "--intr"; "42"; "--floatr"; "42.0" + "ignored" + "--boolr"; "--stringr"; "--"; "--intr"; "42"; "--floatr"; "42.0" + "--stringso"; "aaa"; "--stringso"; "bbb" ] let cmdErr = CommandParser(cs).parse(args) h.log("Parsed: " + cmdErr.string()) @@ -199,6 +323,10 @@ class iso _TestLongsNext is UnitTest 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()) + let stringso = cmd.option("stringso") + h.assert_eq[USize](2, stringso.string_seq().size()) + h.assert_eq[String]("aaa", stringso.string_seq()(0)) + h.assert_eq[String]("bbb", stringso.string_seq()(1)) class iso _TestEnvs is UnitTest fun name(): String => "ponycli/envs" @@ -211,10 +339,10 @@ class iso _TestEnvs is UnitTest "ignored" ] let envs: Array[String] = [ - "SHORTS_BOOLR=true" - "SHORTS_STRINGR=astring" - "SHORTS_INTR=42" - "SHORTS_FLOATR=42.0" + "SIMPLE_BOOLR=true" + "SIMPLE_STRINGR=astring" + "SIMPLE_INTR=42" + "SIMPLE_FLOATR=42.0" ] let cmdErr = CommandParser(cs).parse(args, envs) h.log("Parsed: " + cmdErr.string()) @@ -254,9 +382,9 @@ class iso _TestDuplicate is UnitTest let cs = _Fixtures.simple_cli_spec() let args: Array[String] = [ - "ignored" - "--boolr=true"; "--stringr=astring"; "--intr=42"; "--floatr=42.0" - "--stringr=newstring" + "ignored" + "--boolr=true"; "--stringr=astring"; "--intr=42"; "--floatr=42.0" + "--stringr=newstring" ] let cmdErr = CommandParser(cs).parse(args) h.log("Parsed: " + cmdErr.string()) @@ -265,29 +393,15 @@ class iso _TestDuplicate is UnitTest 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" +class iso _TestChat is UnitTest + fun name(): String => "ponycli/chat" fun apply(h: TestHelper) ? => let cs = _Fixtures.chat_cli_spec() let args: Array[String] = [ - "ignored" - "--admin"; "--name=carl"; "say"; "-v80"; "hello" + "ignored" + "--admin"; "--name=carl"; "say"; "-v80"; "hi"; "yo"; "hey" ] let cmdErr = CommandParser(cs).parse(args) @@ -295,6 +409,8 @@ class iso _TestChatAll is UnitTest let cmd = match cmdErr | let c: Command => c else error end + h.assert_eq[String]("chat/say", cmd.fullname()) + h.assert_eq[String]("say", cmd.spec().name()) let f1 = cmd.option("admin") @@ -311,7 +427,32 @@ class iso _TestChatAll is UnitTest let a1 = cmd.arg("words") h.assert_eq[String]("words", a1.spec().name()) - h.assert_eq[String]("hello", a1.string()) + let words = a1.string_seq() + h.assert_eq[USize](3, words.size()) + h.assert_eq[String]("hi", words(0)) + h.assert_eq[String]("yo", words(1)) + h.assert_eq[String]("hey", words(2)) + +class iso _TestMustBeLeaf is UnitTest + fun name(): String => "ponycli/must_be_leaf" + + // Negative test: can't just supply parent command + fun apply(h: TestHelper) ? => + let cs = _Fixtures.chat_cli_spec() + + let args: Array[String] = [ + "ignored" + "--admin"; "--name=carl"; "config" + ] + + let cmdErr = CommandParser(cs).parse(args) + h.log("Parsed: " + cmdErr.string()) + + match cmdErr + | let se: SyntaxError => None + else + h.fail("expected syntax error for non-leaf command: " + cmdErr.string()) + end class iso _TestHelp is UnitTest fun name(): String => "ponycli/help" @@ -342,7 +483,7 @@ primitive _Fixtures """ Builds and returns the spec for a CLI with short options of each type. """ - CommandSpec.leaf("shorts", + CommandSpec.leaf("simple", "A sample program with various short options, optional and required", [ OptionSpec.bool("boolr" where short' = 'B') OptionSpec.bool("boolo" where short' = 'b', default' = true) @@ -352,8 +493,10 @@ primitive _Fixtures 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)) + OptionSpec.string_seq("stringso" where short' = 'z') ], [ ArgSpec.string("words" where default' = "hello") + ArgSpec.string_seq("argz") ]) fun chat_cli_spec(): CommandSpec box ? => @@ -363,17 +506,18 @@ primitive _Fixtures 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') + OptionSpec.f64("volume", "Chat volume" where short' = 'v', default' = 1.0) ], [ CommandSpec.leaf("say", "Say something", Array[OptionSpec](), [ - ArgSpec.string("words", "The words to say") + ArgSpec.string_seq("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.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/cli.pony b/packages/cli/cli.pony new file mode 100644 index 0000000000..be00d06d53 --- /dev/null +++ b/packages/cli/cli.pony @@ -0,0 +1,122 @@ +""" +# CLI Package + +The CLI package provides enhanced Posix+GNU command line parsing with the +feature of commands that can be specified in a hierarchy. + +See [RFC-0038](https://github.com/ponylang/rfcs/blob/master/text/0038-cli-format.md) for more background. + +The general EBNF of a command line is: +```ebnf + 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: ls [] [ ...] + usage: make [] [] [ ...] + usage: chat [] [] [ ...] +``` + +## Usage + +The types in the cli package are broken down into three groups: + +### Specs + +Pony programs use constructors to create the spec objects to specify their +command line syntax. Many aspects of the spec are checked for correctness at +compile time, and the result represents everything the parser needs to know +when parsing a command line or forming syntax help messages. + +#### Option and Arg value types + +Options and Args parse values from the command line as one of four Pony types: +`Bool`, `String`, `I64` and `F64`. Values of each of these types can then be +retrieved using the corresponding accessor funtions. + +In addition, there is a string_seq type that accepts string values from the +command line and collects them into a sequence which can then be retrieved as +a `ReadSeq[String]` using the `string_seq()` accessor function. + +Some specific details: + +- bool Options: have a default value of 'true' if no value is given. That is, + `-f` is equivalent to `-f=true`. + +- string_seq Options: the option prefix has to be used each time, like: + `--file=f1 --file=f2 --file=f3` with the results being collected into + a single sequence. + +- string_seq Args: there is no way to indicate termination, so a string_seq + Arg should be the last arg for a command, and will consume all remaining + command line arguments. + +### Parser + +Programs then use the CommandSpec they've built to instantiate a parser to +parse any given command line. This is often env.args(), but could also be +commands from files or other input sources. The result of a parse is either a +parsed command, a command help, or a syntax error object. + +### Commands + +Programs then match the object returned by the parser to determine what kind +it is. Errors and help requests typically print messages and exit. For +commands, the fullname can be matched and the effective values for the +command's options and arguments can be retrieved. + +# Example program + +This program echos its command line arguments with the option of uppercasing +them. + +```pony +use "cli" + +actor Main + new create(env: Env) => + let cs = + try + CommandSpec.leaf("echo", "A sample echo program", [ + OptionSpec.bool("upper", "Uppercase words" + where short' = 'U', default' = false) + ], [ + ArgSpec.string_seq("words", "The words to echo") + ]).>add_help() + else + env.exitcode(-1) // some kind of coding error + return + end + + 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 + + let upper = cmd.option("upper").bool() + let words = cmd.arg("words").string_seq() + for word in words.values() do + env.out.write(if upper then word.upper() else word end + " ") + end + env.out.print("") +``` +""" diff --git a/packages/cli/command.pony b/packages/cli/command.pony index ee018aa221..97ba4091b6 100644 --- a/packages/cli/command.pony +++ b/packages/cli/command.pony @@ -6,20 +6,26 @@ class box Command and effective options and arguments, ready to use. """ let _spec: CommandSpec box + let _fullname: String let _options: Map[String, Option] box let _args: Map[String, Arg] box - new create( + new _create( spec': CommandSpec box, + fullname': String, options': Map[String, Option] box, args': Map[String, Arg] box) => _spec = spec' + _fullname = fullname' _options = options' _args = args' - fun string(): String => - let s: String iso = _spec.name().clone() + fun string(): String iso^ => + """ + Returns a representational string for this command. + """ + let s: String iso = fullname().clone() for o in _options.values() do s.append(" ") s.append(o.deb_string()) @@ -30,12 +36,28 @@ class box Command end s - fun box spec() : CommandSpec box => _spec + fun spec() : CommandSpec box => + """ + Returns the spec for this command. + """ + _spec + + fun fullname() : String => + """ + Returns the full name of this command, with its parents prefixed. + """ + _fullname fun box option(name: String): Option => + """ + Returns the Option by name, defaulting to a fake Option if unknown. + """ try _options(name) else Option(OptionSpec.bool(name), false) end fun box arg(name: String): Arg => + """ + Returns the Arg by name, defaulting to a fake Arg if unknown. + """ try _args(name) else Arg(ArgSpec.bool(name), false) end class val Option @@ -49,20 +71,45 @@ class val Option _spec = spec' _value = value' + fun _append(next: Option): Option => + Option(_spec, _spec._typ_p().append(_value, next._value)) + fun spec() : OptionSpec => _spec fun bool(): Bool => + """ + Returns the option value as a Bool, defaulting to false. + """ try _value as Bool else false end fun string(): String => + """ + Returns the option value as a String, defaulting to empty. + """ try _value as String else "" end fun i64(): I64 => + """ + Returns the option value as an I64, defaulting to 0. + """ try _value as I64 else I64(0) end fun f64(): F64 => + """ + Returns the option value as an F64, defaulting to 0.0. + """ try _value as F64 else F64(0) end + fun string_seq(): ReadSeq[String] val => + """ + Returns the option value as a ReadSeq[String], defaulting to empty. + """ + try + _value as _StringSeq val + else + recover val Array[String]() end + end + fun deb_string(): String => _spec.deb_string() + "=" + _value.string() @@ -77,20 +124,45 @@ class val Arg _spec = spec' _value = value' + fun _append(next: Arg): Arg => + Arg(_spec, _spec._typ_p().append(_value, next._value)) + fun spec(): ArgSpec => _spec fun bool(): Bool => + """ + Returns the arg value as a Bool, defaulting to false. + """ try _value as Bool else false end fun string(): String => + """ + Returns the arg value as a String, defaulting to empty. + """ try _value as String else "" end fun i64(): I64 => + """ + Returns the arg value as an I64, defaulting to 0. + """ try _value as I64 else I64(0) end fun f64(): F64 => + """ + Returns the arg value as an F64, defaulting to 0.0. + """ try _value as F64 else F64(0) end + fun string_seq(): ReadSeq[String] val => + """ + Returns the arg value as a ReadSeq[String], defaulting to empty. + """ + try + _value as _StringSeq val + else + recover val Array[String]() end + end + fun deb_string(): String => "(" + _spec.deb_string() + "=)" + _value.string() diff --git a/packages/cli/command_help.pony b/packages/cli/command_help.pony index b3ad2f81f7..5915374d15 100644 --- a/packages/cli/command_help.pony +++ b/packages/cli/command_help.pony @@ -2,11 +2,18 @@ use "buffered" primitive Help fun general(cs: CommandSpec box): CommandHelp => + """ + Creates a command help that can print a general program help message. + """ CommandHelp._create(None, cs) fun for_command(cs: CommandSpec box, argv: Array[String] box) : (CommandHelp | SyntaxError) => + """ + Creates a command help for a specific command that can print a detailed + help message. + """ _parse(cs, CommandHelp._create(None, cs), argv) fun _parse(cs: CommandSpec box, ch: CommandHelp, argv: Array[String] box) @@ -31,8 +38,8 @@ 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. + identified to print help about. Use `Help.general()` or `Help.for_command()` + to create a CommandHelp instance. """ let _parent: (CommandHelp box | None) let _spec: CommandSpec box @@ -51,13 +58,19 @@ class box CommandHelp fun box string(): String => fullname() fun box help_string(): String => + """ + Renders the help message as a 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 + str fun box print_help(os: OutStream) => + """ + Prints the help message to an OutStream. + """ let w: Writer = Writer _write_help(w) os.writev(w.done()) diff --git a/packages/cli/command_parser.pony b/packages/cli/command_parser.pony index a925728d08..ce5060b66f 100644 --- a/packages/cli/command_parser.pony +++ b/packages/cli/command_parser.pony @@ -5,6 +5,9 @@ class CommandParser let _parent: (CommandParser box | None) new box create(spec': CommandSpec box) => + """ + Creates a new parser for a given command spec. + """ _spec = spec' _parent = None @@ -12,7 +15,7 @@ class CommandParser _spec = spec' _parent = parent' - fun box parse( + fun parse( argv: Array[String] box, envs: (Array[String] box | None) = None) : (Command | CommandHelp | SyntaxError) @@ -30,14 +33,21 @@ class CommandParser EnvVars(envs, _spec.name().upper() + "_", true), false) - fun box _root_spec(): CommandSpec box => + fun _fullname(): String => + match _parent + | let p: CommandParser box => p._fullname() + "/" + _spec.name() + else + _spec.name() + end + + fun _root_spec(): CommandSpec box => match _parent | let p: CommandParser box => p._root_spec() else _spec end - fun box _parse_command( + fun _parse_command( tokens: Array[String] ref, options: Map[String,Option] ref, args: Map[String,Arg] ref, @@ -59,7 +69,15 @@ class CommandParser 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 o: Option => + if o.spec()._typ_p().is_seq() then + try + options.upsert(o.spec().name(), o, + {(x: Option, n: Option): Option^ => x._append(n) }) + end + else + options.update(o.spec().name(), o) + end | let se: SyntaxError => return se end @@ -68,7 +86,14 @@ class CommandParser match _parse_short_options(token.substring(1), tokens) | let os: Array[Option] => for o in os.values() do - options.update(o.spec().name(), o) + if o.spec()._typ_p().is_seq() then + try + options.upsert(o.spec().name(), o, + {(x: Option, n: Option): Option^ => x._append(n) }) + end + else + options.update(o.spec().name(), o) + end end | let se: SyntaxError => return se @@ -87,8 +112,16 @@ class CommandParser end else match _parse_arg(token, arg_pos) - | let a: Arg => args.update(a.spec().name(), a) - arg_pos = arg_pos + 1 + | let a: Arg => + if a.spec()._typ_p().is_seq() then + try + args.upsert(a.spec().name(), a, + {(x: Arg, n: Arg): Arg^ => x._append(n) }) + end + else + args.update(a.spec().name(), a) + arg_pos = arg_pos + 1 + end | let se: SyntaxError => return se end end @@ -154,18 +187,25 @@ class CommandParser 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") + if not args.contains(ars.name()) then // latest arg may be a seq + if ars.required() then + return SyntaxError(ars.name(), "missing value for required argument") + end + args.update(ars.name(), Arg(ars, ars._default_p())) 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) + // Specifying only the parent and not a leaf command is an error. + if _spec.commands().size() > 0 then + return SyntaxError(_spec.name(), "missing subcommand") + end + + // A successfully parsed and populated leaf Command. + Command._create(_spec, _fullname(), consume options, args) - fun box _parse_long_option( + fun _parse_long_option( token: String, args: Array[String] ref) : (Option | SyntaxError) @@ -182,7 +222,7 @@ class CommandParser | None => SyntaxError(name, "unknown long option") end - fun box _parse_short_options( + fun _parse_short_options( token: String, args: Array[String] ref) : (Array[Option] | SyntaxError) @@ -212,8 +252,8 @@ class CommandParser 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 + | let os: OptionSpec => + if os._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() @@ -223,16 +263,16 @@ class CommandParser 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) + match _OptionParser.parse(os, arg, args) + | let o: Option => options.push(o) | let se: SyntaxError => return se end - | None => SyntaxError(_short_string(c), "unknown short option") + | None => return SyntaxError(_short_string(c), "unknown short option") end end options - fun box _parse_arg(token: String, arg_pos: USize): (Arg | SyntaxError) => + fun _parse_arg(token: String, arg_pos: USize): (Arg | SyntaxError) => try let arg_spec = _spec.args()(arg_pos) _ArgParser.parse(arg_spec, token) @@ -240,7 +280,7 @@ class CommandParser return SyntaxError(token, "too many positional arguments") end - fun box _option_with_name(name: String): (OptionSpec | None) => + fun _option_with_name(name: String): (OptionSpec | None) => try return _spec.options()(name) end @@ -250,7 +290,7 @@ class CommandParser None end - fun box _option_with_short(short: U8): (OptionSpec | None) => + fun _option_with_short(short: U8): (OptionSpec | None) => for o in _spec.options().values() do if o._has_short(short) then return o @@ -265,7 +305,7 @@ class CommandParser fun tag _short_string(c: U8): String => recover String.from_utf32(c.u32()) end - fun box _help_name(): String => + fun _help_name(): String => _root_spec().help_name() primitive _OptionParser @@ -307,12 +347,7 @@ primitive _ArgParser 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 + typ.value_of(arg) 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 index ba46002f36..00cd9319d6 100644 --- a/packages/cli/command_spec.pony +++ b/packages/cli/command_spec.pony @@ -1,25 +1,5 @@ -""" -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" +use pc = "collections/persistent" class CommandSpec """ @@ -50,7 +30,7 @@ class CommandSpec commands': Array[CommandSpec] box = Array[CommandSpec]()) ? => """ - Create a command spec that can accept options and child commands, but not + Creates a command spec that can accept options and child commands, but not arguments. """ _name = _assertName(name') @@ -69,7 +49,7 @@ class CommandSpec args': Array[ArgSpec] box = Array[ArgSpec]()) ? => """ - Create a command spec that can accept options and arguments, but not child + Creates a command spec that can accept options and arguments, but not child commands. """ _name = _assertName(name') @@ -94,14 +74,14 @@ class CommandSpec fun ref add_command(cmd: CommandSpec box) ? => """ - Add an additional child command to this parent command. + Adds 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. + Adds a standard help option and, optionally command, to a root command. """ _help_name = hname let help_option = OptionSpec.bool(_help_name, "", 'h', false) @@ -113,19 +93,46 @@ class CommandSpec _commands.update(_help_name, help_cmd) end - fun box name(): String => _name + fun name(): String => + """ + Returns the name of this command. + """ + _name - fun box descr(): String => _descr + fun descr(): String => + """ + Returns the description for this command. + """ + _descr - fun box options(): Map[String, OptionSpec] box => _options + fun options(): Map[String, OptionSpec] box => + """ + Returns a map by name of the named options of this command. + """ + _options - fun box commands(): Map[String, CommandSpec box] box => _commands + fun commands(): Map[String, CommandSpec box] box => + """ + Returns a map by name of the child commands of this command. + """ + _commands - fun box args(): Array[ArgSpec] box => _args + fun args(): Array[ArgSpec] box => + """ + Returns an array of the positional arguments of this command. + """ + _args - fun box help_name(): String => _help_name + fun help_name(): String => + """ + Returns the name of the help command, which defaults to "help". + """ + _help_name - fun box help_string(): String => + fun help_string(): String => + """ + Returns a formated help string for this command and all of its arguments. + """ let s = _name.clone() s.append(" ") for a in _args.values() do @@ -139,8 +146,8 @@ class val OptionSpec 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. + Options can be placed anywhere before or after commands, and can be thought + of as named arguments. """ let _name: String let _descr: String @@ -155,9 +162,6 @@ class val OptionSpec 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( @@ -166,6 +170,12 @@ class val OptionSpec short': (U8 | None) = None, default': (Bool | None) = None) => + """ + Creates an Option with a Bool typed value that can be used like + `--opt` or `-O` or `--opt=true` or `-O=true` + to yield an option value like + `cmd.option("opt").bool() == true`. + """ _name = name' _descr = descr' _short = short' @@ -177,6 +187,12 @@ class val OptionSpec short': (U8 | None) = None, default': (String | None) = None) => + """ + Creates an Option with a String typed value that can be used like + `--file=dir/filename` or `-F=dir/filename` or `-Fdir/filename` + to yield an option value like + `cmd.option("file").string() == "dir/filename"`. + """ _name = name' _descr = descr' _short = short' @@ -187,6 +203,12 @@ class val OptionSpec short': (U8 | None) = None, default': (I64 | None) = None) => + """ + Creates an Option with an I64 typed value that can be used like + `--count=42 -C=42` + to yield an option value like + `cmd.option("count").i64() == I64(64)`. + """ _name = name' _descr = descr' _short = short' @@ -197,20 +219,54 @@ class val OptionSpec short': (U8 | None) = None, default': (F64 | None) = None) => + """ + Creates an Option with a F64 typed value that can be used like + `--ratio=1.039` or `-R=1.039` + to yield an option value like + `cmd.option("ratio").f64() == F64(1.039)`. + """ _name = name' _descr = descr' _short = short' (_typ, _default, _required) = _init(_F64Type, default') - fun name(): String => _name + new val string_seq( + name': String, + descr': String = "", + short': (U8 | None) = None) + => + """ + Creates an Option with a ReadSeq[String] typed value that can be used like + `--files=file1 --files=files2 --files=files2` + to yield a sequence of three strings equivalent to + `cmd.option("ratio").string_seq() (equiv) ["file1"; "file2"; "file3"]`. + """ + _name = name' + _descr = descr' + _short = short' + (_typ, _default, _required) = _init(_StringSeqType, _StringSeq.empty()) + + fun name(): String => + """ + Returns the name of this option. + """ + _name - fun descr(): String => _descr + fun descr(): String => + """ + Returns the description for this option. + """ + _descr fun _typ_p(): _ValueType => _typ fun _default_p(): _Value => _default - fun required(): Bool => _required + fun required(): Bool => + """ + Returns true iff this option is required to be present in the command line. + """ + _required // Other than bools, all options require args. fun _requires_arg(): Bool => @@ -236,6 +292,9 @@ class val OptionSpec end fun help_string(): String => + """ + Returns a formated help string for this option. + """ let s = match _short | let ss: U8 => "-" + String.from_utf32(ss.u32()) + ", " @@ -252,7 +311,8 @@ class val OptionSpec 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. + 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. @@ -269,9 +329,6 @@ class val ArgSpec 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( @@ -279,6 +336,12 @@ class val ArgSpec descr': String = "", default': (Bool | None) = None) => + """ + Creates an Arg with a Bool typed value that can be used like + ` true` + to yield an arg value like + `cmd.arg("opt").bool() == true`. + """ _name = name' _descr = descr' (_typ, _default, _required) = _init(_BoolType, default') @@ -288,6 +351,12 @@ class val ArgSpec descr': String = "", default': (String | None) = None) => + """ + Creates an Arg with a String typed value that can be used like + ` filename` + to yield an arg value + `cmd.arg("file").string() == "filename"`. + """ _name = name' _descr = descr' (_typ, _default, _required) = _init(_StringType, default') @@ -296,6 +365,12 @@ class val ArgSpec descr': String = "", default': (I64 | None) = None) => + """ + Creates an Arg with an I64 typed value that can be used like + ` 42` + to yield an arg value like + `cmd.arg("count").i64() == I64(42)`. + """ _name = name' _descr = descr' (_typ, _default, _required) = _init(_I64Type, default') @@ -304,43 +379,128 @@ class val ArgSpec descr': String = "", default': (F64 | None) = None) => + """ + Creates an Arg with a F64 typed value that can be used like + ` 1.039` + to yield an arg value like + `cmd.arg("ratio").f64() == F64(1.039)`. + """ _name = name' _descr = descr' (_typ, _default, _required) = _init(_F64Type, default') - fun name(): String => _name + new val string_seq( + name': String, + descr': String = "") + => + """ + Creates an Arg with a ReadSeq[String] typed value that can be used like + ` file1 file2 file3` + to yield a sequence of three strings equivalent to + `cmd.arg("file").string_seq() (equiv) ["file1"; "file2"; "file3"]`. + """ + _name = name' + _descr = descr' + (_typ, _default, _required) = _init(_StringSeqType, _StringSeq.empty()) + + fun name(): String => + """ + Returns the name of this arg. + """ + _name - fun descr(): String => _descr + fun descr(): String => + """ + Returns the description for this arg. + """ + _descr fun _typ_p(): _ValueType => _typ fun _default_p(): _Value => _default - fun required(): Bool => _required + fun required(): Bool => + """ + Returns true iff this arg is required to be present in the command line. + """ + _required fun help_string(): String => + """ + Returns a formated help string for this arg. + """ "<" + _name + ">" fun deb_string(): String => _name + "[" + _typ.string() + "]" + if not _required then "(=" + _default.string() + ")" else "" end -type _Value is (Bool | String | I64 | F64) +class _StringSeq is ReadSeq[String] + """ + _StringSeq is a wrapper / helper class for working with String sequence + values while parsing. It assists in collecting the strings as they are + parsed, and producing a ReadSeq[String] as a result. + """ + let strings: pc.Vec[String] + + new val empty() => + strings = pc.Vec[String] + + new val from_string(s: String) => + strings = (pc.Vec[String]).push(s) -primitive _BoolType + new val from_concat(ss0: _StringSeq val, ss1: _StringSeq val) => + strings = ss0.strings.concat(ss1.strings.values()) + + fun string(): String iso^ => + let str = recover String() end + str.push('[') + for s in strings.values() do + if str.size() > 1 then str.push(',') end + str.append(s) + end + str.push(']') + str + + fun size(): USize => strings.size() + fun apply(i: USize): this->String ? => strings(i) + fun values(): Iterator[this->String]^ => strings.values() + +type _Value is (Bool | String | I64 | F64 | _StringSeq val) + +trait val _ValueType + fun string(): String + fun value_of(s: String): _Value ? + fun is_seq(): Bool => false + fun append(v1: _Value, v2: _Value): _Value => v1 + +primitive _BoolType is _ValueType fun string(): String => "Bool" + fun value_of(s: String): _Value ? => s.bool() -primitive _StringType +primitive _StringType is _ValueType fun string(): String => "String" + fun value_of(s: String): _Value => s -primitive _I64Type +primitive _I64Type is _ValueType fun string(): String => "I64" + fun value_of(s: String): _Value ? => s.i64() -primitive _F64Type +primitive _F64Type is _ValueType fun string(): String => "F64" + fun value_of(s: String): _Value => s.f64() -type _ValueType is - ( _BoolType - | _StringType - | _I64Type - | _F64Type ) +primitive _StringSeqType is _ValueType + fun string(): String => "ReadSeq[String]" + fun value_of(s: String): _Value => _StringSeq.from_string(s) + fun is_seq(): Bool => true + fun append(v1: _Value, v2: _Value): _Value => + """ + When is_seq() returns true, append() is called during parsing to append + a new parsed value onto an existing value. + """ + try + _StringSeq.from_concat(v1 as _StringSeq val, v2 as _StringSeq val) + else + v1 + end diff --git a/packages/cli/env_vars.pony b/packages/cli/env_vars.pony index 1130cdc195..c93b71919e 100644 --- a/packages/cli/env_vars.pony +++ b/packages/cli/env_vars.pony @@ -9,7 +9,7 @@ primitive EnvVars => """ Turns an array of strings that look like environment variables, ie. - key=value, into a map from string to string. Can optionally filter for + key=value, into a map of string to string. Can optionally filter for keys matching a 'prefix', and will squash resulting keys to lowercase iff 'squash' is true.