feat(shortcuts): typed @file/stdin input + enum completion/help parity

Bring TypedShortcut[T] to parity with legacy common.Shortcut on two flag
capabilities that were silently missing on the typed path:

- @file / stdin: declare `input:"file,stdin"` on an Args field; the binder
  resolves @path (file) and - (stdin) before binding, recursing into OneOf
  buckets and groups. resolveInputForFlag is extracted from the legacy
  resolveInputFlags so both paths behave identically.
- enum: registerLeaf registers shell completion and typed help renders the
  candidate list, matching legacy. enum/input tags on non-string fields now
  error at Mount time instead of being silently skipped at runtime.

Legacy behavior unchanged. Adds binder_input_enum_test.go (11 cases).
This commit is contained in:
shanglei
2026-05-29 14:55:40 +08:00
parent a510e07dfc
commit c4eb18cecc
5 changed files with 375 additions and 45 deletions

View File

@@ -13,6 +13,7 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
// fieldSpec is the binder's intermediate representation of one Args field.
@@ -24,6 +25,7 @@ type fieldSpec struct {
Description string
DefaultValue string
EnumValues []string
Input []string
Required bool
IsOneOfBkt bool
IsGroup bool
@@ -77,6 +79,18 @@ func parseFieldSpec(f reflect.StructField) (fieldSpec, error) {
if _, has := f.Tag.Lookup("required"); has {
spec.Required = true
}
if input := f.Tag.Get("input"); input != "" {
for _, src := range strings.Split(input, ",") {
switch src = strings.TrimSpace(src); src {
case "":
// tolerate stray commas
case File, Stdin:
spec.Input = append(spec.Input, src)
default:
return spec, fmt.Errorf("field %s: unknown input source %q (allowed: %q, %q)", f.Name, src, File, Stdin)
}
}
}
ft := f.Type
spec.FieldType = ft
if ft.Kind() == reflect.Ptr {
@@ -92,6 +106,20 @@ func parseFieldSpec(f reflect.StructField) (fieldSpec, error) {
} else {
spec.IsGroup = true
}
return spec, nil
}
// Leaf field: enum / input tags only make sense on string-kinded leaves
// (plain string or string-alias types like ChatID). Reject them on
// int / bool / other kinds at Mount time instead of silently skipping the
// check at runtime — enum validation reads the flag via GetString and
// @file resolution requires a string flag.
if ft.Kind() != reflect.String {
if len(spec.EnumValues) > 0 {
return spec, fmt.Errorf("field %s: enum tag is only supported on string fields", f.Name)
}
if len(spec.Input) > 0 {
return spec, fmt.Errorf("field %s: input tag is only supported on string fields", f.Name)
}
}
return spec, nil
}
@@ -141,6 +169,46 @@ func registerLeaf(cmd *cobra.Command, s fieldSpec, t reflect.Type) error {
if s.Required {
_ = cmd.MarkFlagRequired(s.FlagName)
}
if len(s.EnumValues) > 0 {
vals := s.EnumValues
cmdutil.RegisterFlagCompletion(cmd, s.FlagName, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return vals, cobra.ShellCompDirectiveNoFileComp
})
}
return nil
}
// resolveTypedInputs applies @file / stdin resolution to every leaf flag that
// declared an `input:"file,stdin"` tag, recursing into OneOf buckets and groups
// (cobra flags are flat, so a nested variant's flag resolves the same way). It
// runs before bindFlags so the file/stdin content is read back into the cobra
// flag and then bound into the Args struct. This is the typed counterpart of
// runShortcut's resolveInputFlags, which only sees the legacy shell's (empty)
// Flags slice — both ultimately call resolveInputForFlag.
func resolveTypedInputs(rctx *RuntimeContext, specs []fieldSpec) error {
stdinUsed := false
return resolveTypedInputsRec(rctx, specs, &stdinUsed)
}
func resolveTypedInputsRec(rctx *RuntimeContext, specs []fieldSpec, stdinUsed *bool) error {
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := resolveTypedInputsRec(rctx, inner, stdinUsed); err != nil {
return err
}
continue
}
if s.FlagName == "" || len(s.Input) == 0 {
continue
}
if err := resolveInputForFlag(rctx, s.FlagName, s.Input, stdinUsed); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
)
// newTypedInputRuntime registers the given specs on a fresh cobra command,
// parses argv, and returns a RuntimeContext wired with a fake stdin — the
// typed-binder analogue of newTestRuntimeWithStdin in runner_input_test.go.
func newTypedInputRuntime(t *testing.T, specs []fieldSpec, argv []string, stdin string) *RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags(argv); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
return &RuntimeContext{
Cmd: cmd,
Factory: &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{In: strings.NewReader(stdin)},
},
}
}
// --- @file / stdin on typed shortcuts -------------------------------------
type fileInputArgs struct {
Content string `flag:"content" input:"file,stdin"`
}
func TestResolveTypedInputs_File(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
body := "## Title\n\nbody from a file\n"
if err := os.WriteFile("body.md", []byte(body), 0o644); err != nil {
t.Fatal(err)
}
specs, err := walkArgs(reflect.TypeOf(&fileInputArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
rt := newTypedInputRuntime(t, specs, []string{"--content", "@body.md"}, "")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &fileInputArgs{}
if err := bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if out.Content != body {
t.Errorf("Content = %q, want file body %q", out.Content, body)
}
}
func TestResolveTypedInputs_Stdin(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
rt := newTypedInputRuntime(t, specs, []string{"--content", "-"}, "piped stdin body")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &fileInputArgs{}
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
if out.Content != "piped stdin body" {
t.Errorf("Content = %q, want stdin body", out.Content)
}
}
func TestResolveTypedInputs_PlainValueUnchanged(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
rt := newTypedInputRuntime(t, specs, []string{"--content", "literal text"}, "")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &fileInputArgs{}
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
if out.Content != "literal text" {
t.Errorf("Content = %q, want unchanged literal", out.Content)
}
}
// A OneOf variant flag that declares @file/stdin must resolve too — the binder
// recurses into buckets because cobra flags are flat regardless of nesting.
type nestedInputVariant struct {
Body *string `flag:"body" input:"file,stdin"`
Raw *string `flag:"raw"`
}
func (nestedInputVariant) OneOf() {}
type nestedInputArgs struct {
Content nestedInputVariant
}
func TestResolveTypedInputs_NestedInOneOf(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
body := "nested variant body\n"
if err := os.WriteFile("v.md", []byte(body), 0o644); err != nil {
t.Fatal(err)
}
specs, err := walkArgs(reflect.TypeOf(&nestedInputArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
rt := newTypedInputRuntime(t, specs, []string{"--body", "@v.md"}, "")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &nestedInputArgs{}
if err := bindBuckets(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindBuckets: %v", err)
}
if out.Content.Body == nil {
t.Fatal("Content.Body is nil — variant not bound")
}
if *out.Content.Body != body {
t.Errorf("Content.Body = %q, want file body %q", *out.Content.Body, body)
}
}
// --- Mount-time validation: enum / input only on string leaves ------------
type enumOnIntArgs struct {
Level int `flag:"level" enum:"1,2,3"`
}
func TestWalkArgs_EnumOnNonStringErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&enumOnIntArgs{}))
if err == nil {
t.Fatal("expected error for enum on int field")
}
if !strings.Contains(err.Error(), "enum tag is only supported on string") {
t.Errorf("unexpected error: %v", err)
}
}
type inputOnBoolArgs struct {
Flag bool `flag:"flag" input:"file"`
}
func TestWalkArgs_InputOnNonStringErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&inputOnBoolArgs{}))
if err == nil {
t.Fatal("expected error for input on bool field")
}
if !strings.Contains(err.Error(), "input tag is only supported on string") {
t.Errorf("unexpected error: %v", err)
}
}
type unknownInputSrcArgs struct {
Content string `flag:"content" input:"bogus"`
}
func TestWalkArgs_UnknownInputSource(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&unknownInputSrcArgs{}))
if err == nil {
t.Fatal("expected error for unknown input source")
}
if !strings.Contains(err.Error(), "unknown input source") {
t.Errorf("unexpected error: %v", err)
}
}
// A string-alias enum field (e.g. an argstype primitive) must be accepted.
type enumOnStringArgs struct {
Priority string `flag:"priority" enum:"low,normal,high"`
}
func TestWalkArgs_EnumOnStringOK(t *testing.T) {
specs, err := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(specs) != 1 || len(specs[0].EnumValues) != 3 {
t.Errorf("enum not parsed: %+v", specs)
}
}
// --- enum shell completion + help candidate rendering ---------------------
func TestRegisterLeaf_EnumCompletion(t *testing.T) {
prev := cmdutil.FlagCompletionsEnabled()
cmdutil.SetFlagCompletionsEnabled(true)
defer cmdutil.SetFlagCompletionsEnabled(prev)
specs, _ := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
cmd := &cobra.Command{Use: "test"}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
fn, ok := cmd.GetFlagCompletionFunc("priority")
if !ok || fn == nil {
t.Fatal("expected a completion func registered for --priority")
}
vals, _ := fn(cmd, nil, "")
want := map[string]bool{"low": true, "normal": true, "high": true}
if len(vals) != 3 {
t.Fatalf("completion candidates = %v, want low/normal/high", vals)
}
for _, v := range vals {
if !want[v] {
t.Errorf("unexpected completion candidate %q", v)
}
}
}
func TestFormatLeafLine_EnumCandidates(t *testing.T) {
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "normal", "high"}}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(one of: low|normal|high)") {
t.Errorf("help line missing enum candidates: %q", line)
}
}
func TestFormatLeafLine_EnumAndDefault(t *testing.T) {
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "high"}, DefaultValue: "low"}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(one of: low|high)") || !strings.Contains(line, `(default "low")`) {
t.Errorf("help line missing enum or default: %q", line)
}
}

View File

@@ -855,56 +855,69 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
stdinUsed := false
for _, fl := range flags {
if len(fl.Input) == 0 {
continue
if err := resolveInputForFlag(rctx, fl.Name, fl.Input, &stdinUsed); err != nil {
return err
}
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
}
return nil
}
// resolveInputForFlag applies @file / stdin / @@-escape resolution to a single
// string flag. sources lists the accepted inputs (File / Stdin); empty sources
// or an empty/plain flag value is a no-op. stdinUsed is shared across all flags
// of one invocation so stdin (-) is consumed by at most one flag. Both the
// legacy resolveInputFlags loop and the typed binder (resolveTypedInputs) call
// through here, so @file / stdin behaves identically on TypedShortcut[T].
func resolveInputForFlag(rctx *RuntimeContext, name string, sources []string, stdinUsed *bool) error {
if len(sources) == 0 {
return nil
}
raw, err := rctx.Cmd.Flags().GetString(name)
if err != nil {
return FlagErrorf("--%s: Input is only supported for string flags", name)
}
if raw == "" {
return nil
}
// stdin: -
if raw == "-" {
if !slices.Contains(sources, Stdin) {
return FlagErrorf("--%s does not support stdin (-)", name)
}
if *stdinUsed {
return FlagErrorf("--%s: stdin (-) can only be used by one flag", name)
}
*stdinUsed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return FlagErrorf("--%s: Input is only supported for string flags", fl.Name)
}
if raw == "" {
continue
return FlagErrorf("--%s: failed to read from stdin: %v", name, err)
}
rctx.Cmd.Flags().Set(name, string(data))
return nil
}
// stdin: -
if raw == "-" {
if !slices.Contains(fl.Input, Stdin) {
return FlagErrorf("--%s does not support stdin (-)", fl.Name)
}
if stdinUsed {
return FlagErrorf("--%s: stdin (-) can only be used by one flag", fl.Name)
}
stdinUsed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return FlagErrorf("--%s: failed to read from stdin: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue
}
// escape: @@ → literal @
if strings.HasPrefix(raw, "@@") {
rctx.Cmd.Flags().Set(name, raw[1:]) // strip first @
return nil
}
// escape: @@ → literal @
if strings.HasPrefix(raw, "@@") {
rctx.Cmd.Flags().Set(fl.Name, raw[1:]) // strip first @
continue
// file: @path
if strings.HasPrefix(raw, "@") {
if !slices.Contains(sources, File) {
return FlagErrorf("--%s does not support file input (@path)", name)
}
// file: @path
if strings.HasPrefix(raw, "@") {
if !slices.Contains(fl.Input, File) {
return FlagErrorf("--%s does not support file input (@path)", fl.Name)
}
path := strings.TrimSpace(raw[1:])
if path == "" {
return FlagErrorf("--%s: file path cannot be empty after @", fl.Name)
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return FlagErrorf("--%s: %v", fl.Name, err)
}
rctx.Cmd.Flags().Set(fl.Name, string(data))
continue
path := strings.TrimSpace(raw[1:])
if path == "" {
return FlagErrorf("--%s: file path cannot be empty after @", name)
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return FlagErrorf("--%s: %v", name, err)
}
rctx.Cmd.Flags().Set(name, string(data))
return nil
}
return nil
}

View File

@@ -48,6 +48,9 @@ func buildTypedHelp(specs []fieldSpec, examples []HelpExample) func(*cobra.Comma
// same information density as cobra's legacy default help.
func formatLeafLine(indent string, s fieldSpec) string {
line := fmt.Sprintf("%s--%s %s", indent, s.FlagName, s.Description)
if len(s.EnumValues) > 0 {
line += fmt.Sprintf(" (one of: %s)", strings.Join(s.EnumValues, "|"))
}
if s.DefaultValue != "" {
line += fmt.Sprintf(" (default %q)", s.DefaultValue)
}

View File

@@ -118,9 +118,12 @@ func (s TypedShortcut[T]) MountWithContext(ctx context.Context, parent *cobra.Co
//
// 1. identity / scopes / runtime — handled by Shortcut.runShortcut
// 2. validateEnumFlags — Shortcut machinery
// 3. resolveInputFlags — @file / stdin
// 3. resolveInputFlags — @file / stdin (legacy shell only; the
// synthesized shell's Flags slice is empty, so this is a no-op for typed —
// typed flags declare inputs via the `input` tag, resolved in step 5)
// 4. ValidateJqFlags — --jq
// 5. shell.Validate — binds T, runs Normalize / ValidateValue /
// 5. shell.Validate — resolveTypedInputs (@file / stdin for
// `input`-tagged flags), binds T, runs Normalize / ValidateValue /
// framework rules / ArgsValidator / user-typed Validate
// 6. --dry-run gate — shell.DryRun reads typed args from rt
// 7. high-risk-write confirmation — when Risk == "high-risk-write"
@@ -161,6 +164,12 @@ func mountTyped[T any](ctx context.Context, parent *cobra.Command, f *cmdutil.Fa
}
},
Validate: func(c context.Context, rt *RuntimeContext) error {
// @file / stdin resolution for flags that declared an `input` tag.
// Runs before bindFlags so the resolved file/stdin content is what
// gets bound into the Args struct.
if err := resolveTypedInputs(rt, specs); err != nil {
return err
}
args := reflect.New(argsType.Elem()).Interface().(T)
argsVal := reflect.ValueOf(args).Elem()
if err := bindFlags(rt.Cmd, argsVal, specs); err != nil {