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