github.com/cycloidio/terraform@v1.1.10-0.20220513142504-76d5c768dc63/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/cycloidio/terraform/configs" 20 "github.com/cycloidio/terraform/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 }