github.com/iaas-resource-provision/iaas-rpc@v1.0.7-0.20211021023331-ed21f798c408/internal/command/fmt.go (about)

     1  package command
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"io"
     7  	"io/ioutil"
     8  	"log"
     9  	"os"
    10  	"os/exec"
    11  	"path/filepath"
    12  	"strings"
    13  
    14  	"github.com/hashicorp/hcl/v2"
    15  	"github.com/hashicorp/hcl/v2/hclsyntax"
    16  	"github.com/hashicorp/hcl/v2/hclwrite"
    17  	"github.com/mitchellh/cli"
    18  
    19  	"github.com/iaas-resource-provision/iaas-rpc/internal/configs"
    20  	"github.com/iaas-resource-provision/iaas-rpc/internal/tfdiags"
    21  )
    22  
    23  const (
    24  	stdinArg = "-"
    25  )
    26  
    27  // FmtCommand is a Command implementation that rewrites Terraform config
    28  // files to a canonical format and style.
    29  type FmtCommand struct {
    30  	Meta
    31  	list      bool
    32  	write     bool
    33  	diff      bool
    34  	check     bool
    35  	recursive bool
    36  	input     io.Reader // STDIN if nil
    37  }
    38  
    39  func (c *FmtCommand) Run(args []string) int {
    40  	if c.input == nil {
    41  		c.input = os.Stdin
    42  	}
    43  
    44  	args = c.Meta.process(args)
    45  	cmdFlags := c.Meta.defaultFlagSet("fmt")
    46  	cmdFlags.BoolVar(&c.list, "list", true, "list")
    47  	cmdFlags.BoolVar(&c.write, "write", true, "write")
    48  	cmdFlags.BoolVar(&c.diff, "diff", false, "diff")
    49  	cmdFlags.BoolVar(&c.check, "check", false, "check")
    50  	cmdFlags.BoolVar(&c.recursive, "recursive", false, "recursive")
    51  	cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
    52  	if err := cmdFlags.Parse(args); err != nil {
    53  		c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error()))
    54  		return 1
    55  	}
    56  
    57  	args = cmdFlags.Args()
    58  	if len(args) > 1 {
    59  		c.Ui.Error("The fmt command expects at most one argument.")
    60  		cmdFlags.Usage()
    61  		return 1
    62  	}
    63  
    64  	var paths []string
    65  	if len(args) == 0 {
    66  		paths = []string{"."}
    67  	} else if args[0] == stdinArg {
    68  		c.list = false
    69  		c.write = false
    70  	} else {
    71  		paths = []string{args[0]}
    72  	}
    73  
    74  	var output io.Writer
    75  	list := c.list // preserve the original value of -list
    76  	if c.check {
    77  		// set to true so we can use the list output to check
    78  		// if the input needs formatting
    79  		c.list = true
    80  		c.write = false
    81  		output = &bytes.Buffer{}
    82  	} else {
    83  		output = &cli.UiWriter{Ui: c.Ui}
    84  	}
    85  
    86  	diags := c.fmt(paths, c.input, output)
    87  	c.showDiagnostics(diags)
    88  	if diags.HasErrors() {
    89  		return 2
    90  	}
    91  
    92  	if c.check {
    93  		buf := output.(*bytes.Buffer)
    94  		ok := buf.Len() == 0
    95  		if list {
    96  			io.Copy(&cli.UiWriter{Ui: c.Ui}, buf)
    97  		}
    98  		if ok {
    99  			return 0
   100  		} else {
   101  			return 3
   102  		}
   103  	}
   104  
   105  	return 0
   106  }
   107  
   108  func (c *FmtCommand) fmt(paths []string, stdin io.Reader, stdout io.Writer) tfdiags.Diagnostics {
   109  	var diags tfdiags.Diagnostics
   110  
   111  	if len(paths) == 0 { // Assuming stdin, then.
   112  		if c.write {
   113  			diags = diags.Append(fmt.Errorf("Option -write cannot be used when reading from stdin"))
   114  			return diags
   115  		}
   116  		fileDiags := c.processFile("<stdin>", stdin, stdout, true)
   117  		diags = diags.Append(fileDiags)
   118  		return diags
   119  	}
   120  
   121  	for _, path := range paths {
   122  		path = c.normalizePath(path)
   123  		info, err := os.Stat(path)
   124  		if err != nil {
   125  			diags = diags.Append(fmt.Errorf("No file or directory at %s", path))
   126  			return diags
   127  		}
   128  		if info.IsDir() {
   129  			dirDiags := c.processDir(path, stdout)
   130  			diags = diags.Append(dirDiags)
   131  		} else {
   132  			switch filepath.Ext(path) {
   133  			case ".tf", ".tfvars":
   134  				f, err := os.Open(path)
   135  				if err != nil {
   136  					// Open does not produce error messages that are end-user-appropriate,
   137  					// so we'll need to simplify here.
   138  					diags = diags.Append(fmt.Errorf("Failed to read file %s", path))
   139  					continue
   140  				}
   141  
   142  				fileDiags := c.processFile(c.normalizePath(path), f, stdout, false)
   143  				diags = diags.Append(fileDiags)
   144  				f.Close()
   145  			default:
   146  				diags = diags.Append(fmt.Errorf("Only .tf and .tfvars files can be processed with terraform fmt"))
   147  				continue
   148  			}
   149  		}
   150  	}
   151  
   152  	return diags
   153  }
   154  
   155  func (c *FmtCommand) processFile(path string, r io.Reader, w io.Writer, isStdout bool) tfdiags.Diagnostics {
   156  	var diags tfdiags.Diagnostics
   157  
   158  	log.Printf("[TRACE] terraform fmt: Formatting %s", path)
   159  
   160  	src, err := ioutil.ReadAll(r)
   161  	if err != nil {
   162  		diags = diags.Append(fmt.Errorf("Failed to read %s", path))
   163  		return diags
   164  	}
   165  
   166  	// Register this path as a synthetic configuration source, so that any
   167  	// diagnostic errors can include the source code snippet
   168  	c.registerSynthConfigSource(path, src)
   169  
   170  	// File must be parseable as HCL native syntax before we'll try to format
   171  	// it. If not, the formatter is likely to make drastic changes that would
   172  	// be hard for the user to undo.
   173  	_, syntaxDiags := hclsyntax.ParseConfig(src, path, hcl.Pos{Line: 1, Column: 1})
   174  	if syntaxDiags.HasErrors() {
   175  		diags = diags.Append(syntaxDiags)
   176  		return diags
   177  	}
   178  
   179  	result := c.formatSourceCode(src, path)
   180  
   181  	if !bytes.Equal(src, result) {
   182  		// Something was changed
   183  		if c.list {
   184  			fmt.Fprintln(w, path)
   185  		}
   186  		if c.write {
   187  			err := ioutil.WriteFile(path, result, 0644)
   188  			if err != nil {
   189  				diags = diags.Append(fmt.Errorf("Failed to write %s", path))
   190  				return diags
   191  			}
   192  		}
   193  		if c.diff {
   194  			diff, err := bytesDiff(src, result, path)
   195  			if err != nil {
   196  				diags = diags.Append(fmt.Errorf("Failed to generate diff for %s: %s", path, err))
   197  				return diags
   198  			}
   199  			w.Write(diff)
   200  		}
   201  	}
   202  
   203  	if !c.list && !c.write && !c.diff {
   204  		_, err = w.Write(result)
   205  		if err != nil {
   206  			diags = diags.Append(fmt.Errorf("Failed to write result"))
   207  		}
   208  	}
   209  
   210  	return diags
   211  }
   212  
   213  func (c *FmtCommand) processDir(path string, stdout io.Writer) tfdiags.Diagnostics {
   214  	var diags tfdiags.Diagnostics
   215  
   216  	log.Printf("[TRACE] terraform fmt: looking for files in %s", path)
   217  
   218  	entries, err := ioutil.ReadDir(path)
   219  	if err != nil {
   220  		switch {
   221  		case os.IsNotExist(err):
   222  			diags = diags.Append(fmt.Errorf("There is no configuration directory at %s", path))
   223  		default:
   224  			// ReadDir does not produce error messages that are end-user-appropriate,
   225  			// so we'll need to simplify here.
   226  			diags = diags.Append(fmt.Errorf("Cannot read directory %s", path))
   227  		}
   228  		return diags
   229  	}
   230  
   231  	for _, info := range entries {
   232  		name := info.Name()
   233  		if configs.IsIgnoredFile(name) {
   234  			continue
   235  		}
   236  		subPath := filepath.Join(path, name)
   237  		if info.IsDir() {
   238  			if c.recursive {
   239  				subDiags := c.processDir(subPath, stdout)
   240  				diags = diags.Append(subDiags)
   241  			}
   242  
   243  			// We do not recurse into child directories by default because we
   244  			// want to mimic the file-reading behavior of "terraform plan", etc,
   245  			// operating on one module at a time.
   246  			continue
   247  		}
   248  
   249  		ext := filepath.Ext(name)
   250  		switch ext {
   251  		case ".tf", ".tfvars":
   252  			f, err := os.Open(subPath)
   253  			if err != nil {
   254  				// Open does not produce error messages that are end-user-appropriate,
   255  				// so we'll need to simplify here.
   256  				diags = diags.Append(fmt.Errorf("Failed to read file %s", subPath))
   257  				continue
   258  			}
   259  
   260  			fileDiags := c.processFile(c.normalizePath(subPath), f, stdout, false)
   261  			diags = diags.Append(fileDiags)
   262  			f.Close()
   263  		}
   264  	}
   265  
   266  	return diags
   267  }
   268  
   269  // formatSourceCode is the formatting logic itself, applied to each file that
   270  // is selected (directly or indirectly) on the command line.
   271  func (c *FmtCommand) formatSourceCode(src []byte, filename string) []byte {
   272  	f, diags := hclwrite.ParseConfig(src, filename, hcl.InitialPos)
   273  	if diags.HasErrors() {
   274  		// It would be weird to get here because the caller should already have
   275  		// checked for syntax errors and returned them. We'll just do nothing
   276  		// in this case, returning the input exactly as given.
   277  		return src
   278  	}
   279  
   280  	c.formatBody(f.Body(), nil)
   281  
   282  	return f.Bytes()
   283  }
   284  
   285  func (c *FmtCommand) formatBody(body *hclwrite.Body, inBlocks []string) {
   286  	attrs := body.Attributes()
   287  	for name, attr := range attrs {
   288  		if len(inBlocks) == 1 && inBlocks[0] == "variable" && name == "type" {
   289  			cleanedExprTokens := c.formatTypeExpr(attr.Expr().BuildTokens(nil))
   290  			body.SetAttributeRaw(name, cleanedExprTokens)
   291  			continue
   292  		}
   293  		cleanedExprTokens := c.formatValueExpr(attr.Expr().BuildTokens(nil))
   294  		body.SetAttributeRaw(name, cleanedExprTokens)
   295  	}
   296  
   297  	blocks := body.Blocks()
   298  	for _, block := range blocks {
   299  		// Normalize the label formatting, removing any weird stuff like
   300  		// interleaved inline comments and using the idiomatic quoted
   301  		// label syntax.
   302  		block.SetLabels(block.Labels())
   303  
   304  		inBlocks := append(inBlocks, block.Type())
   305  		c.formatBody(block.Body(), inBlocks)
   306  	}
   307  }
   308  
   309  func (c *FmtCommand) formatValueExpr(tokens hclwrite.Tokens) hclwrite.Tokens {
   310  	if len(tokens) < 5 {
   311  		// Can't possibly be a "${ ... }" sequence without at least enough
   312  		// tokens for the delimiters and one token inside them.
   313  		return tokens
   314  	}
   315  	oQuote := tokens[0]
   316  	oBrace := tokens[1]
   317  	cBrace := tokens[len(tokens)-2]
   318  	cQuote := tokens[len(tokens)-1]
   319  	if oQuote.Type != hclsyntax.TokenOQuote || oBrace.Type != hclsyntax.TokenTemplateInterp || cBrace.Type != hclsyntax.TokenTemplateSeqEnd || cQuote.Type != hclsyntax.TokenCQuote {
   320  		// Not an interpolation sequence at all, then.
   321  		return tokens
   322  	}
   323  
   324  	inside := tokens[2 : len(tokens)-2]
   325  
   326  	// We're only interested in sequences that are provable to be single
   327  	// interpolation sequences, which we'll determine by hunting inside
   328  	// the interior tokens for any other interpolation sequences. This is
   329  	// likely to produce false negatives sometimes, but that's better than
   330  	// false positives and we're mainly interested in catching the easy cases
   331  	// here.
   332  	quotes := 0
   333  	for _, token := range inside {
   334  		if token.Type == hclsyntax.TokenOQuote {
   335  			quotes++
   336  			continue
   337  		}
   338  		if token.Type == hclsyntax.TokenCQuote {
   339  			quotes--
   340  			continue
   341  		}
   342  		if quotes > 0 {
   343  			// Interpolation sequences inside nested quotes are okay, because
   344  			// they are part of a nested expression.
   345  			// "${foo("${bar}")}"
   346  			continue
   347  		}
   348  		if token.Type == hclsyntax.TokenTemplateInterp || token.Type == hclsyntax.TokenTemplateSeqEnd {
   349  			// We've found another template delimiter within our interior
   350  			// tokens, which suggests that we've found something like this:
   351  			// "${foo}${bar}"
   352  			// That isn't unwrappable, so we'll leave the whole expression alone.
   353  			return tokens
   354  		}
   355  		if token.Type == hclsyntax.TokenQuotedLit {
   356  			// If there's any literal characters in the outermost
   357  			// quoted sequence then it is not unwrappable.
   358  			return tokens
   359  		}
   360  	}
   361  
   362  	// If we got down here without an early return then this looks like
   363  	// an unwrappable sequence, but we'll trim any leading and trailing
   364  	// newlines that might result in an invalid result if we were to
   365  	// naively trim something like this:
   366  	// "${
   367  	//    foo
   368  	// }"
   369  	trimmed := c.trimNewlines(inside)
   370  
   371  	// Finally, we check if the unwrapped expression is on multiple lines. If
   372  	// so, we ensure that it is surrounded by parenthesis to make sure that it
   373  	// parses correctly after unwrapping. This may be redundant in some cases,
   374  	// but is required for at least multi-line ternary expressions.
   375  	isMultiLine := false
   376  	hasLeadingParen := false
   377  	hasTrailingParen := false
   378  	for i, token := range trimmed {
   379  		switch {
   380  		case i == 0 && token.Type == hclsyntax.TokenOParen:
   381  			hasLeadingParen = true
   382  		case token.Type == hclsyntax.TokenNewline:
   383  			isMultiLine = true
   384  		case i == len(trimmed)-1 && token.Type == hclsyntax.TokenCParen:
   385  			hasTrailingParen = true
   386  		}
   387  	}
   388  	if isMultiLine && !(hasLeadingParen && hasTrailingParen) {
   389  		wrapped := make(hclwrite.Tokens, 0, len(trimmed)+2)
   390  		wrapped = append(wrapped, &hclwrite.Token{
   391  			Type:  hclsyntax.TokenOParen,
   392  			Bytes: []byte("("),
   393  		})
   394  		wrapped = append(wrapped, trimmed...)
   395  		wrapped = append(wrapped, &hclwrite.Token{
   396  			Type:  hclsyntax.TokenCParen,
   397  			Bytes: []byte(")"),
   398  		})
   399  
   400  		return wrapped
   401  	}
   402  
   403  	return trimmed
   404  }
   405  
   406  func (c *FmtCommand) formatTypeExpr(tokens hclwrite.Tokens) hclwrite.Tokens {
   407  	switch len(tokens) {
   408  	case 1:
   409  		kwTok := tokens[0]
   410  		if kwTok.Type != hclsyntax.TokenIdent {
   411  			// Not a single type keyword, then.
   412  			return tokens
   413  		}
   414  
   415  		// Collection types without an explicit element type mean
   416  		// the element type is "any", so we'll normalize that.
   417  		switch string(kwTok.Bytes) {
   418  		case "list", "map", "set":
   419  			return hclwrite.Tokens{
   420  				kwTok,
   421  				{
   422  					Type:  hclsyntax.TokenOParen,
   423  					Bytes: []byte("("),
   424  				},
   425  				{
   426  					Type:  hclsyntax.TokenIdent,
   427  					Bytes: []byte("any"),
   428  				},
   429  				{
   430  					Type:  hclsyntax.TokenCParen,
   431  					Bytes: []byte(")"),
   432  				},
   433  			}
   434  		default:
   435  			return tokens
   436  		}
   437  
   438  	case 3:
   439  		// A pre-0.12 legacy quoted string type, like "string".
   440  		oQuote := tokens[0]
   441  		strTok := tokens[1]
   442  		cQuote := tokens[2]
   443  		if oQuote.Type != hclsyntax.TokenOQuote || strTok.Type != hclsyntax.TokenQuotedLit || cQuote.Type != hclsyntax.TokenCQuote {
   444  			// Not a quoted string sequence, then.
   445  			return tokens
   446  		}
   447  
   448  		// Because this quoted syntax is from Terraform 0.11 and
   449  		// earlier, which didn't have the idea of "any" as an,
   450  		// element type, we use string as the default element
   451  		// type. That will avoid oddities if somehow the configuration
   452  		// was relying on numeric values being auto-converted to
   453  		// string, as 0.11 would do. This mimicks what terraform
   454  		// 0.12upgrade used to do, because we'd found real-world
   455  		// modules that were depending on the auto-stringing.)
   456  		switch string(strTok.Bytes) {
   457  		case "string":
   458  			return hclwrite.Tokens{
   459  				{
   460  					Type:  hclsyntax.TokenIdent,
   461  					Bytes: []byte("string"),
   462  				},
   463  			}
   464  		case "list":
   465  			return hclwrite.Tokens{
   466  				{
   467  					Type:  hclsyntax.TokenIdent,
   468  					Bytes: []byte("list"),
   469  				},
   470  				{
   471  					Type:  hclsyntax.TokenOParen,
   472  					Bytes: []byte("("),
   473  				},
   474  				{
   475  					Type:  hclsyntax.TokenIdent,
   476  					Bytes: []byte("string"),
   477  				},
   478  				{
   479  					Type:  hclsyntax.TokenCParen,
   480  					Bytes: []byte(")"),
   481  				},
   482  			}
   483  		case "map":
   484  			return hclwrite.Tokens{
   485  				{
   486  					Type:  hclsyntax.TokenIdent,
   487  					Bytes: []byte("map"),
   488  				},
   489  				{
   490  					Type:  hclsyntax.TokenOParen,
   491  					Bytes: []byte("("),
   492  				},
   493  				{
   494  					Type:  hclsyntax.TokenIdent,
   495  					Bytes: []byte("string"),
   496  				},
   497  				{
   498  					Type:  hclsyntax.TokenCParen,
   499  					Bytes: []byte(")"),
   500  				},
   501  			}
   502  		default:
   503  			// Something else we're not expecting, then.
   504  			return tokens
   505  		}
   506  	default:
   507  		return tokens
   508  	}
   509  }
   510  
   511  func (c *FmtCommand) trimNewlines(tokens hclwrite.Tokens) hclwrite.Tokens {
   512  	if len(tokens) == 0 {
   513  		return nil
   514  	}
   515  	var start, end int
   516  	for start = 0; start < len(tokens); start++ {
   517  		if tokens[start].Type != hclsyntax.TokenNewline {
   518  			break
   519  		}
   520  	}
   521  	for end = len(tokens); end > 0; end-- {
   522  		if tokens[end-1].Type != hclsyntax.TokenNewline {
   523  			break
   524  		}
   525  	}
   526  	return tokens[start:end]
   527  }
   528  
   529  func (c *FmtCommand) Help() string {
   530  	helpText := `
   531  Usage: terraform [global options] fmt [options] [DIR]
   532  
   533  	Rewrites all Terraform configuration files to a canonical format. Both
   534  	configuration files (.tf) and variables files (.tfvars) are updated.
   535  	JSON files (.tf.json or .tfvars.json) are not modified.
   536  
   537  	If DIR is not specified then the current working directory will be used.
   538  	If DIR is "-" then content will be read from STDIN. The given content must
   539  	be in the Terraform language native syntax; JSON is not supported.
   540  
   541  Options:
   542  
   543    -list=false    Don't list files whose formatting differs
   544                   (always disabled if using STDIN)
   545  
   546    -write=false   Don't write to source files
   547                   (always disabled if using STDIN or -check)
   548  
   549    -diff          Display diffs of formatting changes
   550  
   551    -check         Check if the input is formatted. Exit status will be 0 if all
   552                   input is properly formatted and non-zero otherwise.
   553  
   554    -no-color      If specified, output won't contain any color.
   555  
   556    -recursive     Also process files in subdirectories. By default, only the
   557                   given directory (or current directory) is processed.
   558  `
   559  	return strings.TrimSpace(helpText)
   560  }
   561  
   562  func (c *FmtCommand) Synopsis() string {
   563  	return "Reformat your configuration in the standard style"
   564  }
   565  
   566  func bytesDiff(b1, b2 []byte, path string) (data []byte, err error) {
   567  	f1, err := ioutil.TempFile("", "")
   568  	if err != nil {
   569  		return
   570  	}
   571  	defer os.Remove(f1.Name())
   572  	defer f1.Close()
   573  
   574  	f2, err := ioutil.TempFile("", "")
   575  	if err != nil {
   576  		return
   577  	}
   578  	defer os.Remove(f2.Name())
   579  	defer f2.Close()
   580  
   581  	f1.Write(b1)
   582  	f2.Write(b2)
   583  
   584  	data, err = exec.Command("diff", "--label=old/"+path, "--label=new/"+path, "-u", f1.Name(), f2.Name()).CombinedOutput()
   585  	if len(data) > 0 {
   586  		// diff exits with a non-zero status when the files don't match.
   587  		// Ignore that failure as long as we get output.
   588  		err = nil
   589  	}
   590  	return
   591  }