From 195569ddfa1af7a3d3ea6e78b50747f49d1ed02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Wed, 29 Jul 2026 00:59:43 +0800 Subject: [PATCH] fix(cli): harden parameter preparse integration --- internal/app/preparse_presentation_test.go | 91 ++++++++++ internal/generator/cmd_param_aliases/main.go | 2 +- .../generator/cmd_param_aliases/main_test.go | 9 + internal/pipeline/cobra.go | 157 +++++++++++++++++- internal/pipeline/cobra_test.go | 112 +++++++++++-- internal/pipeline/flagmatch.go | 152 +++++++++++++++++ internal/pipeline/handlers/alias.go | 33 +--- internal/pipeline/handlers/paramname.go | 51 +----- internal/pipeline/handlers/sticky.go | 67 +------- 9 files changed, 507 insertions(+), 167 deletions(-) create mode 100644 internal/app/preparse_presentation_test.go create mode 100644 internal/pipeline/flagmatch.go diff --git a/internal/app/preparse_presentation_test.go b/internal/app/preparse_presentation_test.go new file mode 100644 index 00000000..076730e0 --- /dev/null +++ b/internal/app/preparse_presentation_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "bytes" + "io" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {name: "camel case", args: []string{"--dryRun", "chat", "bot", "find", "--help"}}, + {name: "fuzzy boolean", args: []string{"--dry-rnu", "chat", "bot", "find", "--help"}}, + {name: "fuzzy value", args: []string{"--profle", "corp:user", "chat", "bot", "find", "--help"}}, + {name: "sticky value", args: []string{"--timeout30", "chat", "bot", "find", "--help"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := NewSchemaSourceRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", test.args, err) + } + if ctx == nil || ctx.Command != "dws chat bot find" || len(ctx.Corrections) == 0 { + t.Fatalf("RunPreParseArgs(%v) context = %#v", test.args, ctx) + } + if err := root.Execute(); err != nil { + t.Fatalf("corrected leading persistent flag failed: %v", err) + } + }) + } +} + +func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) { + root := NewSchemaSourceRootCommand() + args := []string{ + "chat", "message", "send", + "--user-id", "123", "--user", "456", "--text", "hi", + "--format", "table", "--debug", + } + _, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err == nil { + t.Fatal("alias/canonical conflict unexpectedly succeeded") + } + if wantsJSONErrors(root) { + t.Fatal("--format table was not applied before rendering the PreParse error") + } + if got := resolveVerbosity(root); got != apperrors.VerbosityDebug { + t.Fatalf("PreParse error verbosity = %v, want debug", got) + } + + err = apperrors.NewValidation( + err.Error(), + apperrors.WithReason("parameter_conflict"), + apperrors.WithHint("Remove the duplicate alias/canonical spelling and pass the parameter exactly once."), + apperrors.WithCause(err), + ) + var output bytes.Buffer + if printErr := printExecutionError(root, &output, &output, err); printErr != nil { + t.Fatalf("printExecutionError() error = %v", printErr) + } + rendered := output.String() + if strings.HasPrefix(strings.TrimSpace(rendered), "{") { + t.Fatalf("--format table rendered JSON:\n%s", rendered) + } + if !strings.Contains(rendered, "Reason: parameter_conflict") || !strings.Contains(rendered, "Cause:") { + t.Fatalf("--debug details missing from early error:\n%s", rendered) + } +} diff --git a/internal/generator/cmd_param_aliases/main.go b/internal/generator/cmd_param_aliases/main.go index 76e049b8..e7d4a5db 100644 --- a/internal/generator/cmd_param_aliases/main.go +++ b/internal/generator/cmd_param_aliases/main.go @@ -40,7 +40,7 @@ import ( const defaultOutput = "internal/cli/param_aliases_generated.go" var ( - newParamAliasRoot = app.NewRootCommand + newParamAliasRoot = app.NewSchemaSourceRootCommand reduceParamAliasEntries = cli.ReduceParamAliases formatParamAliasSource = format.Source writeParamAliasFile = os.WriteFile diff --git a/internal/generator/cmd_param_aliases/main_test.go b/internal/generator/cmd_param_aliases/main_test.go index eb6bbd55..cf2e548c 100644 --- a/internal/generator/cmd_param_aliases/main_test.go +++ b/internal/generator/cmd_param_aliases/main_test.go @@ -14,10 +14,19 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/spf13/cobra" ) +func TestParamAliasGeneratorUsesDistributionOwnedCommandTree(t *testing.T) { + got := reflect.ValueOf(newParamAliasRoot).Pointer() + want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer() + if got != want { + t.Fatal("parameter alias generator must use the distribution-owned Schema source command tree") + } +} + func preserveParamAliasGeneratorGlobals(t *testing.T) { t.Helper() oldArgs, oldFlags := os.Args, flag.CommandLine diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go index 63dfd18f..8e87e012 100644 --- a/internal/pipeline/cobra.go +++ b/internal/pipeline/cobra.go @@ -76,6 +76,12 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co } if err := engine.RunPhase(PreParse, ctx); err != nil { + // Cobra has not parsed persistent flags yet, but this error is rendered + // immediately by app.Execute. Prime only the presentation controls so + // --format/--debug/--verbose affect this early error exactly as they do + // errors returned after Cobra parsing. No credentials, profiles, output + // paths, or execution controls are applied here. + primeEarlyErrorPresentation(root, target, ctx.Args) slog.Debug("pipeline pre-parse", "error", err) return ctx, err } @@ -105,6 +111,7 @@ func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string { return rawArgs } + matcher := newFlagTokenMatcher(flags) out := make([]string, 0, len(rawArgs)) for index := 0; index < len(rawArgs); index++ { argument := rawArgs[index] @@ -112,7 +119,7 @@ func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string { out = append(out, rawArgs[index:]...) break } - flag, inlineValue, matched := persistentFlagToken(flags, argument) + flag, inlineValue, matched := matcher.matchTraversalToken(argument) if !matched { out = append(out, argument) continue @@ -125,14 +132,87 @@ func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string { } func persistentFlagToken(flags *pflag.FlagSet, argument string) (*pflag.Flag, bool, bool) { - if flags == nil || argument == "" || argument == "-" || argument == "--" { + return newFlagTokenMatcher(flags).matchTraversalToken(argument) +} + +type longFlagMatch struct { + flag *pflag.Flag + value string + hasValue bool + recognized bool +} + +type flagTokenMatcher struct { + byName map[string]*pflag.Flag + byShorthand map[string]*pflag.Flag + known map[string]bool + candidates []string + specByName map[string]FlagInfo +} + +func newFlagTokenMatcher(flagSets ...*pflag.FlagSet) *flagTokenMatcher { + matcher := &flagTokenMatcher{ + byName: make(map[string]*pflag.Flag), + byShorthand: make(map[string]*pflag.Flag), + known: make(map[string]bool), + specByName: make(map[string]FlagInfo), + } + for _, flags := range flagSets { + if flags == nil { + continue + } + flags.VisitAll(func(flag *pflag.Flag) { + if _, exists := matcher.byName[flag.Name]; exists { + return + } + matcher.byName[flag.Name] = flag + matcher.known[flag.Name] = true + matcher.candidates = append(matcher.candidates, flag.Name) + matcher.specByName[flag.Name] = flagInfoFromPflag(flag) + if flag.Shorthand != "" { + matcher.byShorthand[flag.Shorthand] = flag + } + }) + } + return matcher +} + +func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch { + if m == nil || !strings.HasPrefix(argument, "--") || argument == "--" { + return longFlagMatch{} + } + + canonical := argument + body := strings.TrimPrefix(canonical, "--") + name, value, hasValue := strings.Cut(body, "=") + if flag := m.byName[name]; flag != nil { + return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: true} + } + + if normalized, ok := NormalizeFlagToken(argument, m.known); ok { + canonical = normalized + } else if split, ok := SplitStickyFlag(argument, m.specByName); ok { + name = strings.TrimPrefix(split.Flag, "--") + return longFlagMatch{flag: m.byName[name], value: split.Value, hasValue: true, recognized: true} + } else if fuzzy, ok := FuzzyMatchFlag(argument, m.known, m.candidates); ok { + canonical = fuzzy + } else { + return longFlagMatch{} + } + + body = strings.TrimPrefix(canonical, "--") + name, value, hasValue = strings.Cut(body, "=") + flag := m.byName[name] + return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil} +} + +func (m *flagTokenMatcher) matchTraversalToken(argument string) (*pflag.Flag, bool, bool) { + if m == nil || argument == "" || argument == "-" || argument == "--" { return nil, false, false } if strings.HasPrefix(argument, "--") { - body := strings.TrimPrefix(argument, "--") - name, _, inlineValue := strings.Cut(body, "=") - flag := flags.Lookup(name) - return flag, inlineValue, flag != nil + match := m.matchLongToken(argument) + return match.flag, match.hasValue, match.recognized } if !strings.HasPrefix(argument, "-") { return nil, false, false @@ -141,7 +221,7 @@ func persistentFlagToken(flags *pflag.FlagSet, argument string) (*pflag.Flag, bo body := strings.TrimPrefix(argument, "-") shorthands := []rune(body) for index, shorthand := range shorthands { - flag := flags.ShorthandLookup(string(shorthand)) + flag := m.byShorthand[string(shorthand)] if flag == nil { return nil, false, false } @@ -151,7 +231,68 @@ func persistentFlagToken(flags *pflag.FlagSet, argument string) (*pflag.Flag, bo return flag, index < len(shorthands)-1, true } } - return flags.ShorthandLookup(string(shorthands[0])), true, true + return m.byShorthand[string(shorthands[0])], true, true +} + +func primeEarlyErrorPresentation(root, target *cobra.Command, rawArgs []string) { + if root == nil || target == nil || len(rawArgs) == 0 { + return + } + rootFlags := root.PersistentFlags() + presentation := map[string]bool{"format": true, "debug": true, "verbose": true} + matcher := newFlagTokenMatcher(target.Flags(), target.InheritedFlags()) + for index := 0; index < len(rawArgs); index++ { + argument := rawArgs[index] + if argument == "--" { + break + } + + if strings.HasPrefix(argument, "--") { + match := matcher.matchLongToken(argument) + if !match.recognized || match.flag == nil { + continue + } + value, hasValue := match.value, match.hasValue + if !hasValue && match.flag.NoOptDefVal != "" { + value, hasValue = match.flag.NoOptDefVal, true + } else if !hasValue && index+1 < len(rawArgs) { + index++ + value, hasValue = rawArgs[index], true + } + if presentation[match.flag.Name] && hasValue { + _ = rootFlags.Set(match.flag.Name, value) + } + continue + } + + if !strings.HasPrefix(argument, "-") || argument == "-" { + continue + } + shorthands := []rune(strings.TrimPrefix(argument, "-")) + for shorthandIndex, shorthand := range shorthands { + flag := matcher.byShorthand[string(shorthand)] + if flag == nil { + break + } + value, hasValue := "", false + if flag.NoOptDefVal != "" { + value, hasValue = flag.NoOptDefVal, true + } else if shorthandIndex+1 < len(shorthands) { + value = string(shorthands[shorthandIndex+1:]) + value = strings.TrimPrefix(value, "=") + hasValue = true + } else if index+1 < len(rawArgs) { + index++ + value, hasValue = rawArgs[index], true + } + if presentation[flag.Name] && hasValue { + _ = rootFlags.Set(flag.Name, value) + } + if flag.NoOptDefVal == "" { + break + } + } + } } // FlagInfoFromCommand extracts FlagInfo entries from a Cobra diff --git a/internal/pipeline/cobra_test.go b/internal/pipeline/cobra_test.go index f31146b7..def2b294 100644 --- a/internal/pipeline/cobra_test.go +++ b/internal/pipeline/cobra_test.go @@ -117,14 +117,19 @@ func TestCrossPlatformCoverageRunPreParseAppliesCorrectionsOnlyOnSuccess(t *test func TestRunPreParseResolvesCommandPastLeadingPersistentFlags(t *testing.T) { tests := []struct { - name string - args []string + name string + args []string + executable bool }{ - {name: "boolean long flag", args: []string{"--dry-run", "calendar", "event", "list", "--date", "2026-03-10"}}, - {name: "valued long flag", args: []string{"--profile", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}}, - {name: "valued shorthand", args: []string{"-f", "json", "calendar", "event", "list", "--date", "2026-03-10"}}, - {name: "attached shorthand", args: []string{"-fjson", "calendar", "event", "list", "--date", "2026-03-10"}}, - {name: "clustered attached shorthand", args: []string{"-vfjson", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "boolean long flag", args: []string{"--dry-run", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, + {name: "camel-case boolean long flag", args: []string{"--dryRun", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "fuzzy boolean long flag", args: []string{"--dry-rnu", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "valued long flag", args: []string{"--profile", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, + {name: "fuzzy valued long flag", args: []string{"--profle", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "sticky valued long flag", args: []string{"--timeout30", "calendar", "event", "list", "--date", "2026-03-10"}}, + {name: "valued shorthand", args: []string{"-f", "json", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, + {name: "attached shorthand", args: []string{"-fjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, + {name: "clustered attached shorthand", args: []string{"-vfjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true}, } for _, test := range tests { @@ -132,6 +137,7 @@ func TestRunPreParseResolvesCommandPastLeadingPersistentFlags(t *testing.T) { root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} root.PersistentFlags().Bool("dry-run", false, "") root.PersistentFlags().String("profile", "", "") + root.PersistentFlags().Int("timeout", 0, "") root.PersistentFlags().StringP("format", "f", "json", "") root.PersistentFlags().BoolP("verbose", "v", false, "") @@ -173,11 +179,68 @@ func TestRunPreParseResolvesCommandPastLeadingPersistentFlags(t *testing.T) { if ctx == nil || len(ctx.Corrections) != 1 { t.Fatalf("RunPreParseArgs() context = %#v", ctx) } - if err := root.Execute(); err != nil { - t.Fatalf("corrected command failed: %v", err) + if test.executable { + if err := root.Execute(); err != nil { + t.Fatalf("corrected command failed: %v", err) + } + if value != "2026-03-10" { + t.Fatalf("canonical --start value = %q", value) + } } - if value != "2026-03-10" { - t.Fatalf("canonical --start value = %q", value) + }) + } +} + +func TestRunPreParsePrimesPresentationFlagsForEarlyErrors(t *testing.T) { + tests := []struct { + name string + args []string + wantFormat string + wantDebug bool + wantVerbose bool + }{ + { + name: "canonical flags after command", + args: []string{"child", "--name", "demo", "--format", "table", "--debug"}, + wantFormat: "table", + wantDebug: true, + }, + { + name: "corrected leading flags", + args: []string{"--dryRun", "--formt=pretty", "--verbos", "child", "--name", "demo"}, + wantFormat: "pretty", + wantVerbose: true, + }, + { + name: "clustered shorthands", + args: []string{"-vfraw", "child", "--name", "demo"}, + wantFormat: "raw", + wantVerbose: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().StringP("format", "f", "json", "") + root.PersistentFlags().Bool("debug", false, "") + root.PersistentFlags().BoolP("verbose", "v", false, "") + child := &cobra.Command{Use: "child"} + child.Flags().String("name", "", "") + root.AddCommand(child) + + engine := NewEngine() + engine.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("early") })) + ctx, err := RunPreParseArgs(root, engine, test.args) + if err == nil || ctx == nil { + t.Fatalf("RunPreParseArgs() = %#v, %v; want early error with context", ctx, err) + } + format, _ := root.PersistentFlags().GetString("format") + debug, _ := root.PersistentFlags().GetBool("debug") + verbose, _ := root.PersistentFlags().GetBool("verbose") + if format != test.wantFormat || debug != test.wantDebug || verbose != test.wantVerbose { + t.Fatalf("presentation flags = format:%q debug:%v verbose:%v; want %q/%v/%v", format, debug, verbose, test.wantFormat, test.wantDebug, test.wantVerbose) } }) } @@ -201,6 +264,12 @@ func TestCommandTraversalFlagTokenEdges(t *testing.T) { if flag, inline, matched := persistentFlagToken(nil, "--verbose"); flag != nil || inline || matched { t.Fatalf("nil flag set matched: %#v, %v, %v", flag, inline, matched) } + if flag, inline, matched := (*flagTokenMatcher)(nil).matchTraversalToken(""); flag != nil || inline || matched { + t.Fatalf("nil matcher matched: %#v, %v, %v", flag, inline, matched) + } + if match := (*flagTokenMatcher)(nil).matchLongToken("--verbose"); match.recognized { + t.Fatalf("nil long matcher matched: %#v", match) + } if flag, inline, matched := persistentFlagToken(root.PersistentFlags(), "-x"); flag != nil || inline || matched { t.Fatalf("unknown shorthand matched: %#v, %v, %v", flag, inline, matched) } @@ -209,3 +278,24 @@ func TestCommandTraversalFlagTokenEdges(t *testing.T) { t.Fatalf("boolean shorthand cluster = %#v, %v, %v", flag, inline, matched) } } + +func TestPrimeEarlyErrorPresentationEdges(t *testing.T) { + primeEarlyErrorPresentation(nil, nil, nil) + + root := &cobra.Command{Use: "root"} + root.PersistentFlags().StringP("format", "f", "json", "") + root.PersistentFlags().Bool("debug", false, "") + child := &cobra.Command{Use: "child"} + child.Flags().String("name", "", "") + root.AddCommand(child) + + primeEarlyErrorPresentation(root, child, []string{ + "child", "--unknown", "value", "-x", "--name", "demo", + "-f", "table", "--", "--debug", + }) + format, _ := root.PersistentFlags().GetString("format") + debug, _ := root.PersistentFlags().GetBool("debug") + if format != "table" || debug { + t.Fatalf("presentation after edge argv = format:%q debug:%v", format, debug) + } +} diff --git a/internal/pipeline/flagmatch.go b/internal/pipeline/flagmatch.go new file mode 100644 index 00000000..edc61337 --- /dev/null +++ b/internal/pipeline/flagmatch.go @@ -0,0 +1,152 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" +) + +const maxFlagEditDistance = 2 + +// NormalizeFlagToken folds a long flag's morphological spelling and returns a +// canonical token only when the folded name is a real flag. Both the normal +// PreParse handler chain and Cobra command traversal use this primitive so a +// spelling accepted after a command is also recognised before it. +func NormalizeFlagToken(argument string, known map[string]bool) (string, bool) { + if !strings.HasPrefix(argument, "--") { + return "", false + } + + bare := argument[2:] + if bare == "" { + return "", false + } + + var suffix string + if index := strings.IndexByte(bare, '='); index >= 0 { + suffix = bare[index:] + bare = bare[:index] + } + if known[bare] { + return "", false + } + + normalized := cmdutil.Morph(bare) + if normalized == bare || !known[normalized] { + return "", false + } + return "--" + normalized + suffix, true +} + +// StickyFlagPair is the canonical flag and detached value resolved from one +// glued long-flag token. +type StickyFlagPair struct { + Flag string + Value string +} + +// SplitStickyFlag splits a safely recognisable glued flag/value token. The +// suffix must satisfy the real flag's type/format/enum contract, preventing a +// typo from being reinterpreted as data. +func SplitStickyFlag(argument string, specByName map[string]FlagInfo) (StickyFlagPair, bool) { + if !strings.HasPrefix(argument, "--") || strings.Contains(argument, "=") { + return StickyFlagPair{}, false + } + + bare := argument[2:] + if bare == "" { + return StickyFlagPair{}, false + } + if _, ok := specByName[bare]; ok { + return StickyFlagPair{}, false + } + if _, ok := specByName[cmdutil.Morph(bare)]; ok { + return StickyFlagPair{}, false + } + + bestLength := 0 + bestFlag := "" + for index := len(bare) - 1; index >= 1; index-- { + prefix := bare[:index] + matchedFlag := "" + if _, ok := specByName[prefix]; ok { + matchedFlag = prefix + } else if normalized := cmdutil.Morph(prefix); normalized != "" { + if _, ok := specByName[normalized]; ok { + matchedFlag = normalized + } + } + if matchedFlag != "" && index > bestLength { + bestLength = index + bestFlag = matchedFlag + break + } + } + if bestFlag == "" { + return StickyFlagPair{}, false + } + + suffix := bare[bestLength:] + spec := specByName[bestFlag] + if !cmdutil.SuffixLooksLikeValue(suffix, spec.Type, spec.Format, spec.Enum) { + return StickyFlagPair{}, false + } + return StickyFlagPair{Flag: "--" + bestFlag, Value: suffix}, true +} + +// FuzzyMatchFlag returns the unique closest real long flag within the +// conservative edit-distance threshold used by ParamNameHandler. +func FuzzyMatchFlag(argument string, known map[string]bool, candidates []string) (string, bool) { + if !strings.HasPrefix(argument, "--") { + return "", false + } + + bare := argument[2:] + if bare == "" { + return "", false + } + + var suffix string + if index := strings.IndexByte(bare, '='); index >= 0 { + suffix = bare[index:] + bare = bare[:index] + } + if known[bare] { + return "", false + } + + threshold := maxFlagEditDistance + if len(bare) <= 3 { + threshold = 1 + } + bestDistance := threshold + 1 + bestMatch := "" + ambiguous := false + for _, candidate := range candidates { + distance := cmdutil.LevenshteinDist(bare, candidate) + if distance < bestDistance { + bestDistance = distance + bestMatch = candidate + ambiguous = false + } else if distance == bestDistance && candidate != bestMatch { + ambiguous = true + } + } + if bestDistance > threshold || ambiguous || bestMatch == "" { + return "", false + } + return "--" + bestMatch + suffix, true +} diff --git a/internal/pipeline/handlers/alias.go b/internal/pipeline/handlers/alias.go index 89c898a1..073ec9aa 100644 --- a/internal/pipeline/handlers/alias.go +++ b/internal/pipeline/handlers/alias.go @@ -14,8 +14,6 @@ package handlers import ( - "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" ) @@ -67,36 +65,7 @@ func (AliasHandler) Handle(ctx *pipeline.Context) error { // normalised to a known flag name. It handles both bare flags and // "--flag=value" syntax. func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) { - if !strings.HasPrefix(arg, "--") { - return "", false - } - - bare := arg[2:] - if bare == "" { - return "", false - } - - // Handle --flag=value syntax: split, normalise the key, reassemble. - var suffix string - if idx := strings.IndexByte(bare, '='); idx >= 0 { - suffix = bare[idx:] // includes "=" - bare = bare[:idx] - } - - // Already a known flag in its current form — no change needed. - if known[bare] { - return "", false - } - - normalised := toKebabCase(bare) - if normalised == bare { - return "", false - } - if !known[normalised] { - return "", false - } - - return "--" + normalised + suffix, true + return pipeline.NormalizeFlagToken(arg, known) } // toKebabCase converts a string from camelCase, PascalCase, or snake_case to diff --git a/internal/pipeline/handlers/paramname.go b/internal/pipeline/handlers/paramname.go index 01e25ed4..9e3744a5 100644 --- a/internal/pipeline/handlers/paramname.go +++ b/internal/pipeline/handlers/paramname.go @@ -14,8 +14,6 @@ package handlers import ( - "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" ) @@ -35,8 +33,6 @@ import ( // done and fuzzy matching only handles genuine near-misses. type ParamNameHandler struct{} -const maxEditDistance = 2 - func (ParamNameHandler) Name() string { return "paramname" } func (ParamNameHandler) Phase() pipeline.Phase { return pipeline.PreParse } @@ -79,50 +75,5 @@ func (ParamNameHandler) Handle(ctx *pipeline.Context) error { // tryFuzzyMatch attempts to correct an unrecognised "--flag" token by // finding the closest known flag name within the edit distance threshold. func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (string, bool) { - if !strings.HasPrefix(arg, "--") { - return "", false - } - - bare := arg[2:] - if bare == "" { - return "", false - } - - // Handle --flag=value syntax. - var suffix string - if idx := strings.IndexByte(bare, '='); idx >= 0 { - suffix = bare[idx:] - bare = bare[:idx] - } - - // Already known — nothing to fix. - if known[bare] { - return "", false - } - - threshold := maxEditDistance - if len(bare) <= 3 { - threshold = 1 - } - - bestDist := threshold + 1 - bestMatch := "" - ambiguous := false - - for _, candidate := range candidates { - dist := cmdutil.LevenshteinDist(bare, candidate) - if dist < bestDist { - bestDist = dist - bestMatch = candidate - ambiguous = false - } else if dist == bestDist && candidate != bestMatch { - ambiguous = true - } - } - - if bestDist > threshold || ambiguous || bestMatch == "" { - return "", false - } - - return "--" + bestMatch + suffix, true + return pipeline.FuzzyMatchFlag(arg, known, candidates) } diff --git a/internal/pipeline/handlers/sticky.go b/internal/pipeline/handlers/sticky.go index a986f338..32360c0e 100644 --- a/internal/pipeline/handlers/sticky.go +++ b/internal/pipeline/handlers/sticky.go @@ -14,8 +14,6 @@ package handlers import ( - "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" ) @@ -87,69 +85,8 @@ type stickyPair struct { // that camelCase+glued values like "--pageSize50" are correctly // split to "--page-size", "50". func trySplitSticky(arg string, specByName map[string]pipeline.FlagInfo) (stickyPair, bool) { - if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") { - return stickyPair{}, false - } - - // Strip "--" prefix to work with the bare token. - bare := arg[2:] - if bare == "" { - return stickyPair{}, false - } - - // If the whole token is a known flag, it is not sticky — it is - // a normal flag expecting a separate value token. - if _, ok := specByName[bare]; ok { - return stickyPair{}, false - } - if _, ok := specByName[toKebabCase(bare)]; ok { - return stickyPair{}, false - } - - // Try longest-prefix match: walk from len-1 down to 1, looking - // for the longest known flag that is a prefix of bare. For each - // candidate prefix, try both the raw form and kebab-case form. - bestLen := 0 - bestFlag := "" - for i := len(bare) - 1; i >= 1; i-- { - prefix := bare[:i] - - matchedFlag := "" - if _, ok := specByName[prefix]; ok { - matchedFlag = prefix - } else { - kebab := toKebabCase(prefix) - if kebab != "" { - if _, ok := specByName[kebab]; ok { - matchedFlag = kebab - } - } - } - - if matchedFlag != "" && i > bestLen { - bestLen = i - bestFlag = matchedFlag - break // longest first since we walk from the end - } - } - if bestFlag == "" { - return stickyPair{}, false - } - - suffix := bare[bestLen:] - // Guard: only split if the suffix plausibly looks like a value - // for this flag's declared type/format/enum. Otherwise leave the - // token untouched so Cobra reports "unknown flag" instead of - // silently corrupting the value. - fi := specByName[bestFlag] - if !cmdutil.SuffixLooksLikeValue(suffix, fi.Type, fi.Format, fi.Enum) { - return stickyPair{}, false - } - - return stickyPair{ - flag: "--" + bestFlag, - value: suffix, - }, true + pair, ok := pipeline.SplitStickyFlag(arg, specByName) + return stickyPair{flag: pair.Flag, value: pair.Value}, ok } // buildFlagSpecIndex creates an index of known flag names (without "--"