Skip to content

Commit eb301d0

Browse files
committed
feat: add dependency injection with Bind and simplify Runner interface
- Change Runner.Run signature from (ctx, args) to (ctx) - Add cli.Args type for positional argument injection - Add cli.Bind(v) for injecting dependencies by concrete type - Add cli.BindTo(v, iface) for injecting as interface type - Auto-strip program name from args (can pass os.Args directly) - Fields with flag/arg/env tags are not eligible for injection - Update all tests and documentation
1 parent d477968 commit eb301d0

20 files changed

Lines changed: 1088 additions & 575 deletions

README.md

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ type GreetCmd struct {
3232
Name string `flag:"name" short:"n" default:"World" help:"Who to greet"`
3333
}
3434

35-
func (g *GreetCmd) Run(_ context.Context, _ []string) error {
35+
func (g *GreetCmd) Run(_ context.Context) error {
3636
fmt.Printf("Hello, %s!\n", g.Name)
3737
return nil
3838
}
3939

4040
func main() {
41-
cli.ExecuteAndExit(context.Background(), &GreetCmd{}, os.Args[1:])
41+
cli.ExecuteAndExit(context.Background(), &GreetCmd{}, os.Args)
4242
}
4343
```
4444

@@ -60,14 +60,16 @@ Every command must implement `Runner`:
6060

6161
```go
6262
type Runner interface {
63-
Run(ctx context.Context, args []string) error
63+
Run(ctx context.Context) error
6464
}
6565
```
6666

67+
Positional arguments are available via the `Args` field (see [Positional Arguments](#positional-arguments)).
68+
6769
For simple cases, `RunFunc` adapts a plain function:
6870

6971
```go
70-
cmd := cli.RunFunc(func(ctx context.Context, args []string) error {
72+
cmd := cli.RunFunc(func(ctx context.Context) error {
7173
fmt.Println("Hello!")
7274
return nil
7375
})
@@ -246,8 +248,8 @@ Implement `Parent` to declare subcommands:
246248
```go
247249
type App struct{}
248250

249-
func (a *App) Run(_ context.Context, _ []string) error { return nil }
250-
func (a *App) Name() string { return "myapp" }
251+
func (a *App) Run(_ context.Context) error { return nil }
252+
func (a *App) Name() string { return "myapp" }
251253
func (a *App) Subcommands() []cli.Runner {
252254
return []cli.Runner{&ServeCmd{}, &MigrateCmd{}}
253255
}
@@ -372,9 +374,9 @@ Implement `Middlewarer` to wrap the run function:
372374
func (c *Cmd) Middleware() []func(next cli.RunFunc) cli.RunFunc {
373375
return []func(next cli.RunFunc) cli.RunFunc{
374376
func(next cli.RunFunc) cli.RunFunc {
375-
return func(ctx context.Context, args []string) error {
377+
return func(ctx context.Context) error {
376378
start := time.Now()
377-
err := next(ctx, args)
379+
err := next(ctx)
378380
log.Printf("took %s", time.Since(start))
379381
return err
380382
}
@@ -398,16 +400,69 @@ $ app -vvv # works with counters too
398400

399401
All flags except the last must be bool or counter (no value). The last flag may take a value from the next argument.
400402

403+
## Positional Arguments
404+
405+
Commands that need positional arguments declare an `Args` field:
406+
407+
```go
408+
type CopyCmd struct {
409+
Args cli.Args
410+
Verbose bool `flag:"verbose" short:"v"`
411+
}
412+
413+
func (c *CopyCmd) Run(_ context.Context) error {
414+
for _, file := range c.Args {
415+
fmt.Println("copying", file)
416+
}
417+
return nil
418+
}
419+
```
420+
421+
```
422+
$ copy -v file1.txt file2.txt
423+
copying file1.txt
424+
copying file2.txt
425+
```
426+
427+
## Dependency Injection with Bind
428+
429+
Use `cli.Bind` to inject dependencies into commands by type:
430+
431+
```go
432+
func main() {
433+
db := openDB()
434+
cache := redis.New()
435+
436+
cli.ExecuteAndExit(ctx, &App{}, os.Args,
437+
cli.Bind(db), // inject *sql.DB
438+
cli.BindTo(cache, (*Cache)(nil)), // inject as interface
439+
)
440+
}
441+
442+
type ServeCmd struct {
443+
DB *sql.DB // injected by type match
444+
Cache Cache // injected as interface
445+
Port int `flag:"port" default:"8080"`
446+
}
447+
448+
func (s *ServeCmd) Run(_ context.Context) error {
449+
// s.DB and s.Cache are ready to use
450+
return nil
451+
}
452+
```
453+
454+
Fields with `flag:`, `arg:`, or `env:` tags are not eligible for injection.
455+
401456
## Error Handling
402457

403458
`Execute` returns errors. `ExecuteAndExit` wraps it with `os.Exit`:
404459

405460
```go
406-
// In main:
407-
cli.ExecuteAndExit(ctx, root, os.Args[1:])
461+
// In main — pass os.Args directly, the framework strips the program name:
462+
cli.ExecuteAndExit(ctx, root, os.Args)
408463

409464
// Or handle errors yourself:
410-
if err := cli.Execute(ctx, root, os.Args[1:]); err != nil {
465+
if err := cli.Execute(ctx, root, os.Args); err != nil {
411466
log.Fatal(err)
412467
}
413468
```
@@ -439,9 +494,9 @@ cli.Execute(ctx, root, args,
439494
)
440495
```
441496

442-
## Dependency Injection
497+
## Manual Dependency Injection
443498

444-
No framework magic. Two patterns:
499+
Beyond `cli.Bind`, two manual patterns work well:
445500

446501
**Constructor wiring** — parent builds children with dependencies:
447502

@@ -484,6 +539,8 @@ func (a *App) Before(ctx context.Context) (context.Context, error) {
484539
| `WithSuggest(bool)` | `true` | "Did you mean?" suggestions |
485540
| `WithShortOptionHandling(bool)` | `false` | POSIX short option combining |
486541
| `WithPrefixMatching(bool)` | `false` | Unique prefix subcommand matching |
542+
| `Bind(v)` || Inject dependency by concrete type |
543+
| `BindTo(v, iface)` || Inject dependency as interface type |
487544

488545
## Contributing
489546

bind.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package cli
2+
3+
import (
4+
"path/filepath"
5+
"reflect"
6+
"strings"
7+
)
8+
9+
// Args is a slice of positional arguments. Commands can declare an Args field
10+
// to receive positional arguments via dependency injection:
11+
//
12+
// type ServeCmd struct {
13+
// Args cli.Args
14+
// Port int `flag:"port"`
15+
// }
16+
//
17+
// func (s *ServeCmd) Run(ctx context.Context) error {
18+
// for _, file := range s.Args {
19+
// // process file
20+
// }
21+
// }
22+
type Args []string
23+
24+
// binding holds a registered dependency and its target type.
25+
type binding struct {
26+
value any
27+
targetType reflect.Type // interface type for BindTo, nil for Bind
28+
}
29+
30+
// Bind registers a dependency for injection into command structs.
31+
// The value is matched by its concrete type against struct fields.
32+
//
33+
// db := openDB()
34+
// cli.Execute(ctx, root, args, cli.Bind(db))
35+
//
36+
// Commands declare the dependency as a struct field (no tag needed):
37+
//
38+
// type ServeCmd struct {
39+
// DB *sql.DB
40+
// Port int `flag:"port"`
41+
// }
42+
//
43+
// func (s *ServeCmd) Run(ctx context.Context) error {
44+
// s.DB.Query("SELECT ...") // ready to use
45+
// }
46+
//
47+
// Fields with flag:, arg:, or env: tags are not eligible for injection.
48+
func Bind(v any) Option {
49+
return func(o *options) {
50+
o.bindings = append(o.bindings, binding{value: v})
51+
}
52+
}
53+
54+
// BindTo registers a dependency as a specific interface type. Use this when
55+
// you want commands to depend on an interface rather than a concrete type.
56+
//
57+
// cli.Execute(ctx, root, args,
58+
// cli.BindTo(redisCache, (*Cache)(nil)),
59+
// )
60+
//
61+
// Commands declare the interface type:
62+
//
63+
// type ServeCmd struct {
64+
// Store Cache
65+
// }
66+
func BindTo(v any, iface any) Option {
67+
ifaceType := reflect.TypeOf(iface)
68+
if ifaceType.Kind() == reflect.Ptr {
69+
ifaceType = ifaceType.Elem()
70+
}
71+
return func(o *options) {
72+
o.bindings = append(o.bindings, binding{
73+
value: v,
74+
targetType: ifaceType,
75+
})
76+
}
77+
}
78+
79+
// injectBindings populates injectable fields on all commands in the chain.
80+
// A field is injectable if it has no flag:, arg:, or env: tag.
81+
func injectBindings(chain []Runner, bindings []binding) error {
82+
if len(bindings) == 0 {
83+
return nil
84+
}
85+
86+
// Build type -> value index for fast lookup.
87+
byType := make(map[reflect.Type]any, len(bindings))
88+
for _, b := range bindings {
89+
key := b.targetType
90+
if key == nil {
91+
key = reflect.TypeOf(b.value)
92+
}
93+
byType[key] = b.value
94+
}
95+
96+
for _, cmd := range chain {
97+
injectIntoStruct(cmd, byType)
98+
}
99+
return nil
100+
}
101+
102+
func injectIntoStruct(cmd any, byType map[reflect.Type]any) {
103+
v := reflect.ValueOf(cmd)
104+
if v.Kind() != reflect.Ptr {
105+
return
106+
}
107+
v = v.Elem()
108+
if v.Kind() != reflect.Struct {
109+
return
110+
}
111+
112+
t := v.Type()
113+
for i := range t.NumField() {
114+
field := t.Field(i)
115+
116+
// Skip unexported fields.
117+
if !field.IsExported() {
118+
continue
119+
}
120+
121+
// Recurse into exported embedded structs.
122+
if field.Anonymous && field.Type.Kind() == reflect.Struct {
123+
injectIntoStruct(v.Field(i).Addr().Interface(), byType)
124+
continue
125+
}
126+
127+
// Skip fields with CLI tags — they're populated by flag/arg/env parsing.
128+
if hasCliTag(field) {
129+
continue
130+
}
131+
132+
// Try to find a matching binding.
133+
val, found := findBinding(field.Type, byType)
134+
if !found {
135+
continue
136+
}
137+
138+
fv := v.Field(i)
139+
fv.Set(reflect.ValueOf(val))
140+
}
141+
}
142+
143+
// hasCliTag returns true if the field has any tag that makes it a CLI input.
144+
func hasCliTag(field reflect.StructField) bool {
145+
_, hasFlag := field.Tag.Lookup("flag")
146+
_, hasArg := field.Tag.Lookup("arg")
147+
_, hasEnv := field.Tag.Lookup("env")
148+
return hasFlag || hasArg || hasEnv
149+
}
150+
151+
// findBinding looks up a value for the target type. It first tries an exact
152+
// type match, then checks if any bound value implements the target interface.
153+
func findBinding(target reflect.Type, byType map[reflect.Type]any) (any, bool) {
154+
// Exact type match.
155+
if val, ok := byType[target]; ok {
156+
return val, true
157+
}
158+
159+
// Interface matching: check if any bound value implements target.
160+
if target.Kind() == reflect.Interface {
161+
for _, val := range byType {
162+
if reflect.TypeOf(val).Implements(target) {
163+
return val, true
164+
}
165+
}
166+
}
167+
168+
return nil, false
169+
}
170+
171+
// stripProgramName removes the first argument if it looks like a program path.
172+
// This allows callers to pass os.Args directly instead of os.Args[1:].
173+
func stripProgramName(args []string, cmdName string) []string {
174+
if len(args) == 0 {
175+
return args
176+
}
177+
178+
first := args[0]
179+
180+
// Check if first arg looks like a path (contains / or \).
181+
if strings.ContainsAny(first, `/\`) {
182+
base := filepath.Base(first)
183+
// Strip extension for Windows .exe comparison.
184+
base = strings.TrimSuffix(base, filepath.Ext(base))
185+
if strings.EqualFold(base, cmdName) {
186+
return args[1:]
187+
}
188+
// Looks like a path but doesn't match — probably still the binary.
189+
// Be conservative: if it starts with common path prefixes, strip it.
190+
if strings.HasPrefix(first, "/") || strings.HasPrefix(first, "./") || strings.HasPrefix(first, "../") {
191+
return args[1:]
192+
}
193+
}
194+
195+
return args
196+
}

0 commit comments

Comments
 (0)