Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions flag_text.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package cli

import (
"encoding"
"strings"
)

// TextMarshalUnmarshaler is the interface implemented by types that can marshal
// themselves to and from a textual form. It is the value type used by TextFlag,
// mirroring the standard library's flag.TextVar, and is satisfied by types such
// as *slog.LevelVar, *net/netip.Addr, and *time.Time.
type TextMarshalUnmarshaler interface {
encoding.TextMarshaler
encoding.TextUnmarshaler
}

type TextFlag = FlagBase[TextMarshalUnmarshaler, StringConfig, textValue]

// -- TextMarshalUnmarshaler Value
type textValue struct {
destination TextMarshalUnmarshaler
trimSpace bool
}

// Below functions are to satisfy the ValueCreator interface

func (t textValue) Create(val TextMarshalUnmarshaler, p *TextMarshalUnmarshaler, c StringConfig) Value {
// Only overwrite the target when a non-nil default value is given, so that
// a Destination pointing at an existing target is preserved (unlike the
// concrete flag types, T here is an interface whose nil default would
// otherwise clobber the destination).
if val != nil {
*p = val
}
return &textValue{
destination: *p,
trimSpace: c.TrimSpace,
}
}

func (t textValue) ToString(val TextMarshalUnmarshaler) string {
if val == nil {
return ""
}
text, err := val.MarshalText()
if err != nil {
return ""
}
return string(text)
}

// Below functions are to satisfy the flag.Value interface

func (t *textValue) Set(val string) error {
if t.destination == nil {
return nil
}
if t.trimSpace {
val = strings.TrimSpace(val)
}
return t.destination.UnmarshalText([]byte(val))
}

func (t *textValue) Get() any { return t.destination }

func (t *textValue) String() string {
if t.destination == nil {
return ""
}
text, err := t.destination.MarshalText()
if err != nil {
return ""
}
return string(text)
}

// Text looks up the value of a local TextFlag, returns nil if not found
func (cmd *Command) Text(name string) TextMarshalUnmarshaler {
if v, ok := cmd.Value(name).(TextMarshalUnmarshaler); ok {
tracef("text available for flag name %[1]q with value=%[2]v (cmd=%[3]q)", name, v, cmd.Name)
return v
}

tracef("text NOT available for flag name %[1]q (cmd=%[2]q)", name, cmd.Name)
return nil
}
158 changes: 158 additions & 0 deletions flag_text_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cli

import (
"errors"
"log/slog"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// errText is a TextMarshalUnmarshaler whose MarshalText always fails, used to
// exercise the error branches of textValue.ToString and textValue.String.
type errText struct{}

func (errText) MarshalText() ([]byte, error) { return nil, errors.New("marshal boom") }

func (errText) UnmarshalText([]byte) error { return nil }

func TestTextFlagSetFromArg(t *testing.T) {
lv := &slog.LevelVar{}
cmd := &Command{
Flags: []Flag{
&TextFlag{Name: "level", Value: lv},
},
}

require.NoError(t, cmd.Run(buildTestContext(t), []string{"", "--level", "WARN"}))
assert.Equal(t, slog.LevelWarn, lv.Level())
}

func TestTextFlagDefaultValue(t *testing.T) {
lv := &slog.LevelVar{}
lv.Set(slog.LevelError)
cmd := &Command{
Flags: []Flag{
&TextFlag{Name: "level", Value: lv},
},
}

// Without the flag being passed, the value keeps its default.
require.NoError(t, cmd.Run(buildTestContext(t), []string{""}))
assert.Equal(t, slog.LevelError, lv.Level())
}

func TestTextFlagDestination(t *testing.T) {
lv := &slog.LevelVar{}
var dest TextMarshalUnmarshaler = lv
cmd := &Command{
Flags: []Flag{
&TextFlag{Name: "level", Destination: &dest},
},
}

require.NoError(t, cmd.Run(buildTestContext(t), []string{"", "--level", "DEBUG"}))
assert.Equal(t, slog.LevelDebug, lv.Level())
}

func TestTextFlagTrimSpace(t *testing.T) {
lv := &slog.LevelVar{}
cmd := &Command{
Flags: []Flag{
&TextFlag{Name: "level", Value: lv, Config: StringConfig{TrimSpace: true}},
},
}

require.NoError(t, cmd.Run(buildTestContext(t), []string{"", "--level", " INFO "}))
assert.Equal(t, slog.LevelInfo, lv.Level())
}

func TestTextFlagFromEnvSource(t *testing.T) {
t.Setenv("LOG_LEVEL", "ERROR")
lv := &slog.LevelVar{}
cmd := &Command{
Flags: []Flag{
&TextFlag{Name: "level", Value: lv, Sources: EnvVars("LOG_LEVEL")},
},
}

require.NoError(t, cmd.Run(buildTestContext(t), []string{""}))
assert.Equal(t, slog.LevelError, lv.Level())
}

func TestTextFlagInvalidValue(t *testing.T) {
lv := &slog.LevelVar{}
cmd := &Command{
Flags: []Flag{
&TextFlag{Name: "level", Value: lv},
},
}

err := cmd.Run(buildTestContext(t), []string{"", "--level", "NOPE"})
require.Error(t, err)
}

func TestTextFlagValueFromCommand(t *testing.T) {
lv := &slog.LevelVar{}
f := &TextFlag{Name: "level", Value: lv}
cmd := &Command{
Flags: []Flag{f},
}

require.NoError(t, cmd.Set("level", "WARN"))
require.Equal(t, lv, cmd.Text(f.Name))
}

func TestTextFlagTextNotAvailable(t *testing.T) {
cmd := &Command{
Flags: []Flag{
&StringFlag{Name: "str"},
},
}

// The flag exists but its value is a string, not a
// TextMarshalUnmarshaler, so Text returns nil.
require.NoError(t, cmd.Set("str", "value"))
assert.Nil(t, cmd.Text("str"))

// An unknown flag name also returns nil.
assert.Nil(t, cmd.Text("missing"))
}

func TestTextValueToString(t *testing.T) {
var tv textValue

// A nil value marshals to the empty string.
assert.Equal(t, "", tv.ToString(nil))

// A MarshalText error is swallowed and yields the empty string.
assert.Equal(t, "", tv.ToString(errText{}))

// A value that marshals cleanly is rendered.
lv := &slog.LevelVar{}
lv.Set(slog.LevelWarn)
assert.Equal(t, "WARN", tv.ToString(lv))
}

func TestTextValueSetNilDestination(t *testing.T) {
// Set is a no-op when there is no destination to unmarshal into.
tv := &textValue{}
require.NoError(t, tv.Set("anything"))
}

func TestTextValueString(t *testing.T) {
// A nil destination stringifies to the empty string.
tv := &textValue{}
assert.Equal(t, "", tv.String())

// A MarshalText error is swallowed and yields the empty string.
tvErr := &textValue{destination: errText{}}
assert.Equal(t, "", tvErr.String())

// A destination that marshals cleanly is rendered.
lv := &slog.LevelVar{}
lv.Set(slog.LevelWarn)
tvOK := &textValue{destination: lv}
assert.Equal(t, "WARN", tvOK.String())
}
14 changes: 14 additions & 0 deletions godoc-current.txt
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,9 @@ func (cmd *Command) StringSlice(name string) []string
StringSlice looks up the value of a local StringSliceFlag, returns nil if
not found

func (cmd *Command) Text(name string) TextMarshalUnmarshaler
Text looks up the value of a local TextFlag, returns nil if not found

func (cmd *Command) Timestamp(name string) time.Time
Timestamp gets the timestamp from a flag name

Expand Down Expand Up @@ -1416,6 +1419,17 @@ type SuggestCommandFunc func(commands []*Command, provided string) string

type SuggestFlagFunc func(flags []Flag, provided string, hideHelp bool) string

type TextFlag = FlagBase[TextMarshalUnmarshaler, StringConfig, textValue]

type TextMarshalUnmarshaler interface {
encoding.TextMarshaler
encoding.TextUnmarshaler
}
TextMarshalUnmarshaler is the interface implemented by types that can
marshal themselves to and from a textual form. It is the value type used by
TextFlag, mirroring the standard library's flag.TextVar, and is satisfied by
types such as *slog.LevelVar, *net/netip.Addr, and *time.Time.

type TimestampArg = ArgumentBase[time.Time, TimestampConfig, timestampValue]

type TimestampArgs = ArgumentsBase[time.Time, TimestampConfig, timestampValue]
Expand Down
14 changes: 14 additions & 0 deletions testdata/godoc-v3.x.txt
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,9 @@ func (cmd *Command) StringSlice(name string) []string
StringSlice looks up the value of a local StringSliceFlag, returns nil if
not found

func (cmd *Command) Text(name string) TextMarshalUnmarshaler
Text looks up the value of a local TextFlag, returns nil if not found

func (cmd *Command) Timestamp(name string) time.Time
Timestamp gets the timestamp from a flag name

Expand Down Expand Up @@ -1416,6 +1419,17 @@ type SuggestCommandFunc func(commands []*Command, provided string) string

type SuggestFlagFunc func(flags []Flag, provided string, hideHelp bool) string

type TextFlag = FlagBase[TextMarshalUnmarshaler, StringConfig, textValue]

type TextMarshalUnmarshaler interface {
encoding.TextMarshaler
encoding.TextUnmarshaler
}
TextMarshalUnmarshaler is the interface implemented by types that can
marshal themselves to and from a textual form. It is the value type used by
TextFlag, mirroring the standard library's flag.TextVar, and is satisfied by
types such as *slog.LevelVar, *net/netip.Addr, and *time.Time.

type TimestampArg = ArgumentBase[time.Time, TimestampConfig, timestampValue]

type TimestampArgs = ArgumentsBase[time.Time, TimestampConfig, timestampValue]
Expand Down