Skip to content

Commit d7b8eaf

Browse files
authored
Allow short arguments and flags to be combined (#102)
Fixes #7 This PR allows flags like `-abtrue` to mean `-a -b true`, by walking the combined flag one character at a time and seeing if each character is a flag that takes zero values, or a non-flag argument that takes one value, which is set to the remaining part of the combined flag (in the case above, `"true"`). This allows some nice shorthands, like `./mill -wk -j10` rather than `./mill -w -k -j 10` This is the only way I could find that allows both combining multiple flags like `-ab`, and combining flags with values like `-btrue`. Trying to handle more sophisticated cases like `tar tfv <file>` where `f` is given `<file>` is fundamentally incompatible, and would need some user-facing configuration to enable. Combining multiple short arguments together with gflags `=` syntax, e.g. `-ab=true`, is currently not allowed. This follows the current limitation where we do not allow single short args to be used with `=`, e.g. `-f=bar` is prohibited. In general, combined short arguments are not allowed to have `=` in it. If you want the `=` to be part of the value, you can move it into a separate token e.g. `-ab =true` For now, this improvement can be done fully transparently and backwards-compatibly without any need for user opt-in or configuration, and should cover the 99% use case. There is no conflict with existing long arguments, as those currently require two dashes `--`, and so a multi-character token starting with a single `-` is currently disallowed. Adding additional flexibility and configuration can come later future Covered by additional unit tests
1 parent 96c5325 commit d7b8eaf

File tree

7 files changed

+310
-61
lines changed

7 files changed

+310
-61
lines changed

build.sc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ trait ExampleModule extends ScalaModule {
9090
def moduleDeps = Seq(mainargs.jvm(scala213))
9191
}
9292

93-
object example {
93+
object example extends Module {
9494
object hello extends ExampleModule
9595
object hello2 extends ExampleModule
9696
object caseclass extends ExampleModule
@@ -102,4 +102,5 @@ object example {
102102
}
103103
object vararg extends ExampleModule
104104
object vararg2 extends ExampleModule
105+
object short extends ExampleModule
105106
}

example/short/src/Main.scala

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package example.hello
2+
import mainargs.{main, arg, ParserForMethods, Flag}
3+
4+
object Main {
5+
@main
6+
def bools(a: Flag, b: Boolean = false, c: Flag) = println(Seq(a.value, b, c.value))
7+
8+
@main
9+
def strs(a: Flag, b: String) = println(Seq(a.value, b))
10+
11+
def main(args: Array[String]): Unit = ParserForMethods(this).runOrExit(args)
12+
}

mainargs/src/TokenGrouping.scala

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,24 +26,85 @@ object TokenGrouping {
2626
.flatMap { x => getNames(x).map(_ -> x) }
2727
.toMap[String, ArgSig]
2828

29-
lazy val keywordArgMap = makeKeywordArgMap(
30-
x => x.name.map("--" + _) ++ x.shortName.map("-" + _)
31-
)
29+
lazy val shortArgMap: Map[Char, ArgSig] = argSigs
30+
.collect{case (a, _) if !a.positional => a.shortName.map(_ -> a)}
31+
.flatten
32+
.toMap[Char, ArgSig]
33+
34+
lazy val shortFlagArgMap: Map[Char, ArgSig] = argSigs
35+
.collect{case (a, r: TokensReader.Flag) if !a.positional => a.shortName.map(_ -> a)}
36+
.flatten
37+
.toMap[Char, ArgSig]
38+
39+
lazy val longKeywordArgMap = makeKeywordArgMap(_.name.map("--" + _))
40+
41+
def parseCombinedShortTokens(current: Map[ArgSig, Vector[String]],
42+
head: String,
43+
rest: List[String]) = {
44+
val chars = head.drop(1)
45+
var rest2 = rest
46+
var i = 0
47+
var currentMap = current
48+
var failure: Result.Failure = null
49+
50+
while (i < chars.length) {
51+
val c = chars(i)
52+
shortFlagArgMap.get(c) match {
53+
case Some(a) =>
54+
// For `Flag`s in chars, we consume the char, set it to `true`, and continue
55+
currentMap = Util.appendMap(currentMap, a, "")
56+
i += 1
57+
case None =>
58+
// For other kinds of short arguments, we consume the char, set the value to
59+
// the remaining characters, and exit
60+
shortArgMap.get(c) match {
61+
case Some(a) =>
62+
if (i < chars.length - 1) {
63+
currentMap = Util.appendMap(currentMap, a, chars.drop(i + 1).stripPrefix("="))
64+
} else {
65+
// If the non-flag argument is the last in the combined token, we look
66+
// ahead to grab the next token and assign it as this argument's value
67+
rest2 match {
68+
case Nil =>
69+
// If there is no next token, it is an error
70+
failure = Result.Failure.MismatchedArguments(incomplete = Some(a))
71+
case next :: remaining =>
72+
currentMap = Util.appendMap(currentMap, a, next)
73+
rest2 = remaining
74+
}
75+
}
76+
case None =>
77+
// If we encounter a character that is neither a short flag or a
78+
// short argument, it is an error
79+
failure = Result.Failure.MismatchedArguments(unknown = Seq("-" + c.toString))
80+
}
81+
i = chars.length
82+
}
3283

33-
lazy val longKeywordArgMap = makeKeywordArgMap(x => x.name.map("--" + _))
84+
}
85+
86+
if (failure != null) Left(failure)
87+
else Right((rest2, currentMap))
88+
}
89+
90+
def lookupArgMap(k: String, m: Map[String, ArgSig]): Option[(ArgSig, mainargs.TokensReader[_])] = {
91+
m.get(k).map(a => (a, a.reader))
92+
}
3493

3594
@tailrec def rec(
3695
remaining: List[String],
3796
current: Map[ArgSig, Vector[String]]
3897
): Result[TokenGrouping[B]] = {
3998
remaining match {
4099
case head :: rest =>
100+
// special handling for combined short args of the style "-xvf" or "-j10"
101+
if (head.startsWith("-") && head.lift(1).exists(c => c != '-')){
102+
parseCombinedShortTokens(current, head, rest) match{
103+
case Left(failure) => failure
104+
case Right((rest2, currentMap)) => rec(rest2, currentMap)
105+
}
41106

42-
def lookupArgMap(k: String, m: Map[String, ArgSig]): Option[(ArgSig, mainargs.TokensReader[_])] = {
43-
m.get(k).map(a => (a, a.reader))
44-
}
45-
46-
if (head.startsWith("-") && head.exists(_ != '-')) {
107+
} else if (head.startsWith("-") && head.exists(_ != '-')) {
47108
head.split("=", 2) match{
48109
case Array(first, second) =>
49110
lookupArgMap(first, longKeywordArgMap) match {
@@ -54,7 +115,7 @@ object TokenGrouping {
54115
}
55116

56117
case _ =>
57-
lookupArgMap(head, keywordArgMap) match {
118+
lookupArgMap(head, longKeywordArgMap) match {
58119
case Some((cliArg, _: TokensReader.Flag)) =>
59120
rec(rest, Util.appendMap(current, cliArg, ""))
60121
case Some((cliArg, _: TokensReader.Simple[_])) =>

mainargs/test/src/EqualsSyntaxTests.scala

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,15 @@ object EqualsSyntaxTests extends TestSuite {
2727
ParserForMethods(Main).runOrThrow(Array("--foo=bar=qux")) ==>
2828
"bar=quxbar=qux false"
2929
}
30+
test("empty") {
31+
// --foo= syntax sets `foo` to an empty string
32+
ParserForMethods(Main).runOrThrow(Array("--foo=")) ==>
33+
" false"
34+
}
3035
test("shortName") {
31-
// -f=bar syntax doesn't work for short names
36+
// -f=bar syntax does work for short names
3237
ParserForMethods(Main).runEither(Array("-f=bar")) ==>
33-
Left(
34-
"""Missing argument: -f --foo <str>
35-
|Unknown argument: "-f=bar"
36-
|Expected Signature: run
37-
| -f --foo <str> String to print repeatedly
38-
| --my-num <int> How many times to print string
39-
| --bool Example flag
40-
|
41-
|""".stripMargin)
38+
Right("barbar false")
4239
}
4340
test("notFlags") {
4441
// -f=bar syntax doesn't work for flags

mainargs/test/src/FlagTests.scala

Lines changed: 109 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,132 @@ object FlagTests extends TestSuite {
55

66
object Base {
77
@main
8-
def flaggy(a: Flag, b: Boolean, c: Flag) = a.value || b || c.value
8+
def bool(a: Flag, b: Boolean, c: Flag) = Seq(a.value, b, c.value)
9+
@main
10+
def str(a: Flag, b: String) = Seq(a.value, b)
911
}
12+
1013
val check = new Checker(ParserForMethods(Base), allowPositional = true)
1114

1215
val tests = Tests {
1316
test - check(
14-
List("-b", "true"),
15-
Result.Success(true)
17+
List("bool", "-b", "true"),
18+
Result.Success(Seq(false, true, false))
1619
)
1720
test - check(
18-
List("-b", "false"),
19-
Result.Success(false)
21+
List("bool", "-b", "false"),
22+
Result.Success(Seq(false, false, false))
2023
)
2124

2225
test - check(
23-
List("-a", "-b", "false"),
24-
Result.Success(true)
26+
List("bool", "-a", "-b", "false"),
27+
Result.Success(Seq(true, false, false))
2528
)
2629

2730
test - check(
28-
List("-c", "-b", "false"),
29-
Result.Success(true)
31+
List("bool", "-c", "-b", "false"),
32+
Result.Success(Seq(false, false, true))
3033
)
3134

3235
test - check(
33-
List("-a", "-c", "-b", "false"),
34-
Result.Success(true)
36+
List("bool", "-a", "-c", "-b", "false"),
37+
Result.Success(Seq(true, false, true))
3538
)
3639

40+
test("combined"){
41+
test - check(
42+
List("bool", "-bfalse"),
43+
Result.Success(List(false, false, false))
44+
)
45+
test - check(
46+
List("bool", "-btrue"),
47+
Result.Success(List(false, true, false))
48+
)
49+
50+
test - check(
51+
List("bool", "-abtrue"),
52+
Result.Success(List(true, true, false))
53+
)
54+
test - check(
55+
List("bool", "-abfalse"),
56+
Result.Success(List(true, false, false))
57+
)
58+
59+
test - check(
60+
List("bool", "-a", "-btrue"),
61+
Result.Success(List(true, true, false))
62+
)
63+
64+
test - check(
65+
List("bool", "-a", "-bfalse"),
66+
Result.Success(List(true, false, false))
67+
)
68+
69+
test - check(
70+
List("bool", "-acbtrue"),
71+
Result.Success(List(true, true, true))
72+
)
73+
74+
test - check(
75+
List("bool", "-acbfalse"),
76+
Result.Success(List(true, false, true))
77+
)
78+
79+
test - check(
80+
List("bool", "-a", "-c", "-btrue"),
81+
Result.Success(List(true, true, true))
82+
)
83+
84+
test - check(
85+
List("bool", "-a", "-c", "-bfalse"),
86+
Result.Success(List(true, false, true))
87+
)
88+
89+
test - check(
90+
List("bool", "-a", "-btrue", "-c"),
91+
Result.Success(List(true, true, true))
92+
)
93+
94+
test - check(
95+
List("bool", "-a", "-bfalse", "-c"),
96+
Result.Success(List(true, false, true))
97+
)
98+
99+
test - check(
100+
List("bool", "-ba"),
101+
Result.Failure.InvalidArguments(
102+
List(
103+
Result.ParamError.Failed(
104+
new ArgSig(None, Some('b'), None, None, mainargs.TokensReader.BooleanRead, false, false),
105+
Vector("a"),
106+
"java.lang.IllegalArgumentException: For input string: \"a\""
107+
)
108+
)
109+
)
110+
)
111+
112+
test - check(
113+
List("bool", "-ab"),
114+
Result.Failure.MismatchedArguments(
115+
Nil,
116+
Nil,
117+
Nil,
118+
Some(
119+
new ArgSig(None, Some('b'), None, None, TokensReader.BooleanRead, false, false)
120+
)
121+
)
122+
)
123+
124+
test - check(List("str", "-b=value", "-a"), Result.Success(List(true, "value")))
125+
test - check(List("str", "-b=", "-a"), Result.Success(List(true, "")))
126+
127+
test - check(List("str", "-ab=value"), Result.Success(List(true, "value")))
128+
129+
test - check(
130+
List("str", "-bvalue", "-akey=value"),
131+
Result.Failure.MismatchedArguments(Nil, List("-k"), Nil, None)
132+
)
133+
}
134+
37135
}
38136
}

mainargs/test/src/PositionalTests.scala

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -44,31 +44,7 @@ object PositionalTests extends TestSuite {
4444
)
4545
test - check(
4646
List("-x", "true", "-y", "false", "-z", "false"),
47-
Result.Failure.MismatchedArguments(
48-
Vector(
49-
ArgSig(
50-
None,
51-
Some('y'),
52-
None,
53-
None,
54-
TokensReader.BooleanRead,
55-
positional = true,
56-
hidden = false
57-
),
58-
ArgSig(
59-
None,
60-
Some('z'),
61-
None,
62-
None,
63-
TokensReader.BooleanRead,
64-
positional = false,
65-
hidden = false
66-
)
67-
),
68-
List("-y", "false", "-z", "false"),
69-
List(),
70-
None
71-
)
47+
Result.Failure.MismatchedArguments(List(), List("-y"), List(), None)
7248
)
7349
}
7450
}

0 commit comments

Comments
 (0)