Repository: alecthomas/kong
Pull request #472 (2024-11-22 20:14:38)

feat: Allow kong.Path to describe remaining unparsed args
As Kong traces a sequence of command line arguments, it parses them and appends them to the parsed `Path` sequence. For each element in `Path`, these is a corresponding sequence of unparsed arguments. This change enables `Path` to yield these.

I have a package that uses Kong's hooks to instrument Kong applications (to monitor usage, reliability, etc of internal tools). I would like to instrument the commandline arguments as well.

This change would enable it to work roughly as follows:
```golang
func (Foo) BeforeApply(app *kong.Kong, ctx *kong.Context, t *Tracker) error {
	command := []string{ctx.Model.Name}
	var args []string

	for _, path := range ctx.Path {
		if path.Command != nil {
			command = append(command, path.Command.Name)
			args = path.Rest()
		}
	}

	app.Exit = t.exit(app.Exit)

	t.WithCommand(strings.Join(command, " ")).WithArgs(args)
	return nil
}
```

alecthomas: Seems reasonable, but could you add a test? And I think a better name would be `Remainder()`
alecthomas: Ping.
boblail: Sorry for the delay! I just rebased and pushed a commit.
- Renamed `Rest()` to `Remainder()`
- Added a test

Patch:
diff --git a/context.go b/context.go
index dcaf099..16a7353 100644
--- a/context.go
+++ b/context.go
@@ -26,6 +26,9 @@ type Path struct {
 
 	// True if this Path element was created as the result of a resolver.
 	Resolved bool
+
+	// Remaining tokens after this node
+	remainder []Token
 }
 
 // Node returns the Node associated with this Path, or nil if Path is a non-Node.
@@ -64,6 +67,15 @@ func (p *Path) Visitable() Visitable {
 	return nil
 }
 
+// Remainder returns the remaining unparsed args after this Path element.
+func (p *Path) Remainder() []string {
+	args := []string{}
+	for _, token := range p.remainder {
+		args = append(args, token.String())
+	}
+	return args
+}
+
 // Context contains the current parse context.
 type Context struct {
 	*Kong
@@ -87,14 +99,15 @@ type Context struct {
 // This just constructs a new trace. To fully apply the trace you must call Reset(), Resolve(),
 // Validate() and Apply().
 func Trace(k *Kong, args []string) (*Context, error) {
+	s := Scan(args...)
 	c := &Context{
 		Kong: k,
 		Args: args,
 		Path: []*Path{
-			{App: k.Model, Flags: k.Model.Flags},
+			{App: k.Model, Flags: k.Model.Flags, remainder: s.PeekAll()},
 		},
 		values:   map[*Value]reflect.Value{},
-		scan:     Scan(args...),
+		scan:     s,
 		bindings: bindings{},
 	}
 	c.Error = c.trace(c.Model.Node)
@@ -477,6 +490,7 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo
 				c.Path = append(c.Path, &Path{
 					Parent:     node,
 					Positional: arg,
+					remainder:  c.scan.PeekAll(),
 				})
 				positional++
 				break
@@ -508,9 +522,10 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo
 				if branch.Type == CommandNode && branch.Name == token.Value {
 					c.scan.Pop()
 					c.Path = append(c.Path, &Path{
-						Parent:  node,
-						Command: branch,
-						Flags:   branch.Flags,
+						Parent:    node,
+						Command:   branch,
+						Flags:     branch.Flags,
+						remainder: c.scan.PeekAll(),
 					})
 					return c.trace(branch)
 				}
@@ -522,9 +537,10 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo
 					arg := branch.Argument
 					if err := arg.Parse(c.scan, c.getValue(arg)); err == nil {
 						c.Path = append(c.Path, &Path{
-							Parent:   node,
-							Argument: branch,
-							Flags:    branch.Flags,
+							Parent:    node,
+							Argument:  branch,
+							Flags:     branch.Flags,
+							remainder: c.scan.PeekAll(),
 						})
 						return c.trace(branch)
 					}
@@ -535,9 +551,10 @@ func (c *Context) trace(node *Node) (err error) { //nolint: gocyclo
 			// matches, take the branch of the default command
 			if node.DefaultCmd != nil && node.DefaultCmd.Tag.Default == "withargs" {
 				c.Path = append(c.Path, &Path{
-					Parent:  node,
-					Command: node.DefaultCmd,
-					Flags:   node.DefaultCmd.Flags,
+					Parent:    node,
+					Command:   node.DefaultCmd,
+					Flags:     node.DefaultCmd.Flags,
+					remainder: c.scan.PeekAll(),
 				})
 				return c.trace(node.DefaultCmd)
 			}
@@ -565,9 +582,10 @@ func (c *Context) maybeSelectDefault(flags []*Flag, node *Node) error {
 	}
 	if node.DefaultCmd != nil {
 		c.Path = append(c.Path, &Path{
-			Parent:  node.DefaultCmd,
-			Command: node.DefaultCmd,
-			Flags:   node.DefaultCmd.Flags,
+			Parent:    node.DefaultCmd,
+			Command:   node.DefaultCmd,
+			Flags:     node.DefaultCmd.Flags,
+			remainder: c.scan.PeekAll(),
 		})
 	}
 	return nil
@@ -612,8 +630,9 @@ func (c *Context) Resolve() error {
 				return err
 			}
 			inserted = append(inserted, &Path{
-				Flag:     flag,
-				Resolved: true,
+				Flag:      flag,
+				Resolved:  true,
+				remainder: c.scan.PeekAll(),
 			})
 		}
 	}
@@ -757,7 +776,10 @@ func (c *Context) parseFlag(flags []*Flag, match string) (err error) {
 			}
 			flag.Value.Apply(value)
 		}
-		c.Path = append(c.Path, &Path{Flag: flag})
+		c.Path = append(c.Path, &Path{
+			Flag:      flag,
+			remainder: c.scan.PeekAll(),
+		})
 		return nil
 	}
 	return &unknownFlagError{Cause: findPotentialCandidates(match, candidates, "unknown flag %s", match)}
diff --git a/scanner.go b/scanner.go
index 262d16f..68f708e 100644
--- a/scanner.go
+++ b/scanner.go
@@ -203,6 +203,11 @@ func (s *Scanner) Peek() Token {
 	return s.args[0]
 }
 
+// PeekAll remaining tokens
+func (s *Scanner) PeekAll() []Token {
+	return s.args
+}
+
 // Push an untyped Token onto the front of the Scanner.
 func (s *Scanner) Push(arg any) *Scanner {
 	s.PushToken(Token{Value: arg})


Test patch:
diff --git a/kong_test.go b/kong_test.go
index 15d1f51..4edda4f 100644
--- a/kong_test.go
+++ b/kong_test.go
@@ -48,6 +48,25 @@ func TestPositionalArguments(t *testing.T) {
 	})
 }
 
+func TestRemainderReturnsUnparsedArgs(t *testing.T) {
+	var cli struct {
+		User struct {
+			Create struct {
+				ID    int    `kong:"arg"`
+				First string `kong:"arg"`
+				Last  string `kong:"arg"`
+			} `kong:"cmd"`
+		} `kong:"cmd"`
+	}
+	p := mustNew(t, &cli)
+	args := []string{"user", "create", "10", "Alec", "Thomas"}
+	ctx, err := p.Parse(args)
+	assert.NoError(t, err)
+	for i, x := range ctx.Path {
+		assert.Equal(t, strings.Join(args[i:], " "), strings.Join(x.Remainder(), " "))
+	}
+}
+
 func TestBranchingArgument(t *testing.T) {
 	/*
 		app user create <id> <first> <last>