diff --git a/.env.example b/.env.example index a9507d9..3acb2f8 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,11 @@ #!/bin/bash # -*- mode: bash -*- # vi: ft=bash - +# shellcheck disable=SC2034,SC2148,SC2016 +# # Use Dottie (https://github.com/jippi/dottie) to manage this .env file easier! # # @dottie/source .env.example -# -# shellcheck disable=SC2034,SC2148 ################################################################################ # app @@ -30,7 +29,7 @@ APP_DOMAIN="example.com" # @dottie/validate required,http_url APP_URL="https://${APP_DOMAIN}" -# Application domains used for routing. +# Application domains used for routing # # @see https://docs.pixelfed.org/technical-documentation/config/#admin_domain # @dottie/validate required,fqdn diff --git a/cmd/set/set.go b/cmd/set/set.go index 55d2e27..3fbc5b4 100644 --- a/cmd/set/set.go +++ b/cmd/set/set.go @@ -24,80 +24,7 @@ func Command() *cobra.Command { WithSuffixIsLiteral(true). WithHandlers(render.ExcludeDisabledAssignments). Get(), - RunE: func(cmd *cobra.Command, args []string) error { - filename := cmd.Flag("file").Value.String() - - env, err := pkg.Load(filename) - if err != nil { - return err - } - - if len(args) == 0 { - return errors.New("Missing required argument: KEY=VALUE") - } - - comments, _ := cmd.Flags().GetStringArray("comment") - options := ast.UpsertOptions{ - InsertBefore: shared.StringFlag(cmd.Flags(), "before"), - Comments: comments, - ErrorIfMissing: shared.BoolFlag(cmd.Flags(), "error-if-missing"), - Group: shared.StringFlag(cmd.Flags(), "group"), - SkipValidation: !shared.BoolWithInverseValue(cmd.Flags(), "validate"), - } - - for _, stringPair := range args { - pairSlice := strings.SplitN(stringPair, "=", 2) - if len(pairSlice) != 2 { - return errors.New("expected KEY=VALUE pair, missing '='") - } - - key := pairSlice[0] - value := pairSlice[1] - - assignment := &ast.Assignment{ - Name: key, - Literal: value, - // by default we take the user input and assume its interpolated, - // it will be interpolated inside (*Document).Set if applicable - Interpolated: value, - Active: !shared.BoolFlag(cmd.Flags(), "disabled"), - Quote: token.QuoteFromString(shared.StringFlag(cmd.Flags(), "quote-style")), - } - - // - // Upsert key - // - - assignment, err := env.Upsert(assignment, options) - if err != nil { - fmt.Fprintln(os.Stderr, validation.Explain(env, validation.NewError(assignment, err), false, true)) - - return fmt.Errorf("failed to upsert the key/value pair [%s]", key) - } - - if validationErrors := validation.ValidateSingleAssignment(env, assignment.Name, nil, nil); len(validationErrors) > 0 { - for _, errIsh := range validationErrors { - fmt.Fprintln(os.Stderr, validation.Explain(env, errIsh, false, false)) - } - - return errors.New("validation failed") - } - - tui.Theme.Success.StderrPrinter().Printfln("Key [%s] was successfully upserted", key) - } - - // - // Save file - // - - if err := pkg.Save(shared.StringFlag(cmd.Flags(), "file"), env); err != nil { - return fmt.Errorf("failed to save file: %w", err) - } - - tui.Theme.Success.StderrPrinter().Println("File was successfully saved") - - return nil - }, + RunE: runE, } shared.BoolWithInverse(cmd, "validate", true, "Validate the VALUE input before saving the file", "Do not validate the VALUE input before saving the file") @@ -110,5 +37,112 @@ func Command() *cobra.Command { cmd.Flags().String("quote-style", "double", "The quote style to use (single, double, none)") cmd.Flags().StringSlice("comment", nil, "Set one or multiple lines of comments to the KEY=VALUE pair") + cmd.MarkFlagsMutuallyExclusive("before", "after", "group") + return cmd } + +func runE(cmd *cobra.Command, args []string) error { + filename := cmd.Flag("file").Value.String() + + env, err := pkg.Load(filename) + if err != nil { + return err + } + + if len(args) == 0 { + return errors.New("Missing required argument: KEY=VALUE") + } + + comments, _ := cmd.Flags().GetStringArray("comment") + + options := ast.UpsertOptions{ + UpsertPlacementType: ast.UpsertLast, + Comments: comments, + ErrorIfMissing: shared.BoolFlag(cmd.Flags(), "error-if-missing"), + Group: shared.StringFlag(cmd.Flags(), "group"), + SkipValidation: !shared.BoolWithInverseValue(cmd.Flags(), "validate"), + } + + // If we want placement *BEFORE* another statement + if before, _ := cmd.Flags().GetString("before"); len(before) > 0 { + other := env.Get(before) + if other == nil { + return fmt.Errorf("The key [%s] does not exists in [%s]. Can't be used in [--before]", before, filename) + } + + options.UpsertPlacementType = ast.UpsertBefore + options.UpsertPlacementValue = before + + if other.Group != nil { + options.Group = other.Group.String() + } + } + + // If we want placement *AFTER* another statement + if after, _ := cmd.Flags().GetString("after"); len(after) > 0 { + other := env.Get(after) + if other == nil { + return fmt.Errorf("The key [%s] does not exists in [%s]. Can't be used in [--after]", after, filename) + } + + options.UpsertPlacementType = ast.UpsertAfter + options.UpsertPlacementValue = after + + if other.Group != nil { + options.Group = other.Group.String() + } + } + + // Loop arguments and place them + for _, stringPair := range args { + pairSlice := strings.SplitN(stringPair, "=", 2) + if len(pairSlice) != 2 { + return errors.New("expected KEY=VALUE pair, missing '='") + } + + key := pairSlice[0] + value := pairSlice[1] + + assignment := &ast.Assignment{ + Name: key, + Literal: value, + Interpolated: value, + Active: !shared.BoolFlag(cmd.Flags(), "disabled"), + Quote: token.QuoteFromString(shared.StringFlag(cmd.Flags(), "quote-style")), + } + + // + // Upsert key + // + + assignment, err := env.Upsert(assignment, options) + if err != nil { + fmt.Fprintln(os.Stderr, validation.Explain(env, validation.NewError(assignment, err), false, true)) + + return fmt.Errorf("failed to upsert the key/value pair [%s]", key) + } + + if validationErrors := validation.ValidateSingleAssignment(env, assignment.Name, nil, nil); len(validationErrors) > 0 { + for _, errIsh := range validationErrors { + fmt.Fprintln(os.Stderr, validation.Explain(env, errIsh, false, false)) + } + + return errors.New("validation failed") + } + + tui.Theme.Success.StderrPrinter().Printfln("Key [%s] was successfully upserted", key) + } + + // + // Save file + // + + if err := pkg.Save(shared.StringFlag(cmd.Flags(), "file"), env); err != nil { + return fmt.Errorf("failed to save file: %w", err) + } + + tui.Theme.Success.StderrPrinter().Println("File was successfully saved") + + return nil +} diff --git a/cmd/update/update.go b/cmd/update/update.go index 9852948..4a0e9f3 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/go-getter" "github.com/jippi/dottie/pkg" "github.com/jippi/dottie/pkg/ast" + "github.com/jippi/dottie/pkg/cli/shared" "github.com/jippi/dottie/pkg/tui" "github.com/jippi/dottie/pkg/validation" "github.com/spf13/cobra" @@ -23,6 +24,7 @@ func Command() *cobra.Command { } cmd.Flags().String("source", "", "URL or local file path to the upstream source file. This will take precedence over any [@dottie/source] annotation in the file") + shared.BoolWithInverse(cmd, "error-on-missing-key", true, "Error if a KEY in FILE is missing from SOURCE", "Add KEY to FILE if missing from SOURCE") return cmd } @@ -94,7 +96,7 @@ func runE(cmd *cobra.Command, args []string) error { // Load the soon-to-be-merged file dark.Println("Loading and parsing source") - mergedEnv, err := pkg.Load(tmp.Name()) + sourceDoc, err := pkg.Load(tmp.Name()) if err != nil { return err } @@ -108,15 +110,83 @@ func runE(cmd *cobra.Command, args []string) error { sawError := false lastWasError := false + counter := 0 - for _, stmt := range env.Assignments() { + for _, stmt := range env.AllAssignments() { if !stmt.Active { continue } - changed, err := mergedEnv.Upsert(stmt, ast.UpsertOptions{SkipIfSame: true, ErrorIfMissing: true}) + options := ast.UpsertOptions{ + SkipIfSame: true, + ErrorIfMissing: shared.BoolWithInverseValue(cmd.Flags(), "error-on-missing-key"), + } + + // If the KEY does not exists in the SOURCE doc + if sourceDoc.Get(stmt.Name) == nil { + // Copy comments if the KEY doesn't exist in the SOURCE document + options.Comments = stmt.CommentsSlice() + + // Try to find positioning in the statement list for the new KEY pair + var parent ast.StatementCollection = env + if stmt.Group != nil { + parent = stmt.Group + } + + idx, _ := parent.GetAssignmentIndex(stmt.Name) + + // Try to keep the position of the KEY around where it was before + switch { + // If we can't find any placement, put us last in the list + case idx == -1: + options.UpsertPlacementType = ast.UpsertLast + + // Retain the group name if its still present in the SOURCE doc + if stmt.Group != nil && sourceDoc.HasGroup(stmt.Group.String()) { + options.Group = stmt.Group.String() + } + + // If we were first in the FILE doc, make sure we're first again + case idx == 0: + options.UpsertPlacementType = ast.UpsertFirst + + // Retain the group name if its still present in the SOURCE doc + if stmt.Group != nil && sourceDoc.HasGroup(stmt.Group.String()) { + options.Group = stmt.Group.String() + } + + // If we were not first, then put us behind the key that was + // just before us in the FILE doc + case idx > 0: + before := parent.Assignments()[idx-1] + + options.UpsertPlacementType = ast.UpsertAfter + options.UpsertPlacementValue = before.Name + + if before.Group != nil && sourceDoc.HasGroup(before.Group.String()) { + options.Group = before.Group.String() + } + } + } + + changed, err := sourceDoc.Upsert(stmt, options) if err != nil { - danger.Println(" ERROR", err.Error()) + sawError = true + lastWasError = true + + if counter > 0 { + dark.Println() + } + + dark.Print(" ") + dangerEmphasis.Print(stmt.Name) + dark.Print(" could not be set to ") + primary.Print(stmt.Literal) + dark.Println(" due to error:") + + danger.Println(" ", strings.Repeat(" ", len(stmt.Name)), err.Error()) + + counter++ continue } @@ -125,7 +195,10 @@ func runE(cmd *cobra.Command, args []string) error { sawError = true lastWasError = true - dark.Println() + if counter > 0 { + dark.Println() + } + dark.Print(" ") dangerEmphasis.Print(stmt.Name) dark.Print(" could not be set to ") @@ -136,10 +209,14 @@ func runE(cmd *cobra.Command, args []string) error { danger.Println(" ", strings.Repeat(" ", len(stmt.Name)), strings.TrimSpace(validation.Explain(env, errIsh, false, false))) } + counter++ + continue } if changed != nil { + counter++ + if lastWasError { danger.Println() } @@ -175,7 +252,7 @@ func runE(cmd *cobra.Command, args []string) error { dark.Println("Saving the new", primary.Sprint(filename)) - if err := pkg.Save(filename, mergedEnv); err != nil { + if err := pkg.Save(filename, sourceDoc); err != nil { danger.Println(" ERROR", err.Error()) return err diff --git a/pkg/ast/assignment.go b/pkg/ast/assignment.go index 62d8aba..92f50db 100644 --- a/pkg/ast/assignment.go +++ b/pkg/ast/assignment.go @@ -113,3 +113,13 @@ func (a *Assignment) Disable() { func (a *Assignment) Enable() { a.Active = true } + +func (a *Assignment) CommentsSlice() []string { + res := []string{} + + for _, comment := range a.Comments { + res = append(res, comment.CleanString()) + } + + return res +} diff --git a/pkg/ast/comment.go b/pkg/ast/comment.go index e3735a0..90060d5 100644 --- a/pkg/ast/comment.go +++ b/pkg/ast/comment.go @@ -2,6 +2,7 @@ package ast import ( "reflect" + "strings" "github.com/jippi/dottie/pkg/token" ) @@ -50,3 +51,7 @@ func (c *Comment) statementNode() { func (c Comment) String() string { return c.Value } + +func (c Comment) CleanString() string { + return strings.TrimPrefix(c.Value, "# ") +} diff --git a/pkg/ast/document.go b/pkg/ast/document.go index 4149dfe..43e5e64 100644 --- a/pkg/ast/document.go +++ b/pkg/ast/document.go @@ -8,6 +8,7 @@ import ( "reflect" "github.com/compose-spec/compose-go/template" + "github.com/jippi/dottie/pkg/token" ) // Document node represents .env file statement, that contains assignments and comments. @@ -40,7 +41,7 @@ func (d *Document) BelongsToGroup(name string) bool { func (d *Document) statementNode() { } -func (d *Document) Assignments() []*Assignment { +func (d *Document) AllAssignments() []*Assignment { var assignments []*Assignment for _, statement := range d.Statements { @@ -70,8 +71,12 @@ func (d *Document) GetGroup(name string) *Group { return nil } +func (d *Document) HasGroup(name string) bool { + return d.GetGroup(name) != nil +} + func (d *Document) Get(name string) *Assignment { - for _, assign := range d.Assignments() { + for _, assign := range d.AllAssignments() { if assign.Name == name { return assign } @@ -80,11 +85,19 @@ func (d *Document) Get(name string) *Assignment { return nil } +func (d *Document) Has(name string) bool { + return d.Get(name) != nil +} + func (doc *Document) Interpolate(target *Assignment) (string, error) { if target == nil { return "", errors.New("can't interpolate a nil assignment") } + if target.Quote.Is(token.SingleQuotes.Rune()) { + return target.Literal, nil + } + lookup := func(input string) (string, bool) { // Lookup in process environment if val, ok := os.LookupEnv(input); ok { @@ -114,14 +127,24 @@ func (doc *Document) Interpolate(target *Assignment) (string, error) { return template.Substitute(target.Literal, lookup) } +type UpsertPlacement uint + +const ( + UpsertLast UpsertPlacement = iota + UpsertAfter + UpsertBefore + UpsertFirst +) + type UpsertOptions struct { - InsertBefore string - Comments []string - ErrorIfMissing bool - Group string - SkipIfSame bool - SkipIfSet bool - SkipValidation bool + UpsertPlacementType UpsertPlacement + UpsertPlacementValue string + Comments []string + ErrorIfMissing bool + Group string + SkipIfSame bool + SkipIfSet bool + SkipValidation bool } func (doc *Document) Upsert(input *Assignment, options UpsertOptions) (*Assignment, error) { @@ -154,12 +177,22 @@ func (doc *Document) Upsert(input *Assignment, options UpsertOptions) (*Assignme Group: group, } - if len(options.InsertBefore) > 0 { - before := options.InsertBefore + existingStatements := doc.Statements + if existing.Group != nil { + existingStatements = group.Statements + } + + var res []Statement + + switch options.UpsertPlacementType { + case UpsertFirst: + res = append([]Statement{existing}, existingStatements...) - var res []Statement + case UpsertLast: + res = append(existingStatements, existing) - for _, stmt := range group.Statements { + case UpsertAfter, UpsertBefore: + for _, stmt := range existingStatements { assignment, ok := stmt.(*Assignment) if !ok { res = append(res, stmt) @@ -167,28 +200,23 @@ func (doc *Document) Upsert(input *Assignment, options UpsertOptions) (*Assignme continue } - if assignment.Name == before { - res = append(res, existing) - } + switch { + case options.UpsertPlacementType == UpsertBefore && assignment.Name == options.UpsertPlacementValue: + res = append(res, existing, stmt) - res = append(res, stmt) - } + case options.UpsertPlacementType == UpsertAfter && assignment.Name == options.UpsertPlacementValue: + res = append(res, stmt, existing) - group.Statements = res + default: + res = append(res, stmt) + } + } } if group != nil { - group.Statements = append(group.Statements, existing) + group.Statements = res } else { - idx := len(doc.Statements) - 1 - - // if last statement is a newline, replace it with the new assignment - if idx > 1 && doc.Statements[idx].Is(&Newline{}) { - doc.Statements[idx] = existing - } else { - // otherwise append it - doc.Statements = append(doc.Statements, existing) - } + doc.Statements = res } } @@ -259,7 +287,19 @@ func (d *Document) GetConfig(name string) (string, error) { return "", fmt.Errorf("could not find config key: [%s]", name) } -func (d *Document) GetPosition(name string) (int, *Assignment) { +func (d *Document) Assignments() []*Assignment { + var assignments []*Assignment + + for _, statement := range d.Statements { + if assign, ok := statement.(*Assignment); ok { + assignments = append(assignments, assign) + } + } + + return assignments +} + +func (d *Document) GetAssignmentIndex(name string) (int, *Assignment) { for i, assign := range d.Assignments() { if assign.Name == name { return i, assign diff --git a/pkg/ast/group.go b/pkg/ast/group.go index 9cec4a9..5c03b45 100644 --- a/pkg/ast/group.go +++ b/pkg/ast/group.go @@ -43,3 +43,25 @@ func (g *Group) BelongsToGroup(name string) bool { func (g *Group) String() string { return strings.TrimPrefix(g.Name, "# ") } + +func (g *Group) Assignments() []*Assignment { + var assignments []*Assignment + + for _, statement := range g.Statements { + if assign, ok := statement.(*Assignment); ok { + assignments = append(assignments, assign) + } + } + + return assignments +} + +func (g *Group) GetAssignmentIndex(name string) (int, *Assignment) { + for i, assign := range g.Assignments() { + if assign.Name == name { + return i, assign + } + } + + return -1, nil +} diff --git a/pkg/ast/shared.go b/pkg/ast/shared.go index c1a2555..32a0212 100644 --- a/pkg/ast/shared.go +++ b/pkg/ast/shared.go @@ -11,6 +11,11 @@ type Statement interface { Type() string } +type StatementCollection interface { + Assignments() []*Assignment + GetAssignmentIndex(name string) (int, *Assignment) +} + type Position struct { File string `json:"file"` Line uint `json:"line"` diff --git a/pkg/validation/validation.go b/pkg/validation/validation.go index f04c400..f5ae137 100644 --- a/pkg/validation/validation.go +++ b/pkg/validation/validation.go @@ -31,7 +31,7 @@ func Validate(doc *ast.Document, handlers []render.Handler, ignoreErrors []strin fieldOrder := []string{} NEXT: - for _, assignment := range doc.Assignments() { + for _, assignment := range doc.AllAssignments() { handlerInput := &render.HandlerInput{ CurrentStatement: assignment, PreviousStatement: nil,