-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathparse.go
More file actions
357 lines (325 loc) · 10.4 KB
/
parse.go
File metadata and controls
357 lines (325 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
package cli
import (
"errors"
"flag"
"fmt"
"io"
"regexp"
"slices"
"strconv"
"strings"
"github.com/pressly/cli/xflag"
)
// Parse traverses the command hierarchy and parses arguments. It returns an error if parsing fails
// at any point.
//
// This function is the main entry point for parsing command-line arguments and should be called
// with the root command and the arguments to parse, typically os.Args[1:]. Once parsing is
// complete, the root command is ready to be executed with the [Run] function.
func Parse(root *Command, args []string) error {
if root == nil {
return fmt.Errorf("failed to parse: root command is nil")
}
if err := validateCommands(root, nil); err != nil {
return fmt.Errorf("failed to parse: %w", err)
}
// Initialize or update root state
if root.state == nil {
root.state = &State{
path: []*Command{root},
}
} else {
// Reset command path but preserve other state
root.state.path = []*Command{root}
}
argsToParse, remainingArgs := splitAtDelimiter(args)
current, err := resolveCommandPath(root, argsToParse)
if err != nil {
return err
}
current.Flags.Usage = func() { /* suppress default usage */ }
// Check for help flags after resolving the correct command
for _, arg := range argsToParse {
if arg == "-h" || arg == "--h" || arg == "-help" || arg == "--help" {
// Combine flags first so the help message includes all inherited flags
combineFlags(root.state.path)
return ErrHelp
}
}
combinedFlags := combineFlags(root.state.path)
// Let ParseToEnd handle the flag parsing
if err := xflag.ParseToEnd(combinedFlags, argsToParse); err != nil {
return fmt.Errorf("command %q: %w", getCommandPath(root.state.path), err)
}
if err := checkRequiredFlags(root.state.path, combinedFlags); err != nil {
return err
}
root.state.Args = collectArgs(root.state.path, combinedFlags.Args(), remainingArgs)
if current.Exec == nil {
return fmt.Errorf("command %q: no exec function defined", getCommandPath(root.state.path))
}
return nil
}
// splitAtDelimiter splits args at the first "--" delimiter. Returns the args before the delimiter
// and any args after it.
func splitAtDelimiter(args []string) (argsToParse, remaining []string) {
for i, arg := range args {
if arg == "--" {
return args[:i], args[i+1:]
}
}
return args, nil
}
// resolveCommandPath walks argsToParse to resolve the subcommand chain, building root.state.path
// and initializing flag sets along the way. Returns the terminal (deepest) command.
func resolveCommandPath(root *Command, argsToParse []string) (*Command, error) {
current := root
if current.Flags == nil {
current.Flags = flag.NewFlagSet(root.Name, flag.ContinueOnError)
}
i := 0
for i < len(argsToParse) {
arg := argsToParse[i]
// Skip flags and their values
if strings.HasPrefix(arg, "-") {
// For formats like -flag=x or --flag=x
if strings.Contains(arg, "=") {
i++
continue
}
// Check if this flag expects a value across all commands in the chain (not just the
// current command), since flags from ancestor commands are inherited and can appear
// anywhere. Also check short flag aliases from FlagOptions.
name := strings.TrimLeft(arg, "-")
skipValue := false
for _, cmd := range root.state.path {
localFlags := localFlagSet(cmd.FlagOptions)
// Skip local flags on ancestor commands (any command already in the path is an
// ancestor of the not-yet-resolved terminal command).
if localFlags[name] {
continue
}
// First try direct lookup.
f := cmd.Flags.Lookup(name)
// If not found, check if it's a short alias.
if f == nil {
for _, fm := range cmd.FlagOptions {
if fm.Short == name {
if localFlags[fm.Name] {
break
}
f = cmd.Flags.Lookup(fm.Name)
break
}
}
}
if f != nil {
if _, isBool := f.Value.(interface{ IsBoolFlag() bool }); !isBool {
skipValue = true
}
break
}
}
if skipValue {
// Skip both flag and its value
i += 2
continue
}
i++
continue
}
// Try to traverse to subcommand
if len(current.SubCommands) > 0 {
if sub := current.findSubCommand(arg); sub != nil {
root.state.path = append(slices.Clone(root.state.path), sub)
if sub.Flags == nil {
sub.Flags = flag.NewFlagSet(sub.Name, flag.ContinueOnError)
}
current = sub
i++
continue
}
return nil, current.formatUnknownCommandError(arg)
}
break
}
return current, nil
}
// combineFlags merges flags from the command path into a single FlagSet. Flags are added in reverse
// order (deepest command first) so that child flags take precedence over parent flags. Short flag
// aliases from FlagOptions are also registered, sharing the same Value as their long counterpart.
func combineFlags(path []*Command) *flag.FlagSet {
combined := flag.NewFlagSet(path[0].Name, flag.ContinueOnError)
combined.SetOutput(io.Discard)
terminalIdx := len(path) - 1
for i := terminalIdx; i >= 0; i-- {
cmd := path[i]
if cmd.Flags == nil {
continue
}
localFlags := localFlagSet(cmd.FlagOptions)
shortMap := shortFlagMap(cmd.FlagOptions)
isAncestor := i < terminalIdx
cmd.Flags.VisitAll(func(f *flag.Flag) {
// Skip local flags from ancestor commands — they are not inherited.
if isAncestor && localFlags[f.Name] {
return
}
if combined.Lookup(f.Name) == nil {
combined.Var(f.Value, f.Name, f.Usage)
}
// Register the short alias pointing to the same Value.
if short, ok := shortMap[f.Name]; ok {
if combined.Lookup(short) == nil {
combined.Var(f.Value, short, f.Usage)
}
}
})
}
return combined
}
// localFlagSet builds a set of flag names that are marked as local in FlagOptions.
func localFlagSet(options []FlagOption) map[string]bool {
m := make(map[string]bool, len(options))
for _, fm := range options {
if fm.Local {
m[fm.Name] = true
}
}
return m
}
// shortFlagMap builds a map from long flag name to short alias from FlagOptions.
func shortFlagMap(options []FlagOption) map[string]string {
m := make(map[string]string, len(options))
for _, fm := range options {
if fm.Short != "" {
m[fm.Name] = fm.Short
}
}
return m
}
// checkRequiredFlags verifies that all flags marked as required in FlagOptions were explicitly set
// during parsing.
func checkRequiredFlags(path []*Command, combined *flag.FlagSet) error {
// Build a set of flags that were explicitly set during parsing. Visit (unlike VisitAll) only
// iterates over flags that were actually provided by the user, regardless of their value.
setFlags := make(map[string]struct{})
combined.Visit(func(f *flag.Flag) {
setFlags[f.Name] = struct{}{}
})
terminalIdx := len(path) - 1
var missingFlags []string
for i, cmd := range path {
for _, fo := range cmd.FlagOptions {
if !fo.Required {
continue
}
// Skip required-flag checks for local flags on ancestor commands.
if fo.Local && i < terminalIdx {
continue
}
if combined.Lookup(fo.Name) == nil {
return fmt.Errorf("command %q: internal error: required flag %s not found in flag set", getCommandPath(path), formatFlagName(fo.Name))
}
if _, ok := setFlags[fo.Name]; !ok {
missingFlags = append(missingFlags, formatFlagName(fo.Name))
}
}
}
if len(missingFlags) > 0 {
msg := "required flag"
if len(missingFlags) > 1 {
msg += "s"
}
return fmt.Errorf("command %q: %s %q not set", getCommandPath(path), msg, strings.Join(missingFlags, ", "))
}
return nil
}
// collectArgs strips resolved command names from the parsed positional args and appends any args
// that appeared after the "--" delimiter.
func collectArgs(path []*Command, parsed, remaining []string) []string {
// Skip past command names in remaining args. Only strip the exact command names that were
// resolved during traversal (path[1:], since root never appears in user args), in order and
// only once each.
startIdx := 0
chainIdx := 1 // Skip root
for startIdx < len(parsed) && chainIdx < len(path) {
if strings.EqualFold(parsed[startIdx], path[chainIdx].Name) {
startIdx++
chainIdx++
} else {
break
}
}
var finalArgs []string
if startIdx < len(parsed) {
finalArgs = append(finalArgs, parsed[startIdx:]...)
}
if len(remaining) > 0 {
finalArgs = append(finalArgs, remaining...)
}
return finalArgs
}
var validNameRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
func validateName(root *Command) error {
if !validNameRegex.MatchString(root.Name) {
return fmt.Errorf("name must start with a letter and contain only letters, numbers, dashes (-) or underscores (_)")
}
return nil
}
func validateCommands(root *Command, path []string) error {
if root.Name == "" {
if len(path) == 0 {
return errors.New("root command has no name")
}
return fmt.Errorf("subcommand in path [%s] has no name", strings.Join(path, ", "))
}
currentPath := append(path, root.Name)
if err := validateName(root); err != nil {
quoted := make([]string, len(currentPath))
for i, p := range currentPath {
quoted[i] = strconv.Quote(p)
}
return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err)
}
if err := validateFlagOptions(root); err != nil {
quoted := make([]string, len(currentPath))
for i, p := range currentPath {
quoted[i] = strconv.Quote(p)
}
return fmt.Errorf("command [%s]: %w", strings.Join(quoted, ", "), err)
}
for _, sub := range root.SubCommands {
if err := validateCommands(sub, currentPath); err != nil {
return err
}
}
return nil
}
// validateFlagOptions checks that each FlagOption entry refers to a flag that exists in the
// command's FlagSet, that Short aliases are single ASCII letters, and that no two entries share the
// same Short alias.
func validateFlagOptions(cmd *Command) error {
if len(cmd.FlagOptions) == 0 {
return nil
}
seenShorts := make(map[string]string) // short -> flag name
for _, fm := range cmd.FlagOptions {
if cmd.Flags == nil || cmd.Flags.Lookup(fm.Name) == nil {
return fmt.Errorf("flag option references unknown flag %q", fm.Name)
}
if fm.Short == "" {
continue
}
if len(fm.Short) != 1 || fm.Short[0] < 'a' || fm.Short[0] > 'z' {
if fm.Short[0] < 'A' || fm.Short[0] > 'Z' {
return fmt.Errorf("flag %q: short alias must be a single ASCII letter, got %q", fm.Name, fm.Short)
}
}
if other, ok := seenShorts[fm.Short]; ok {
return fmt.Errorf("duplicate short flag %q: used by both %q and %q", fm.Short, other, fm.Name)
}
seenShorts[fm.Short] = fm.Name
}
return nil
}