Skip to content
This repository has been archived by the owner on Jan 8, 2024. It is now read-only.

Add a waypoint fmt command #1037

Merged
merged 7 commits into from
Feb 5, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ require (
github.com/posener/complete v1.2.3
github.com/r3labs/diff v1.1.0
github.com/rs/cors v1.7.0 // indirect
github.com/sebdah/goldie/v2 v2.5.3
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/slack-go/slack v0.6.5
github.com/stretchr/testify v1.6.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,8 @@ github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8=
github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
Expand Down
142 changes: 142 additions & 0 deletions internal/cli/fmt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package cli

import (
"fmt"
"io/ioutil"
"os"
"path/filepath"

"github.com/posener/complete"

"github.com/hashicorp/waypoint-plugin-sdk/terminal"
"github.com/hashicorp/waypoint/internal/clierrors"
configpkg "github.com/hashicorp/waypoint/internal/config"
"github.com/hashicorp/waypoint/internal/pkg/flag"
)

type FmtCommand struct {
*baseCommand

flagWrite bool
}

func (c *FmtCommand) Run(args []string) int {
// Initialize. If we fail, we just exit since Init handles the UI.
if err := c.Init(
WithArgs(args),
WithFlags(c.Flags()),
WithNoConfig(),
WithClient(false),
); err != nil {
return 1
}

// If we have too many args, error immediately.
if len(c.args) > 1 {
c.ui.Output("At most one argument is expected.\n\n"+c.Help(), terminal.WithErrorStyle())
briancain marked this conversation as resolved.
Show resolved Hide resolved
return 1
}

// If we have no args, default to the filename
if len(c.args) == 0 {
c.args = []string{configpkg.Filename}
}

// Read the input
src, err := c.readInput()
if err != nil {
c.ui.Output(
"Error reading input to format: %s", clierrors.Humanize(err),
terminal.WithErrorStyle(),
)
return 1
}

// Format it
name := "<stdin>"
stdin := true
if c.args[0] != "-" {
name = filepath.Base(c.args[0])
stdin = false
}
out, err := configpkg.Format(src, name)
if err != nil {
c.ui.Output(
"Error formatting: %s", clierrors.Humanize(err),
terminal.WithErrorStyle(),
)
return 1
}

// If we're writing then write it to the file. stdin never writes to a file
if c.flagWrite && !stdin {
if err := ioutil.WriteFile(c.args[0], out, 0644); err != nil {
c.ui.Output(
"Error writing formatted output: %s", clierrors.Humanize(err),
terminal.WithErrorStyle(),
)
return 1
}
} else {
// We must use fmt here and not c.ui since c.ui may wordwrap and trim.
fmt.Print(string(out))
}

return 0
}

func (c *FmtCommand) readInput() ([]byte, error) {
// If we have non-stdin input then read it
if c.args[0] != "-" {
return ioutil.ReadFile(c.args[0])
}

// Otherwise it is stdin
return ioutil.ReadAll(os.Stdin)
}

func (c *FmtCommand) Flags() *flag.Sets {
return c.flagSet(0, func(sets *flag.Sets) {
f := sets.NewSet("Command Options")

f.BoolVar(&flag.BoolVar{
Name: "write",
Target: &c.flagWrite,
Default: false,
Usage: "Overwrite the input file. If this is false, the formatted " +
mitchellh marked this conversation as resolved.
Show resolved Hide resolved
"output will be written to STDOUT. This has no effect when formatting" +
"from STDIN.",
})
})
}

func (c *FmtCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}

func (c *FmtCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}

func (c *FmtCommand) Synopsis() string {
return "Rewrite waypoint.hcl configuration to a canonical format"
}

func (c *FmtCommand) Help() string {
return formatHelp(`
Usage: waypoint fmt [FILE]

Rewrite a waypoint.hcl file to a canonical format.

This only works for HCL-formatted Waypoint configuration files. JSON-formatted
files do not work and will result in an error.

If FILE is not specified, then the current directory will be searched
for a "waypoint.hcl" file. If FILE is "-" then the content will be read
from stdin.

This command does not validate the waypoint.hcl configuration. This will
work for older and newer configuration formats.

` + c.Flags().Help())
}
6 changes: 6 additions & 0 deletions internal/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,12 @@ func Commands(
baseCommand: baseCommand,
}, nil
},

"fmt": func() (cli.Command, error) {
return &FmtCommand{
baseCommand: baseCommand,
}, nil
},
}

// register our aliases
Expand Down
1 change: 1 addition & 0 deletions internal/cli/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func (c *VersionCommand) Synopsis() string {
func (c *VersionCommand) Help() string {
return formatHelp(`
Usage: waypoint version

Prints the version of this Waypoint CLI.

There are no arguments or flags to this command. Any additional arguments or
Expand Down
133 changes: 133 additions & 0 deletions internal/config/fmt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package config

import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/hclwrite"
)

// Format auto-formats the input configuration and returns the formatted result.
//
// The "path" argument is used only for error messages. It doesn't have to be
// a valid path. For inputs from stdin, it is common to use a synthetic path
// value such as "<stdin>".
//
// If input is already formatted, it will be returned as-is in the result.
//
// This does not require valid Waypoint configuration. This will work with
// almost any HCL-formatted file. However, we may introduce Waypoint-specific
// opinions at some point so this is in the Waypoint configuration package.
func Format(input []byte, path string) ([]byte, error) {
// File must be parseable as HCL native syntax before we'll try to format
// it. If not, the formatter is likely to make drastic changes that would
// be hard for the user to undo.
f, diags := hclwrite.ParseConfig(input, path, hcl.InitialPos)
if diags.HasErrors() {
return nil, diags
}

formatBody(f.Body())
return f.Bytes(), nil
}

func formatBody(body *hclwrite.Body) {
for name, attr := range body.Attributes() {
body.SetAttributeRaw(
name,
formatValueExpr(attr.Expr().BuildTokens(nil)),
)
}

for _, block := range body.Blocks() {
// Normalize the label formatting, removing any weird stuff like
// interleaved inline comments and using the idiomatic quoted
// label syntax.
block.SetLabels(block.Labels())

formatBody(block.Body())
}
}

func formatValueExpr(tokens hclwrite.Tokens) hclwrite.Tokens {
if len(tokens) < 5 {
// Can't possibly be a "${ ... }" sequence without at least enough
// tokens for the delimiters and one token inside them.
return tokens
}

oQuote := tokens[0]
oBrace := tokens[1]
cBrace := tokens[len(tokens)-2]
cQuote := tokens[len(tokens)-1]
if oQuote.Type != hclsyntax.TokenOQuote || oBrace.Type != hclsyntax.TokenTemplateInterp || cBrace.Type != hclsyntax.TokenTemplateSeqEnd || cQuote.Type != hclsyntax.TokenCQuote {
// Not an interpolation sequence at all, then.
return tokens
}

inside := tokens[2 : len(tokens)-2]

// We're only interested in sequences that are provable to be single
// interpolation sequences, which we'll determine by hunting inside
// the interior tokens for any other interpolation sequences. This is
// likely to produce false negatives sometimes, but that's better than
// false positives and we're mainly interested in catching the easy cases
// here.
quotes := 0
for _, token := range inside {
if token.Type == hclsyntax.TokenOQuote {
quotes++
continue
}
if token.Type == hclsyntax.TokenCQuote {
quotes--
continue
}
if quotes > 0 {
// Interpolation sequences inside nested quotes are okay, because
// they are part of a nested expression.
// "${foo("${bar}")}"
continue
}
if token.Type == hclsyntax.TokenTemplateInterp || token.Type == hclsyntax.TokenTemplateSeqEnd {
// We've found another template delimiter within our interior
// tokens, which suggests that we've found something like this:
// "${foo}${bar}"
// That isn't unwrappable, so we'll leave the whole expression alone.
return tokens
}
if token.Type == hclsyntax.TokenQuotedLit {
// If there's any literal characters in the outermost
// quoted sequence then it is not unwrappable.
return tokens
}
}

// If we got down here without an early return then this looks like
// an unwrappable sequence, but we'll trim any leading and trailing
// newlines that might result in an invalid result if we were to
// naively trim something like this:
// "${
// foo
// }"
return formatTrimNewlines(inside)
}

func formatTrimNewlines(tokens hclwrite.Tokens) hclwrite.Tokens {
if len(tokens) == 0 {
return nil
}

var start, end int
for start = 0; start < len(tokens); start++ {
if tokens[start].Type != hclsyntax.TokenNewline {
break
}
}
for end = len(tokens); end > 0; end-- {
if tokens[end-1].Type != hclsyntax.TokenNewline {
break
}
}

return tokens[start:end]
}
45 changes: 45 additions & 0 deletions internal/config/fmt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package config

import (
"io/ioutil"
"path/filepath"
"strings"
"testing"

"github.com/sebdah/goldie/v2"
"github.com/stretchr/testify/require"
)

func TestFormat(t *testing.T) {
const outSuffix = ".out"
path := filepath.Join("testdata", "fmt")
entries, err := ioutil.ReadDir(path)
require.NoError(t, err)

g := goldie.New(t,
goldie.WithFixtureDir(filepath.Join("testdata", "fmt")),
goldie.WithNameSuffix(outSuffix),
)

for _, entry := range entries {
// Ignore golden files
if strings.HasSuffix(entry.Name(), outSuffix) {
continue
}

t.Run(entry.Name(), func(t *testing.T) {
require := require.New(t)

// Read the input file
src, err := ioutil.ReadFile(filepath.Join(path, entry.Name()))
require.NoError(err)

// Format it!
out, err := Format(src, entry.Name())
require.NoError(err)

// Compare
g.Assert(t, entry.Name(), out)
})
}
}
1 change: 1 addition & 0 deletions internal/config/testdata/fmt/general.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
project="foo"
1 change: 1 addition & 0 deletions internal/config/testdata/fmt/general.hcl.out
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
project = "foo"
Loading