Skip to content

Commit f7659f8

Browse files
authored
Merge pull request #2474 from ybai08/fix/digit-flag-names
fix: parse single-dash flags whose names start with a digit Reviewed-by: Eng Zer Jun <engzerjun@gmail.com> Tested-by: Eng Zer Jun <engzerjun@gmail.com> Reviewed-by: Naveen Gogineni <dear.chap@gmail.com>
2 parents 28c8db5 + 4d431a5 commit f7659f8

2 files changed

Lines changed: 116 additions & 2 deletions

File tree

‎command_parse.go‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cli
22

33
import (
44
"fmt"
5+
"slices"
56
"strings"
67
"unicode"
78
"unicode/utf8"
@@ -134,8 +135,9 @@ func (cmd *Command) parseFlags(args Args) (Args, error) {
134135
if firstArg[1] == '-' {
135136
numMinuses++
136137
shortOptionHandling = false
137-
} else if firstRune, _ := utf8.DecodeRuneInString(firstArg[1:]); !unicode.IsLetter(firstRune) {
138-
// this is not a flag
138+
} else if firstRune, _ := utf8.DecodeRuneInString(firstArg[1:]); !unicode.IsLetter(firstRune) &&
139+
!cmd.startsWithAppliedFlag(firstArg[1:], firstRune, shortOptionHandling) {
140+
// this is not a flag, e.g. a negative number
139141
tracef("parseFlags not a unicode letter. Stop parsing")
140142
posArgs = append(posArgs, rargs...)
141143
return &stringSliceArgs{posArgs}, nil
@@ -272,3 +274,21 @@ func (cmd *Command) parseFlags(args Args) (Args, error) {
272274
tracef("returning-2 (cmd=%[1]q) args %[2]q", cmd.Name, posArgs)
273275
return &stringSliceArgs{posArgs}, nil
274276
}
277+
278+
// startsWithAppliedFlag reports whether a single-dash argument, given without
279+
// its dash, names a flag even though it does not start with a letter, as
280+
// "-4" does when a flag is named "4". With short option handling, a group
281+
// such as "-46" names a flag when its first flag does.
282+
func (cmd *Command) startsWithAppliedFlag(arg string, firstRune rune, shortOptionHandling bool) bool {
283+
name, _, _ := strings.Cut(arg, "=")
284+
// lookupAppliedFlag would call InvalidFlagAccessHandler for an
285+
// argument that names no flag, such as the negative number "-5"
286+
for _, f := range cmd.appliedFlags {
287+
names := f.Names()
288+
if slices.Contains(names, name) ||
289+
(shortOptionHandling && slices.Contains(names, string(firstRune))) {
290+
return true
291+
}
292+
}
293+
return false
294+
}

‎command_test.go‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1328,6 +1328,100 @@ func TestCommand_SingleRuneUnicodeFlag(t *testing.T) {
13281328
assert.Equal(t, []string{"operand"}, args)
13291329
}
13301330

1331+
func TestCommand_DigitFlag(t *testing.T) {
1332+
tests := []struct {
1333+
name string
1334+
shortOptions bool
1335+
args []string
1336+
expectedFour bool
1337+
expectedSix bool
1338+
expectedLevel string
1339+
expectedArgs []string
1340+
}{
1341+
{
1342+
name: "single digit flag",
1343+
args: []string{"app", "-4", "operand"},
1344+
expectedFour: true,
1345+
expectedArgs: []string{"operand"},
1346+
},
1347+
{
1348+
name: "digit flags followed by another flag",
1349+
args: []string{"app", "-4", "-6", "-level", "high", "operand"},
1350+
expectedFour: true,
1351+
expectedSix: true,
1352+
expectedLevel: "high",
1353+
expectedArgs: []string{"operand"},
1354+
},
1355+
{
1356+
name: "flag name starting with a digit",
1357+
args: []string{"app", "-2fa=on", "operand"},
1358+
expectedLevel: "on",
1359+
expectedArgs: []string{"operand"},
1360+
},
1361+
{
1362+
name: "digit flags in a short option group",
1363+
shortOptions: true,
1364+
args: []string{"app", "-46", "operand"},
1365+
expectedFour: true,
1366+
expectedSix: true,
1367+
expectedArgs: []string{"operand"},
1368+
},
1369+
{
1370+
name: "undefined digit flag is still a positional argument",
1371+
args: []string{"app", "-5", "-4"},
1372+
expectedArgs: []string{"-5", "-4"},
1373+
},
1374+
}
1375+
1376+
for _, test := range tests {
1377+
t.Run(test.name, func(t *testing.T) {
1378+
var four, six bool
1379+
var level string
1380+
var args []string
1381+
cmd := &Command{
1382+
Name: "app",
1383+
UseShortOptionHandling: test.shortOptions,
1384+
Flags: []Flag{
1385+
&BoolFlag{Name: "4"},
1386+
&BoolFlag{Name: "6"},
1387+
&StringFlag{Name: "level", Aliases: []string{"2fa"}},
1388+
},
1389+
Action: func(_ context.Context, cmd *Command) error {
1390+
four = cmd.Bool("4")
1391+
six = cmd.Bool("6")
1392+
level = cmd.String("level")
1393+
args = cmd.Args().Slice()
1394+
return nil
1395+
},
1396+
}
1397+
1398+
require.NoError(t, cmd.Run(buildTestContext(t), test.args))
1399+
assert.Equal(t, test.expectedFour, four)
1400+
assert.Equal(t, test.expectedSix, six)
1401+
assert.Equal(t, test.expectedLevel, level)
1402+
assert.Equal(t, test.expectedArgs, args)
1403+
})
1404+
}
1405+
}
1406+
1407+
func TestCommand_NegativeNumberArg_InvalidFlagAccessHandler(t *testing.T) {
1408+
for _, shortOptions := range []bool{false, true} {
1409+
t.Run(fmt.Sprintf("shortOptions=%v", shortOptions), func(t *testing.T) {
1410+
var invalid []string
1411+
cmd := &Command{
1412+
UseShortOptionHandling: shortOptions,
1413+
InvalidFlagAccessHandler: func(_ context.Context, _ *Command, name string) {
1414+
invalid = append(invalid, name)
1415+
},
1416+
Action: func(context.Context, *Command) error { return nil },
1417+
}
1418+
1419+
require.NoError(t, cmd.Run(buildTestContext(t), []string{"app", "-5"}))
1420+
assert.Empty(t, invalid)
1421+
})
1422+
}
1423+
}
1424+
13311425
func TestCommand_EqualsFlagValueValidation(t *testing.T) {
13321426
for _, kind := range []string{"int", "bool"} {
13331427
for _, whitespace := range []string{"", " ", "\t"} {

0 commit comments

Comments
 (0)