diff --git a/.vscode/settings.json b/.vscode/settings.json index 13bb05a..1ae5a11 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,6 +16,7 @@ }, "cSpell.words": [ "bitmask", + "Printfln", "Upsert", "upserter" ] diff --git a/cmd/disable/disable.go b/cmd/disable/disable.go index 8924d2f..7c395e0 100644 --- a/cmd/disable/disable.go +++ b/cmd/disable/disable.go @@ -1,12 +1,12 @@ package disable import ( - "errors" "fmt" "github.com/jippi/dottie/pkg" "github.com/jippi/dottie/pkg/cli/shared" "github.com/jippi/dottie/pkg/render" + "github.com/jippi/dottie/pkg/tui" "github.com/spf13/cobra" ) @@ -15,13 +15,10 @@ func NewCommand() *cobra.Command { Use: "disable KEY", Short: "Disable (comment out) a KEY if it exists", GroupID: "manipulate", + Args: cobra.ExactArgs(1), ValidArgsFunction: shared.NewCompleter().WithHandlers(render.ExcludeDisabledAssignments).Get(), RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return errors.New("Missing required argument: KEY") - } - - key := args[0] + key := cmd.Flags().Arg(0) filename := cmd.Flag("file").Value.String() @@ -30,14 +27,28 @@ func NewCommand() *cobra.Command { return err } - existing := env.Get(key) - if existing == nil { - return fmt.Errorf("Could not find KEY [%s]", key) + assignment := env.Get(key) + if assignment == nil { + return fmt.Errorf("Could not find KEY [ %s ]", key) + } + + if !assignment.Enabled { + tui.MaybePrintWarnings(cmd.Context(), fmt.Errorf("The key [ %s ] is already disabled", key)) + + return nil + } + + assignment.Disable() + + if err := pkg.Save(cmd.Context(), filename, env); err != nil { + return fmt.Errorf("could not save file: %w", err) } - existing.Disable() + tui.StdoutFromContext(cmd.Context()). + Success(). + Printfln("Key [ %s ] was successfully disabled", key) - return pkg.Save(filename, env) + return nil }, } } diff --git a/cmd/disable/disable_test.go b/cmd/disable/disable_test.go index 0b53104..088db2d 100644 --- a/cmd/disable/disable_test.go +++ b/cmd/disable/disable_test.go @@ -6,8 +6,8 @@ import ( "github.com/jippi/dottie/pkg/test_helpers" ) -func TestCommand(t *testing.T) { +func TestDisableCommand(t *testing.T) { t.Parallel() - test_helpers.RunFilebasedCommandTests(t, 0, "disable") + test_helpers.RunFileBasedCommandTests(t, 0, "disable") } diff --git a/cmd/disable/tests/disable-a-key-already-disabled.command.txt b/cmd/disable/tests/disable-a-key-already-disabled.run similarity index 100% rename from cmd/disable/tests/disable-a-key-already-disabled.command.txt rename to cmd/disable/tests/disable-a-key-already-disabled.run diff --git a/cmd/disable/tests/disable-a-key-already-disabled/stderr.golden b/cmd/disable/tests/disable-a-key-already-disabled/stderr.golden new file mode 100644 index 0000000..5f374f5 --- /dev/null +++ b/cmd/disable/tests/disable-a-key-already-disabled/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [disable KEY_B] +WARNING: The key [ KEY_B ] is already disabled diff --git a/cmd/disable/tests/disable-a-key-already-disabled/stdout.golden b/cmd/disable/tests/disable-a-key-already-disabled/stdout.golden new file mode 100644 index 0000000..b8fe0b5 --- /dev/null +++ b/cmd/disable/tests/disable-a-key-already-disabled/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [disable KEY_B] +(no output to stdout) diff --git a/cmd/disable/tests/disable-a-key/stderr.golden b/cmd/disable/tests/disable-a-key/stderr.golden new file mode 100644 index 0000000..b9de686 --- /dev/null +++ b/cmd/disable/tests/disable-a-key/stderr.golden @@ -0,0 +1 @@ +---- exec command line 0: [disable KEY_B] diff --git a/cmd/disable/tests/disable-a-key/stdout.golden b/cmd/disable/tests/disable-a-key/stdout.golden new file mode 100644 index 0000000..69e127d --- /dev/null +++ b/cmd/disable/tests/disable-a-key/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [disable KEY_B] +Key [ KEY_B ] was successfully disabled diff --git a/cmd/disable/tests/disable-key-b.env b/cmd/disable/tests/disable-key-b.env new file mode 100644 index 0000000..710f007 --- /dev/null +++ b/cmd/disable/tests/disable-key-b.env @@ -0,0 +1,6 @@ +KEY_A="I'm key A" + +# Comment for KEY_B +KEY_B="I'm key B" + +KEY_C="I'm key C" diff --git a/cmd/disable/tests/disable-a-key.command.txt b/cmd/disable/tests/disable-key-b.run similarity index 100% rename from cmd/disable/tests/disable-a-key.command.txt rename to cmd/disable/tests/disable-key-b.run diff --git a/cmd/disable/tests/disable-a-key.env b/cmd/disable/tests/disable-key-b/env.golden similarity index 100% rename from cmd/disable/tests/disable-a-key.env rename to cmd/disable/tests/disable-key-b/env.golden diff --git a/cmd/disable/tests/disable-key-b/stderr.golden b/cmd/disable/tests/disable-key-b/stderr.golden new file mode 100644 index 0000000..7a47971 --- /dev/null +++ b/cmd/disable/tests/disable-key-b/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [disable KEY_B] +(no output to stderr) diff --git a/cmd/disable/tests/disable-key-b/stdout.golden b/cmd/disable/tests/disable-key-b/stdout.golden new file mode 100644 index 0000000..69e127d --- /dev/null +++ b/cmd/disable/tests/disable-key-b/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [disable KEY_B] +Key [ KEY_B ] was successfully disabled diff --git a/cmd/disable/tests/invalid-key.command.txt b/cmd/disable/tests/invalid-key.run similarity index 100% rename from cmd/disable/tests/invalid-key.command.txt rename to cmd/disable/tests/invalid-key.run diff --git a/cmd/disable/tests/invalid-key/stderr.golden b/cmd/disable/tests/invalid-key/stderr.golden index 9a17e3f..39a71a7 100644 --- a/cmd/disable/tests/invalid-key/stderr.golden +++ b/cmd/disable/tests/invalid-key/stderr.golden @@ -1 +1,3 @@ -Could not find KEY [NONEXISTING_KEY] \ No newline at end of file +---- exec command line 0: [disable NONEXISTING_KEY] +Error: Could not find KEY [ NONEXISTING_KEY ] +Run 'dottie disable --help' for usage. diff --git a/cmd/disable/tests/invalid-key/stdout.golden b/cmd/disable/tests/invalid-key/stdout.golden new file mode 100644 index 0000000..f791f4f --- /dev/null +++ b/cmd/disable/tests/invalid-key/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [disable NONEXISTING_KEY] +(no output to stdout) diff --git a/cmd/disable/tests/missing-key.command.txt b/cmd/disable/tests/missing-key.run similarity index 100% rename from cmd/disable/tests/missing-key.command.txt rename to cmd/disable/tests/missing-key.run diff --git a/cmd/disable/tests/missing-key/stderr.golden b/cmd/disable/tests/missing-key/stderr.golden index 252b497..0c56eca 100644 --- a/cmd/disable/tests/missing-key/stderr.golden +++ b/cmd/disable/tests/missing-key/stderr.golden @@ -1 +1,3 @@ -Missing required argument: KEY \ No newline at end of file +---- exec command line 0: [disable] +Error: accepts 1 arg(s), received 0 +Run 'dottie disable --help' for usage. diff --git a/cmd/disable/tests/missing-key/stdout.golden b/cmd/disable/tests/missing-key/stdout.golden new file mode 100644 index 0000000..ade1f17 --- /dev/null +++ b/cmd/disable/tests/missing-key/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [disable] +(no output to stdout) diff --git a/cmd/enable/enable.go b/cmd/enable/enable.go index eec96af..e7cec0b 100644 --- a/cmd/enable/enable.go +++ b/cmd/enable/enable.go @@ -1,12 +1,12 @@ package enable import ( - "errors" "fmt" "github.com/jippi/dottie/pkg" "github.com/jippi/dottie/pkg/cli/shared" "github.com/jippi/dottie/pkg/render" + "github.com/jippi/dottie/pkg/tui" "github.com/spf13/cobra" ) @@ -14,30 +14,39 @@ func NewCommand() *cobra.Command { return &cobra.Command{ Use: "enable KEY", Short: "Enable (uncomment) a KEY if it exists", + Args: cobra.ExactArgs(1), GroupID: "manipulate", ValidArgsFunction: shared.NewCompleter().WithHandlers(render.ExcludeActiveAssignments).Get(), RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return errors.New("Missing required argument: KEY") - } - filename := cmd.Flag("file").Value.String() - env, err := pkg.Load(filename) + document, err := pkg.Load(filename) if err != nil { return err } - key := args[0] + key := cmd.Flags().Arg(0) + + assignment := document.Get(key) + if assignment == nil { + return fmt.Errorf("Could not find KEY [ %s ]", key) + } + + if assignment.Enabled { + tui.MaybePrintWarnings(cmd.Context(), fmt.Errorf("The key [ %s ] is already enabled", key)) + } + + assignment.Enable() - existing := env.Get(key) - if existing == nil { - return fmt.Errorf("Could not find KEY [%s]", key) + if err := pkg.Save(cmd.Context(), filename, document); err != nil { + return fmt.Errorf("could not save file: %w", err) } - existing.Enable() + tui.StdoutFromContext(cmd.Context()). + Success(). + Printfln("Key [ %s ] was successfully enabled", key) - return pkg.Save(filename, env) + return nil }, } } diff --git a/cmd/enable/enable_test.go b/cmd/enable/enable_test.go index 4e39650..42eed73 100644 --- a/cmd/enable/enable_test.go +++ b/cmd/enable/enable_test.go @@ -6,8 +6,8 @@ import ( "github.com/jippi/dottie/pkg/test_helpers" ) -func TestCommand(t *testing.T) { +func TestEnableCommand(t *testing.T) { t.Parallel() - test_helpers.RunFilebasedCommandTests(t, 0, "enable") + test_helpers.RunFileBasedCommandTests(t, 0, "enable") } diff --git a/cmd/enable/tests/enable-a-key-already-enabled.command.txt b/cmd/enable/tests/enable-a-key-already-enabled.run similarity index 100% rename from cmd/enable/tests/enable-a-key-already-enabled.command.txt rename to cmd/enable/tests/enable-a-key-already-enabled.run diff --git a/cmd/enable/tests/enable-a-key-already-enabled/stderr.golden b/cmd/enable/tests/enable-a-key-already-enabled/stderr.golden new file mode 100644 index 0000000..e1955d6 --- /dev/null +++ b/cmd/enable/tests/enable-a-key-already-enabled/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [enable KEY_B] +WARNING: The key [ KEY_B ] is already enabled diff --git a/cmd/enable/tests/enable-a-key-already-enabled/stdout.golden b/cmd/enable/tests/enable-a-key-already-enabled/stdout.golden new file mode 100644 index 0000000..cf24473 --- /dev/null +++ b/cmd/enable/tests/enable-a-key-already-enabled/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [enable KEY_B] +Key [ KEY_B ] was successfully enabled diff --git a/cmd/enable/tests/enable-a-key.command.txt b/cmd/enable/tests/enable-a-key.run similarity index 100% rename from cmd/enable/tests/enable-a-key.command.txt rename to cmd/enable/tests/enable-a-key.run diff --git a/cmd/enable/tests/enable-a-key/stderr.golden b/cmd/enable/tests/enable-a-key/stderr.golden new file mode 100644 index 0000000..6915875 --- /dev/null +++ b/cmd/enable/tests/enable-a-key/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [enable KEY_B] +(no output to stderr) diff --git a/cmd/enable/tests/enable-a-key/stdout.golden b/cmd/enable/tests/enable-a-key/stdout.golden new file mode 100644 index 0000000..cf24473 --- /dev/null +++ b/cmd/enable/tests/enable-a-key/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [enable KEY_B] +Key [ KEY_B ] was successfully enabled diff --git a/cmd/enable/tests/invalid-key.command.txt b/cmd/enable/tests/invalid-key.run similarity index 100% rename from cmd/enable/tests/invalid-key.command.txt rename to cmd/enable/tests/invalid-key.run diff --git a/cmd/enable/tests/invalid-key/stderr.golden b/cmd/enable/tests/invalid-key/stderr.golden index 9a17e3f..75ec7f6 100644 --- a/cmd/enable/tests/invalid-key/stderr.golden +++ b/cmd/enable/tests/invalid-key/stderr.golden @@ -1 +1,3 @@ -Could not find KEY [NONEXISTING_KEY] \ No newline at end of file +---- exec command line 0: [enable NONEXISTING_KEY] +Error: Could not find KEY [ NONEXISTING_KEY ] +Run 'dottie enable --help' for usage. diff --git a/cmd/enable/tests/invalid-key/stdout.golden b/cmd/enable/tests/invalid-key/stdout.golden new file mode 100644 index 0000000..f53d6f0 --- /dev/null +++ b/cmd/enable/tests/invalid-key/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [enable NONEXISTING_KEY] +(no output to stdout) diff --git a/cmd/enable/tests/missing-key.command.txt b/cmd/enable/tests/missing-key.run similarity index 100% rename from cmd/enable/tests/missing-key.command.txt rename to cmd/enable/tests/missing-key.run diff --git a/cmd/enable/tests/missing-key/stderr.golden b/cmd/enable/tests/missing-key/stderr.golden index 252b497..a3c97c0 100644 --- a/cmd/enable/tests/missing-key/stderr.golden +++ b/cmd/enable/tests/missing-key/stderr.golden @@ -1 +1,3 @@ -Missing required argument: KEY \ No newline at end of file +---- exec command line 0: [enable] +Error: accepts 1 arg(s), received 0 +Run 'dottie enable --help' for usage. diff --git a/cmd/enable/tests/missing-key/stdout.golden b/cmd/enable/tests/missing-key/stdout.golden new file mode 100644 index 0000000..0a0fea8 --- /dev/null +++ b/cmd/enable/tests/missing-key/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [enable] +(no output to stdout) diff --git a/cmd/fmt/fmt.go b/cmd/fmt/fmt.go index d7bdc98..0dddd04 100644 --- a/cmd/fmt/fmt.go +++ b/cmd/fmt/fmt.go @@ -10,20 +10,23 @@ func NewCommand() *cobra.Command { return &cobra.Command{ Use: "fmt", Short: "Format a .env file", + Args: cobra.NoArgs, GroupID: "manipulate", RunE: func(cmd *cobra.Command, args []string) error { filename := cmd.Flag("file").Value.String() - env, err := pkg.Load(filename) + document, err := pkg.Load(filename) if err != nil { return err } - if err := pkg.Save(filename, env); err != nil { + if err := pkg.Save(cmd.Context(), filename, document); err != nil { return err } - tui.Theme.Success.StdoutPrinter().Printfln("File [%s] was successfully formatted", filename) + tui.StdoutFromContext(cmd.Context()). + Success(). + Printfln("File [ %s ] was successfully formatted", filename) return nil }, diff --git a/cmd/groups/groups.go b/cmd/groups/groups.go index aff3acc..f961b32 100644 --- a/cmd/groups/groups.go +++ b/cmd/groups/groups.go @@ -15,31 +15,33 @@ func NewCommand() *cobra.Command { return &cobra.Command{ Use: "groups", Short: "Print groups found in the .env file", + Args: cobra.NoArgs, GroupID: "output", RunE: func(cmd *cobra.Command, args []string) error { filename := cmd.Flag("file").Value.String() - env, err := pkg.Load(filename) + document, err := pkg.Load(filename) if err != nil { return err } - groups := env.Groups + groups := document.Groups if len(groups) == 0 { return errors.New("No groups found") } - width := longesGroupName(groups) + maxWidth := longesGroupName(groups) - light := tui.Theme.Secondary.BuffPrinter(cmd.OutOrStdout()) - key := tui.Theme.Primary.BuffPrinter(cmd.OutOrStdout()) - info := tui.Theme.Info.BuffPrinter(cmd.OutOrStdout()) - info.Box("Groups in " + filename) + stdout := tui.StdoutFromContext(cmd.Context()) + primary := stdout.Primary() + secondary := stdout.Secondary() + + stdout.Info().Box("Groups in " + filename) for _, group := range groups { - key.Printf("%-"+strconv.Itoa(width)+"s", slug.Make(group.String())) - key.Print(" ") - light.Printfln("(%s:%d)", filename, group.Position.FirstLine) + primary.Printf("%-"+strconv.Itoa(maxWidth)+"s", slug.Make(group.String())) + primary.Print(" ") + secondary.Printfln("(%s:%d)", filename, group.Position.FirstLine) } return nil diff --git a/cmd/groups/groups_test.go b/cmd/groups/groups_test.go index b99d17c..8291f96 100644 --- a/cmd/groups/groups_test.go +++ b/cmd/groups/groups_test.go @@ -6,8 +6,8 @@ import ( "github.com/jippi/dottie/pkg/test_helpers" ) -func TestCommand(t *testing.T) { +func TestGroupsCommand(t *testing.T) { t.Parallel() - test_helpers.RunFilebasedCommandTests(t, test_helpers.SkipEnvCopy, "groups") + test_helpers.RunFileBasedCommandTests(t, test_helpers.ReadOnly, "groups") } diff --git a/cmd/groups/tests/multiple-groups.command.txt b/cmd/groups/tests/multiple-groups.run similarity index 100% rename from cmd/groups/tests/multiple-groups.command.txt rename to cmd/groups/tests/multiple-groups.run diff --git a/cmd/groups/tests/multiple-groups/stderr.golden b/cmd/groups/tests/multiple-groups/stderr.golden new file mode 100644 index 0000000..a4fd48d --- /dev/null +++ b/cmd/groups/tests/multiple-groups/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [groups] +(no output to stderr) diff --git a/cmd/groups/tests/multiple-groups/stdout.golden b/cmd/groups/tests/multiple-groups/stdout.golden index 044f458..c54c0e7 100644 --- a/cmd/groups/tests/multiple-groups/stdout.golden +++ b/cmd/groups/tests/multiple-groups/stdout.golden @@ -1,8 +1,9 @@ -┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Groups in tests/multiple-groups.env │ -│ │ -└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +---- exec command line 0: [groups] +┌──────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Groups in tests/multiple-groups.env │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ my-first-group (tests/multiple-groups.env:2) my-second-group (tests/multiple-groups.env:13) my-third-group (tests/multiple-groups.env:17) diff --git a/cmd/groups/tests/no-groups.command.txt b/cmd/groups/tests/no-groups.run similarity index 100% rename from cmd/groups/tests/no-groups.command.txt rename to cmd/groups/tests/no-groups.run diff --git a/cmd/groups/tests/no-groups/stderr.golden b/cmd/groups/tests/no-groups/stderr.golden index 42b9460..333fabb 100644 --- a/cmd/groups/tests/no-groups/stderr.golden +++ b/cmd/groups/tests/no-groups/stderr.golden @@ -1 +1,3 @@ -No groups found \ No newline at end of file +---- exec command line 0: [groups] +Error: No groups found +Run 'dottie groups --help' for usage. diff --git a/cmd/groups/tests/no-groups/stdout.golden b/cmd/groups/tests/no-groups/stdout.golden new file mode 100644 index 0000000..ec6a993 --- /dev/null +++ b/cmd/groups/tests/no-groups/stdout.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [groups] +(no output to stdout) diff --git a/cmd/groups/tests/single-group.command.txt b/cmd/groups/tests/single-group.run similarity index 100% rename from cmd/groups/tests/single-group.command.txt rename to cmd/groups/tests/single-group.run diff --git a/cmd/groups/tests/single-group/stderr.golden b/cmd/groups/tests/single-group/stderr.golden new file mode 100644 index 0000000..a4fd48d --- /dev/null +++ b/cmd/groups/tests/single-group/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [groups] +(no output to stderr) diff --git a/cmd/groups/tests/single-group/stdout.golden b/cmd/groups/tests/single-group/stdout.golden index 18777e6..d449141 100644 --- a/cmd/groups/tests/single-group/stdout.golden +++ b/cmd/groups/tests/single-group/stdout.golden @@ -1,6 +1,7 @@ -┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ Groups in tests/single-group.env │ -│ │ -└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +---- exec command line 0: [groups] +┌──────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Groups in tests/single-group.env │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ my-group (tests/single-group.env:2) diff --git a/cmd/json/json.go b/cmd/json/json.go index e285b2d..4baa465 100644 --- a/cmd/json/json.go +++ b/cmd/json/json.go @@ -12,21 +12,22 @@ func NewCommand() *cobra.Command { return &cobra.Command{ Use: "json", Short: "Print as JSON", + Args: cobra.NoArgs, GroupID: "output", RunE: func(cmd *cobra.Command, args []string) error { filename := cmd.Flag("file").Value.String() - env, err := pkg.Load(filename) + document, err := pkg.Load(filename) if err != nil { return err } - b, err := json.MarshalIndent(env, "", " ") + output, err := json.MarshalIndent(document, "", " ") if err != nil { return err } - fmt.Println(string(b)) + fmt.Println(string(output)) return nil }, diff --git a/cmd/print/prin_test.go b/cmd/print/prin_test.go deleted file mode 100644 index 937c571..0000000 --- a/cmd/print/prin_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package print_cmd_test - -import ( - "testing" - - "github.com/jippi/dottie/pkg/test_helpers" -) - -func TestCommand(t *testing.T) { - t.Parallel() - - test_helpers.RunFilebasedCommandTests(t, test_helpers.SkipEnvCopy, "print") -} diff --git a/cmd/print/print.go b/cmd/print/print.go index 6c505e0..15f4d41 100644 --- a/cmd/print/print.go +++ b/cmd/print/print.go @@ -1,15 +1,12 @@ package print_cmd import ( - "fmt" - "github.com/jippi/dottie/pkg" "github.com/jippi/dottie/pkg/ast" "github.com/jippi/dottie/pkg/cli/shared" "github.com/jippi/dottie/pkg/render" "github.com/jippi/dottie/pkg/tui" "github.com/spf13/cobra" - "github.com/spf13/pflag" "go.uber.org/multierr" ) @@ -17,20 +14,9 @@ func NewCommand() *cobra.Command { cmd := &cobra.Command{ Use: "print", Short: "Print environment variables", + Args: cobra.NoArgs, GroupID: "output", - RunE: func(cmd *cobra.Command, args []string) error { - env, settings, warnings, err := setup(cmd.Flags()) - if warnings != nil { - tui.Theme.Warning.BuffPrinter(cmd.ErrOrStderr()).Printfln("%+v", warnings) - } - if err != nil { - return err - } - - fmt.Fprintln(cmd.OutOrStdout(), render.NewRenderer(*settings).Statement(env).String()) - - return nil - }, + RunE: runE, } cmd.Flags().Bool("pretty", false, "implies --color --comments --blank-lines --group-banners") @@ -48,7 +34,26 @@ func NewCommand() *cobra.Command { return cmd } -func setup(flags *pflag.FlagSet) (*ast.Document, *render.Settings, error, error) { +func runE(cmd *cobra.Command, args []string) error { + document, settings, err := setup(cmd) + if err != nil { + return err + } + + tui.StdoutFromContext(cmd.Context()). + NoColor(). + Println( + render.NewRenderer(*settings). + Statement(cmd.Context(), document). + String(), + ) + + return nil +} + +func setup(cmd *cobra.Command) (*ast.Document, *render.Settings, error) { + flags := cmd.Flags() + boolFlag := func(name string) bool { return shared.BoolFlag(flags, name) } @@ -59,7 +64,7 @@ func setup(flags *pflag.FlagSet) (*ast.Document, *render.Settings, error, error) doc, err := pkg.Load(stringFlag("file")) if err != nil { - return nil, nil, nil, err + return nil, nil, err } settings := render.NewSettings( @@ -95,5 +100,7 @@ func setup(flags *pflag.FlagSet) (*ast.Document, *render.Settings, error, error) settings.Apply(render.WithFormattedOutput(true)) } - return doc, settings, allWarnings, allErrors + tui.MaybePrintWarnings(cmd.Context(), allWarnings) + + return doc, settings, allErrors } diff --git a/cmd/print/print_test.go b/cmd/print/print_test.go new file mode 100644 index 0000000..6ccc9af --- /dev/null +++ b/cmd/print/print_test.go @@ -0,0 +1,13 @@ +package print_cmd_test + +import ( + "testing" + + "github.com/jippi/dottie/pkg/test_helpers" +) + +func TestPrintCommand(t *testing.T) { + t.Parallel() + + test_helpers.RunFileBasedCommandTests(t, test_helpers.ReadOnly, "print") +} diff --git a/cmd/print/tests/empty.command.txt b/cmd/print/tests/empty.run similarity index 100% rename from cmd/print/tests/empty.command.txt rename to cmd/print/tests/empty.run diff --git a/cmd/print/tests/empty/stderr.golden b/cmd/print/tests/empty/stderr.golden new file mode 100644 index 0000000..069fc9c --- /dev/null +++ b/cmd/print/tests/empty/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [print --no-color] +(no output to stderr) diff --git a/cmd/print/tests/empty/stdout.golden b/cmd/print/tests/empty/stdout.golden index 139597f..34f18f8 100644 --- a/cmd/print/tests/empty/stdout.golden +++ b/cmd/print/tests/empty/stdout.golden @@ -1,2 +1,3 @@ +---- exec command line 0: [print --no-color] diff --git a/cmd/print/tests/full.command.txt b/cmd/print/tests/full.command.txt deleted file mode 100644 index 89701d5..0000000 --- a/cmd/print/tests/full.command.txt +++ /dev/null @@ -1,2 +0,0 @@ ---pretty ---no-color diff --git a/cmd/print/tests/full.run b/cmd/print/tests/full.run new file mode 100644 index 0000000..13e33d6 --- /dev/null +++ b/cmd/print/tests/full.run @@ -0,0 +1 @@ +--no-color --pretty diff --git a/cmd/print/tests/full/stderr.golden b/cmd/print/tests/full/stderr.golden new file mode 100644 index 0000000..22e39bb --- /dev/null +++ b/cmd/print/tests/full/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [print --no-color --pretty] +(no output to stderr) diff --git a/cmd/print/tests/full/stdout.golden b/cmd/print/tests/full/stdout.golden index 37e14d0..42a2e27 100644 --- a/cmd/print/tests/full/stdout.golden +++ b/cmd/print/tests/full/stdout.golden @@ -1,3 +1,4 @@ +---- exec command line 0: [print --no-color --pretty] ################################################################################ # My first group ################################################################################ diff --git a/cmd/print/tests/simple-pretty.env b/cmd/print/tests/simple-pretty.env index 710f007..d10d7cd 100644 --- a/cmd/print/tests/simple-pretty.env +++ b/cmd/print/tests/simple-pretty.env @@ -1,6 +1,4 @@ KEY_A="I'm key A" - # Comment for KEY_B KEY_B="I'm key B" - KEY_C="I'm key C" diff --git a/cmd/print/tests/simple-pretty.run b/cmd/print/tests/simple-pretty.run new file mode 100644 index 0000000..13e33d6 --- /dev/null +++ b/cmd/print/tests/simple-pretty.run @@ -0,0 +1 @@ +--no-color --pretty diff --git a/cmd/print/tests/simple-pretty/stderr.golden b/cmd/print/tests/simple-pretty/stderr.golden new file mode 100644 index 0000000..22e39bb --- /dev/null +++ b/cmd/print/tests/simple-pretty/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [print --no-color --pretty] +(no output to stderr) diff --git a/cmd/print/tests/simple-pretty/stdout.golden b/cmd/print/tests/simple-pretty/stdout.golden index 6a7ce24..cc9e540 100644 --- a/cmd/print/tests/simple-pretty/stdout.golden +++ b/cmd/print/tests/simple-pretty/stdout.golden @@ -1,4 +1,8 @@ +---- exec command line 0: [print --no-color --pretty] KEY_A="I'm key A" + +# Comment for KEY_B KEY_B="I'm key B" + KEY_C="I'm key C" diff --git a/cmd/print/tests/simple.command.txt b/cmd/print/tests/simple.command.txt deleted file mode 100644 index 6150730..0000000 --- a/cmd/print/tests/simple.command.txt +++ /dev/null @@ -1 +0,0 @@ ---no-color diff --git a/cmd/print/tests/simple-pretty.command.txt b/cmd/print/tests/simple.run similarity index 100% rename from cmd/print/tests/simple-pretty.command.txt rename to cmd/print/tests/simple.run diff --git a/cmd/print/tests/simple/stderr.golden b/cmd/print/tests/simple/stderr.golden new file mode 100644 index 0000000..069fc9c --- /dev/null +++ b/cmd/print/tests/simple/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [print --no-color] +(no output to stderr) diff --git a/cmd/print/tests/simple/stdout.golden b/cmd/print/tests/simple/stdout.golden index 6a7ce24..c650e31 100644 --- a/cmd/print/tests/simple/stdout.golden +++ b/cmd/print/tests/simple/stdout.golden @@ -1,3 +1,4 @@ +---- exec command line 0: [print --no-color] KEY_A="I'm key A" KEY_B="I'm key B" KEY_C="I'm key C" diff --git a/cmd/print/tests/specific-group.command.txt b/cmd/print/tests/specific-group.command.txt deleted file mode 100644 index de5ce56..0000000 --- a/cmd/print/tests/specific-group.command.txt +++ /dev/null @@ -1,3 +0,0 @@ ---no-color ---group -my-first-group diff --git a/cmd/print/tests/specific-group.run b/cmd/print/tests/specific-group.run new file mode 100644 index 0000000..e145c7f --- /dev/null +++ b/cmd/print/tests/specific-group.run @@ -0,0 +1 @@ +--no-color --group my-first-group diff --git a/cmd/print/tests/specific-group/stderr.golden b/cmd/print/tests/specific-group/stderr.golden new file mode 100644 index 0000000..a034bd6 --- /dev/null +++ b/cmd/print/tests/specific-group/stderr.golden @@ -0,0 +1,2 @@ +---- exec command line 0: [print --no-color --group my-first-group] +(no output to stderr) diff --git a/cmd/print/tests/specific-group/stdout.golden b/cmd/print/tests/specific-group/stdout.golden index 6a7ce24..ac0287b 100644 --- a/cmd/print/tests/specific-group/stdout.golden +++ b/cmd/print/tests/specific-group/stdout.golden @@ -1,3 +1,4 @@ +---- exec command line 0: [print --no-color --group my-first-group] KEY_A="I'm key A" KEY_B="I'm key B" KEY_C="I'm key C" diff --git a/cmd/root.go b/cmd/root.go index b7af47a..22a1f91 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,8 +1,9 @@ package cmd import ( + "context" + "io" "strings" - "sync" goversion "github.com/caarlos0/go-version" "github.com/davecgh/go-spew/spew" @@ -16,6 +17,7 @@ import ( "github.com/jippi/dottie/cmd/update" "github.com/jippi/dottie/cmd/validate" "github.com/jippi/dottie/cmd/value" + "github.com/jippi/dottie/pkg/tui" "github.com/spf13/cobra" ) @@ -34,11 +36,13 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}}{{if .Copyright}} {{end}} ` -var mutex sync.Mutex - -func NewCommand() *cobra.Command { - __globalSetup() +func init() { + spew.Config.DisablePointerMethods = true + spew.Config.DisableMethods = true + cobra.EnableCommandSorting = false +} +func RunCommand(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) (*cobra.Command, error) { root := &cobra.Command{ Use: "dottie", Short: "Simplify working with .env files", @@ -47,6 +51,13 @@ func NewCommand() *cobra.Command { Version: buildVersion().String(), } + ctx = tui.NewContext(ctx, stdout, stderr) + + root.SetArgs(args) + root.SetContext(ctx) + root.SetErr(stderr) + root.SetOut(stdout) + root.AddGroup(&cobra.Group{ID: "manipulate", Title: "Manipulation Commands"}) root.AddGroup(&cobra.Group{ID: "output", Title: "Output Commands"}) @@ -64,16 +75,14 @@ func NewCommand() *cobra.Command { root.PersistentFlags().StringP("file", "f", ".env", "Load this file") - return root -} - -func __globalSetup() { - mutex.Lock() - defer mutex.Unlock() + command, err := root.ExecuteC() + if err != nil { + stderr := tui.WriterFromContext(ctx, tui.Stderr) + stderr.Danger().Copy(tui.WithEmphasis(true)).Printfln("%s %+v", command.ErrPrefix(), err) + stderr.Info().Printfln("Run '%v --help' for usage.", command.CommandPath()) + } - spew.Config.DisablePointerMethods = true - spew.Config.DisableMethods = true - cobra.EnableCommandSorting = false + return command, err } func indent(in string) string { diff --git a/cmd/set/set.go b/cmd/set/set.go index 028361f..ccca723 100644 --- a/cmd/set/set.go +++ b/cmd/set/set.go @@ -1,9 +1,7 @@ package set import ( - "errors" "fmt" - "os" "strings" "github.com/jippi/dottie/pkg" @@ -23,6 +21,7 @@ func NewCommand() *cobra.Command { Use: "set KEY=VALUE [KEY=VALUE ...]", Short: "Set/update one or multiple key=value pairs", GroupID: "manipulate", + Args: cobra.MinimumNArgs(1), ValidArgsFunction: shared.NewCompleter(). WithSuffixIsLiteral(true). WithHandlers(render.ExcludeDisabledAssignments). @@ -34,6 +33,9 @@ func NewCommand() *cobra.Command { cmd.Flags().Bool("disabled", false, "Set/change the flag to be disabled (commented out)") cmd.Flags().Bool("error-if-missing", false, "Exit with an error if the KEY does not exists in the .env file already") + cmd.Flags().Bool("skip-if-exists", false, "If the already KEY exists, do not set or change any settings") + cmd.Flags().Bool("skip-if-same", false, "If the already KEY exists, and it the value is identical, do not set or change any settings") + cmd.Flags().String("group", "", "The (optional) group name to add the KEY=VALUE pair under") cmd.Flags().String("before", "", "If the key doesn't exist, add it to the file *before* this KEY") cmd.Flags().String("after", "", "If the key doesn't exist, add it to the file *after* this KEY") @@ -53,10 +55,6 @@ func runE(cmd *cobra.Command, args []string) error { return err } - if len(args) == 0 { - return errors.New("Missing required argument: KEY=VALUE") - } - // // Initialize Upserter // @@ -65,6 +63,8 @@ func runE(cmd *cobra.Command, args []string) error { document, upsert.WithGroup(shared.StringFlag(cmd.Flags(), "group")), upsert.WithSettingIf(upsert.ErrorIfMissing, shared.BoolFlag(cmd.Flags(), "error-if-missing")), + upsert.WithSettingIf(upsert.SkipIfExists, shared.BoolFlag(cmd.Flags(), "skip-if-exists")), + upsert.WithSettingIf(upsert.SkipIfSame, shared.BoolFlag(cmd.Flags(), "skip-if-same")), upsert.WithSettingIf(upsert.UpdateComments, cmd.Flag("comment").Changed), ) if err != nil { @@ -87,12 +87,15 @@ func runE(cmd *cobra.Command, args []string) error { // Loop arguments and place them // - var allErrors error + var ( + allErrors error + stdout, stderr = tui.WritersFromContext(cmd.Context()) + ) for _, stringPair := range args { pairSlice := strings.SplitN(stringPair, "=", 2) if len(pairSlice) != 2 { - allErrors = multierr.Append(allErrors, fmt.Errorf("Key: '%s' Error: expected KEY=VALUE pair, missing '='", stringPair)) + allErrors = multierr.Append(allErrors, fmt.Errorf("Key [ %s ] Error: expected KEY=VALUE pair, missing '='", stringPair)) continue } @@ -106,21 +109,19 @@ func runE(cmd *cobra.Command, args []string) error { Interpolated: value, Enabled: !shared.BoolFlag(cmd.Flags(), "disabled"), Quote: token.QuoteFromString(shared.StringFlag(cmd.Flags(), "quote-style")), - Comments: ast.NewCommentsFromSlice(shared.StringSliceFlag(cmd.Flags(), "comments")), + Comments: ast.NewCommentsFromSlice(shared.StringSliceFlag(cmd.Flags(), "comment")), } // // Upsert the assignment // - assignment, warnings, err := upserter.Upsert(assignment) - if warnings != nil { - tui.Theme.Warning.StderrPrinter().Println("WARNING:", warnings) - } + assignment, warnings, err := upserter.Upsert(cmd.Context(), assignment) + tui.MaybePrintWarnings(cmd.Context(), warnings) if err != nil { z := validation.NewError(assignment, err) - fmt.Fprintln(os.Stderr, validation.Explain(document, z, z, false, true)) + stderr.NoColor().Println(validation.Explain(cmd.Context(), document, z, z, false, true)) if shared.BoolWithInverseValue(cmd.Flags(), "validate") { allErrors = multierr.Append(allErrors, err) @@ -129,22 +130,22 @@ func runE(cmd *cobra.Command, args []string) error { } } - tui.Theme.Success.StderrPrinter().Printfln("Key [%s] was successfully upserted", key) + stdout.Success().Printfln("Key [ %s ] was successfully upserted", key) } if allErrors != nil { - return errors.New("validation error") + return fmt.Errorf("validation error: %+w", allErrors) } // // Save file // - if err := pkg.Save(shared.StringFlag(cmd.Flags(), "file"), document); err != nil { + if err := pkg.Save(cmd.Context(), shared.StringFlag(cmd.Flags(), "file"), document); err != nil { return fmt.Errorf("failed to save file: %w", err) } - tui.Theme.Success.StderrPrinter().Println("File was successfully saved") + stdout.Success().Println("File was successfully saved") return nil } diff --git a/cmd/set/set_test.go b/cmd/set/set_test.go new file mode 100644 index 0000000..e86616e --- /dev/null +++ b/cmd/set/set_test.go @@ -0,0 +1,13 @@ +package set_test + +import ( + "testing" + + "github.com/jippi/dottie/pkg/test_helpers" +) + +func TestSetCommand(t *testing.T) { + t.Parallel() + + test_helpers.RunFileBasedCommandTests(t, 0, "set") +} diff --git a/cmd/set/tests/error-if-missing.env b/cmd/set/tests/error-if-missing.env new file mode 100644 index 0000000..8afe00b --- /dev/null +++ b/cmd/set/tests/error-if-missing.env @@ -0,0 +1 @@ +SOME_KEY="ERROR THE VALUE WAS NOT CHANGED" diff --git a/cmd/set/tests/error-if-missing.run b/cmd/set/tests/error-if-missing.run new file mode 100644 index 0000000..de23a8b --- /dev/null +++ b/cmd/set/tests/error-if-missing.run @@ -0,0 +1,2 @@ +--error-if-missing MISSING_KEY="THIS MUST FAIL" +--error-if-missing SOME_KEY="SUCCESS" diff --git a/cmd/set/tests/error-if-missing/env.golden b/cmd/set/tests/error-if-missing/env.golden new file mode 100644 index 0000000..1a7fe8f --- /dev/null +++ b/cmd/set/tests/error-if-missing/env.golden @@ -0,0 +1 @@ +SOME_KEY="SUCCESS" diff --git a/cmd/set/tests/error-if-missing/stderr.golden b/cmd/set/tests/error-if-missing/stderr.golden new file mode 100644 index 0000000..ac6afd4 --- /dev/null +++ b/cmd/set/tests/error-if-missing/stderr.golden @@ -0,0 +1,7 @@ +---- exec command line 0: [set --error-if-missing MISSING_KEY=THIS MUST FAIL] +(error *errors.errorString) key [MISSING_KEY] does not exists in the document + +Error: validation error: key [MISSING_KEY] does not exists in the document +Run 'dottie set --help' for usage. +---- exec command line 1: [set --error-if-missing SOME_KEY=SUCCESS] +(no output to stderr) diff --git a/cmd/set/tests/error-if-missing/stdout.golden b/cmd/set/tests/error-if-missing/stdout.golden new file mode 100644 index 0000000..1216cda --- /dev/null +++ b/cmd/set/tests/error-if-missing/stdout.golden @@ -0,0 +1,5 @@ +---- exec command line 0: [set --error-if-missing MISSING_KEY=THIS MUST FAIL] +(no output to stdout) +---- exec command line 1: [set --error-if-missing SOME_KEY=SUCCESS] +Key [ SOME_KEY ] was successfully upserted +File was successfully saved diff --git a/cmd/set/tests/manipulate-empty.run b/cmd/set/tests/manipulate-empty.run new file mode 100644 index 0000000..f7606e1 --- /dev/null +++ b/cmd/set/tests/manipulate-empty.run @@ -0,0 +1,9 @@ +SOME_KEY=SOME_VALUE +ANOTHER_KEY=ANOTHER_VALUE --quote-style single +SECOND_KEY="should be before ANOTHER_KEY" --before ANOTHER_KEY +TRUE_SECOND_KEY="HA, I'm after SOME_KEY, so I'm before ANOTHER_KEY now" --after SOME_KEY +SECOND_KEY="damn, I'm the third key now" +SOME_KEY=ANOTHER_VALUE --comment "I'm a comment" --comment "I'm another comment" +A_NUMBER=1 --comment "@dottie/validate number" +NOT_A_NUMBER=abc --comment "@dottie/validate number" +A_NUMBER=2 diff --git a/cmd/set/tests/manipulate-empty/env.golden b/cmd/set/tests/manipulate-empty/env.golden new file mode 100644 index 0000000..d6707e9 --- /dev/null +++ b/cmd/set/tests/manipulate-empty/env.golden @@ -0,0 +1,10 @@ +# I'm a comment +# I'm another comment +SOME_KEY="ANOTHER_VALUE" + +TRUE_SECOND_KEY="HA, I'm after SOME_KEY, so I'm before ANOTHER_KEY now" +SECOND_KEY="damn, I'm the third key now" +ANOTHER_KEY='ANOTHER_VALUE' + +# @dottie/validate number +A_NUMBER="2" diff --git a/cmd/set/tests/manipulate-empty/stderr.golden b/cmd/set/tests/manipulate-empty/stderr.golden new file mode 100644 index 0000000..9ddd180 --- /dev/null +++ b/cmd/set/tests/manipulate-empty/stderr.golden @@ -0,0 +1,22 @@ +---- exec command line 0: [set SOME_KEY=SOME_VALUE] +(no output to stderr) +---- exec command line 1: [set ANOTHER_KEY=ANOTHER_VALUE --quote-style single] +(no output to stderr) +---- exec command line 2: [set SECOND_KEY=should be before ANOTHER_KEY --before ANOTHER_KEY] +(no output to stderr) +---- exec command line 3: [set TRUE_SECOND_KEY=HA, I'm after SOME_KEY, so I'm before ANOTHER_KEY now --after SOME_KEY] +(no output to stderr) +---- exec command line 4: [set SECOND_KEY=damn, I'm the third key now] +(no output to stderr) +---- exec command line 5: [set SOME_KEY=ANOTHER_VALUE --comment I'm a comment --comment I'm another comment] +(no output to stderr) +---- exec command line 6: [set A_NUMBER=1 --comment @dottie/validate number] +(no output to stderr) +---- exec command line 7: [set NOT_A_NUMBER=abc --comment @dottie/validate number] +NOT_A_NUMBER (-:2) + * (number) The value [abc] is not a valid number. + +Error: validation error: Key: 'NOT_A_NUMBER' Error:Field validation for 'NOT_A_NUMBER' failed on the 'number' tag +Run 'dottie set --help' for usage. +---- exec command line 8: [set A_NUMBER=2] +(no output to stderr) diff --git a/cmd/set/tests/manipulate-empty/stdout.golden b/cmd/set/tests/manipulate-empty/stdout.golden new file mode 100644 index 0000000..b9c0e55 --- /dev/null +++ b/cmd/set/tests/manipulate-empty/stdout.golden @@ -0,0 +1,26 @@ +---- exec command line 0: [set SOME_KEY=SOME_VALUE] +Key [ SOME_KEY ] was successfully upserted +File was successfully saved +---- exec command line 1: [set ANOTHER_KEY=ANOTHER_VALUE --quote-style single] +Key [ ANOTHER_KEY ] was successfully upserted +File was successfully saved +---- exec command line 2: [set SECOND_KEY=should be before ANOTHER_KEY --before ANOTHER_KEY] +Key [ SECOND_KEY ] was successfully upserted +File was successfully saved +---- exec command line 3: [set TRUE_SECOND_KEY=HA, I'm after SOME_KEY, so I'm before ANOTHER_KEY now --after SOME_KEY] +Key [ TRUE_SECOND_KEY ] was successfully upserted +File was successfully saved +---- exec command line 4: [set SECOND_KEY=damn, I'm the third key now] +Key [ SECOND_KEY ] was successfully upserted +File was successfully saved +---- exec command line 5: [set SOME_KEY=ANOTHER_VALUE --comment I'm a comment --comment I'm another comment] +Key [ SOME_KEY ] was successfully upserted +File was successfully saved +---- exec command line 6: [set A_NUMBER=1 --comment @dottie/validate number] +Key [ A_NUMBER ] was successfully upserted +File was successfully saved +---- exec command line 7: [set NOT_A_NUMBER=abc --comment @dottie/validate number] +(no output to stdout) +---- exec command line 8: [set A_NUMBER=2] +Key [ A_NUMBER ] was successfully upserted +File was successfully saved diff --git a/cmd/set/tests/skip-if-exists.run b/cmd/set/tests/skip-if-exists.run new file mode 100644 index 0000000..0eb9aff --- /dev/null +++ b/cmd/set/tests/skip-if-exists.run @@ -0,0 +1,2 @@ +--skip-if-exists SOME_KEY="SUCCESS" +--skip-if-exists SOME_KEY="ERROR; THE VALUE MUST NOT CHANGE" diff --git a/cmd/set/tests/skip-if-exists/env.golden b/cmd/set/tests/skip-if-exists/env.golden new file mode 100644 index 0000000..1a7fe8f --- /dev/null +++ b/cmd/set/tests/skip-if-exists/env.golden @@ -0,0 +1 @@ +SOME_KEY="SUCCESS" diff --git a/cmd/set/tests/skip-if-exists/stderr.golden b/cmd/set/tests/skip-if-exists/stderr.golden new file mode 100644 index 0000000..a9afcd8 --- /dev/null +++ b/cmd/set/tests/skip-if-exists/stderr.golden @@ -0,0 +1,4 @@ +---- exec command line 0: [set --skip-if-exists SOME_KEY=SUCCESS] +(no output to stderr) +---- exec command line 1: [set --skip-if-exists SOME_KEY=ERROR; THE VALUE MUST NOT CHANGE] +(no output to stderr) diff --git a/cmd/set/tests/skip-if-exists/stdout.golden b/cmd/set/tests/skip-if-exists/stdout.golden new file mode 100644 index 0000000..afa258a --- /dev/null +++ b/cmd/set/tests/skip-if-exists/stdout.golden @@ -0,0 +1,6 @@ +---- exec command line 0: [set --skip-if-exists SOME_KEY=SUCCESS] +Key [ SOME_KEY ] was successfully upserted +File was successfully saved +---- exec command line 1: [set --skip-if-exists SOME_KEY=ERROR; THE VALUE MUST NOT CHANGE] +Key [ SOME_KEY ] was successfully upserted +File was successfully saved diff --git a/cmd/set/tests/skip-if-same.run b/cmd/set/tests/skip-if-same.run new file mode 100644 index 0000000..c22d1b0 --- /dev/null +++ b/cmd/set/tests/skip-if-same.run @@ -0,0 +1,2 @@ +--skip-if-same SOME_KEY="ERROR THIS SHOULD BE CHANGED BY NEXT LINE" +--skip-if-same SOME_KEY="SUCCESS" diff --git a/cmd/set/tests/skip-if-same/env.golden b/cmd/set/tests/skip-if-same/env.golden new file mode 100644 index 0000000..1a7fe8f --- /dev/null +++ b/cmd/set/tests/skip-if-same/env.golden @@ -0,0 +1 @@ +SOME_KEY="SUCCESS" diff --git a/cmd/set/tests/skip-if-same/stderr.golden b/cmd/set/tests/skip-if-same/stderr.golden new file mode 100644 index 0000000..486b3a8 --- /dev/null +++ b/cmd/set/tests/skip-if-same/stderr.golden @@ -0,0 +1,4 @@ +---- exec command line 0: [set --skip-if-same SOME_KEY=ERROR THIS SHOULD BE CHANGED BY NEXT LINE] +(no output to stderr) +---- exec command line 1: [set --skip-if-same SOME_KEY=SUCCESS] +(no output to stderr) diff --git a/cmd/set/tests/skip-if-same/stdout.golden b/cmd/set/tests/skip-if-same/stdout.golden new file mode 100644 index 0000000..a1b27e3 --- /dev/null +++ b/cmd/set/tests/skip-if-same/stdout.golden @@ -0,0 +1,6 @@ +---- exec command line 0: [set --skip-if-same SOME_KEY=ERROR THIS SHOULD BE CHANGED BY NEXT LINE] +Key [ SOME_KEY ] was successfully upserted +File was successfully saved +---- exec command line 1: [set --skip-if-same SOME_KEY=SUCCESS] +Key [ SOME_KEY ] was successfully upserted +File was successfully saved diff --git a/cmd/update/update.go b/cmd/update/update.go index 492f79a..c35b974 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -22,6 +22,7 @@ func NewCommand() *cobra.Command { Use: "update", Short: "Update the .env file from a source", GroupID: "manipulate", + Args: cobra.NoArgs, RunE: runE, } @@ -35,17 +36,20 @@ func NewCommand() *cobra.Command { func runE(cmd *cobra.Command, args []string) error { filename := cmd.Flag("file").Value.String() - originalEnv, err := pkg.Load(filename) + originalDocument, err := pkg.Load(filename) if err != nil { return err } - dark := tui.Theme.Dark.StdoutPrinter() - info := tui.Theme.Info.StdoutPrinter() - danger := tui.Theme.Danger.StdoutPrinter() - dangerEmphasis := tui.Theme.Danger.StdoutPrinter(tui.WithEmphasis(true)) - success := tui.Theme.Success.StdoutPrinter() - primary := tui.Theme.Primary.StdoutPrinter() + stdout, stderr := tui.WritersFromContext(cmd.Context()) + + dark := stdout.Dark() + info := stdout.Info() + danger := stdout.Danger() + dangerEmphasis := stdout.Danger().Copy(tui.WithEmphasis(true)) + success := stdout.Success() + primary := stdout.Primary() + warningStderr := stderr.Warning() info.Box("Starting update of " + filename + " from upstream") info.Println() @@ -54,7 +58,7 @@ func runE(cmd *cobra.Command, args []string) error { source, _ := cmd.Flags().GetString("source") if len(source) == 0 { - source, err = originalEnv.GetConfig("dottie/source") + source, err = originalDocument.GetConfig("dottie/source") if err != nil { return err } @@ -99,7 +103,7 @@ func runE(cmd *cobra.Command, args []string) error { // Load the soon-to-be-merged file dark.Println("Loading and parsing source") - sourceDoc, err := pkg.Load(tmp.Name()) + sourceDocument, err := pkg.Load(tmp.Name()) if err != nil { return err } @@ -115,13 +119,13 @@ func runE(cmd *cobra.Command, args []string) error { lastWasError := false counter := 0 - for _, originalStatement := range originalEnv.AllAssignments() { + for _, originalStatement := range originalDocument.AllAssignments() { if !originalStatement.Enabled { continue } upserter, err := upsert.New( - sourceDoc, + sourceDocument, upsert.WithSetting(upsert.SkipIfSame), upsert.WithSettingIf(upsert.ErrorIfMissing, shared.BoolWithInverseValue(cmd.Flags(), "error-on-missing-key")), ) @@ -130,9 +134,9 @@ func runE(cmd *cobra.Command, args []string) error { } // If the KEY does *NOT* exists in the SOURCE doc - if sourceDoc.Get(originalStatement.Name) == nil { + if sourceDocument.Get(originalStatement.Name) == nil { // Try to find positioning in the statement list for the new KEY pair - var parent ast.StatementCollection = originalEnv + var parent ast.StatementCollection = originalDocument if originalStatement.Group != nil { parent = originalStatement.Group @@ -147,7 +151,7 @@ func runE(cmd *cobra.Command, args []string) error { upserter.ApplyOptions(upsert.WithPlacement(upsert.AddLast)) // Retain the group name if its still present in the SOURCE doc - if originalStatement.Group != nil && sourceDoc.HasGroup(originalStatement.Group.String()) { + if originalStatement.Group != nil && sourceDocument.HasGroup(originalStatement.Group.String()) { upserter.ApplyOptions(upsert.WithGroup(originalStatement.Group.String())) } @@ -156,7 +160,7 @@ func runE(cmd *cobra.Command, args []string) error { upserter.ApplyOptions(upsert.WithPlacement(upsert.AddFirst)) // Retain the group name if its still present in the SOURCE doc - if originalStatement.Group != nil && sourceDoc.HasGroup(originalStatement.Group.String()) { + if originalStatement.Group != nil && sourceDocument.HasGroup(originalStatement.Group.String()) { upserter.ApplyOptions(upsert.WithGroup(originalStatement.Group.String())) } @@ -169,15 +173,15 @@ func runE(cmd *cobra.Command, args []string) error { return err } - if before.Group != nil && sourceDoc.HasGroup(before.Group.String()) { + if before.Group != nil && sourceDocument.HasGroup(before.Group.String()) { upserter.ApplyOptions(upsert.WithGroup(before.Group.String())) } } } - changed, warn, err := upserter.Upsert(originalStatement) + changed, warn, err := upserter.Upsert(cmd.Context(), originalStatement) if warn != nil { - tui.Theme.Warning.StderrPrinter().Println(warn) + warningStderr.Println(warn) } if err != nil { @@ -201,7 +205,7 @@ func runE(cmd *cobra.Command, args []string) error { continue } - if errors := validation.ValidateSingleAssignment(originalEnv, originalStatement, nil, []string{"file", "dir"}); len(errors) > 0 { + if errors := validation.ValidateSingleAssignment(cmd.Context(), originalDocument, originalStatement, nil, []string{"file", "dir"}); len(errors) > 0 { sawError = true lastWasError = true @@ -216,7 +220,7 @@ func runE(cmd *cobra.Command, args []string) error { dark.Println(" due to validation error:") for _, errIsh := range errors { - danger.Println(" ", strings.Repeat(" ", len(originalStatement.Name)), strings.TrimSpace(validation.Explain(originalEnv, errIsh, errIsh, false, false))) + danger.Println(" ", strings.Repeat(" ", len(originalStatement.Name)), strings.TrimSpace(validation.Explain(cmd.Context(), originalDocument, errIsh, errIsh, false, false))) } counter++ @@ -262,7 +266,7 @@ func runE(cmd *cobra.Command, args []string) error { dark.Println("Saving the new", primary.Sprint(filename)) - if err := pkg.Save(filename, sourceDoc); err != nil { + if err := pkg.Save(cmd.Context(), filename, sourceDocument); err != nil { danger.Println(" ERROR", err.Error()) return err diff --git a/cmd/validate/validate.go b/cmd/validate/validate.go index ea69633..9faad53 100644 --- a/cmd/validate/validate.go +++ b/cmd/validate/validate.go @@ -18,6 +18,7 @@ func NewCommand() *cobra.Command { Use: "validate", Short: "Validate an .env file", GroupID: "output", + Args: cobra.NoArgs, RunE: runE, } @@ -32,7 +33,7 @@ func NewCommand() *cobra.Command { func runE(cmd *cobra.Command, args []string) error { filename := cmd.Flag("file").Value.String() - env, err := pkg.Load(filename) + document, err := pkg.Load(filename) if err != nil { return err } @@ -41,25 +42,24 @@ func runE(cmd *cobra.Command, args []string) error { // Build filters // - fix := shared.BoolWithInverseValue(cmd.Flags(), "fix") - ignoreRules, _ := cmd.Flags().GetStringSlice("ignore-rule") - handlers := []render.Handler{} handlers = append(handlers, render.ExcludeDisabledAssignments) - slice, _ := cmd.Flags().GetStringSlice("exclude-prefix") - for _, filter := range slice { + excludedPrefixes := shared.StringSliceFlag(cmd.Flags(), "exclude-prefix") + for _, filter := range excludedPrefixes { handlers = append(handlers, render.ExcludeKeyPrefix(filter)) } + stderr := tui.WriterFromContext(cmd.Context(), tui.Stderr) + // // Interpolate // - warn, err := env.InterpolateAll() + warn, err := document.InterpolateAll() if warn != nil { - tui.Theme.Warning.StderrPrinter().Printfln("%+v", warn) + stderr.Warning().Printfln("%+v", warn) } if err != nil { @@ -70,43 +70,48 @@ func runE(cmd *cobra.Command, args []string) error { // Validate // - res := validation.Validate(env, handlers, ignoreRules) - if len(res) == 0 { - tui.Theme.Success.StderrPrinter().Box("No validation errors found") + ignoreRules := shared.StringSliceFlag(cmd.Flags(), "ignore-rule") + + validationErrors := validation.Validate(cmd.Context(), document, handlers, ignoreRules) + if len(validationErrors) == 0 { + stderr.Success().Box("No validation errors found") return nil } - stderr := tui.Theme.Danger.StderrPrinter() - stderr.Box(fmt.Sprintf("%d validation errors found", len(res))) - stderr.Println() + attemptFixOfValidationError := shared.BoolWithInverseValue(cmd.Flags(), "fix") + + danger := stderr.Danger() + danger.Box(fmt.Sprintf("%d validation errors found", len(validationErrors))) + danger.Println() - for _, errIsh := range res { - fmt.Fprintln(os.Stderr, validation.Explain(env, errIsh, errIsh, fix, true)) + for _, errIsh := range validationErrors { + fmt.Fprintln(os.Stderr, validation.Explain(cmd.Context(), document, errIsh, errIsh, attemptFixOfValidationError, true)) } // // Validate file again, in case some of the fixers from before fixed them // - env, err = pkg.Load(cmd.Flag("file").Value.String()) + document, err = pkg.Load(cmd.Flag("file").Value.String()) if err != nil { return fmt.Errorf("failed to reload .env file: %w", err) } - newRes := validation.Validate(env, handlers, ignoreRules) + newRes := validation.Validate(cmd.Context(), document, handlers, ignoreRules) if len(newRes) == 0 { - tui.Theme.Success.StderrPrinter().Println("All validation errors fixed") + stderr.Success().Println("All validation errors fixed") return nil } - diff := len(res) - len(newRes) + diff := len(validationErrors) - len(newRes) if diff > 0 { - tui.Theme.Warning.StderrPrinter().Box( - fmt.Sprintf("%d validation errors left", len(newRes)), - tui.Theme.Success.StderrPrinter().Sprintf("%d validation errors was fixed", diff), - ) + stderr.Warning(). + Box( + fmt.Sprintf("%d validation errors left", len(newRes)), + stderr.Success().Sprintf("%d validation errors was fixed", diff), + ) } return errors.New("Validation failed") diff --git a/cmd/value/value.go b/cmd/value/value.go index 593634a..c38e56b 100644 --- a/cmd/value/value.go +++ b/cmd/value/value.go @@ -1,7 +1,6 @@ package value import ( - "errors" "fmt" "github.com/jippi/dottie/pkg" @@ -16,39 +15,36 @@ func NewCommand() *cobra.Command { Use: "value KEY", Short: "Print value of a env key if it exists", GroupID: "output", + Args: cobra.ExactArgs(1), ValidArgsFunction: shared.NewCompleter().WithHandlers(render.ExcludeDisabledAssignments).Get(), RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return errors.New("Missing required argument: KEY") - } - filename := cmd.Flag("file").Value.String() - env, err := pkg.Load(filename) + document, err := pkg.Load(filename) if err != nil { return err } - key := args[0] + key := cmd.Flags().Arg(0) - existing := env.Get(key) - if existing == nil { - return fmt.Errorf("Key [%s] does not exists", key) + assignment := document.Get(key) + if assignment == nil { + return fmt.Errorf("Key [ %s ] does not exists", key) } - if !existing.Enabled && !shared.BoolFlag(cmd.Flags(), "include-commented") { - return fmt.Errorf("Key [%s] exists, but is commented out - use [--include-commented] to include it", key) + if !assignment.Enabled && !shared.BoolFlag(cmd.Flags(), "include-commented") { + return fmt.Errorf("Key [ %s ] exists, but is commented out - use [--include-commented] to include it", key) } - warn, err := env.InterpolateStatement(existing) - if warn != nil { - tui.Theme.Warning.StderrPrinter().Printfln("%+v", warn) - } + warnings, err := document.InterpolateStatement(assignment) + tui.MaybePrintWarnings(cmd.Context(), warnings) if err != nil { return err } - fmt.Println(existing.Interpolated) + tui.StdoutFromContext(cmd.Context()). + NoColor(). + Println(assignment.Interpolated) return nil }, diff --git a/go.mod b/go.mod index 5049a70..57d31a6 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,8 @@ require ( github.com/charmbracelet/huh v0.3.0 github.com/charmbracelet/lipgloss v0.9.1 github.com/davecgh/go-spew v1.1.1 - github.com/erikgeiser/promptkit v0.9.0 github.com/go-playground/validator/v10 v10.17.0 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gosimple/slug v1.13.1 github.com/hashicorp/go-getter v1.7.3 github.com/muesli/termenv v0.15.2 diff --git a/go.sum b/go.sum index 149019e..0a130db 100644 --- a/go.sum +++ b/go.sum @@ -242,8 +242,6 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/erikgeiser/promptkit v0.9.0 h1:3qL1mS/ntCrXdb8sTP/ka82CJ9kEQaGuYXNrYJkWYBc= -github.com/erikgeiser/promptkit v0.9.0/go.mod h1:pU9dtogSe3Jlc2AY77EP7R4WFP/vgD4v+iImC83KsCo= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= @@ -334,6 +332,8 @@ github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLe github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.4 h1:1kZ/sQM3srePvKs3tXAvQzo66XfcReoqFpIpIccE7Oc= github.com/google/s2a-go v0.1.4/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/main.go b/main.go index 4b5940e..1131fd8 100644 --- a/main.go +++ b/main.go @@ -1,18 +1,15 @@ package main import ( + "context" "os" "github.com/jippi/dottie/cmd" - "github.com/jippi/dottie/pkg/tui" ) func main() { - root := cmd.NewCommand() - if c, err := root.ExecuteC(); err != nil { - tui.Theme.Danger.BuffPrinter(root.ErrOrStderr(), tui.WithEmphasis(true)).Printfln("%s %+v", c.ErrPrefix(), err) - tui.Theme.Info.BuffPrinter(root.ErrOrStderr()).Printfln("Run '%v --help' for usage.\n", c.CommandPath()) - + _, err := cmd.RunCommand(context.Background(), os.Args[1:], os.Stdout, os.Stderr) + if err != nil { os.Exit(1) } } diff --git a/pkg/ast/document.go b/pkg/ast/document.go index a61fd76..bb9e162 100644 --- a/pkg/ast/document.go +++ b/pkg/ast/document.go @@ -295,3 +295,40 @@ func (document *Document) Initialize() { } } } + +func (document *Document) Replace(assignment *Assignment) error { + existing := document.Get(assignment.Name) + if existing == nil { + return fmt.Errorf("No KEY named [%s] exists in the document", assignment.Name) + } + + if existing.Group != nil { + for idx, stmt := range existing.Group.Statements { + val, ok := stmt.(*Assignment) + if !ok { + continue + } + + if val.Name == assignment.Name { + existing.Group.Statements[idx] = assignment + + return nil + } + } + } + + for idx, stmt := range document.Statements { + val, ok := stmt.(*Assignment) + if !ok { + continue + } + + if val.Name == assignment.Name { + document.Statements[idx] = assignment + + return nil + } + } + + return fmt.Errorf("Could not find+replace KEY named [%s] in document", assignment.Name) +} diff --git a/pkg/ast/upsert/upsert.go b/pkg/ast/upsert/upsert.go index 5490dd9..66bd621 100644 --- a/pkg/ast/upsert/upsert.go +++ b/pkg/ast/upsert/upsert.go @@ -1,10 +1,14 @@ package upsert import ( + "context" "fmt" "slices" "github.com/jippi/dottie/pkg/ast" + "github.com/jippi/dottie/pkg/parser" + "github.com/jippi/dottie/pkg/render" + "github.com/jippi/dottie/pkg/scanner" "github.com/jippi/dottie/pkg/validation" "go.uber.org/multierr" ) @@ -47,41 +51,38 @@ func (u *Upserter) ApplyOptions(options ...Option) error { } // Upsert will, depending on its options, either Update or Insert (thus, "[Up]date + In[sert]"). -func (u *Upserter) Upsert(input *ast.Assignment) (*ast.Assignment, error, error) { +func (u *Upserter) Upsert(ctx context.Context, input *ast.Assignment) (*ast.Assignment, error, error) { assignment := u.document.Get(input.Name) - found := assignment != nil + exists := assignment != nil // Short circuit with some quick settings checks switch { // The assignment exists, so return early - case found && u.settings.Has(SkipIfExists): + case exists && u.settings.Has(SkipIfExists): return nil, nil, nil + // The assignment does *NOT* exists, and we require it to + case !exists && u.settings.Has(ErrorIfMissing): + return nil, nil, fmt.Errorf("key [%s] does not exists in the document", input.Name) + // The assignment exists, has a literal value, and the literal value isn't what we should consider empty - case found && u.settings.Has(SkipIfSet) && len(assignment.Literal) > 0 && !slices.Contains(u.valuesConsideredEmpty, assignment.Literal): + case exists && u.settings.Has(SkipIfSet) && len(assignment.Literal) > 0 && !slices.Contains(u.valuesConsideredEmpty, assignment.Literal): return nil, nil, nil // The assignment exists, the literal values are the same, and they have same 'Enabled' level - case found && u.settings.Has(SkipIfSame) && assignment.Literal == input.Literal && assignment.Enabled == input.Enabled: + case exists && u.settings.Has(SkipIfSame) && assignment.Literal == input.Literal && assignment.Enabled == input.Enabled: return nil, nil, nil - // The assignment does *NOT* exists, and we require it to - case !found && u.settings.Has(ErrorIfMissing): - return nil, nil, fmt.Errorf("key [%s] does not exists in the document", input.Name) - // The KEY was *NOT* found, and all other preconditions are not triggering - case !found: + case !exists: var err error // Create and insert the (*ast.Assignment) into the Statement list - assignment, err = u.createAndInsert(input) + assignment, err = u.createAndInsert(ctx, input) if err != nil { return nil, nil, err } - - // Recalculate the index order of all Statements (for interpolation) - u.document.ReindexStatements() } // Replace comments on the assignment if the Setting is on @@ -93,15 +94,32 @@ func (u *Upserter) Upsert(input *ast.Assignment) (*ast.Assignment, error, error) assignment.Literal = input.Literal assignment.Quote = input.Quote assignment.Interpolated = input.Literal + + var ( + tempDoc *ast.Document + err, warnings error + ) + + // Render and parse back the Statement to ensure annotations and such are properly handled + tempDoc, err = parser.New(scanner.New(render.NewFormatter().Statement(ctx, assignment).String()), "-").Parse() + if err != nil { + return nil, nil, fmt.Errorf("failed to parse assignment: %w", err) + } + + assignment = tempDoc.Get(assignment.Name) assignment.Initialize() if _, ok := assignment.Dependencies[assignment.Name]; ok { return nil, nil, fmt.Errorf("Key [%s] may not reference itself!", assignment.Name) } - u.document.Initialize() + // Replace the Assignment in the document + // + // This is necessary since its a different pointer address after we rendered+parsed earlier + u.document.Replace(assignment) - var err, warnings error + // Reinitialize the document so all indices and such are correct + u.document.Initialize() // Interpolate the Assignment if it is enabled if assignment.Enabled { @@ -113,7 +131,7 @@ func (u *Upserter) Upsert(input *ast.Assignment) (*ast.Assignment, error, error) // Validate if u.settings.Has(Validate) { - if validationErrors := validation.ValidateSingleAssignment(u.document, assignment, nil, nil); len(validationErrors) > 0 { + if validationErrors := validation.ValidateSingleAssignment(ctx, u.document, assignment, nil, nil); len(validationErrors) > 0 { var errorCollection error for _, err := range validationErrors { @@ -127,19 +145,26 @@ func (u *Upserter) Upsert(input *ast.Assignment) (*ast.Assignment, error, error) return assignment, warnings, nil } -func (u *Upserter) createAndInsert(input *ast.Assignment) (*ast.Assignment, error) { - // Ensure the group exists (may return 'nil' if no group is required) - group := u.document.EnsureGroup(u.group) - +func (u *Upserter) createAndInsert(ctx context.Context, input *ast.Assignment) (*ast.Assignment, error) { // Create the new newAssignment newAssignment := &ast.Assignment{ Comments: input.Comments, Enabled: input.Enabled, - Group: group, Literal: input.Literal, Name: input.Name, } + doc, err := parser.New(scanner.New(render.NewFormatter().Statement(ctx, newAssignment).String()), "-").Parse() + if err != nil { + return nil, fmt.Errorf("failed to parse assignment: %w", err) + } + + // Ensure the group exists (may return 'nil' if no group is required) + group := u.document.EnsureGroup(u.group) + + newAssignment = doc.Get(newAssignment.Name) + newAssignment.Group = group + // Find the statement slice to operate on statements := u.document.Statements if newAssignment.Group != nil { diff --git a/pkg/cli/shared/complete.go b/pkg/cli/shared/complete.go index dca741f..f5986d0 100644 --- a/pkg/cli/shared/complete.go +++ b/pkg/cli/shared/complete.go @@ -64,7 +64,7 @@ func (c *Completer) Get() CobraCompleter { lines := render. NewUnfilteredRenderer(render.NewSettings(c.options...), c.handlers...). - Statement(doc). + Statement(cmd.Context(), doc). Lines() if c.suffixIsLiteral && strings.HasSuffix(toComplete, "=") { diff --git a/pkg/file.go b/pkg/file.go index 87364b6..5f8cc81 100644 --- a/pkg/file.go +++ b/pkg/file.go @@ -1,6 +1,7 @@ package pkg import ( + "context" "errors" "io" "os" @@ -21,14 +22,14 @@ func Load(filename string) (*ast.Document, error) { return Parse(file, filename) } -func Save(filename string, doc *ast.Document) error { +func Save(ctx context.Context, filename string, doc *ast.Document) error { file, err := os.Create(filename) if err != nil { return err } defer file.Close() - res := render.NewFormatter().Statement(doc) + res := render.NewFormatter().Statement(ctx, doc) if res.IsEmpty() { return errors.New("The rendered .env file is unexpectedly 0 bytes long - please report this as a bug (unless your file is empty)") } diff --git a/pkg/render/handler.go b/pkg/render/handler.go index 560361f..44ec613 100644 --- a/pkg/render/handler.go +++ b/pkg/render/handler.go @@ -1,10 +1,12 @@ package render import ( + "context" + "github.com/jippi/dottie/pkg/ast" ) -type Handler func(hi *HandlerInput) HandlerSignal +type Handler func(ctx context.Context, hi *HandlerInput) HandlerSignal type HandlerInput struct { CurrentStatement any diff --git a/pkg/render/handler_filters.go b/pkg/render/handler_filters.go index ba98cac..e61074b 100644 --- a/pkg/render/handler_filters.go +++ b/pkg/render/handler_filters.go @@ -1,6 +1,8 @@ package render import ( + "context" + "github.com/jippi/dottie/pkg/ast" ) @@ -17,7 +19,7 @@ func RetainKeyPrefix(value string) Handler { return newSelectorHandler(ast.Ret func RetainExactKey(value ...string) Handler { return newSelectorHandler(ast.RetainExactKey(value...)) } func newSelectorHandler(selector ast.Selector) Handler { - return func(input *HandlerInput) HandlerSignal { + return func(ctx context.Context, input *HandlerInput) HandlerSignal { switch stmt := input.CurrentStatement.(type) { case ast.Statement: if selector(stmt) == ast.Exclude { diff --git a/pkg/render/render_formatter.go b/pkg/render/handler_formatter.go similarity index 92% rename from pkg/render/render_formatter.go rename to pkg/render/handler_formatter.go index 8ec79e5..6b52948 100644 --- a/pkg/render/render_formatter.go +++ b/pkg/render/handler_formatter.go @@ -1,6 +1,8 @@ package render import ( + "context" + "github.com/jippi/dottie/pkg/ast" ) @@ -20,7 +22,7 @@ func NewFormatter() *Renderer { // FormatterHandler is responsible for formatting an .env file according // to our opinionated style. -func FormatterHandler(input *HandlerInput) HandlerSignal { +func FormatterHandler(ctx context.Context, input *HandlerInput) HandlerSignal { switch statement := input.CurrentStatement.(type) { case *ast.Newline: if !input.Settings.showBlankLines { @@ -40,7 +42,7 @@ func FormatterHandler(input *HandlerInput) HandlerSignal { return input.Stop() case *ast.Group: - output := input.Renderer.group(statement) + output := input.Renderer.group(ctx, statement) if output.IsEmpty() { return input.Stop() } @@ -56,7 +58,7 @@ func FormatterHandler(input *HandlerInput) HandlerSignal { return input.Return(buf) case *ast.Assignment: - output := input.Renderer.assignment(statement) + output := input.Renderer.assignment(ctx, statement) if output.IsEmpty() { return input.Stop() } diff --git a/pkg/render/output.go b/pkg/render/output.go index 93c425c..6520923 100644 --- a/pkg/render/output.go +++ b/pkg/render/output.go @@ -1,10 +1,14 @@ package render -import "github.com/jippi/dottie/pkg/ast" +import ( + "context" + + "github.com/jippi/dottie/pkg/ast" +) type Output interface { - GroupBanner(group *ast.Group, settings Settings) *Lines - Assignment(assignment *ast.Assignment, settings Settings) *Lines - Comment(comment *ast.Comment, settings Settings) *Lines - Newline(newline *ast.Newline, settings Settings) *Lines + GroupBanner(ctx context.Context, group *ast.Group, settings Settings) *Lines + Assignment(ctx context.Context, assignment *ast.Assignment, settings Settings) *Lines + Comment(ctx context.Context, comment *ast.Comment, settings Settings) *Lines + Newline(ctx context.Context, newline *ast.Newline, settings Settings) *Lines } diff --git a/pkg/render/output_colorized.go b/pkg/render/output_colorized.go index 59f3678..a9eac96 100644 --- a/pkg/render/output_colorized.go +++ b/pkg/render/output_colorized.go @@ -2,6 +2,7 @@ package render import ( "bytes" + "context" "github.com/jippi/dottie/pkg/ast" "github.com/jippi/dottie/pkg/tui" @@ -11,10 +12,10 @@ var _ Output = (*ColorizedOutput)(nil) type ColorizedOutput struct{} -func (ColorizedOutput) GroupBanner(group *ast.Group, settings Settings) *Lines { +func (ColorizedOutput) GroupBanner(ctx context.Context, group *ast.Group, settings Settings) *Lines { var buf bytes.Buffer - out := tui.Theme.Info.Printer(tui.RendererWithTTY(&buf)) + out := tui.NewWriter(ctx, &buf).Success() out.Println("################################################################################") out.ApplyStyle(tui.Bold).Println(group.Name) @@ -23,11 +24,13 @@ func (ColorizedOutput) GroupBanner(group *ast.Group, settings Settings) *Lines { return NewLinesCollection().Add(buf.String()) } -func (ColorizedOutput) Assignment(assignment *ast.Assignment, settings Settings) *Lines { +func (ColorizedOutput) Assignment(ctx context.Context, assignment *ast.Assignment, settings Settings) *Lines { var buf bytes.Buffer + printer := tui.NewWriter(ctx, &buf) + if !assignment.Enabled { - tui.Theme.Danger.Printer(tui.RendererWithTTY(&buf)).Print("#") + printer.Danger().Print("#") } val := assignment.Literal @@ -36,19 +39,19 @@ func (ColorizedOutput) Assignment(assignment *ast.Assignment, settings Settings) val = assignment.Interpolated } - tui.Theme.Primary.Printer(tui.RendererWithTTY(&buf)).Print(assignment.Name) - tui.Theme.Dark.Printer(tui.RendererWithTTY(&buf)).Print("=") - tui.Theme.Success.Printer(tui.RendererWithTTY(&buf)).Print(assignment.Quote) - tui.Theme.Warning.Printer(tui.RendererWithTTY(&buf)).Print(val) - tui.Theme.Success.Printer(tui.RendererWithTTY(&buf)).Print(assignment.Quote) + printer.Primary().Print(assignment.Name) + printer.Dark().Print("=") + printer.Success().Print(assignment.Quote) + printer.Warning().Print(val) + printer.Success().Print(assignment.Quote) return NewLinesCollection().Add(buf.String()) } -func (ColorizedOutput) Comment(comment *ast.Comment, settings Settings) *Lines { +func (ColorizedOutput) Comment(ctx context.Context, comment *ast.Comment, settings Settings) *Lines { var buf bytes.Buffer - out := tui.Theme.Success.Printer(tui.RendererWithTTY(&buf)) + out := tui.NewWriter(ctx, &buf).Success() if comment.Annotation == nil { out.Print(comment.Value) @@ -66,7 +69,7 @@ func (ColorizedOutput) Comment(comment *ast.Comment, settings Settings) *Lines { return NewLinesCollection().Add(buf.String()) } -func (ColorizedOutput) Newline(newline *ast.Newline, settings Settings) *Lines { +func (ColorizedOutput) Newline(ctx context.Context, newline *ast.Newline, settings Settings) *Lines { if newline.Blank && !settings.ShowBlankLines() { return nil } diff --git a/pkg/render/output_completion_keys.go b/pkg/render/output_completion_keys.go index 1d12c32..82d949d 100644 --- a/pkg/render/output_completion_keys.go +++ b/pkg/render/output_completion_keys.go @@ -1,6 +1,8 @@ package render import ( + "context" + "github.com/jippi/dottie/pkg/ast" ) @@ -8,18 +10,18 @@ var _ Output = (*CompletionOutputKeys)(nil) type CompletionOutputKeys struct{} -func (CompletionOutputKeys) GroupBanner(group *ast.Group, settings Settings) *Lines { +func (CompletionOutputKeys) GroupBanner(ctx context.Context, group *ast.Group, settings Settings) *Lines { return nil } -func (CompletionOutputKeys) Assignment(a *ast.Assignment, settings Settings) *Lines { - return NewLinesCollection().Add(a.Name + "\t" + a.DocumentationSummary()) +func (CompletionOutputKeys) Assignment(ctx context.Context, assignment *ast.Assignment, settings Settings) *Lines { + return NewLinesCollection().Add(assignment.Name + "\t" + assignment.DocumentationSummary()) } -func (r CompletionOutputKeys) Comment(comment *ast.Comment, settings Settings) *Lines { +func (r CompletionOutputKeys) Comment(ctx context.Context, comment *ast.Comment, settings Settings) *Lines { return nil } -func (r CompletionOutputKeys) Newline(newline *ast.Newline, settings Settings) *Lines { +func (r CompletionOutputKeys) Newline(ctx context.Context, newline *ast.Newline, settings Settings) *Lines { return nil } diff --git a/pkg/render/output_plain.go b/pkg/render/output_plain.go index e3a2dfe..9ca3222 100644 --- a/pkg/render/output_plain.go +++ b/pkg/render/output_plain.go @@ -2,6 +2,7 @@ package render import ( "bytes" + "context" "fmt" "github.com/jippi/dottie/pkg/ast" @@ -11,7 +12,7 @@ var _ Output = (*PlainOutput)(nil) type PlainOutput struct{} -func (PlainOutput) GroupBanner(group *ast.Group, settings Settings) *Lines { +func (PlainOutput) GroupBanner(ctx context.Context, group *ast.Group, settings Settings) *Lines { out := NewLinesCollection() out.Add("################################################################################") @@ -21,7 +22,7 @@ func (PlainOutput) GroupBanner(group *ast.Group, settings Settings) *Lines { return out } -func (PlainOutput) Assignment(assignment *ast.Assignment, settings Settings) *Lines { +func (PlainOutput) Assignment(ctx context.Context, assignment *ast.Assignment, settings Settings) *Lines { var buf bytes.Buffer if !assignment.Enabled { @@ -39,11 +40,11 @@ func (PlainOutput) Assignment(assignment *ast.Assignment, settings Settings) *Li return NewLinesCollection().Add(buf.String()) } -func (r PlainOutput) Comment(comment *ast.Comment, settings Settings) *Lines { +func (r PlainOutput) Comment(ctx context.Context, comment *ast.Comment, settings Settings) *Lines { return NewLinesCollection().Add(comment.Value) } -func (r PlainOutput) Newline(newline *ast.Newline, settings Settings) *Lines { +func (r PlainOutput) Newline(ctx context.Context, newline *ast.Newline, settings Settings) *Lines { if newline.Blank && !settings.ShowBlankLines() { return nil } diff --git a/pkg/render/render.go b/pkg/render/render.go index bc02276..1572775 100644 --- a/pkg/render/render.go +++ b/pkg/render/render.go @@ -1,6 +1,7 @@ package render import ( + "context" "fmt" "github.com/jippi/dottie/pkg/ast" @@ -43,11 +44,11 @@ func NewUnfilteredRenderer(settings *Settings, additionalHandlers ...Handler) *R // // It's responsible for delegating statements to handlers, calling the right // Output functions and track the ordering of Statements being rendered -func (r *Renderer) Statement(currentStatement any) *Lines { +func (r *Renderer) Statement(ctx context.Context, currentStatement any) *Lines { handlerInput := r.newHandlerInput(currentStatement) for _, handler := range r.handlers { - status := handler(handlerInput) + status := handler(ctx, handlerInput) switch status { // Stop processing the statement and return nothing @@ -86,19 +87,19 @@ func (r *Renderer) Statement(currentStatement any) *Lines { switch statement := currentStatement.(type) { case *ast.Document: - return r.document(statement) + return r.document(ctx, statement) case *ast.Group: - return r.group(statement) + return r.group(ctx, statement) case *ast.Comment: - return r.comment(statement) + return r.comment(ctx, statement) case *ast.Assignment: - return r.assignment(statement) + return r.assignment(ctx, statement) case *ast.Newline: - return r.newline(statement) + return r.newline(ctx, statement) // // Lists of different statements will be iterated over @@ -108,7 +109,7 @@ func (r *Renderer) Statement(currentStatement any) *Lines { buf := NewLinesCollection() for _, group := range statement { - buf.Append(r.Statement(group)) + buf.Append(r.Statement(ctx, group)) } return buf @@ -117,7 +118,7 @@ func (r *Renderer) Statement(currentStatement any) *Lines { buf := NewLinesCollection() for _, stmt := range statement { - buf.Append(r.Statement(stmt)) + buf.Append(r.Statement(ctx, stmt)) } return buf @@ -126,7 +127,7 @@ func (r *Renderer) Statement(currentStatement any) *Lines { buf := NewLinesCollection() for _, comment := range statement { - buf.Append(r.Statement(comment)) + buf.Append(r.Statement(ctx, comment)) } return buf @@ -144,14 +145,14 @@ func (r *Renderer) Statement(currentStatement any) *Lines { // // Direct Document Statements are rendered first, followed by any // Group Statements in order they show up in the original source. -func (r *Renderer) document(document *ast.Document) *Lines { +func (r *Renderer) document(ctx context.Context, document *ast.Document) *Lines { return NewLinesCollection(). - Append(r.Statement(document.Statements)). - Append(r.Statement(document.Groups)) + Append(r.Statement(ctx, document.Statements)). + Append(r.Statement(ctx, document.Groups)) } // group renders "Group" Statements. -func (r *Renderer) group(group *ast.Group) *Lines { +func (r *Renderer) group(ctx context.Context, group *ast.Group) *Lines { // Capture the *current* Previous Statement in case we need to restore it (see below) prev := r.PreviousStatement @@ -162,7 +163,7 @@ func (r *Renderer) group(group *ast.Group) *Lines { // rather than whatever *actually* was the previous statement. r.PreviousStatement = group - rendered := r.Statement(group.Statements) + rendered := r.Statement(ctx, group.Statements) if rendered.IsEmpty() { // If the Group Statements didn't yield any output, restore the old "PreviousStatement" before @@ -176,7 +177,7 @@ func (r *Renderer) group(group *ast.Group) *Lines { // Render the optional Group banner if necessary. if r.Settings.ShowGroupBanners { - buf.Append(r.Output.GroupBanner(group, r.Settings)) + buf.Append(r.Output.GroupBanner(ctx, group, r.Settings)) if r.Settings.showBlankLines { buf.Newline("Group:ShowGroupBanners", r.PreviousStatement.Type(), "(type doesn't matter)") @@ -187,29 +188,29 @@ func (r *Renderer) group(group *ast.Group) *Lines { } // assignment renders "Assignment" Statements. -func (r *Renderer) assignment(assignment *ast.Assignment) *Lines { +func (r *Renderer) assignment(ctx context.Context, assignment *ast.Assignment) *Lines { // When done rendering this statement, mark it as the previous statement defer func() { r.PreviousStatement = assignment }() return NewLinesCollection(). - Append(r.Statement(assignment.Comments)). - Append(r.Output.Assignment(assignment, r.Settings)) + Append(r.Statement(ctx, assignment.Comments)). + Append(r.Output.Assignment(ctx, assignment, r.Settings)) } // comment renders "Comment" Statements. -func (r *Renderer) comment(comment *ast.Comment) *Lines { +func (r *Renderer) comment(ctx context.Context, comment *ast.Comment) *Lines { // When done rendering this statement, mark it as the previous statement defer func() { r.PreviousStatement = comment }() - return r.Output.Comment(comment, r.Settings) + return r.Output.Comment(ctx, comment, r.Settings) } // newline renders "Newline" Statements. -func (r *Renderer) newline(newline *ast.Newline) *Lines { +func (r *Renderer) newline(ctx context.Context, newline *ast.Newline) *Lines { // When done rendering this statement, mark it as the previous statement defer func() { r.PreviousStatement = newline }() - return r.Output.Newline(newline, r.Settings) + return r.Output.Newline(ctx, newline, r.Settings) } func (r *Renderer) newHandlerInput(statement any) *HandlerInput { diff --git a/pkg/render/render_test.go b/pkg/render/render_test.go index c59d460..197c798 100644 --- a/pkg/render/render_test.go +++ b/pkg/render/render_test.go @@ -1,6 +1,7 @@ package render_test import ( + "context" "log" "os" "strings" @@ -17,16 +18,11 @@ func TestFormatter(t *testing.T) { golden := goldie.New( t, - goldie.WithFixtureDir("test-fixtures/formatter"), + goldie.WithFixtureDir("test-fixtures/formatter/output"), goldie.WithNameSuffix(".golden.env"), goldie.WithDiffEngine(goldie.ColoredDiff), ) - files, err := os.ReadDir("test-fixtures/formatter") - if err != nil { - log.Fatal(err) - } - // Build test data set type testData struct { name string @@ -35,10 +31,19 @@ func TestFormatter(t *testing.T) { tests := []testData{} + files, err := os.ReadDir("test-fixtures/formatter") + if err != nil { + log.Fatal(err) + } + for _, file := range files { + if file.IsDir() { + continue + } + switch { - case strings.HasSuffix(file.Name(), ".input.env"): - testName := strings.TrimSuffix(file.Name(), ".input.env") + case strings.HasSuffix(file.Name(), ".env"): + testName := strings.TrimSuffix(file.Name(), ".env") test := testData{ name: testName, @@ -46,9 +51,8 @@ func TestFormatter(t *testing.T) { } tests = append(tests, test) - case strings.HasSuffix(file.Name(), ".golden.env"): default: - panic("unexpected file") + require.FailNow(t, "unexpected test file: ["+file.Name()+"]") } } @@ -63,7 +67,7 @@ func TestFormatter(t *testing.T) { env, err := pkg.Load(tt.filename) require.NoError(t, err) - golden.Assert(t, tt.name, []byte(render.NewFormatter().Statement(env).String())) + golden.Assert(t, tt.name, []byte(render.NewFormatter().Statement(context.Background(), env).String())) }) } } diff --git a/pkg/render/test-fixtures/formatter/comment-spacing.input.env b/pkg/render/test-fixtures/formatter/comment-spacing.env similarity index 100% rename from pkg/render/test-fixtures/formatter/comment-spacing.input.env rename to pkg/render/test-fixtures/formatter/comment-spacing.env diff --git a/pkg/render/test-fixtures/formatter/compressed.input.env b/pkg/render/test-fixtures/formatter/compressed.env similarity index 100% rename from pkg/render/test-fixtures/formatter/compressed.input.env rename to pkg/render/test-fixtures/formatter/compressed.env diff --git a/pkg/render/test-fixtures/formatter/empty.input.env b/pkg/render/test-fixtures/formatter/empty.env similarity index 100% rename from pkg/render/test-fixtures/formatter/empty.input.env rename to pkg/render/test-fixtures/formatter/empty.env diff --git a/pkg/render/test-fixtures/formatter/just-a-comment.golden.env b/pkg/render/test-fixtures/formatter/just-a-comment.env similarity index 100% rename from pkg/render/test-fixtures/formatter/just-a-comment.golden.env rename to pkg/render/test-fixtures/formatter/just-a-comment.env diff --git a/pkg/render/test-fixtures/formatter/comment-spacing.golden.env b/pkg/render/test-fixtures/formatter/output/comment-spacing.golden.env similarity index 100% rename from pkg/render/test-fixtures/formatter/comment-spacing.golden.env rename to pkg/render/test-fixtures/formatter/output/comment-spacing.golden.env diff --git a/pkg/render/test-fixtures/formatter/compressed.golden.env b/pkg/render/test-fixtures/formatter/output/compressed.golden.env similarity index 100% rename from pkg/render/test-fixtures/formatter/compressed.golden.env rename to pkg/render/test-fixtures/formatter/output/compressed.golden.env diff --git a/pkg/render/test-fixtures/formatter/empty.golden.env b/pkg/render/test-fixtures/formatter/output/empty.golden.env similarity index 100% rename from pkg/render/test-fixtures/formatter/empty.golden.env rename to pkg/render/test-fixtures/formatter/output/empty.golden.env diff --git a/pkg/render/test-fixtures/formatter/just-a-comment.input.env b/pkg/render/test-fixtures/formatter/output/just-a-comment.golden.env similarity index 100% rename from pkg/render/test-fixtures/formatter/just-a-comment.input.env rename to pkg/render/test-fixtures/formatter/output/just-a-comment.golden.env diff --git a/pkg/render/test-fixtures/formatter/pixelfed-full.golden.env b/pkg/render/test-fixtures/formatter/output/pixelfed-full.golden.env similarity index 100% rename from pkg/render/test-fixtures/formatter/pixelfed-full.golden.env rename to pkg/render/test-fixtures/formatter/output/pixelfed-full.golden.env diff --git a/pkg/render/test-fixtures/formatter/single-pair.golden.env b/pkg/render/test-fixtures/formatter/output/single-pair.golden.env similarity index 100% rename from pkg/render/test-fixtures/formatter/single-pair.golden.env rename to pkg/render/test-fixtures/formatter/output/single-pair.golden.env diff --git a/pkg/render/test-fixtures/formatter/two-pairs-newlines.golden.env b/pkg/render/test-fixtures/formatter/output/two-pairs-newlines.golden.env similarity index 100% rename from pkg/render/test-fixtures/formatter/two-pairs-newlines.golden.env rename to pkg/render/test-fixtures/formatter/output/two-pairs-newlines.golden.env diff --git a/pkg/render/test-fixtures/formatter/two-pairs.golden.env b/pkg/render/test-fixtures/formatter/output/two-pairs.golden.env similarity index 100% rename from pkg/render/test-fixtures/formatter/two-pairs.golden.env rename to pkg/render/test-fixtures/formatter/output/two-pairs.golden.env diff --git a/pkg/render/test-fixtures/formatter/pixelfed-full.input.env b/pkg/render/test-fixtures/formatter/pixelfed-full.env similarity index 100% rename from pkg/render/test-fixtures/formatter/pixelfed-full.input.env rename to pkg/render/test-fixtures/formatter/pixelfed-full.env diff --git a/pkg/render/test-fixtures/formatter/single-pair.input.env b/pkg/render/test-fixtures/formatter/single-pair.env similarity index 100% rename from pkg/render/test-fixtures/formatter/single-pair.input.env rename to pkg/render/test-fixtures/formatter/single-pair.env diff --git a/pkg/render/test-fixtures/formatter/two-pairs-newlines.input.env b/pkg/render/test-fixtures/formatter/two-pairs-newlines.env similarity index 61% rename from pkg/render/test-fixtures/formatter/two-pairs-newlines.input.env rename to pkg/render/test-fixtures/formatter/two-pairs-newlines.env index 18f57bc..ec12fc8 100644 --- a/pkg/render/test-fixtures/formatter/two-pairs-newlines.input.env +++ b/pkg/render/test-fixtures/formatter/two-pairs-newlines.env @@ -2,4 +2,4 @@ KEY=VALUE -FOO=baz +FOO=baz \ No newline at end of file diff --git a/pkg/render/test-fixtures/formatter/two-pairs.input.env b/pkg/render/test-fixtures/formatter/two-pairs.env similarity index 100% rename from pkg/render/test-fixtures/formatter/two-pairs.input.env rename to pkg/render/test-fixtures/formatter/two-pairs.env diff --git a/pkg/test_helpers/filebased_command_tests.go b/pkg/test_helpers/filebased_command_tests.go index 1787997..4470c5a 100644 --- a/pkg/test_helpers/filebased_command_tests.go +++ b/pkg/test_helpers/filebased_command_tests.go @@ -2,6 +2,8 @@ package test_helpers import ( "bytes" + "context" + "errors" "fmt" "io" "os" @@ -9,6 +11,7 @@ import ( "testing" "unicode" + "github.com/google/shlex" "github.com/jippi/dottie/cmd" "github.com/sebdah/goldie/v2" "github.com/stretchr/testify/assert" @@ -19,7 +22,7 @@ import ( type Setting int const ( - SkipEnvCopy Setting = 1 << iota + ReadOnly Setting = 1 << iota ) // Has checks if [check] exists in the [settings] bitmask or not. @@ -34,7 +37,7 @@ func (bitmask Setting) Has(setting Setting) bool { return bitmask&setting != 0 } -func RunFilebasedCommandTests(t *testing.T, settings Setting, globalArgs ...string) { +func RunFileBasedCommandTests(t *testing.T, settings Setting, globalArgs ...string) { t.Helper() files, err := os.ReadDir("tests") @@ -49,7 +52,7 @@ func RunFilebasedCommandTests(t *testing.T, settings Setting, globalArgs ...stri goldenStdout string goldenStderr string goldenEnv string - commandArgs []string + commands [][]string } tests := []testData{} @@ -59,20 +62,31 @@ func RunFilebasedCommandTests(t *testing.T, settings Setting, globalArgs ...stri continue } - if !strings.HasSuffix(file.Name(), ".command.txt") { + if !strings.HasSuffix(file.Name(), ".run") { continue } - base := strings.TrimSuffix(file.Name(), ".command.txt") + base := strings.TrimSuffix(file.Name(), ".run") content, err := os.ReadFile("tests/" + file.Name()) require.NoErrorf(t, err, "failed to read file: %s", "tests/"+file.Name()) - var commandArgs []string + var commands [][]string str := string(bytes.TrimFunc(content, unicode.IsSpace)) if len(str) > 0 { - commandArgs = strings.Split(str, "\n") + commandArgs := strings.Split(str, "\n") + for _, commandStr := range commandArgs { + command, err := shlex.Split(commandStr) + + require.NoError(t, err) + + commands = append(commands, command) + } + } + + if len(commands) == 0 { + commands = append(commands, []string{}) } test := testData{ @@ -81,7 +95,7 @@ func RunFilebasedCommandTests(t *testing.T, settings Setting, globalArgs ...stri goldenStderr: "stderr", goldenEnv: "env", envFile: base + ".env", - commandArgs: commandArgs, + commands: commands, } tests = append(tests, test) @@ -105,54 +119,62 @@ func RunFilebasedCommandTests(t *testing.T, settings Setting, globalArgs ...stri dotEnvFile := "tests/" + tt.envFile - if !settings.Has(SkipEnvCopy) { - tmpDir := t.TempDir() - dotEnvFile = tmpDir + "/tmp.env" - - // Copy the input.env to temporary place - err := copyFile(t, "tests/"+tt.envFile, tmpDir+"/tmp.env") - require.NoErrorf(t, err, "failed to copy [%s] to TempDir", tt.envFile) + if !settings.Has(ReadOnly) { + dotEnvFile = t.TempDir() + "/tmp.env" + + if _, err := os.Stat("tests/" + tt.envFile); errors.Is(err, os.ErrNotExist) { + // Create a temporary empty .env file + _, err := os.Create(dotEnvFile) + require.NoErrorf(t, err, "failed to create empty .env file [ %s ] in TempDir", tt.envFile) + } else { + // Copy the input.env to temporary place + err := copyFile(t, "tests/"+tt.envFile, dotEnvFile) + require.NoErrorf(t, err, "failed to copy [ %s ] to TempDir", tt.envFile) + } } - // Point args to the copied temp env file - args := []string{"--file", dotEnvFile} - args = append(args, globalArgs...) - args = append(args, tt.commandArgs...) - // Prepare output buffers - stdout := bytes.Buffer{} - stderr := bytes.Buffer{} - - // Prepare command - root := cmd.NewCommand() - root.SetArgs(args) - root.SetOut(&stdout) - root.SetErr(&stderr) - - // Run command - out, err := root.ExecuteC() - if err != nil { - // Append errors to stderr - stderr.WriteString(fmt.Sprintf("%+v", err)) - } + combinedStdout := bytes.Buffer{} + combinedStderr := bytes.Buffer{} - // Assert we got a Cobra command back - require.NotNil(t, out, "expected a return value") + ctx := context.Background() - // Assert stdout + stderr + modified env file is as expected - if stdout.Len() == 0 { - assert.NoFileExists(t, "tests/"+tt.name+"/stdout.golden") - } else { - golden.Assert(t, tt.goldenStdout, stdout.Bytes()) - } + for idx, command := range tt.commands { + // Point args to the copied temp env file + args := []string{} + args = append(args, globalArgs...) + args = append(args, command...) - if stderr.Len() == 0 { - assert.NoFileExists(t, "tests/"+tt.name+"/stderr.golden") - } else { - golden.Assert(t, tt.goldenStderr, stderr.Bytes()) + combinedStdout.WriteString(fmt.Sprintf("---- exec command line %d: %+v\n", idx, args)) + combinedStderr.WriteString(fmt.Sprintf("---- exec command line %d: %+v\n", idx, args)) + + commandArgs := append(args, "--file", dotEnvFile) + + // Run command + stdout := bytes.Buffer{} + stderr := bytes.Buffer{} + out, _ := cmd.RunCommand(ctx, commandArgs, &stdout, &stderr) + + if stdout.Len() == 0 { + stdout.WriteString("(no output to stdout)\n") + } + + if stderr.Len() == 0 { + stderr.WriteString("(no output to stderr)\n") + } + + stdout.WriteTo(&combinedStdout) + stderr.WriteTo(&combinedStderr) + + // Assert we got a Cobra command back + require.NotNil(t, out, "expected a return value") } - if !settings.Has(SkipEnvCopy) { + // Assert stdout + stderr + modified env file is as expected + golden.Assert(t, tt.goldenStdout, combinedStdout.Bytes()) + golden.Assert(t, tt.goldenStderr, combinedStderr.Bytes()) + + if !settings.Has(ReadOnly) { // Read the modified .env file back modifiedEnv, err := os.ReadFile(dotEnvFile) diff --git a/pkg/tui/color.go b/pkg/tui/color.go deleted file mode 100644 index af48877..0000000 --- a/pkg/tui/color.go +++ /dev/null @@ -1,90 +0,0 @@ -package tui - -import ( - "io" - "os" - - "github.com/charmbracelet/lipgloss" -) - -type Box struct { - Header lipgloss.Style - Body lipgloss.Style -} - -func (b Box) Copy() Box { - return Box{ - Header: b.Header.Copy(), - Body: b.Body.Copy(), - } -} - -type Color struct { - Text lipgloss.AdaptiveColor - TextEmphasis lipgloss.AdaptiveColor - Background lipgloss.AdaptiveColor - Border lipgloss.AdaptiveColor -} - -func NewColor(config ColorConfig) Color { - color := Color{ - Text: config.Text.AdaptiveColor(), - TextEmphasis: config.TextEmphasis.AdaptiveColor(), - Background: config.Background.AdaptiveColor(), - Border: config.Border.AdaptiveColor(), - } - - if len(color.Text.Dark) == 0 { - color.Text.Dark = color.TextEmphasis.Dark - } - - return color -} - -func (c Color) Printer(renderer *lipgloss.Renderer, options ...PrinterOption) Print { - return NewPrinter(c, renderer, options...) -} - -func (c Color) BuffPrinter(w io.Writer, options ...PrinterOption) Print { - return c.Printer(Renderer(w), options...) -} - -func (c Color) StderrPrinter(options ...PrinterOption) Print { - return NewPrinter(c, Renderer(os.Stderr), options...) -} - -func (c Color) StdoutPrinter(options ...PrinterOption) Print { - return NewPrinter(c, Renderer(os.Stdout), options...) -} - -func (c Color) TextStyle(style lipgloss.Style) lipgloss.Style { - return style. - Foreground(c.Text) -} - -func (c Color) TextEmphasisStyle(style lipgloss.Style) lipgloss.Style { - return style. - Foreground(c.TextEmphasis). - Background(c.Background). - Bold(true). - BorderForeground(c.Border) -} - -func (c Color) BoxStyles(header, body lipgloss.Style) Box { - return Box{ - Header: header. - Align(lipgloss.Center, lipgloss.Center). - Border(headerBorder). - BorderForeground(c.Border). - PaddingBottom(1). - PaddingTop(1). - Inherit(c.TextEmphasisStyle(header)), - - Body: body. - Align(lipgloss.Left). - Border(bodyBorder). - BorderForeground(c.Border). - BorderTop(false). - Padding(1), - } -} diff --git a/pkg/tui/colors_gen.go b/pkg/tui/colors.go similarity index 99% rename from pkg/tui/colors_gen.go rename to pkg/tui/colors.go index dbba22a..c8e2fdf 100644 --- a/pkg/tui/colors_gen.go +++ b/pkg/tui/colors.go @@ -4,6 +4,11 @@ import ( "github.com/charmbracelet/lipgloss" ) +type ColorPair struct { + Name string + Value lipgloss.Color +} + const ( White = lipgloss.Color("#fff") Black = lipgloss.Color("#000") diff --git a/pkg/tui/config.go b/pkg/tui/config.go deleted file mode 100644 index 543269e..0000000 --- a/pkg/tui/config.go +++ /dev/null @@ -1,103 +0,0 @@ -package tui - -import ( - "github.com/charmbracelet/lipgloss" -) - -type ColorConfig struct { - Text ComponentColor - TextEmphasis ComponentColor - Background ComponentColor - Border ComponentColor -} - -func NewColorComponentConfig(baseColor lipgloss.Color) ColorConfig { - base := ColorToHex(baseColor) - - config := ColorConfig{ - Text: ComponentColor{ - Light: ComponentColorConfig{ - Color: base, - }, - }, - TextEmphasis: ComponentColor{ - Light: ComponentColorConfig{ - Color: base, - Filter: "shade", - Percent: 0.6, - }, - Dark: ComponentColorConfig{ - Color: base, - Filter: "tint", - Percent: 0.4, - }, - }, - Background: ComponentColor{ - Light: ComponentColorConfig{ - Color: base, - Filter: "tint", - Percent: 0.8, - }, - Dark: ComponentColorConfig{ - Color: base, - Filter: "shade", - Percent: 0.8, - }, - }, - Border: ComponentColor{ - Light: ComponentColorConfig{ - Color: base, - Filter: "tint", - Percent: 0.6, - }, - Dark: ComponentColorConfig{ - Color: base, - Filter: "shade", - Percent: 0.4, - }, - }, - } - - return config -} - -type ComponentColor struct { - Light ComponentColorConfig - Dark ComponentColorConfig -} - -func (cc ComponentColor) AdaptiveColor() lipgloss.AdaptiveColor { - result := lipgloss.AdaptiveColor{} - result.Light = cc.Light.AsHex() - result.Dark = cc.Dark.AsHex() - - return result -} - -type ComponentColorConfig struct { - Color string - MixColor string - Filter string - Percent float64 -} - -func (ccc ComponentColorConfig) AsHex() string { - switch ccc.Filter { - case "shade": - return ColorToHex(ShadeColor(ccc.Color, ccc.Percent)) - - case "tint": - return ColorToHex(TintColor(ccc.Color, ccc.Percent)) - - case "mix": - percent := ccc.Percent - if percent == 0 { - percent = 0.5 - } - - return ColorToHex(MixColors(ccc.Color, ccc.MixColor, percent)) - - default: - return ccc.Color - } -} diff --git a/pkg/tui/context.go b/pkg/tui/context.go new file mode 100644 index 0000000..d045845 --- /dev/null +++ b/pkg/tui/context.go @@ -0,0 +1,61 @@ +package tui + +import ( + "context" + "io" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +type fileDescriptorKey int + +const ( + Stdout fileDescriptorKey = iota + Stderr +) + +type contextKey int + +const ( + themeContextValue contextKey = iota + colorProfileContextValue +) + +func NewContext(ctx context.Context, stdout, stderr io.Writer) context.Context { + theme := NewTheme() + + stdoutOutput := lipgloss.NewRenderer(stdout, termenv.WithColorCache(true)) + stderrOutput := lipgloss.NewRenderer(stderr, termenv.WithColorCache(true)) + + ctx = context.WithValue(ctx, themeContextValue, theme) + ctx = context.WithValue(ctx, colorProfileContextValue, stdoutOutput.ColorProfile()) + ctx = context.WithValue(ctx, Stdout, theme.Writer(stdoutOutput)) + ctx = context.WithValue(ctx, Stderr, theme.Writer(stderrOutput)) + + return ctx +} + +func ThemeFromContext(ctx context.Context) Theme { + return ctx.Value(themeContextValue).(Theme) //nolint:forcetypeassert +} + +func ColorProfileFromContext(ctx context.Context) termenv.Profile { + return ctx.Value(colorProfileContextValue).(termenv.Profile) //nolint:forcetypeassert +} + +func WriterFromContext(ctx context.Context, descriptor fileDescriptorKey) Writer { + return ctx.Value(descriptor).(Writer) //nolint:forcetypeassert +} + +func StdoutFromContext(ctx context.Context) Writer { + return WriterFromContext(ctx, Stdout) +} + +func StderrFromContext(ctx context.Context) Writer { + return WriterFromContext(ctx, Stderr) +} + +func WritersFromContext(ctx context.Context) (Writer, Writer) { + return StdoutFromContext(ctx), StderrFromContext(ctx) +} diff --git a/pkg/tui/conventions.go b/pkg/tui/conventions.go index 4ac440f..cc684db 100644 --- a/pkg/tui/conventions.go +++ b/pkg/tui/conventions.go @@ -4,11 +4,6 @@ import "github.com/charmbracelet/lipgloss" const borderWidth = 2 -type ColorPair struct { - Name string - Value lipgloss.Color -} - var ( headerBorder = lipgloss.Border{ Top: "─", diff --git a/pkg/tui/helpers.go b/pkg/tui/helpers.go index 0c69c83..3adb296 100644 --- a/pkg/tui/helpers.go +++ b/pkg/tui/helpers.go @@ -1,23 +1,12 @@ package tui import ( - "io" + "context" "github.com/charmbracelet/lipgloss" - "github.com/muesli/termenv" "github.com/teacat/noire" ) -func Renderer(w io.Writer, opts ...termenv.OutputOption) *lipgloss.Renderer { - return lipgloss.NewRenderer(w, opts...) -} - -func RendererWithTTY(w io.Writer, opts ...termenv.OutputOption) *lipgloss.Renderer { - opts = append(opts, termenv.WithTTY(true)) - - return lipgloss.NewRenderer(w, opts...) -} - func ShadeColor(in string, percent float64) lipgloss.Color { if percent < 0 || percent > 1 { panic("ShadeColor [percent] must be between 0.0 and 1.0 (0.5 == 50%)") @@ -34,14 +23,32 @@ func TintColor(in string, percent float64) lipgloss.Color { return lipgloss.Color("#" + noire.NewHex(in).Tint(percent).Hex()) } -func MixColors(a, b string, weight float64) lipgloss.Color { - if weight < 0 || weight > 1 { - panic("MixColors [weight] must be between 0.0 and 1.0 (0.5 == 50%)") - } +func ColorToHex(in lipgloss.Color) string { + return string(in) +} - return lipgloss.Color("#" + noire.NewHex(a).Mix(noire.NewHex(b), weight).Hex()) +func TransformColor(base, filter string, percent float64) string { + switch filter { + case "shade": + return ColorToHex(ShadeColor(base, percent)) + + case "tint": + return ColorToHex(TintColor(base, percent)) + + case "mix": + panic("unexpected mix filter") + + default: + return base + } } -func ColorToHex(in lipgloss.Color) string { - return string(in) +func MaybePrintWarnings(ctx context.Context, warnings error) { + if warnings == nil { + return + } + + StderrFromContext(ctx). + Warning(). + Printfln("WARNING: %+v", warnings) } diff --git a/pkg/tui/printer.go b/pkg/tui/printer.go index 5764511..7416148 100644 --- a/pkg/tui/printer.go +++ b/pkg/tui/printer.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/charmbracelet/lipgloss" - "github.com/erikgeiser/promptkit" ) type StyleChanger func(*lipgloss.Style) @@ -16,155 +15,65 @@ var Bold = func(s *lipgloss.Style) { s.Bold(true) } -type PrinterOption func(p *Print) +type PrinterOption func(p *Printer) // Printer mirrors the [fmt] package print/sprint functions, wraps them in a [lipgloss.Style] -// and an optional [WordWrap] configuration with a configured [MaxWidth]. +// and an optional [WordWrap] configuration with a configured [BoxWidth]. // -// Additionally, [Print*] methods writes to the configured [Writer] instead of [os.Stdout] -type Printer interface { - // ---------------------------------------- - // print to a specific io.Writer - // ---------------------------------------- - - // Fprint mirrors [fmt.Fprint] signature and behavior, with the configured style - // and (optional) word wrapping applied - Fprint(w io.Writer, a ...any) (n int, err error) - - // Fprintf mirrors [fmt.Fprintf] signature and behavior, with the configured style - // and (optional) word wrapping applied - Fprintf(w io.Writer, format string, a ...any) (n int, err error) - - // Fprintfln mirrors [fmt.Fprintfln] signature and behavior, with the configured style - // and (optional) word wrapping applied - Fprintfln(w io.Writer, format string, a ...any) (n int, err error) - - // Fprintln mirrors [fmt.Fprintln] signature and behavior, with the configured style - // and (optional) word wrapping applied - Fprintln(w io.Writer, a ...any) (n int, err error) - - // ---------------------------------------- - // print to the default io.Writer - // ---------------------------------------- - - // Print mirrors [fmt.Print] signature and behavior, with the configured style - // and (optional) word wrapping applied. - // - // Instead of writing to [os.Stdout] it will write to the configured [io.Writer]. - Print(a ...any) (n int, err error) - - // Printf mirrors [fmt.Printf] signature and behavior, with the configured style - // and (optional) word wrapping applied. - // - // Instead of writing to [os.Stdout] it will write to the configured [io.Writer]. - Printf(format string, a ...any) (n int, err error) - - // Printfln behaves like [fmt.Printf] but supports the [formatter] signature. - // - // This does *not* map to a Go native printer, but a mix for formatting + newline - Printfln(format string, a ...any) (n int, err error) - - // Println mirrors [fmt.Println] signature and behavior, with the configured style - // and (optional) word wrapping applied. - // - // Instead of writing to [os.Stdout] it will write to the configured [io.Writer]. - Println(a ...any) (n int, err error) - - // ---------------------------------------- - // return string - // ---------------------------------------- - - // Sprint mirrors [fmt.Sprint] signature and behavior, with the configured style - // and (optional) word wrapping applied. - Sprint(a ...any) string - - // Sprintf mirrors [fmt.Sprintf] signature and behavior, with the configured style - // and (optional) word wrapping applied. - Sprintf(format string, a ...any) string - - // Sprintfln behaves like [fmt.Sprintln] but supports the [formatter] signature. - // - // This does *not* map to a Go native printer, but a mix for formatting + newline - Sprintfln(format string, a ...any) string - - // Sprintln mirrors [fmt.Sprintln] signature and behavior, with the configured style - // and (optional) word wrapping applied. - Sprintln(a ...any) string - - // ---------------------------------------- - // helper methods - // ---------------------------------------- - - Copy(options ...PrinterOption) Print - - // GetMaxWidth returns the configured [MaxWidth] for word wrapping - MaxWidth() int - - // TextStyle returns a *copy* of the current [lipgloss.Style] - Style() lipgloss.Style - - // ApplyTextStyle returns a new copy of [StylePrint] instance with the [Style] based on the callback changes - ApplyStyle(changer StyleChanger) Print - - // WrapMode returns the configured [WrapMode] - WrapMode() promptkit.WrapMode - - // Writer returns the configured [io.Writer] - Writer() io.Writer - - // Create a visual box with the printer style - Box(header string, body ...string) -} - -type Print struct { - maxWidth int // Max width for strings when using WrapMode - wrapMode promptkit.WrapMode // WrapMode controls if line-wrapping should be off [nil], soft [promptkit.WordWrap] or hard [promptkit.HardWrap] - writer io.Writer // Writer controls where implicit print output goes for [Print], [Printf], [Printfln] and [Println] - renderer *lipgloss.Renderer // The renderer responsible for providing the output and color management - color Color // Color config - theme ThemeConfig // Theme config - - textStyle lipgloss.Style - textEmphasis bool - boxStyles Box -} - -func NewPrinter(color Color, renderer *lipgloss.Renderer, options ...PrinterOption) Print { +// Additionally, [Printer*] methods writes to the configured [Writer] instead of [os.Stdout] +type Printer struct { + boxWidth int // Max width for strings when using WrapMode + writer io.Writer // Writer controls where implicit print output goes for [Print], [Printf], [Printfln] and [Println] + renderer *lipgloss.Renderer // The renderer responsible for providing the output and color management + style Style // Style config + textStyle lipgloss.Style + boxHeaderStyle lipgloss.Style + boxBodyStyle lipgloss.Style +} + +func NewPrinter(style Style, renderer *lipgloss.Renderer, options ...PrinterOption) Printer { options = append([]PrinterOption{ - WithColor(color), + WitBoxWidth(80), + WithStyle(style), WithRenderer(renderer), - WithTheme(Theme), - WithEmphasis(false), - WithWrapMode(nil), }, options...) - printer := &Print{} + printer := &Printer{} for _, option := range options { option(printer) } - printer.boxStyles = printer.color.BoxStyles(printer.renderer.NewStyle(), printer.renderer.NewStyle()) + printer.boxHeaderStyle = style.BoxHeader() + printer.boxBodyStyle = style.BoxBody() return *printer } -// ----------------------------------------------------- -// Print to a user-provided io.Writer -// ----------------------------------------------------- +// ---------------------------------------- +// print to a specific io.Writer +// ---------------------------------------- -func (p Print) Fprint(w io.Writer, a ...any) (n int, err error) { +// Fprint mirrors [fmt.Fprint] signature and behavior, with the configured style +// and (optional) word wrapping applied +func (p Printer) Fprint(w io.Writer, a ...any) (n int, err error) { return fmt.Fprint(w, p.Sprint(a...)) } -func (p Print) Fprintf(w io.Writer, format string, a ...any) (n int, err error) { +// Fprintf mirrors [fmt.Fprintf] signature and behavior, with the configured style +// and (optional) word wrapping applied +func (p Printer) Fprintf(w io.Writer, format string, a ...any) (n int, err error) { return p.Fprint(w, p.Sprintf(format, a...)) } -func (p Print) Fprintfln(w io.Writer, format string, a ...any) (n int, err error) { +// Fprintfln mirrors [fmt.Fprintfln] signature and behavior, with the configured style +// and (optional) word wrapping applied +func (p Printer) Fprintfln(w io.Writer, format string, a ...any) (n int, err error) { return p.Fprintln(w, p.Sprintf(format, a...)) } -func (p Print) Fprintln(w io.Writer, a ...any) (n int, err error) { +// Fprintln mirrors [fmt.Fprintln] signature and behavior, with the configured style +// and (optional) word wrapping applied +func (p Printer) Fprintln(w io.Writer, a ...any) (n int, err error) { return fmt.Fprintln(w, p.printHelper(a...)) } @@ -172,19 +81,34 @@ func (p Print) Fprintln(w io.Writer, a ...any) (n int, err error) { // Print to the default [p.writer] over [os.Stdout] // ----------------------------------------------------- -func (p Print) Print(a ...any) (n int, err error) { +// Print mirrors [fmt.Print] signature and behavior, with the configured style +// and (optional) word wrapping applied. +// +// Instead of writing to [os.Stdout] it will write to the configured [io.Writer]. +func (p Printer) Print(a ...any) (n int, err error) { return p.Fprint(p.writer, a...) } -func (p Print) Printf(format string, a ...any) (n int, err error) { +// Printf mirrors [fmt.Printf] signature and behavior, with the configured style +// and (optional) word wrapping applied. +// +// Instead of writing to [os.Stdout] it will write to the configured [io.Writer]. +func (p Printer) Printf(format string, a ...any) (n int, err error) { return p.Fprintf(p.writer, format, a...) } -func (p Print) Printfln(format string, a ...any) (n int, err error) { +// Printfln behaves like [fmt.Printf] but supports the [formatter] signature. +// +// This does *not* map to a Go native printer, but a mix for formatting + newline +func (p Printer) Printfln(format string, a ...any) (n int, err error) { return p.Fprintfln(p.writer, format, a...) } -func (p Print) Println(a ...any) (n int, err error) { +// Println mirrors [fmt.Println] signature and behavior, with the configured style +// and (optional) word wrapping applied. +// +// Instead of writing to [os.Stdout] it will write to the configured [io.Writer] +func (p Printer) Println(a ...any) (n int, err error) { return p.Fprintln(p.writer, a...) } @@ -192,34 +116,44 @@ func (p Print) Println(a ...any) (n int, err error) { // Return string // ----------------------------------------------------- -func (p Print) Sprint(a ...any) string { +// Sprint mirrors [fmt.Sprint] signature and behavior, with the configured style +// and (optional) word wrapping applied. +func (p Printer) Sprint(a ...any) string { return p.render(fmt.Sprint(a...)) } -func (p Print) Sprintf(format string, a ...any) string { +// Sprintf mirrors [fmt.Sprintf] signature and behavior, with the configured style +// and (optional) word wrapping applied. +func (p Printer) Sprintf(format string, a ...any) string { return p.render(fmt.Sprintf(format, a...)) } -func (p Print) Sprintfln(format string, a ...any) string { +// Sprintfln behaves like [fmt.Sprintln] but supports the [formatter] signature. +// +// This does *not* map to a Go native printer, but a mix for formatting + newline +func (p Printer) Sprintfln(format string, a ...any) string { return fmt.Sprintln(p.Sprintf(format, a...)) } -func (p Print) Sprintln(a ...any) string { +// Sprintln mirrors [fmt.Sprintln] signature and behavior, with the configured style +// and (optional) word wrapping applied. +func (p Printer) Sprintln(a ...any) string { return fmt.Sprintln(p.printHelper(a...)) } -func (p Print) Box(header string, bodies ...string) { +// Create a visual box with the printer style +func (p Printer) Box(header string, bodies ...string) { body := strings.Join(bodies, " ") // Copy the box styles to avoid leaking changes to the styles - styles := p.boxStyles.Copy() + headerStyle, bodyStyle := p.boxHeaderStyle.Copy(), p.boxBodyStyle.Copy() // If there are no body, just render the header box directly if len(body) == 0 { fmt.Fprintln( p.writer, - styles.Header. - Width(p.maxWidth-borderWidth). + headerStyle. + Width(p.boxWidth-borderWidth). Border(headerOnlyBorder). Render(header), ) @@ -228,11 +162,11 @@ func (p Print) Box(header string, bodies ...string) { } // Render the header and body box - boxHeader := styles.Header.Width(p.maxWidth - borderWidth).Render(header) - boxBody := styles.Body.Width(p.maxWidth - borderWidth).Render(body) + boxHeader := headerStyle.Width(p.boxWidth - borderWidth).Render(header) + boxBody := bodyStyle.Width(p.boxWidth - borderWidth).Render(body) - // If a maxWidth is set, the boxes will be aligned automatically to the max - if p.maxWidth > 0 { + // If a BoxWidth is set, the boxes will be aligned automatically to the max + if p.boxWidth > 0 { fmt.Fprintln( p.writer, lipgloss.JoinVertical( @@ -252,10 +186,10 @@ func (p Print) Box(header string, bodies ...string) { // Find the shortest box and (re)render it to the length of the longest one switch { case headerWidth > bodyWidth: - boxBody = styles.Body.Width(headerWidth).Render(body) + boxBody = bodyStyle.Width(headerWidth).Render(body) case headerWidth < bodyWidth: - boxHeader = styles.Header.Width(bodyWidth).Render(header) + boxHeader = headerStyle.Width(bodyWidth).Render(header) } fmt.Fprintln( @@ -268,7 +202,7 @@ func (p Print) Box(header string, bodies ...string) { // io.Writer // ----------------------------------------------------- -func (p Print) Write(b []byte) (n int, err error) { +func (p Printer) Write(b []byte) (n int, err error) { return p.Print(string(b)) } @@ -276,19 +210,17 @@ func (p Print) Write(b []byte) (n int, err error) { // Helper methods // ----------------------------------------------------- -func (p Print) WrapMode() promptkit.WrapMode { - return p.wrapMode +// GetBoxWidth returns the configured [BoxWidth] for word wrapping +func (p Printer) BoxWidth() int { + return p.boxWidth } -func (p Print) MaxWidth() int { - return p.maxWidth -} - -func (p Print) Writer() io.Writer { +// Writer returns the configured [io.Writer] +func (p Printer) Writer() io.Writer { return p.writer } -func (p Print) Copy(options ...PrinterOption) Print { +func (p Printer) Copy(options ...PrinterOption) Printer { clone := &p for _, option := range options { @@ -298,18 +230,20 @@ func (p Print) Copy(options ...PrinterOption) Print { return *clone } -func (p Print) Style() lipgloss.Style { +// TextStyle returns a *copy* of the current [lipgloss.Style] +func (p Printer) Style() lipgloss.Style { return p.textStyle.Copy() } -func (p Print) ApplyStyle(callback StyleChanger) Print { +// ApplyTextStyle returns a new copy of [StylePrint] instance with the [Style] based on the callback changes +func (p Printer) ApplyStyle(callback StyleChanger) Printer { style := p.Style() callback(&style) return p.Copy(WithTextStyle(style)) } -func (p Print) GetWriter() io.Writer { +func (p Printer) GetWriter() io.Writer { return p.writer } @@ -317,19 +251,15 @@ func (p Print) GetWriter() io.Writer { // internal helpers // ----------------------------------------------------- -func (p Print) render(input string) string { +func (p Printer) render(input string) string { return p.wrap(p.textStyle.Render(input)) } -func (p Print) wrap(input string) string { - if p.wrapMode == nil { - return input - } - - return p.wrapMode(input, p.maxWidth) +func (p Printer) wrap(input string) string { + return input } -func (p Print) printHelper(a ...any) string { +func (p Printer) printHelper(a ...any) string { var buff bytes.Buffer fmt.Fprintln(&buff, a...) @@ -344,67 +274,46 @@ func (p Print) printHelper(a ...any) string { // Printer options // ----------------------------------------------------- -func WithColor(color Color) PrinterOption { - return func(p *Print) { - p.color = color +func WithStyle(style Style) PrinterOption { + return func(p *Printer) { + p.style = style + p.textStyle = p.renderer.NewStyle().Inherit(style.TextStyle()) } } func WithRenderer(renderer *lipgloss.Renderer) PrinterOption { - return func(p *Print) { + return func(p *Printer) { p.renderer = renderer p.writer = renderer.Output() } } -func WithTheme(theme ThemeConfig) PrinterOption { - return func(p *Print) { - p.theme = theme - p.maxWidth = theme.DefaultWidth - p.wrapMode = theme.WrapMode - } -} - func WithTextStyle(style lipgloss.Style) PrinterOption { - return func(p *Print) { + return func(p *Printer) { p.textStyle = style } } -func WithBoxStyle(style Box) PrinterOption { - return func(p *Print) { - p.boxStyles = style - } -} - func WithEmphasis(b bool) PrinterOption { - return func(printer *Print) { - printer.textEmphasis = b - + return func(printer *Printer) { if b { - printer.textStyle = printer.color.TextEmphasisStyle(printer.renderer.NewStyle()) + printer.textStyle = printer.renderer.NewStyle().Inherit(printer.style.TextEmphasisStyle()) return } - printer.textStyle = printer.color.TextStyle(printer.renderer.NewStyle()) + printer.textStyle = printer.renderer.NewStyle().Inherit(printer.style.TextStyle()) } } func WithWriter(w io.Writer) PrinterOption { - return func(p *Print) { + return func(p *Printer) { p.writer = w } } -func WithMaxWidth(i int) PrinterOption { - return func(p *Print) { - p.maxWidth = i - } -} - -func WithWrapMode(mode promptkit.WrapMode) PrinterOption { - return func(p *Print) { - p.wrapMode = mode +func WitBoxWidth(i int) PrinterOption { + return func(p *Printer) { + p.boxWidth = i } } diff --git a/pkg/tui/style.go b/pkg/tui/style.go new file mode 100644 index 0000000..f9105e8 --- /dev/null +++ b/pkg/tui/style.go @@ -0,0 +1,100 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +type styleIdentifier int + +const ( + Danger styleIdentifier = 1 << iota + Dark + Info + Light + NoColor + Primary + Secondary + Success + Warning +) + +type Style struct { + textColor lipgloss.AdaptiveColor + textStyle lipgloss.Style + textEmphasisColor lipgloss.AdaptiveColor + textEmphasisStyle lipgloss.Style + backgroundColor lipgloss.AdaptiveColor + borderColor lipgloss.AdaptiveColor +} + +func NewStyle(baseColor lipgloss.Color) Style { + base := ColorToHex(baseColor) + + style := Style{ + textColor: lipgloss.AdaptiveColor{ + Light: TransformColor(base, "", 0), + Dark: TransformColor(base, "tint", 0.4), + }, + textEmphasisColor: lipgloss.AdaptiveColor{ + Light: TransformColor(base, "shade", 0.6), + Dark: TransformColor(base, "tint", 0.4), + }, + backgroundColor: lipgloss.AdaptiveColor{ + Light: TransformColor(base, "tint", 0.8), + Dark: TransformColor(base, "shade", 0.8), + }, + borderColor: lipgloss.AdaptiveColor{ + Light: TransformColor(base, "tint", 0.6), + Dark: TransformColor(base, "shade", 0.4), + }, + } + + style.textStyle = lipgloss. + NewStyle(). + Foreground(style.textColor) + + style.textEmphasisStyle = lipgloss. + NewStyle(). + Bold(true). + Foreground(style.textEmphasisColor). + Background(style.backgroundColor). + BorderForeground(style.borderColor) + + return style +} + +func NewStyleWithoutColor() Style { + // Since all lipgloss.Styles are non-pointers, they are by default an empty / unstyled version of themselves + return Style{} +} + +func (style Style) NewPrinter(renderer *lipgloss.Renderer, options ...PrinterOption) Printer { + return NewPrinter(style, renderer, options...) +} + +func (style Style) TextStyle() lipgloss.Style { + return style.textStyle +} + +func (style Style) TextEmphasisStyle() lipgloss.Style { + return style.textEmphasisStyle +} + +func (style Style) BoxHeader() lipgloss.Style { + return lipgloss.NewStyle(). + Align(lipgloss.Center, lipgloss.Center). + Border(headerBorder). + BorderForeground(style.borderColor). + PaddingBottom(1). + PaddingTop(1). + Inherit(style.TextEmphasisStyle()) +} + +func (style Style) BoxBody() lipgloss.Style { + return lipgloss.NewStyle(). + Align(lipgloss.Left). + Border(bodyBorder). + BorderForeground(style.borderColor). + BorderTop(false). + Padding(1) +} diff --git a/pkg/tui/theme.go b/pkg/tui/theme.go index 85734b7..edb2f70 100644 --- a/pkg/tui/theme.go +++ b/pkg/tui/theme.go @@ -1,50 +1,63 @@ package tui import ( - "github.com/erikgeiser/promptkit" -) - -type ThemeConfig struct { - DefaultWidth int + "context" + "io" - // Line wrapping handling - WrapMode promptkit.WrapMode + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) - Danger Color - Dark Color - Info Color - Light Color - Primary Color - Secondary Color - Success Color - Warning Color +type Theme struct { + styles map[styleIdentifier]Style } -var Theme ThemeConfig - -func init() { - Theme = ThemeConfig{} - Theme.DefaultWidth = 100 - Theme.WrapMode = nil // Disabled for now, left here for easy opt-in in the future +func NewTheme() Theme { + theme := Theme{} + theme.styles = make(map[styleIdentifier]Style) + theme.styles[Danger] = NewStyle(Red) + theme.styles[Info] = NewStyle(Cyan) + theme.styles[Light] = NewStyle(Gray300) + theme.styles[NoColor] = NewStyleWithoutColor() + theme.styles[Primary] = NewStyle(Blue) + theme.styles[Secondary] = NewStyle(Gray600) + theme.styles[Success] = NewStyle(Green) + theme.styles[Warning] = NewStyle(Yellow) + + dark := NewStyle(Gray700) + dark.textEmphasisColor.Dark = ColorToHex(Gray300) + dark.backgroundColor.Dark = "#1a1d20" + dark.borderColor.Dark = ColorToHex(Gray800) + + theme.styles[Dark] = dark + + return theme +} - Theme.Danger = NewColor(NewColorComponentConfig(Red)) - Theme.Info = NewColor(NewColorComponentConfig(Cyan)) - Theme.Light = NewColor(NewColorComponentConfig(Gray300)) - Theme.Primary = NewColor(NewColorComponentConfig(Blue)) - Theme.Secondary = NewColor(NewColorComponentConfig(Gray600)) - Theme.Success = NewColor(NewColorComponentConfig(Green)) - Theme.Warning = NewColor(NewColorComponentConfig(Yellow)) +func (theme Theme) Style(id styleIdentifier) Style { + return theme.styles[id] +} - dark := NewColorComponentConfig(Gray700) - dark.TextEmphasis.Dark = ComponentColorConfig{ - Color: ColorToHex(Gray300), - } - dark.Background.Dark = ComponentColorConfig{ - Color: "#1a1d20", +func (theme Theme) Writer(renderer *lipgloss.Renderer) Writer { + return Writer{ + renderer: renderer, + theme: theme, + cache: make(map[styleIdentifier]Printer), } - dark.Border.Dark = ComponentColorConfig{ - Color: ColorToHex(Gray800), +} + +func NewWriter(ctx context.Context, writer io.Writer) Writer { + var options []termenv.OutputOption + + // If the primary (stdout) color profile is in color mode (aka not ASCII), + // force TTY and color profile for the new renderer and writer + if profile := ColorProfileFromContext(ctx); profile != termenv.Ascii { + options = append( + options, + termenv.WithTTY(true), + termenv.WithProfile(profile), + ) } - Theme.Dark = NewColor(dark) + return ThemeFromContext(ctx).Writer(lipgloss.NewRenderer(writer, options...)) } diff --git a/pkg/tui/writer.go b/pkg/tui/writer.go new file mode 100644 index 0000000..e44a801 --- /dev/null +++ b/pkg/tui/writer.go @@ -0,0 +1,57 @@ +package tui + +import ( + "github.com/charmbracelet/lipgloss" +) + +type Writer struct { + cache map[styleIdentifier]Printer + theme Theme + renderer *lipgloss.Renderer +} + +func (w Writer) Danger() Printer { + return w.Style(Danger) +} + +func (w Writer) Dark() Printer { + return w.Style(Dark) +} + +func (w Writer) Info() Printer { + return w.Style(Info) +} + +func (w Writer) Light() Printer { + return w.Style(Light) +} + +func (w Writer) NoColor() Printer { + return w.Style(NoColor) +} + +func (w Writer) Primary() Printer { + return w.Style(Primary) +} + +func (w Writer) Secondary() Printer { + return w.Style(Secondary) +} + +func (w Writer) Success() Printer { + return w.Style(Success) +} + +func (w Writer) Warning() Printer { + return w.Style(Warning) +} + +func (w Writer) Style(colorType styleIdentifier) Printer { + if printer, ok := w.cache[colorType]; ok { + return printer + } + + w.cache[colorType] = w.theme.Style(colorType).NewPrinter(w.renderer) + + return w.cache[colorType] +} diff --git a/pkg/validation/explain.go b/pkg/validation/explain.go index 075e9b5..5d85095 100644 --- a/pkg/validation/explain.go +++ b/pkg/validation/explain.go @@ -2,6 +2,7 @@ package validation import ( "bytes" + "context" "errors" "fmt" "os" @@ -18,23 +19,27 @@ type multiError interface { Errors() []error } -func Explain(doc *ast.Document, inputError any, keyErr ValidationError, applyFixer, showField bool) string { +func Explain(ctx context.Context, doc *ast.Document, inputError any, keyErr ValidationError, applyFixer, showField bool) string { var buff bytes.Buffer - dark := tui.Theme.Dark.BuffPrinter(&buff) - bold := tui.Theme.Warning.BuffPrinter(&buff, tui.WithEmphasis(true)) - danger := tui.Theme.Danger.BuffPrinter(&buff) - light := tui.Theme.Light.BuffPrinter(&buff) - primary := tui.Theme.Primary.BuffPrinter(&buff) + writer := tui.NewWriter(ctx, &buff) + + dark := writer.Dark() + bold := writer.Warning().Copy(tui.WithEmphasis(true)) + danger := writer.Danger() + light := writer.Light() + primary := writer.Primary() + + stderr := tui.WriterFromContext(ctx, tui.Stderr) switch err := inputError.(type) { // Unwrap the ValidationError case ValidationError: - return Explain(doc, err.WrappedError, err, applyFixer, showField) + return Explain(ctx, doc, err.WrappedError, err, applyFixer, showField) case multiError: for _, e := range err.Errors() { - buff.WriteString(Explain(doc, e, ValidationError{}, applyFixer, showField)) + buff.WriteString(Explain(ctx, doc, e, ValidationError{}, applyFixer, showField)) buff.WriteString("\n") } @@ -67,7 +72,7 @@ func Explain(doc *ast.Document, inputError any, keyErr ValidationError, applyFix fmt.Fprintln(os.Stderr, buff.String()) buff.Reset() - AskToCreateDirectory(keyErr.Assignment.Interpolated) + AskToCreateDirectory(ctx, keyErr.Assignment.Interpolated) askToFix = false } @@ -131,10 +136,10 @@ func Explain(doc *ast.Document, inputError any, keyErr ValidationError, applyFix } if askToFix { - fmt.Fprintln(os.Stderr, buff.String()) + stderr.NoColor().Println(buff.String()) buff.Reset() - AskToSetValue(doc, keyErr.Assignment) + AskToSetValue(ctx, doc, keyErr.Assignment) } } @@ -145,8 +150,11 @@ func Explain(doc *ast.Document, inputError any, keyErr ValidationError, applyFix return buff.String() } -func AskToCreateDirectory(path string) { - confirm := true +func AskToCreateDirectory(ctx context.Context, path string) { + var ( + confirm = true + stderr = tui.WriterFromContext(ctx, tui.Stderr) + ) err := huh.NewConfirm(). Title("\nDo you want this program to create the directory for you?"). @@ -155,28 +163,31 @@ func AskToCreateDirectory(path string) { Value(&confirm). Run() if err != nil { - tui.Theme.Warning.StderrPrinter().Println(" Prompt cancelled: " + err.Error()) + stderr.Warning().Println(" Prompt cancelled: " + err.Error()) return } if !confirm { - tui.Theme.Warning.StderrPrinter().Println(" Prompt cancelled") + stderr.Warning().Println(" Prompt cancelled") return } if err := os.MkdirAll(path, os.ModePerm); err != nil { - tui.Theme.Danger.StderrPrinter().Println(" Could not create directory: " + err.Error()) + stderr.Danger().Println(" Could not create directory: " + err.Error()) return } - tui.Theme.Success.StderrPrinter().Println(" Directory was successfully created") + stderr.Success().Println(" Directory was successfully created") } -func AskToSetValue(doc *ast.Document, assignment *ast.Assignment) { - var value string +func AskToSetValue(ctx context.Context, doc *ast.Document, assignment *ast.Assignment) { + var ( + value string + stderr = tui.WriterFromContext(ctx, tui.Stderr) + ) err := huh.NewInput(). Title("Please provide value for " + assignment.Name). @@ -189,7 +200,7 @@ func AskToSetValue(doc *ast.Document, assignment *ast.Assignment) { Assignment: assignment, } - return errors.New(Explain(doc, z, z, false, false)) + return errors.New(Explain(ctx, doc, z, z, false, false)) } return nil @@ -197,17 +208,17 @@ func AskToSetValue(doc *ast.Document, assignment *ast.Assignment) { Value(&value). Run() if err != nil { - tui.Theme.Warning.StderrPrinter().Println(" Prompt cancelled: " + err.Error()) + stderr.Warning().Println(" Prompt cancelled: " + err.Error()) return } assignment.Literal = value - if err := pkg.Save(assignment.Position.File, doc); err != nil { - tui.Theme.Danger.StderrPrinter().Println(" Could not update key with value [" + value + "]: " + err.Error()) + if err := pkg.Save(ctx, assignment.Position.File, doc); err != nil { + stderr.Danger().Println(" Could not update key with value [" + value + "]: " + err.Error()) return } - tui.Theme.Success.StderrPrinter().Println(" Successfully updated key with value [" + value + "]") + stderr.Success().Println(" Successfully updated key with value [" + value + "]") } diff --git a/pkg/validation/validation.go b/pkg/validation/validation.go index 0a00299..e663558 100644 --- a/pkg/validation/validation.go +++ b/pkg/validation/validation.go @@ -1,6 +1,7 @@ package validation import ( + "context" "fmt" "slices" @@ -29,7 +30,7 @@ func NewError(assignment *ast.Assignment, err error) ValidationError { } } -func Validate(doc *ast.Document, handlers []render.Handler, ignoreErrors []string) []ValidationError { +func Validate(ctx context.Context, doc *ast.Document, handlers []render.Handler, ignoreErrors []string) []ValidationError { data := map[string]any{} rules := map[string]any{} @@ -49,7 +50,7 @@ NEXT: } for _, handler := range handlers { - status := handler(handlerInput) + status := handler(ctx, handlerInput) switch status { // Stop processing the statement and return nothing @@ -105,10 +106,11 @@ NEXT_FIELD: return result } -func ValidateSingleAssignment(doc *ast.Document, assignment *ast.Assignment, handlers []render.Handler, ignoreErrors []string) []ValidationError { +func ValidateSingleAssignment(ctx context.Context, doc *ast.Document, assignment *ast.Assignment, handlers []render.Handler, ignoreErrors []string) []ValidationError { keys := AssignmentsToValidateRecursive(assignment) return Validate( + ctx, doc, append( []render.Handler{