diff --git a/gflag/parser.go b/gflag/parser.go index 8ce5d44..6713a8a 100644 --- a/gflag/parser.go +++ b/gflag/parser.go @@ -222,7 +222,11 @@ func (p *Parser) Parse(args []string) (err error) { // POSIX 短选项规范化预处理(EnhanceShort>0 时生效,默认 0 不改变行为) if p.cfg.EnhanceShort > 0 { - args = expandShortArgs(args, p.fSet.shorts, p.fSet.isBoolShort, p.cfg.EnhanceShort) + stopAt := p.reorderStop + if p.cfg.DisableReorderArgs { + stopAt = func(string) bool { return true } + } + args = expandShortArgs(args, p.fSet, p.cfg.EnhanceShort, stopAt) } // 自动重排 args 为标准 "options... arguments" 形态(默认开启)。 diff --git a/gflag/shorts.go b/gflag/shorts.go index 3871b32..26c8ec9 100644 --- a/gflag/shorts.go +++ b/gflag/shorts.go @@ -24,50 +24,71 @@ func (f *FlagSet) isBoolShort(short string) bool { // // 不满足条件的 token 原样保留(不做猜测,避免误伤混合写法如 -aO)。 // -// - shorts: short=>fullName 映射,判断字符是否为已注册短选项 -// - isBool: 判断给定单字符短选项是否为 bool 类型 +// Tokens consumed as option values and tokens after -- or stopAt stay verbatim. +// +// - fs: 注册的选项及 short=>fullName 映射 // - level: EnhanceShort 等级(0 直接返回原 args) -func expandShortArgs(args []string, shorts map[string]string, isBool func(short string) bool, level uint8) []string { +func expandShortArgs(args []string, fs *FlagSet, level uint8, stopAt func(string) bool) []string { if level == 0 || len(args) == 0 { return args } out := make([]string, 0, len(args)) - for _, arg := range args { - // 仅处理单 '-' 开头、长度>2、不含 '=' - if len(arg) <= 2 || arg[0] != '-' || arg[1] == '-' || strings.IndexByte(arg, '=') >= 0 { - out = append(out, arg) - continue + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + out = append(out, args[i:]...) + break } - body := arg[1:] // 去掉前导 '-'。eg: "aux" + // 仅处理单 '-' 开头、长度>2、不含 '=' + if len(arg) > 2 && arg[0] == '-' && arg[1] != '-' && strings.IndexByte(arg, '=') < 0 { + body := arg[1:] // 去掉前导 '-'。eg: "aux" - // level≥1: 全为 bool 短选项 => 逐个拆成 -x - allBool := true - for i := 0; i < len(body); i++ { - if !isBool(string(body[i])) { - allBool = false - break + // level≥1: 全为 bool 短选项 => 逐个拆成 -x + allBool := true + for j := 0; j < len(body); j++ { + if !fs.isBoolShort(string(body[j])) { + allBool = false + break + } } - } - if allBool { - for i := 0; i < len(body); i++ { - out = append(out, "-"+string(body[i])) + if allBool { + for j := 0; j < len(body); j++ { + out = append(out, "-"+string(body[j])) + } + continue } - continue - } - // level≥2: 首字符是取值型已注册短选项 => -X + 余下作为值 - if level >= 2 { - first := string(body[0]) - if _, ok := shorts[first]; ok && !isBool(first) { - out = append(out, "-"+first, body[1:]) - continue + // level≥2: 首字符是取值型已注册短选项 => -X + 余下作为值 + if level >= 2 { + first := string(body[0]) + if _, ok := fs.shorts[first]; ok && !fs.isBoolShort(first) { + out = append(out, "-"+first, body[1:]) + continue + } } } + // Recognize registered short groups before classifying numeric-looking + // tokens as positional arguments (for example, the bool aliases -1 -2). + if stopAt != nil && !looksLikeOption(arg) && stopAt(arg) { + out = append(out, args[i:]...) + break + } + // 不满足 => 原样保留(不猜测) out = append(out, arg) + + // A known value-taking option consumes its next token, even if that + // token resembles a short group or the -- terminator. + if len(arg) > 1 && arg[0] == '-' && strings.IndexByte(arg, '=') < 0 { + name := strings.TrimPrefix(arg[1:], "-") + if known, isBool := fs.optMeta(name); known && !isBool && i+1 < len(args) { + i++ + out = append(out, args[i]) + } + } } return out } diff --git a/gflag/shorts_context_test.go b/gflag/shorts_context_test.go new file mode 100644 index 0000000..12b8b19 --- /dev/null +++ b/gflag/shorts_context_test.go @@ -0,0 +1,167 @@ +package gflag_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/gookit/gcli/v3/gflag" + "github.com/gookit/goutil/x/assert" +) + +func newContextParser(level uint8) (*gflag.Parser, *bool, *bool, *string) { + p := gflag.New("context") + p.ParserCfg().EnhanceShort = level + var alpha, beta bool + var name string + p.BoolOpt(&alpha, "alpha", "a", false, "alpha") + p.BoolOpt(&beta, "beta", "b", false, "beta") + p.StrOpt(&name, "name", "n", "", "name") + return p, &alpha, &beta, &name +} + +func TestParser_EnhanceShortPreservesArgumentContext(t *testing.T) { + tests := []struct { + name string + args []string + wantName string + wantArgs []string + wantBool bool + }{ + {"terminator bool data", []string{"--", "-ab"}, "", []string{"-ab"}, false}, + {"terminator attached data", []string{"--", "-nfoo"}, "", []string{"-nfoo"}, false}, + {"long option bool data", []string{"--name", "-ab"}, "-ab", nil, false}, + {"short option bool data", []string{"-n", "-ab"}, "-ab", nil, false}, + {"long option attached data", []string{"--name", "-nfoo"}, "-nfoo", nil, false}, + {"short option attached data", []string{"-n", "-nfoo"}, "-nfoo", nil, false}, + {"terminator as value", []string{"--name", "--", "-ab"}, "--", nil, true}, + {"equals value", []string{"--name=-ab"}, "-ab", nil, false}, + {"group after value", []string{"--name", "Ada", "-ab"}, "Ada", nil, true}, + {"group after equals", []string{"--name=-ab", "-ab"}, "-ab", nil, true}, + } + for _, level := range []uint8{1, 2} { + for _, reorder := range []bool{false, true} { + for _, tt := range tests { + t.Run(fmt.Sprintf("level%d/reorder%t/%s", level, reorder, tt.name), func(t *testing.T) { + p, alpha, beta, name := newContextParser(level) + p.WithConfigFn(gflag.WithReorderArgs(reorder)) + assert.NoErr(t, p.Parse(tt.args)) + assert.Eq(t, tt.wantName, *name) + assert.Eq(t, tt.wantBool, *alpha) + assert.Eq(t, tt.wantBool, *beta) + if !slices.Equal(tt.wantArgs, p.RawArgs()) { + t.Errorf("positional arguments: want %q, got %q", tt.wantArgs, p.RawArgs()) + } + }) + } + } + } +} + +func TestParser_EnhanceShortStopsAtCommandContext(t *testing.T) { + for _, level := range []uint8{1, 2} { + for _, subcommand := range []bool{false, true} { + t.Run(fmt.Sprintf("level%d/subcommand%t", level, subcommand), func(t *testing.T) { + p, alpha, beta, name := newContextParser(level) + if subcommand { + p.SetReorderStop(func(token string) bool { return token == "serve" }) + } else { + p.WithConfigFn(gflag.WithReorderArgs(false)) + } + args := []string{"serve", "--name", "-ab", "-nfoo"} + assert.NoErr(t, p.Parse(args)) + assert.Eq(t, "", *name) + assert.False(t, *alpha) + assert.False(t, *beta) + assert.Eq(t, args, p.RawArgs()) + }) + } + } +} + +func TestParser_EnhanceShortKeepsUnknownGroupErrors(t *testing.T) { + for _, level := range []uint8{1, 2} { + for _, arg := range []string{"-az", "-an"} { + t.Run(fmt.Sprintf("level%d/%s", level, arg), func(t *testing.T) { + p, alpha, beta, name := newContextParser(level) + assert.Err(t, p.Parse([]string{arg})) + assert.False(t, *alpha) + assert.False(t, *beta) + assert.Eq(t, "", *name) + }) + } + } +} + +func TestParser_EnhanceShortKeepsNumericAliases(t *testing.T) { + for _, level := range []uint8{1, 2} { + for _, reorder := range []bool{false, true} { + for _, value := range []bool{false, true} { + t.Run(fmt.Sprintf("level%d/reorder%t/value%t", level, reorder, value), func(t *testing.T) { + p, alpha, beta, name := newContextParser(level) + p.WithConfigFn(gflag.WithReorderArgs(reorder)) + var one, two bool + p.BoolOpt(&one, "one", "1", false, "one") + p.BoolOpt(&two, "two", "2", false, "two") + args := []string{"-12", "-ab"} + if value { + args = []string{"--name", "-12", "-ab"} + } + assert.NoErr(t, p.Parse(args)) + assert.Eq(t, !value, one) + assert.Eq(t, !value, two) + assert.True(t, *alpha) + assert.True(t, *beta) + if value { + assert.Eq(t, "-12", *name) + } else { + assert.Eq(t, "", *name) + } + }) + } + } + } + + for _, reorder := range []bool{false, true} { + t.Run(fmt.Sprintf("attached/reorder%t", reorder), func(t *testing.T) { + p := gflag.New("context") + p.ParserCfg().EnhanceShort = 2 + p.WithConfigFn(gflag.WithReorderArgs(reorder)) + var name string + p.StrOpt(&name, "name", "1", "", "name") + assert.NoErr(t, p.Parse([]string{"-1Ada"})) + assert.Eq(t, "Ada", name) + }) + } +} + +func TestParser_EnhanceShortPreservesNumericTails(t *testing.T) { + for _, level := range []uint8{1, 2} { + for _, boundary := range []string{"terminator", "positional", "subcommand"} { + t.Run(fmt.Sprintf("level%d/%s", level, boundary), func(t *testing.T) { + p, alpha, beta, name := newContextParser(level) + var one, two bool + p.BoolOpt(&one, "one", "1", false, "one") + p.BoolOpt(&two, "two", "2", false, "two") + args := []string{"--", "-12", "-1.5", "-.5", "-ab"} + wantArgs := args[1:] + if boundary != "terminator" { + args[0] = "serve" + wantArgs = args + if boundary == "positional" { + p.WithConfigFn(gflag.WithReorderArgs(false)) + } else { + p.SetReorderStop(func(token string) bool { return token == "serve" }) + } + } + assert.NoErr(t, p.Parse(args)) + assert.False(t, one) + assert.False(t, two) + assert.False(t, *alpha) + assert.False(t, *beta) + assert.Eq(t, "", *name) + assert.Eq(t, wantArgs, p.RawArgs()) + }) + } + } +} diff --git a/gflag/shorts_test.go b/gflag/shorts_test.go index 3f1204a..4ad6edd 100644 --- a/gflag/shorts_test.go +++ b/gflag/shorts_test.go @@ -7,10 +7,14 @@ import ( ) func TestExpandShortArgs(t *testing.T) { - shorts := map[string]string{"a": "aaa", "u": "uuu", "x": "xxx", "O": "out", "b": "bbb"} - isBool := func(s string) bool { - return map[string]bool{"a": true, "u": true, "x": true}[s] - } + p := New("test") + var a, u, x bool + var out, b string + p.BoolOpt(&a, "aaa", "a", false, "bool a") + p.BoolOpt(&u, "uuu", "u", false, "bool u") + p.BoolOpt(&x, "xxx", "x", false, "bool x") + p.StrOpt(&out, "out", "O", "", "string out") + p.StrOpt(&b, "bbb", "b", "", "string b") tests := []struct { name string @@ -38,7 +42,7 @@ func TestExpandShortArgs(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := expandShortArgs(tt.in, shorts, isBool, tt.level) + got := expandShortArgs(tt.in, p.fSet, tt.level, nil) assert.Eq(t, tt.want, got) }) }