Repository: alecthomas/kong
Pull request #436 (2024-07-05 14:03:50)

fix!: Include `--` in passthrough args
Given a grammar like this:

### Issue

```golang
var cli struct {
        Args []string `arg:"" optional:"" passthrough:""`
}
```

If Kong parses `cli foo -- bar`, it will populate `Args` with `[]string{"foo", "--", "bar"}` (including "`--`"). However, if Kong parses `cli -- foo bar`, will populate `Args` with `[]string{"foo", "bar"}` (leaving off `"--"`).

This differs from the behavior of a passthrough Command, where `"--"` is included with the args in both cases.

### Proposal

There are 3 places where `c.endParsing()` is called
1. When `node.Passthrough` is true: https://github.com/alecthomas/kong/blob/5f9c5cc822bdb888a3671c44d4688a6f602ecb90/context.go#L366-L368
2. When `arg.Passthrough` is true: https://github.com/alecthomas/kong/blob/5f9c5cc822bdb888a3671c44d4688a6f602ecb90/context.go#L451-L453
3. When `"--"` is encountered: https://github.com/alecthomas/kong/blob/5f9c5cc822bdb888a3671c44d4688a6f602ecb90/context.go#L384-L387

The first two do not also pop any tokens. The third one does.

This commit makes `c.scan.Pop()` conditional, skipping it when the next positional argument is passthrough.

### Breaking Change

I believe this will cause Kong to behave a little more consistently — and from my perspective, `--` is relevant for args intended to be passed through! — but it will change the behavior of existing projects that use `arg:"" passthrough:""`.

alecthomas: This was actually intentional for compatibility with the previous behaviour of passthrough, but I think it's okay to change it. I've been meaning to cut a 1.0 release, and this is a good motivation.
alecthomas: If you find any other consistencies, now would be the time.

Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 2c5bfe3..c922a37 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -47,6 +47,8 @@ linters:
     - varcheck    # deprecated since v1.49.0
     - depguard    # nothing to guard against yet
     - tagalign    # hurts readability of kong tags
+    - mnd
+    - perfsprint
 
 linters-settings:
   govet:
@@ -76,6 +78,7 @@ issues:
     - 'bad syntax for struct tag key'
     - 'bad syntax for struct tag pair'
     - 'result .* \(error\) is always nil'
+    - 'Error return value of `fmt.Fprintln` is not checked'
 
   exclude-rules:
     # Don't warn on unused parameters.
diff --git a/README.md b/README.md
index 916ca6f..3936fde 100644
--- a/README.md
+++ b/README.md
@@ -554,29 +554,31 @@ Tags can be in two forms:
 Both can coexist with standard Tag parsing.
 
 | Tag                  | Description                                                                                                                                                                                                                                                                                                                    |
-| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 | `cmd:""`             | If present, struct is a command.                                                                                                                                                                                                                                                                                               |
 | `arg:""`             | If present, field is an argument. Required by default.                                                                                                                                                                                                                                                                         |
 | `env:"X,Y,..."`      | Specify envars to use for default value. The envs are resolved in the declared order. The first value found is used.                                                                                                                                                                                                           |
 | `name:"X"`           | Long name, for overriding field name.                                                                                                                                                                                                                                                                                          |
 | `help:"X"`           | Help text.                                                                                                                                                                                                                                                                                                                     |
 | `type:"X"`           | Specify [named types](#custom-named-decoders) to use.                                                                                                                                                                                                                                                                          |
-| `placeholder:"X"`    | Placeholder text.                                                                                                                                                                                                                                                                                                              |
+| `placeholder:"X"`    | Placeholder input, if flag. e.g. `` `placeholder:"<the-placeholder>"` `` will show `--flag-name=<the-placeholder>` when displaying help.                                                                                                                                                                                       |
 | `default:"X"`        | Default value.                                                                                                                                                                                                                                                                                                                 |
 | `default:"1"`        | On a command, make it the default.                                                                                                                                                                                                                                                                                             |
 | `default:"withargs"` | On a command, make it the default and allow args/flags from that command                                                                                                                                                                                                                                                       |
 | `short:"X"`          | Short name, if flag.                                                                                                                                                                                                                                                                                                           |
-| `aliases:"X,Y"`      | One or more aliases (for cmd or flag).                                                                                                                                                                                                                                                                                                 |
+| `aliases:"X,Y"`      | One or more aliases (for cmd or flag).                                                                                                                                                                                                                                                                                         |
 | `required:""`        | If present, flag/arg is required.                                                                                                                                                                                                                                                                                              |
 | `optional:""`        | If present, flag/arg is optional.                                                                                                                                                                                                                                                                                              |
 | `hidden:""`          | If present, command or flag is hidden.                                                                                                                                                                                                                                                                                         |
 | `negatable:""`       | If present on a `bool` field, supports prefixing a flag with `--no-` to invert the default value                                                                                                                                                                                                                               |
+| `negatable:"X"`      | If present on a `bool` field, supports `--X` to invert the default value                                                                                                                                                                                                                                                       |
 | `format:"X"`         | Format for parsing input, if supported.                                                                                                                                                                                                                                                                                        |
 | `sep:"X"`            | Separator for sequences (defaults to ","). May be `none` to disable splitting.                                                                                                                                                                                                                                                 |
 | `mapsep:"X"`         | Separator for maps (defaults to ";"). May be `none` to disable splitting.                                                                                                                                                                                                                                                      |
 | `enum:"X,Y,..."`     | Set of valid values allowed for this flag. An enum field must be `required` or have a valid `default`.                                                                                                                                                                                                                         |
 | `group:"X"`          | Logical group for a flag or command.                                                                                                                                                                                                                                                                                           |
 | `xor:"X,Y,..."`      | Exclusive OR groups for flags. Only one flag in the group can be used which is restricted within the same command. When combined with `required`, at least one of the `xor` group will be required.                                                                                                                            |
+| `and:"X,Y,..."`      | AND groups for flags. All flags in the group must be used in the same command. When combined with `required`, all flags in the group will be required.                                                                                                                                                                         |
 | `prefix:"X"`         | Prefix for all sub-flags.                                                                                                                                                                                                                                                                                                      |
 | `envprefix:"X"`      | Envar prefix for all sub-flags.                                                                                                                                                                                                                                                                                                |
 | `set:"K=V"`          | Set a variable for expansion by child elements. Multiples can occur.                                                                                                                                                                                                                                                           |
diff --git a/_examples/server/go.mod b/_examples/server/go.mod
index 1feb248..b923839 100644
--- a/_examples/server/go.mod
+++ b/_examples/server/go.mod
@@ -12,5 +12,5 @@ require (
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/kr/pty v1.1.8
 	github.com/mattn/go-isatty v0.0.12 // indirect
-	golang.org/x/crypto v0.25.0
+	golang.org/x/crypto v0.27.0
 )
diff --git a/_examples/server/go.sum b/_examples/server/go.sum
index bf1cd72..fd8f6d6 100644
--- a/_examples/server/go.sum
+++ b/_examples/server/go.sum
@@ -72,6 +72,10 @@ golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+
 golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
 golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
+golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
+golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
+golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -94,6 +98,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -121,6 +126,10 @@ golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
 golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
+golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
+golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -142,6 +151,10 @@ golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk=
 golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
+golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
+golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
+golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
+golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -153,6 +166,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
+golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/bin/.go-1.22.5.pkg b/bin/.go-1.23.1.pkg
similarity index 100%
rename from bin/.go-1.22.5.pkg
rename to bin/.go-1.23.1.pkg
diff --git a/bin/.golangci-lint-1.55.2.pkg b/bin/.golangci-lint-1.60.1.pkg
similarity index 100%
rename from bin/.golangci-lint-1.55.2.pkg
rename to bin/.golangci-lint-1.60.1.pkg
diff --git a/bin/go b/bin/go
index 5c26cb9..2cf16c6 120000
--- a/bin/go
+++ b/bin/go
@@ -1,1 +1,1 @@
-.go-1.22.5.pkg
\ No newline at end of file
+.go-1.23.1.pkg
\ No newline at end of file
diff --git a/bin/gofmt b/bin/gofmt
index 5c26cb9..2cf16c6 120000
--- a/bin/gofmt
+++ b/bin/gofmt
@@ -1,1 +1,1 @@
-.go-1.22.5.pkg
\ No newline at end of file
+.go-1.23.1.pkg
\ No newline at end of file
diff --git a/bin/golangci-lint b/bin/golangci-lint
index 5b19a9d..4857716 120000
--- a/bin/golangci-lint
+++ b/bin/golangci-lint
@@ -1,1 +1,1 @@
-.golangci-lint-1.55.2.pkg
\ No newline at end of file
+.golangci-lint-1.60.1.pkg
\ No newline at end of file
diff --git a/build.go b/build.go
index aed1f72..7791c62 100644
--- a/build.go
+++ b/build.go
@@ -315,6 +315,13 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv
 			}
 			seenFlags["-"+string(tag.Short)] = true
 		}
+		if tag.Negatable != "" {
+			negFlag := negatableFlagName(value.Name, tag.Negatable)
+			if seenFlags[negFlag] {
+				return failField(v, ft, "duplicate negation flag %s", negFlag)
+			}
+			seenFlags[negFlag] = true
+		}
 		flag := &Flag{
 			Value:       value,
 			Aliases:     tag.Aliases,
@@ -323,6 +330,7 @@ func buildField(k *Kong, node *Node, v reflect.Value, ft reflect.StructField, fv
 			Envs:        tag.Envs,
 			Group:       buildGroupForKey(k, tag.Group),
 			Xor:         tag.Xor,
+			And:         tag.And,
 			Hidden:      tag.Hidden,
 		}
 		value.Flag = flag
diff --git a/context.go b/context.go
index 03044ec..2ed5d28 100644
--- a/context.go
+++ b/context.go
@@ -259,7 +259,7 @@ func (c *Context) Validate() error { //nolint: gocyclo
 	if err := checkMissingPositionals(positionals, node.Positional); err != nil {
 		return err
 	}
-	if err := checkXorDuplicates(c.Path); err != nil {
+	if err := checkXorDuplicatedAndAndMissing(c.Path); err != nil {
 		return err
 	}
 
@@ -383,9 +383,13 @@ 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()
 					c.endParsing()
 
+					// Pop the -- token unless the next positional argument accepts passthrough arguments.
+					if !(positional < len(node.Positional) && node.Positional[positional].Passthrough) {
+						c.scan.Pop()
+					}
+
 				// Long flag.
 				case strings.HasPrefix(v, "--"):
 					c.scan.Pop()
@@ -710,13 +714,13 @@ func (c *Context) parseFlag(flags []*Flag, match string) (err error) {
 			candidates = append(candidates, alias)
 		}
 
-		neg := "--no-" + flag.Name
-		if !matched && !(match == neg && flag.Tag.Negatable) {
+		neg := negatableFlagName(flag.Name, flag.Tag.Negatable)
+		if !matched && match != neg {
 			continue
 		}
 		// Found a matching flag.
 		c.scan.Pop()
-		if match == neg && flag.Tag.Negatable {
+		if match == neg && flag.Tag.Negatable != "" {
 			flag.Negated = true
 		}
 		err := flag.Parse(c.scan, c.getValue(flag.Value))
@@ -831,23 +835,35 @@ func (c *Context) PrintUsage(summary bool) error {
 func checkMissingFlags(flags []*Flag) error {
 	xorGroupSet := map[string]bool{}
 	xorGroup := map[string][]string{}
+	andGroupSet := map[string]bool{}
+	andGroup := map[string][]string{}
 	missing := []string{}
+	andGroupRequired := getRequiredAndGroupMap(flags)
 	for _, flag := range flags {
+		for _, and := range flag.And {
+			flag.Required = andGroupRequired[and]
+		}
 		if flag.Set {
 			for _, xor := range flag.Xor {
 				xorGroupSet[xor] = true
 			}
+			for _, and := range flag.And {
+				andGroupSet[and] = true
+			}
 		}
 		if !flag.Required || flag.Set {
 			continue
 		}
-		if len(flag.Xor) > 0 {
+		if len(flag.Xor) > 0 || len(flag.And) > 0 {
 			for _, xor := range flag.Xor {
 				if xorGroupSet[xor] {
 					continue
 				}
 				xorGroup[xor] = append(xorGroup[xor], flag.Summary())
 			}
+			for _, and := range flag.And {
+				andGroup[and] = append(andGroup[and], flag.Summary())
+			}
 		} else {
 			missing = append(missing, flag.Summary())
 		}
@@ -857,6 +873,11 @@ func checkMissingFlags(flags []*Flag) error {
 			missing = append(missing, strings.Join(flags, " or "))
 		}
 	}
+	for _, flags := range andGroup {
+		if len(flags) > 1 {
+			missing = append(missing, strings.Join(flags, " and "))
+		}
+	}
 
 	if len(missing) == 0 {
 		return nil
@@ -867,6 +888,18 @@ func checkMissingFlags(flags []*Flag) error {
 	return fmt.Errorf("missing flags: %s", strings.Join(missing, ", "))
 }
 
+func getRequiredAndGroupMap(flags []*Flag) map[string]bool {
+	andGroupRequired := map[string]bool{}
+	for _, flag := range flags {
+		for _, and := range flag.And {
+			if flag.Required {
+				andGroupRequired[and] = true
+			}
+		}
+	}
+	return andGroupRequired
+}
+
 func checkMissingChildren(node *Node) error {
 	missing := []string{}
 
@@ -977,6 +1010,20 @@ func checkPassthroughArg(target reflect.Value) bool {
 	}
 }
 
+func checkXorDuplicatedAndAndMissing(paths []*Path) error {
+	errs := []string{}
+	if err := checkXorDuplicates(paths); err != nil {
+		errs = append(errs, err.Error())
+	}
+	if err := checkAndMissing(paths); err != nil {
+		errs = append(errs, err.Error())
+	}
+	if len(errs) > 0 {
+		return errors.New(strings.Join(errs, ", "))
+	}
+	return nil
+}
+
 func checkXorDuplicates(paths []*Path) error {
 	for _, path := range paths {
 		seen := map[string]*Flag{}
@@ -995,6 +1042,38 @@ func checkXorDuplicates(paths []*Path) error {
 	return nil
 }
 
+func checkAndMissing(paths []*Path) error {
+	for _, path := range paths {
+		missingMsgs := []string{}
+		andGroups := map[string][]*Flag{}
+		for _, flag := range path.Flags {
+			for _, and := range flag.And {
+				andGroups[and] = append(andGroups[and], flag)
+			}
+		}
+		for _, flags := range andGroups {
+			oneSet := false
+			notSet := []*Flag{}
+			flagNames := []string{}
+			for _, flag := range flags {
+				flagNames = append(flagNames, flag.Name)
+				if flag.Set {
+					oneSet = true
+				} else {
+					notSet = append(notSet, flag)
+				}
+			}
+			if len(notSet) > 0 && oneSet {
+				missingMsgs = append(missingMsgs, fmt.Sprintf("--%s must be used together", strings.Join(flagNames, " and --")))
+			}
+		}
+		if len(missingMsgs) > 0 {
+			return fmt.Errorf("%s", strings.Join(missingMsgs, ", "))
+		}
+	}
+	return nil
+}
+
 func findPotentialCandidates(needle string, haystack []string, format string, args ...interface{}) error {
 	if len(haystack) == 0 {
 		return fmt.Errorf(format, args...)
diff --git a/go.sum b/go.sum
index fe9dd12..c65b342 100644
--- a/go.sum
+++ b/go.sum
@@ -1,15 +1,5 @@
-github.com/alecthomas/assert/v2 v2.4.1 h1:mwPZod/d35nlaCppr6sFP0rbCL05WH9fIo7lvsf47zo=
-github.com/alecthomas/assert/v2 v2.4.1/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM=
-github.com/alecthomas/assert/v2 v2.5.0 h1:OJKYg53BQx06/bMRBSPDCO49CbCDNiUQXwdoNrt6x5w=
-github.com/alecthomas/assert/v2 v2.5.0/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM=
-github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
-github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
-github.com/alecthomas/assert/v2 v2.8.1 h1:YCxnYR6jjpfnEK5AK5SysALKdUEBPGH4Y7As6tBnDw0=
-github.com/alecthomas/assert/v2 v2.8.1/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
 github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
-github.com/alecthomas/repr v0.3.0 h1:NeYzUPfjjlqHY4KtzgKJiWd6sVq2eNUPTi34PiFGjY8=
-github.com/alecthomas/repr v0.3.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
diff --git a/help.go b/help.go
index cf5a912..26f355d 100644
--- a/help.go
+++ b/help.go
@@ -491,27 +491,22 @@ func formatFlag(haveShort bool, flag *Flag) string {
 	name := flag.Name
 	isBool := flag.IsBool()
 	isCounter := flag.IsCounter()
+
+	short := ""
 	if flag.Short != 0 {
-		if isBool && flag.Tag.Negatable {
-			flagString += fmt.Sprintf("-%c, --[no-]%s", flag.Short, name)
-		} else {
-			flagString += fmt.Sprintf("-%c, --%s", flag.Short, name)
-		}
-	} else {
-		if isBool && flag.Tag.Negatable {
-			if haveShort {
-				flagString = fmt.Sprintf("    --[no-]%s", name)
-			} else {
-				flagString = fmt.Sprintf("--[no-]%s", name)
-			}
-		} else {
-			if haveShort {
-				flagString += fmt.Sprintf("    --%s", name)
-			} else {
-				flagString += fmt.Sprintf("--%s", name)
-			}
-		}
+		short = "-" + string(flag.Short) + ", "
+	} else if haveShort {
+		short = "    "
+	}
+
+	if isBool && flag.Tag.Negatable == negatableDefault {
+		name = "[no-]" + name
+	} else if isBool && flag.Tag.Negatable != "" {
+		name += "/" + flag.Tag.Negatable
 	}
+
+	flagString += fmt.Sprintf("%s--%s", short, name)
+
 	if !isBool && !isCounter {
 		flagString += fmt.Sprintf("=%s", flag.FormatPlaceHolder())
 	}
diff --git a/kong.go b/kong.go
index 19ff88e..10235d0 100644
--- a/kong.go
+++ b/kong.go
@@ -167,9 +167,42 @@ func New(grammar interface{}, options ...Option) (*Kong, error) {
 
 	k.bindings.add(k.vars)
 
+	if err = checkOverlappingXorAnd(k); err != nil {
+		return nil, err
+	}
+
 	return k, nil
 }
 
+func checkOverlappingXorAnd(k *Kong) error {
+	xorGroups := map[string][]string{}
+	andGroups := map[string][]string{}
+	for _, flag := range k.Model.Node.Flags {
+		for _, xor := range flag.Xor {
+			xorGroups[xor] = append(xorGroups[xor], flag.Name)
+		}
+		for _, and := range flag.And {
+			andGroups[and] = append(andGroups[and], flag.Name)
+		}
+	}
+	for xor, xorSet := range xorGroups {
+		for and, andSet := range andGroups {
+			overlappingEntries := []string{}
+			for _, xorTag := range xorSet {
+				for _, andTag := range andSet {
+					if xorTag == andTag {
+						overlappingEntries = append(overlappingEntries, xorTag)
+					}
+				}
+			}
+			if len(overlappingEntries) > 1 {
+				return fmt.Errorf("invalid xor and combination, %s and %s overlap with more than one: %s", xor, and, overlappingEntries)
+			}
+		}
+	}
+	return nil
+}
+
 type varStack []Vars
 
 func (v *varStack) head() Vars { return (*v)[len(*v)-1] }
diff --git a/levenshtein.go b/levenshtein.go
index 1816f30..6837d6c 100644
--- a/levenshtein.go
+++ b/levenshtein.go
@@ -31,7 +31,7 @@ func levenshtein(a, b string) int {
 	return f[len(f)-1]
 }
 
-func min(a, b int) int {
+func min(a, b int) int { //nolint:predeclared
 	if a <= b {
 		return a
 	}
diff --git a/model.go b/model.go
index 8d1f82f..3190f3e 100644
--- a/model.go
+++ b/model.go
@@ -405,6 +405,7 @@ type Flag struct {
 	*Value
 	Group       *Group // Logical grouping when displaying. May also be used by configuration loaders to group options logically.
 	Xor         []string
+	And         []string
 	PlaceHolder string
 	Envs        []string
 	Aliases     []string
diff --git a/negatable.go b/negatable.go
new file mode 100644
index 0000000..7d8902a
--- /dev/null
+++ b/negatable.go
@@ -0,0 +1,19 @@
+package kong
+
+// negatableDefault is a placeholder value for the Negatable tag to indicate
+// the negated flag is --no-<flag-name>. This is needed as at the time of
+// parsing a tag, the field's flag name is not yet known.
+const negatableDefault = "_"
+
+// negatableFlagName returns the name of the flag for a negatable field, or
+// an empty string if the field is not negatable.
+func negatableFlagName(name, negation string) string {
+	switch negation {
+	case "":
+		return ""
+	case negatableDefault:
+		return "--no-" + name
+	default:
+		return "--" + negation
+	}
+}
diff --git a/resolver.go b/resolver.go
index 05be7f6..dca4309 100644
--- a/resolver.go
+++ b/resolver.go
@@ -63,6 +63,6 @@ func JSON(r io.Reader) (Resolver, error) {
 }
 
 func snakeCase(name string) string {
-	name = strings.Join(strings.Split(strings.Title(name), "-"), "") //nolint: staticcheck
+	name = strings.Join(strings.Split(strings.Title(name), "-"), "")
 	return strings.ToLower(name[:1]) + name[1:]
 }
diff --git a/tag.go b/tag.go
index 3e37c19..456f7ae 100644
--- a/tag.go
+++ b/tag.go
@@ -32,12 +32,13 @@ type Tag struct {
 	Enum        string
 	Group       string
 	Xor         []string
+	And         []string
 	Vars        Vars
 	Prefix      string // Optional prefix on anonymous structs. All sub-flags will have this prefix.
 	EnvPrefix   string
 	Embed       bool
 	Aliases     []string
-	Negatable   bool
+	Negatable   string
 	Passthrough bool
 
 	// Storage for all tag keys for arbitrary lookups.
@@ -249,14 +250,22 @@ func hydrateTag(t *Tag, typ reflect.Type) error { //nolint: gocyclo
 	for _, xor := range t.GetAll("xor") {
 		t.Xor = append(t.Xor, strings.FieldsFunc(xor, tagSplitFn)...)
 	}
+	for _, and := range t.GetAll("and") {
+		t.And = append(t.And, strings.FieldsFunc(and, tagSplitFn)...)
+	}
 	t.Prefix = t.Get("prefix")
 	t.EnvPrefix = t.Get("envprefix")
 	t.Embed = t.Has("embed")
-	negatable := t.Has("negatable")
-	if negatable && !isBool && !isBoolPtr {
-		return fmt.Errorf("negatable can only be set on booleans")
+	if t.Has("negatable") {
+		if !isBool && !isBoolPtr {
+			return fmt.Errorf("negatable can only be set on booleans")
+		}
+		negatable := t.Get("negatable")
+		if negatable == "" {
+			negatable = negatableDefault // placeholder for default negation of --no-<flag>
+		}
+		t.Negatable = negatable
 	}
-	t.Negatable = negatable
 	aliases := t.Get("aliases")
 	if len(aliases) > 0 {
 		t.Aliases = append(t.Aliases, strings.FieldsFunc(aliases, tagSplitFn)...)


Test patch:
diff --git a/help_test.go b/help_test.go
index 38080a3..e680be2 100644
--- a/help_test.go
+++ b/help_test.go
@@ -71,6 +71,7 @@ func TestHelp(t *testing.T) {
 		Map      map[string]int `help:"A map of strings to ints."`
 		Required bool           `required help:"A required flag."`
 		Sort     bool           `negatable short:"s" help:"Is sortable or not."`
+		Approve  bool           `negatable:"deny" help:"Approve or deny message."`
 
 		One struct {
 			Flag string `help:"Nested flag."`
@@ -118,6 +119,7 @@ Flags:
       --map=KEY=VALUE;...    A map of strings to ints.
       --required             A required flag.
   -s, --[no-]sort            Is sortable or not.
+      --approve/deny         Approve or deny message.
 
 Commands:
   one --required [flags]
@@ -159,6 +161,7 @@ Flags:
       --map=KEY=VALUE;...    A map of strings to ints.
       --required             A required flag.
   -s, --[no-]sort            Is sortable or not.
+      --approve/deny         Approve or deny message.
 
       --flag=STRING          Nested flag under two.
       --required-two
@@ -600,7 +603,7 @@ func TestAutoGroup(t *testing.T) {
 			if node, ok := parent.(*kong.Node); ok {
 				return &kong.Group{
 					Key:   node.Name,
-					Title: strings.Title(node.Name) + " flags:", //nolint
+					Title: strings.Title(node.Name) + " flags:",
 				}
 			}
 			return nil
diff --git a/kong_test.go b/kong_test.go
index f3fb869..f05fc18 100644
--- a/kong_test.go
+++ b/kong_test.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"errors"
 	"fmt"
+	"sort"
 	"strings"
 	"testing"
 
@@ -356,8 +357,9 @@ func TestTraceErrorPartiallySucceeds(t *testing.T) {
 }
 
 type commandWithNegatableFlag struct {
-	Flag bool `kong:"default='true',negatable"`
-	ran  bool
+	Flag   bool `kong:"default='true',negatable"`
+	Custom bool `kong:"default='true',negatable='standard'"`
+	ran    bool
 }
 
 func (c *commandWithNegatableFlag) Run() error {
@@ -367,34 +369,64 @@ func (c *commandWithNegatableFlag) Run() error {
 
 func TestNegatableFlag(t *testing.T) {
 	tests := []struct {
-		name     string
-		args     []string
-		expected bool
+		name           string
+		args           []string
+		expectedFlag   bool
+		expectedCustom bool
 	}{
 		{
-			name:     "no flag",
-			args:     []string{"cmd"},
-			expected: true,
+			name:           "no flag",
+			args:           []string{"cmd"},
+			expectedFlag:   true,
+			expectedCustom: true,
+		},
+		{
+			name:           "boolean flag",
+			args:           []string{"cmd", "--flag"},
+			expectedFlag:   true,
+			expectedCustom: true,
+		},
+		{
+			name:           "custom boolean flag",
+			args:           []string{"cmd", "--custom"},
+			expectedFlag:   true,
+			expectedCustom: true,
 		},
 		{
-			name:     "boolean flag",
-			args:     []string{"cmd", "--flag"},
-			expected: true,
+			name:           "inverted boolean flag",
+			args:           []string{"cmd", "--flag=false"},
+			expectedFlag:   false,
+			expectedCustom: true,
 		},
 		{
-			name:     "inverted boolean flag",
-			args:     []string{"cmd", "--flag=false"},
-			expected: false,
+			name:           "custom inverted boolean flag",
+			args:           []string{"cmd", "--custom=false"},
+			expectedFlag:   true,
+			expectedCustom: false,
 		},
 		{
-			name:     "negated boolean flag",
-			args:     []string{"cmd", "--no-flag"},
-			expected: false,
+			name:           "negated boolean flag",
+			args:           []string{"cmd", "--no-flag"},
+			expectedFlag:   false,
+			expectedCustom: true,
 		},
 		{
-			name:     "inverted negated boolean flag",
-			args:     []string{"cmd", "--no-flag=false"},
-			expected: true,
+			name:           "custom negated boolean flag",
+			args:           []string{"cmd", "--standard"},
+			expectedFlag:   true,
+			expectedCustom: false,
+		},
+		{
+			name:           "inverted negated boolean flag",
+			args:           []string{"cmd", "--no-flag=false"},
+			expectedFlag:   true,
+			expectedCustom: true,
+		},
+		{
+			name:           "inverted custom negated boolean flag",
+			args:           []string{"cmd", "--standard=false"},
+			expectedFlag:   true,
+			expectedCustom: true,
 		},
 	}
 	for _, tt := range tests {
@@ -407,16 +439,34 @@ func TestNegatableFlag(t *testing.T) {
 			p := mustNew(t, &cli)
 			kctx, err := p.Parse(tt.args)
 			assert.NoError(t, err)
-			assert.Equal(t, tt.expected, cli.Cmd.Flag)
+			assert.Equal(t, tt.expectedFlag, cli.Cmd.Flag)
+			assert.Equal(t, tt.expectedCustom, cli.Cmd.Custom)
 
 			err = kctx.Run()
 			assert.NoError(t, err)
-			assert.Equal(t, tt.expected, cli.Cmd.Flag)
+			assert.Equal(t, tt.expectedFlag, cli.Cmd.Flag)
+			assert.Equal(t, tt.expectedCustom, cli.Cmd.Custom)
 			assert.True(t, cli.Cmd.ran)
 		})
 	}
 }
 
+func TestDuplicateNegatableLong(t *testing.T) {
+	cli2 := struct {
+		NoFlag bool
+		Flag   bool `negatable:""` // negation duplicates NoFlag
+	}{}
+	_, err := kong.New(&cli2)
+	assert.EqualError(t, err, "<anonymous struct>.Flag: duplicate negation flag --no-flag")
+
+	cli3 := struct {
+		One bool
+		Two bool `negatable:"one"` // negation duplicates Flag2
+	}{}
+	_, err = kong.New(&cli3)
+	assert.EqualError(t, err, "<anonymous struct>.Two: duplicate negation flag --one")
+}
+
 func TestExistingNoFlag(t *testing.T) {
 	var cli struct {
 		Cmd struct {
@@ -607,7 +657,7 @@ func TestMultilineMessage(t *testing.T) {
 			w := &bytes.Buffer{}
 			var cli struct{}
 			p := mustNew(t, &cli, kong.Writers(w, w))
-			p.Printf(test.text)
+			p.Printf("%s", test.text)
 			assert.Equal(t, test.want, w.String())
 		})
 	}
@@ -919,6 +969,21 @@ func TestXor(t *testing.T) {
 	assert.NoError(t, err)
 }
 
+func TestAnd(t *testing.T) {
+	var cli struct {
+		Hello bool   `and:"another"`
+		One   bool   `and:"group"`
+		Two   string `and:"group"`
+	}
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--hello", "--one"})
+	assert.EqualError(t, err, "--one and --two must be used together")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--one", "--two=hi", "--hello"})
+	assert.NoError(t, err)
+}
+
 func TestXorChild(t *testing.T) {
 	var cli struct {
 		One bool `xor:"group"`
@@ -936,6 +1001,23 @@ func TestXorChild(t *testing.T) {
 	assert.Error(t, err, "--two and --three can't be used together")
 }
 
+func TestAndChild(t *testing.T) {
+	var cli struct {
+		One bool `and:"group"`
+		Cmd struct {
+			Two   string `and:"group"`
+			Three string `and:"group"`
+		} `cmd`
+	}
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--one", "cmd", "--two=hi", "--three=hello"})
+	assert.NoError(t, err)
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--two=hi", "cmd"})
+	assert.Error(t, err, "--two and --three must be used together")
+}
+
 func TestMultiXor(t *testing.T) {
 	var cli struct {
 		Hello bool   `xor:"one,two"`
@@ -952,6 +1034,57 @@ func TestMultiXor(t *testing.T) {
 	assert.EqualError(t, err, "--hello and --two can't be used together")
 }
 
+func TestMultiAnd(t *testing.T) {
+	var cli struct {
+		Hello bool   `and:"one,two"`
+		One   bool   `and:"one"`
+		Two   string `and:"two"`
+	}
+
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--hello"})
+	// Split and combine error so messages always will be in the same order
+	// when testing
+	missingMsgs := strings.Split(err.Error(), ", ")
+	sort.Strings(missingMsgs)
+	err = fmt.Errorf("%s", strings.Join(missingMsgs, ", "))
+	assert.EqualError(t, err, "--hello and --one must be used together, --hello and --two must be used together")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--two=foo"})
+	assert.EqualError(t, err, "--hello and --two must be used together")
+}
+
+func TestXorAnd(t *testing.T) {
+	var cli struct {
+		Hello bool   `xor:"one" and:"two"`
+		One   bool   `xor:"one"`
+		Two   string `and:"two"`
+	}
+
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--hello"})
+	assert.EqualError(t, err, "--hello and --two must be used together")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--one"})
+	assert.NoError(t, err)
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--hello", "--one"})
+	assert.EqualError(t, err, "--hello and --one can't be used together, --hello and --two must be used together")
+}
+
+func TestOverLappingXorAnd(t *testing.T) {
+	var cli struct {
+		Hello bool   `xor:"one" and:"two"`
+		One   bool   `xor:"one" and:"two"`
+		Two   string `xor:"one" and:"two"`
+	}
+	_, err := kong.New(&cli)
+	assert.EqualError(t, err, "invalid xor and combination, one and two overlap with more than one: [hello one two]")
+}
+
 func TestXorRequired(t *testing.T) {
 	var cli struct {
 		One   bool `xor:"one,two" required:""`
@@ -972,6 +1105,26 @@ func TestXorRequired(t *testing.T) {
 	assert.EqualError(t, err, "missing flags: --four, --one or --three, --one or --two")
 }
 
+func TestAndRequired(t *testing.T) {
+	var cli struct {
+		One   bool `and:"one,two" required:""`
+		Two   bool `and:"one" required:""`
+		Three bool `and:"two"`
+		Four  bool `required:""`
+	}
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{"--one", "--two", "--three"})
+	assert.EqualError(t, err, "missing flags: --four")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--four"})
+	assert.EqualError(t, err, "missing flags: --one and --three, --one and --two")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{})
+	assert.EqualError(t, err, "missing flags: --four, --one and --three, --one and --two")
+}
+
 func TestXorRequiredMany(t *testing.T) {
 	var cli struct {
 		One   bool `xor:"one" required:""`
@@ -991,6 +1144,21 @@ func TestXorRequiredMany(t *testing.T) {
 	assert.EqualError(t, err, "missing flags: --one or --two or --three")
 }
 
+func TestAndRequiredMany(t *testing.T) {
+	var cli struct {
+		One   bool `and:"one" required:""`
+		Two   bool `and:"one" required:""`
+		Three bool `and:"one" required:""`
+	}
+	p := mustNew(t, &cli)
+	_, err := p.Parse([]string{})
+	assert.EqualError(t, err, "missing flags: --one and --two and --three")
+
+	p = mustNew(t, &cli)
+	_, err = p.Parse([]string{"--three"})
+	assert.EqualError(t, err, "missing flags: --one and --two")
+}
+
 func TestEnumSequence(t *testing.T) {
 	var cli struct {
 		State []string `enum:"a,b,c" default:"a"`
@@ -1575,10 +1743,16 @@ func TestPassthroughArgs(t *testing.T) {
 			[]string{"something"},
 		},
 		{
-			"DashDashBeforeRecognizedFlag",
+			"DashDashBetweenArgs",
+			[]string{"foo", "--", "bar"},
+			"",
+			[]string{"foo", "--", "bar"},
+		},
+		{
+			"DashDash",
 			[]string{"--", "--flag", "foobar"},
 			"",
-			[]string{"--flag", "foobar"},
+			[]string{"--", "--flag", "foobar"},
 		},
 		{
 			"UnrecognizedFlagAndArgs",