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 }