Repository: alecthomas/kong
Pull request #160 (2021-04-04 17:37:57)

tag: add passthrough for positional arguments
This new tag tells the parser to stop processing flags after the
positional argument is encountered.

This is particularly useful for subcommands that forward their arguments
to an external program, and makes it possible to implement commands
like `kubectl exec` or `docker run`.


Patch:
diff --git a/README.md b/README.md
index 47bd1cb..2d633f3 100644
--- a/README.md
+++ b/README.md
@@ -450,6 +450,7 @@ Tag                    | Description
 `prefix:"X"`           | Prefix for all sub-flags.
 `set:"K=V"`            | Set a variable for expansion by child elements. Multiples can occur.
 `embed`                | If present, this field's children will be embedded in the parent. Useful for composition.
+`passthrough`          | If present, this positional argument stops flag parsing when encountered, as if `--` was processed before. Useful for external command wrappers, like `exec`.
 `-`                    | Ignore the field. Useful for adding non-CLI fields to a configuration struct.
 
 ## Plugins
diff --git a/build.go b/build.go
index 0e3bc5f..4d9081a 100644
--- a/build.go
+++ b/build.go
@@ -195,6 +195,7 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv
 		Tag:          tag,
 		Target:       fv,
 		Enum:         tag.Enum,
+		Passthrough:  tag.Passthrough,
 
 		// Flags are optional by default, and args are required by default.
 		Required: (!tag.Arg && tag.Required) || (tag.Arg && !tag.Optional),
diff --git a/context.go b/context.go
index 554e8b1..d01abfd 100644
--- a/context.go
+++ b/context.go
@@ -324,6 +324,21 @@ func (c *Context) Reset() error {
 	})
 }
 
+func (c *Context) endParsing() {
+	args := []string{}
+	for {
+		token := c.scan.Pop()
+		if token.Type == EOLToken {
+			break
+		}
+		args = append(args, token.String())
+	}
+	// Note: tokens must be pushed in reverse order.
+	for i := range args {
+		c.scan.PushTyped(args[len(args)-1-i], PositionalArgumentToken)
+	}
+}
+
 func (c *Context) trace(node *Node) (err error) { // nolint: gocyclo
 	positional := 0
 
@@ -349,18 +364,7 @@ func (c *Context) trace(node *Node) (err error) { // nolint: gocyclo
 				// Indicates end of parsing. All remaining arguments are treated as positional arguments only.
 				case v == "--":
 					c.scan.Pop()
-					args := []string{}
-					for {
-						token = c.scan.Pop()
-						if token.Type == EOLToken {
-							break
-						}
-						args = append(args, token.String())
-					}
-					// Note: tokens must be pushed in reverse order.
-					for i := range args {
-						c.scan.PushTyped(args[len(args)-1-i], PositionalArgumentToken)
-					}
+					c.endParsing()
 
 				// Long flag.
 				case strings.HasPrefix(v, "--"):
@@ -413,6 +417,11 @@ func (c *Context) trace(node *Node) (err error) { // nolint: gocyclo
 			// Ensure we've consumed all positional arguments.
 			if positional < len(node.Positional) {
 				arg := node.Positional[positional]
+
+				if arg.Passthrough {
+					c.endParsing()
+				}
+
 				err := arg.Parse(c.scan, c.getValue(arg))
 				if err != nil {
 					return err
diff --git a/model.go b/model.go
index d3d1a8f..cb0534a 100644
--- a/model.go
+++ b/model.go
@@ -236,6 +236,7 @@ type Value struct {
 	Set          bool   // Set to true when this value is set through some mechanism.
 	Format       string // Formatting directive, if applicable.
 	Position     int    // Position (for positional arguments).
+	Passthrough  bool   // Set to true to stop flag parsing when encountered.
 }
 
 // EnumMap returns a map of the enums in this value.
diff --git a/tag.go b/tag.go
index deffd86..4d640c9 100644
--- a/tag.go
+++ b/tag.go
@@ -34,6 +34,7 @@ type Tag struct {
 	Embed       bool
 	Aliases     []string
 	Negatable   bool
+	Passthrough bool
 
 	// Storage for all tag keys for arbitrary lookups.
 	items map[string][]string
@@ -184,6 +185,11 @@ func parseTag(fv reflect.Value, ft reflect.StructField) *Tag {
 		t.PlaceHolder = strings.ToUpper(dashedString(fv.Type().Name()))
 	}
 	t.Enum = t.Get("enum")
+	passthrough := t.Has("passthrough")
+	if passthrough && !t.Arg {
+		fail("passthrough only makes sense for positional arguments")
+	}
+	t.Passthrough = passthrough
 	return t
 }
 


Test patch:
diff --git a/mapper_test.go b/mapper_test.go
index a8b84e9..4f052f9 100644
--- a/mapper_test.go
+++ b/mapper_test.go
@@ -207,6 +207,33 @@ func TestSliceConsumesRemainingPositionalArgs(t *testing.T) {
 	require.Equal(t, []string{"ls", "-lart"}, cli.Remainder)
 }
 
+func TestPassthroughStopsParsing(t *testing.T) {
+	type cli struct {
+		Interactive bool     `short:"i"`
+		Image       string   `arg:""`
+		Argv        []string `arg:"" optional:"" passthrough:""`
+	}
+
+	var actual cli
+	p := mustNew(t, &actual)
+
+	_, err := p.Parse([]string{"alpine", "sudo", "-i", "true"})
+	require.NoError(t, err)
+	require.Equal(t, cli{
+		Interactive: false,
+		Image:       "alpine",
+		Argv:        []string{"sudo", "-i", "true"},
+	}, actual)
+
+	_, err = p.Parse([]string{"alpine", "-i", "sudo", "-i", "true"})
+	require.NoError(t, err)
+	require.Equal(t, cli{
+		Interactive: true,
+		Image:       "alpine",
+		Argv:        []string{"sudo", "-i", "true"},
+	}, actual)
+}
+
 type mappedValue struct {
 	decoded string
 }