github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/command/fmt.go (about)

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