Skip to content

Commit b75f9c0

Browse files
authored
Merge pull request #2163 from adrian-thurston/feat/stop-on-nth-arg
Allow the user to stop processing flags after seeing N args
2 parents 2799fd4 + 5e0af0c commit b75f9c0

File tree

7 files changed

+353
-8
lines changed

7 files changed

+353
-8
lines changed

command.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@ type Command struct {
127127
// Whether to read arguments from stdin
128128
// applicable to root command only
129129
ReadArgsFromStdin bool `json:"readArgsFromStdin"`
130+
// StopOnNthArg provides v2-like behavior for specific commands by stopping
131+
// flag parsing after N positional arguments are encountered. When set to N,
132+
// all remaining arguments after the Nth positional argument will be treated
133+
// as arguments, not flags.
134+
//
135+
// A value of 0 means all arguments are treated as positional (no flag parsing).
136+
// A nil value means normal v3 flag parsing behavior (flags can appear anywhere).
137+
StopOnNthArg *int `json:"stopOnNthArg"`
130138

131139
// categories contains the categorized commands and is populated on app startup
132140
categories CommandCategories

command_parse.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ func (cmd *Command) parseFlags(args Args) (Args, error) {
8989
return &stringSliceArgs{posArgs}, nil
9090
}
9191

92+
// Check if we've reached the Nth argument and should stop flag parsing
93+
if cmd.StopOnNthArg != nil && len(posArgs) == *cmd.StopOnNthArg {
94+
// Append current arg and all remaining args without parsing
95+
posArgs = append(posArgs, rargs[0:]...)
96+
return &stringSliceArgs{posArgs}, nil
97+
}
98+
9299
// handle positional args
93100
if firstArg[0] != '-' {
94101
// positional argument probably

command_run.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ func (cmd *Command) run(ctx context.Context, osArgs []string) (_ context.Context
9999
tracef("running with arguments %[1]q (cmd=%[2]q)", osArgs, cmd.Name)
100100
cmd.setupDefaults(osArgs)
101101

102+
// Validate StopOnNthArg
103+
if cmd.StopOnNthArg != nil && *cmd.StopOnNthArg < 0 {
104+
return ctx, fmt.Errorf("StopOnNthArg must be non-negative, got %d", *cmd.StopOnNthArg)
105+
}
106+
102107
if v, ok := ctx.Value(commandContextKey).(*Command); ok {
103108
tracef("setting parent (cmd=%[1]q) command from context.Context value (cmd=%[2]q)", v.Name, cmd.Name)
104109
cmd.parent = v

command_stop_on_nth_arg_test.go

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestCommand_StopOnNthArg(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
stopOnNthArg *int
15+
testArgs []string
16+
expectedArgs []string
17+
expectedFlag string
18+
expectedBool bool
19+
}{
20+
{
21+
name: "nil StopOnNthArg - normal parsing",
22+
stopOnNthArg: nil,
23+
testArgs: []string{"cmd", "--flag", "value", "arg1", "--bool", "arg2"},
24+
expectedArgs: []string{"arg1", "arg2"},
25+
expectedFlag: "value",
26+
expectedBool: true,
27+
},
28+
{
29+
name: "stop after 0 args - all become args",
30+
stopOnNthArg: intPtr(0),
31+
testArgs: []string{"cmd", "--flag", "value", "arg1", "--bool", "arg2"},
32+
expectedArgs: []string{"--flag", "value", "arg1", "--bool", "arg2"},
33+
expectedFlag: "",
34+
expectedBool: false,
35+
},
36+
{
37+
name: "stop after 1 arg",
38+
stopOnNthArg: intPtr(1),
39+
testArgs: []string{"cmd", "--flag", "value", "arg1", "--bool", "arg2"},
40+
expectedArgs: []string{"arg1", "--bool", "arg2"},
41+
expectedFlag: "value",
42+
expectedBool: false,
43+
},
44+
{
45+
name: "stop after 2 args",
46+
stopOnNthArg: intPtr(2),
47+
testArgs: []string{"cmd", "--flag", "value", "arg1", "arg2", "--bool", "arg3"},
48+
expectedArgs: []string{"arg1", "arg2", "--bool", "arg3"},
49+
expectedFlag: "value",
50+
expectedBool: false,
51+
},
52+
{
53+
name: "mixed flags and args - stop after 1",
54+
stopOnNthArg: intPtr(1),
55+
testArgs: []string{"cmd", "--flag", "value", "--bool", "arg1", "--flag2", "value2"},
56+
expectedArgs: []string{"arg1", "--flag2", "value2"},
57+
expectedFlag: "value",
58+
expectedBool: true,
59+
},
60+
{
61+
name: "args before flags - stop after 1",
62+
stopOnNthArg: intPtr(1),
63+
testArgs: []string{"cmd", "arg1", "--flag", "value", "--bool"},
64+
expectedArgs: []string{"arg1", "--flag", "value", "--bool"},
65+
expectedFlag: "",
66+
expectedBool: false,
67+
},
68+
{
69+
name: "ssh command example",
70+
stopOnNthArg: intPtr(1),
71+
testArgs: []string{"ssh", "machine-name", "ls", "-la"},
72+
expectedArgs: []string{"machine-name", "ls", "-la"},
73+
expectedFlag: "",
74+
expectedBool: false,
75+
},
76+
{
77+
name: "with double dash terminator",
78+
stopOnNthArg: intPtr(1),
79+
testArgs: []string{"cmd", "--flag", "value", "--", "arg1", "--not-a-flag"},
80+
expectedArgs: []string{"arg1", "--not-a-flag"},
81+
expectedFlag: "value",
82+
expectedBool: false,
83+
},
84+
{
85+
name: "stop after large number of args",
86+
stopOnNthArg: intPtr(100),
87+
testArgs: []string{"cmd", "--flag", "value", "arg1", "arg2", "--bool"},
88+
expectedArgs: []string{"arg1", "arg2"},
89+
expectedFlag: "value",
90+
expectedBool: true,
91+
},
92+
}
93+
94+
for _, tt := range tests {
95+
t.Run(tt.name, func(t *testing.T) {
96+
var args Args
97+
var flagValue string
98+
var boolValue bool
99+
100+
cmd := &Command{
101+
Name: "test",
102+
StopOnNthArg: tt.stopOnNthArg,
103+
Flags: []Flag{
104+
&StringFlag{Name: "flag", Destination: &flagValue},
105+
&StringFlag{Name: "flag2"},
106+
&BoolFlag{Name: "bool", Destination: &boolValue},
107+
},
108+
Action: func(_ context.Context, cmd *Command) error {
109+
args = cmd.Args()
110+
return nil
111+
},
112+
}
113+
114+
require.NoError(t, cmd.Run(buildTestContext(t), tt.testArgs))
115+
assert.Equal(t, tt.expectedArgs, args.Slice())
116+
assert.Equal(t, tt.expectedFlag, flagValue)
117+
assert.Equal(t, tt.expectedBool, boolValue)
118+
})
119+
}
120+
}
121+
122+
func TestCommand_StopOnNthArg_WithSubcommands(t *testing.T) {
123+
tests := []struct {
124+
name string
125+
parentStopOnNthArg *int
126+
subStopOnNthArg *int
127+
testArgs []string
128+
expectedParentArgs []string
129+
expectedSubArgs []string
130+
expectedSubFlag string
131+
}{
132+
{
133+
name: "parent normal, subcommand stops after 0",
134+
parentStopOnNthArg: nil,
135+
subStopOnNthArg: intPtr(0),
136+
testArgs: []string{"parent", "sub", "--subflag", "value", "subarg", "--not-parsed"},
137+
expectedParentArgs: []string{},
138+
expectedSubArgs: []string{"--subflag", "value", "subarg", "--not-parsed"},
139+
expectedSubFlag: "",
140+
},
141+
{
142+
name: "parent normal, subcommand stops after 1",
143+
parentStopOnNthArg: nil,
144+
subStopOnNthArg: intPtr(1),
145+
testArgs: []string{"parent", "sub", "--subflag", "value", "subarg", "--not-parsed"},
146+
expectedParentArgs: []string{},
147+
expectedSubArgs: []string{"subarg", "--not-parsed"},
148+
expectedSubFlag: "value",
149+
},
150+
{
151+
name: "parent normal, subcommand stops after 2",
152+
parentStopOnNthArg: nil,
153+
subStopOnNthArg: intPtr(2),
154+
testArgs: []string{"parent", "sub", "--subflag", "value", "subarg1", "subarg2", "--not-parsed"},
155+
expectedParentArgs: []string{},
156+
expectedSubArgs: []string{"subarg1", "subarg2", "--not-parsed"},
157+
expectedSubFlag: "value",
158+
},
159+
{
160+
name: "parent normal, subcommand never stops (high StopOnNthArg)",
161+
parentStopOnNthArg: nil,
162+
subStopOnNthArg: intPtr(100),
163+
testArgs: []string{"parent", "sub", "--subflag", "value1", "arg1", "arg2", "--subflag", "value2"},
164+
expectedParentArgs: []string{},
165+
expectedSubArgs: []string{"arg1", "arg2"},
166+
expectedSubFlag: "value2", // Should parse the second --subflag since we never hit the stop limit
167+
},
168+
{
169+
// Meaningless, but okay.
170+
name: "parent stops after 1, subcommand stops after 1",
171+
parentStopOnNthArg: intPtr(1),
172+
subStopOnNthArg: intPtr(1),
173+
testArgs: []string{"parent", "sub", "--subflag", "value", "subarg", "--not-parsed"},
174+
expectedParentArgs: []string{},
175+
expectedSubArgs: []string{"subarg", "--not-parsed"},
176+
expectedSubFlag: "value",
177+
},
178+
}
179+
180+
for _, tt := range tests {
181+
t.Run(tt.name, func(t *testing.T) {
182+
var parentArgs, subArgs Args
183+
var subFlagValue string
184+
subCalled := false
185+
186+
subCmd := &Command{
187+
Name: "sub",
188+
StopOnNthArg: tt.subStopOnNthArg,
189+
Flags: []Flag{
190+
&StringFlag{Name: "subflag", Destination: &subFlagValue},
191+
},
192+
Action: func(_ context.Context, cmd *Command) error {
193+
subCalled = true
194+
subArgs = cmd.Args()
195+
return nil
196+
},
197+
}
198+
199+
parentCmd := &Command{
200+
Name: "parent",
201+
StopOnNthArg: tt.parentStopOnNthArg,
202+
Commands: []*Command{subCmd},
203+
Flags: []Flag{
204+
&StringFlag{Name: "parentflag"},
205+
},
206+
Action: func(_ context.Context, cmd *Command) error {
207+
parentArgs = cmd.Args()
208+
return nil
209+
},
210+
}
211+
212+
err := parentCmd.Run(buildTestContext(t), tt.testArgs)
213+
214+
require.NoError(t, err)
215+
216+
if tt.expectedSubArgs != nil {
217+
assert.True(t, subCalled, "subcommand should have been called")
218+
if len(tt.expectedSubArgs) > 0 {
219+
haveNonEmptySubArgsSlice := subArgs != nil && subArgs.Slice() != nil && len(subArgs.Slice()) > 0
220+
assert.True(t, haveNonEmptySubArgsSlice, "subargs.Slice is not nil")
221+
if haveNonEmptySubArgsSlice {
222+
assert.Equal(t, tt.expectedSubArgs, subArgs.Slice())
223+
}
224+
} else {
225+
assert.True(t, subArgs == nil || subArgs.Slice() == nil || len(subArgs.Slice()) == 0, "subargs.Slice is not nil")
226+
}
227+
assert.Equal(t, tt.expectedSubFlag, subFlagValue)
228+
} else {
229+
assert.False(t, subCalled, "subcommand should not have been called")
230+
assert.Equal(t, tt.expectedParentArgs, parentArgs.Slice())
231+
}
232+
})
233+
}
234+
}
235+
236+
func TestCommand_StopOnNthArg_EdgeCases(t *testing.T) {
237+
t.Run("negative StopOnNthArg returns error", func(t *testing.T) {
238+
cmd := &Command{
239+
Name: "test",
240+
StopOnNthArg: intPtr(-1),
241+
Action: func(_ context.Context, cmd *Command) error {
242+
return nil
243+
},
244+
}
245+
246+
// Negative value should return an error
247+
err := cmd.Run(buildTestContext(t), []string{"cmd", "arg1"})
248+
require.Error(t, err)
249+
assert.Contains(t, err.Error(), "StopOnNthArg must be non-negative")
250+
})
251+
252+
t.Run("zero StopOnNthArg with no args", func(t *testing.T) {
253+
var args Args
254+
var flagValue string
255+
cmd := &Command{
256+
Name: "test",
257+
StopOnNthArg: intPtr(0),
258+
Flags: []Flag{
259+
&StringFlag{Name: "flag", Destination: &flagValue},
260+
},
261+
Action: func(_ context.Context, cmd *Command) error {
262+
args = cmd.Args()
263+
return nil
264+
},
265+
}
266+
267+
// All flags should become args
268+
require.NoError(t, cmd.Run(buildTestContext(t), []string{"cmd", "--flag", "value"}))
269+
assert.Equal(t, []string{"--flag", "value"}, args.Slice())
270+
assert.Equal(t, "", flagValue)
271+
})
272+
273+
t.Run("StopOnNthArg with only flags", func(t *testing.T) {
274+
var args Args
275+
var flagValue string
276+
var boolValue bool
277+
cmd := &Command{
278+
Name: "test",
279+
StopOnNthArg: intPtr(1),
280+
Flags: []Flag{
281+
&StringFlag{Name: "flag", Destination: &flagValue},
282+
&BoolFlag{Name: "bool", Destination: &boolValue},
283+
},
284+
Action: func(_ context.Context, cmd *Command) error {
285+
args = cmd.Args()
286+
return nil
287+
},
288+
}
289+
290+
// Should parse all flags since no args are encountered
291+
require.NoError(t, cmd.Run(buildTestContext(t), []string{"cmd", "--flag", "value", "--bool"}))
292+
assert.Equal(t, []string{}, args.Slice())
293+
assert.Equal(t, "value", flagValue)
294+
assert.True(t, boolValue)
295+
})
296+
}
297+
298+
// Helper function to create int pointer
299+
func intPtr(i int) *int {
300+
return &i
301+
}

0 commit comments

Comments
 (0)