| // Copyright (c) The OpenTofu Authors | |
| // SPDX-License-Identifier: MPL-2.0 | |
| // Copyright (c) 2023 HashiCorp, Inc. | |
| // SPDX-License-Identifier: MPL-2.0 | |
| package command | |
| import ( | |
| "bytes" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "log" | |
| "os" | |
| "os/exec" | |
| "path/filepath" | |
| "strings" | |
| "github.com/hashicorp/hcl/v2" | |
| "github.com/hashicorp/hcl/v2/hclsyntax" | |
| "github.com/hashicorp/hcl/v2/hclwrite" | |
| "github.com/opentofu/opentofu/internal/command/arguments" | |
| "github.com/opentofu/opentofu/internal/command/views" | |
| "github.com/opentofu/opentofu/internal/configs" | |
| "github.com/opentofu/opentofu/internal/tfdiags" | |
| ) | |
| var ( | |
| fmtSupportedExts = []string{ | |
| ".tf", | |
| ".tofu", | |
| ".tfvars", | |
| ".tftest.hcl", | |
| ".tofutest.hcl", | |
| } | |
| ) | |
| func FmtCommander(input io.Reader) Command { | |
| cmd := Command{ | |
| Name: "fmt", | |
| Short: "Reformat your configuration in the standard style", | |
| Long: `Rewrites all OpenTofu configuration files to a canonical format. All configuration files (.tf), variables files (.tfvars), and testing files (.tftest.hcl) are updated. JSON files (.tf.json, .tfvars.json, or .tftest.json) are not modified. | |
| By default, fmt scans the current directory for configuration files. If you provide a directory for the target argument, then fmt will scan that directory instead. If you provide a file, then fmt will process just that file. If you provide a single dash ("-"), then fmt will read from standard input (STDIN). | |
| The content must be in the OpenTofu language native syntax; JSON is not supported.`, | |
| DiagsWithNewline: true, | |
| } | |
| args := arguments.BindFmt(&cmd.CommandLine) | |
| cmd.Run = func(meta Meta) int { | |
| return FmtCommand{meta, input}.Execute(args, views.NewFmt(meta.View)) | |
| } | |
| return cmd | |
| } | |
| // FmtCommand is a Command implementation that rewrites OpenTofu config | |
| // files to a canonical format and style. | |
| type FmtCommand struct { | |
| Meta | |
| input io.Reader // STDIN if nil | |
| } | |
| func (c *FmtCommand) Run(rawArgs []string) int { | |
| return RunCommand(FmtCommander(c.input), c.Meta, rawArgs) | |
| } | |
| func (c FmtCommand) Execute(args *arguments.Fmt, view views.Fmt) int { | |
| if c.input == nil { | |
| c.input = os.Stdin | |
| } | |
| var output io.Writer | |
| list := args.List // preserve the original value of -list | |
| if args.Check { | |
| // set to true so we can use the list output to check | |
| // if the input needs formatting | |
| args.List = true | |
| args.Write = false | |
| output = &bytes.Buffer{} | |
| } else { | |
| output = view.UserOutputWriter() | |
| } | |
| diags := c.fmt(args.Paths, c.input, output, *args) | |
| view.Diagnostics(diags) | |
| if diags.HasErrors() { | |
| return 2 | |
| } | |
| if args.Check { | |
| buf := output.(*bytes.Buffer) | |
| ok := buf.Len() == 0 | |
| if list { | |
| if _, err := io.Copy(view.UserOutputWriter(), buf); err != nil { | |
| log.Printf("[ERROR] Unable to write UI output: %s", err) | |
| } | |
| } | |
| if !ok { | |
| return 3 | |
| } | |
| return 0 | |
| } | |
| return 0 | |
| } | |
| func (c *FmtCommand) fmt(paths []string, stdin io.Reader, stdout io.Writer, args arguments.Fmt) tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| if len(paths) == 0 { // Assuming stdin, then. | |
| if args.Write { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Invalid arguments", | |
| "Option -write cannot be used when reading from stdin", | |
| )) | |
| return diags | |
| } | |
| fileDiags := c.processFile("<stdin>", stdin, stdout, args) | |
| diags = diags.Append(fileDiags) | |
| return diags | |
| } | |
| for _, path := range paths { | |
| path = c.Meta.WorkingDir.NormalizePath(path) | |
| info, err := os.Stat(path) | |
| if err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Invalid file or directory path", | |
| fmt.Sprintf("No file or directory at %s", path), | |
| )) | |
| return diags | |
| } | |
| if info.IsDir() { | |
| dirDiags := c.processDir(path, stdout, args) | |
| diags = diags.Append(dirDiags) | |
| } else { | |
| fmtd := false | |
| for _, ext := range fmtSupportedExts { | |
| if strings.HasSuffix(path, ext) { | |
| f, err := os.Open(path) | |
| if err != nil { | |
| // Open does not produce error messages that are end-user-appropriate, | |
| // so we'll need to simplify here. | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Failed to open file", | |
| fmt.Sprintf("Failed to read file %s", path), | |
| )) | |
| continue | |
| } | |
| fileDiags := c.processFile(c.Meta.WorkingDir.NormalizePath(path), f, stdout, args) | |
| diags = diags.Append(fileDiags) | |
| _ = f.Close() | |
| // Take note that we processed the file. | |
| fmtd = true | |
| // Don't check the remaining extensions. | |
| break | |
| } | |
| } | |
| if !fmtd { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Invalid file extension", | |
| "Only .tf, .tofu, .tfvars, .tftest.hcl, and .tofutest.hcl files can be processed with tofu fmt", | |
| )) | |
| continue | |
| } | |
| } | |
| } | |
| return diags | |
| } | |
| func (c *FmtCommand) processFile(path string, r io.Reader, w io.Writer, args arguments.Fmt) tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| log.Printf("[TRACE] tofu fmt: Formatting %s", path) | |
| src, err := io.ReadAll(r) | |
| if err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Failed to read file", | |
| fmt.Sprintf("Failed to load the content of the %q file", path), | |
| )) | |
| return diags | |
| } | |
| // Register this path as a synthetic configuration source, so that any | |
| // diagnostic errors can include the source code snippet | |
| c.configLoader().ForceFileSource(path, src) | |
| // File must be parseable as HCL native syntax before we'll try to format | |
| // it. If not, the formatter is likely to make drastic changes that would | |
| // be hard for the user to undo. | |
| _, syntaxDiags := hclsyntax.ParseConfig(src, path, hcl.Pos{Line: 1, Column: 1}) | |
| if syntaxDiags.HasErrors() { | |
| diags = diags.Append(syntaxDiags) | |
| return diags | |
| } | |
| result := c.formatSourceCode(src, path) | |
| if !bytes.Equal(src, result) { | |
| // Something was changed | |
| if args.List { | |
| _, _ = fmt.Fprintln(w, path) | |
| } | |
| if args.Write { | |
| err := os.WriteFile(path, result, 0644) | |
| if err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Writing formatted file failed", | |
| fmt.Sprintf("Failed to write %s", path), | |
| )) | |
| return diags | |
| } | |
| } | |
| if args.Diff { | |
| diff, err := bytesDiff(src, result, path) | |
| if err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Failed to generate diff", | |
| fmt.Sprintf("Failed to generate diff for %s: %s", path, err), | |
| )) | |
| return diags | |
| } | |
| if _, err := w.Write(diff); err != nil { | |
| return diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Failed to write format diff", | |
| err.Error(), | |
| )) | |
| } | |
| } | |
| } | |
| if !args.List && !args.Write && !args.Diff { | |
| _, err = w.Write(result) | |
| if err != nil { | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Writing results failed", | |
| fmt.Sprintf("Failed to write result: %s", err), | |
| )) | |
| } | |
| } | |
| return diags | |
| } | |
| func (c *FmtCommand) processDir(path string, stdout io.Writer, args arguments.Fmt) tfdiags.Diagnostics { | |
| var diags tfdiags.Diagnostics | |
| log.Printf("[TRACE] tofu fmt: looking for files in %s", path) | |
| entries, err := os.ReadDir(path) | |
| if err != nil { | |
| switch { | |
| case os.IsNotExist(err): | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Invalid path", | |
| fmt.Sprintf("There is no configuration directory at %s", path), | |
| )) | |
| default: | |
| // ReadDir does not produce error messages that are end-user-appropriate, | |
| // so we'll need to simplify here. | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Invalid path", | |
| fmt.Sprintf("Cannot read directory %s", path), | |
| )) | |
| } | |
| return diags | |
| } | |
| for _, info := range entries { | |
| name := info.Name() | |
| if configs.IsIgnoredFile(name) { | |
| continue | |
| } | |
| subPath := filepath.Join(path, name) | |
| if info.IsDir() { | |
| if args.Recursive { | |
| subDiags := c.processDir(subPath, stdout, args) | |
| diags = diags.Append(subDiags) | |
| } | |
| // We do not recurse into child directories by default because we | |
| // want to mimic the file-reading behavior of "tofu plan", etc, | |
| // operating on one module at a time. | |
| continue | |
| } | |
| for _, ext := range fmtSupportedExts { | |
| if strings.HasSuffix(name, ext) { | |
| f, err := os.Open(subPath) | |
| if err != nil { | |
| // Open does not produce error messages that are end-user-appropriate, | |
| // so we'll need to simplify here. | |
| diags = diags.Append(tfdiags.Sourceless( | |
| tfdiags.Error, | |
| "Failed to open file", | |
| fmt.Sprintf("Failed to read file %s", path), | |
| )) | |
| continue | |
| } | |
| fileDiags := c.processFile(c.Meta.WorkingDir.NormalizePath(subPath), f, stdout, args) | |
| diags = diags.Append(fileDiags) | |
| _ = f.Close() | |
| // Don't need to check the remaining extensions. | |
| break | |
| } | |
| } | |
| } | |
| return diags | |
| } | |
| // formatSourceCode is the formatting logic itself, applied to each file that | |
| // is selected (directly or indirectly) on the command line. | |
| func (c *FmtCommand) formatSourceCode(src []byte, filename string) []byte { | |
| f, diags := hclwrite.ParseConfig(src, filename, hcl.InitialPos) | |
| if diags.HasErrors() || f == nil { // ensure that f is not nil to avoid possible nil pointer dereference later | |
| // It would be weird to get here because the caller should already have | |
| // checked for syntax errors and returned them. We'll just do nothing | |
| // in this case, returning the input exactly as given. | |
| return src | |
| } | |
| c.formatBody(f.Body(), nil) | |
| return f.Bytes() | |
| } | |
| func (c *FmtCommand) formatBody(body *hclwrite.Body, inBlocks []string) { | |
| attrs := body.Attributes() | |
| for name, attr := range attrs { | |
| if len(inBlocks) == 1 && inBlocks[0] == "variable" && name == "type" { | |
| cleanedExprTokens := c.formatTypeExpr(attr.Expr().BuildTokens(nil)) | |
| body.SetAttributeRaw(name, cleanedExprTokens) | |
| continue | |
| } | |
| cleanedExprTokens := c.formatValueExpr(attr.Expr().BuildTokens(nil)) | |
| body.SetAttributeRaw(name, cleanedExprTokens) | |
| } | |
| blocks := body.Blocks() | |
| for _, block := range blocks { | |
| // Normalize the label formatting, removing any weird stuff like | |
| // interleaved inline comments and using the idiomatic quoted | |
| // label syntax. | |
| block.SetLabels(block.Labels()) | |
| inBlocks := append(inBlocks, block.Type()) | |
| c.formatBody(block.Body(), inBlocks) | |
| } | |
| } | |
| func (c *FmtCommand) formatValueExpr(tokens hclwrite.Tokens) hclwrite.Tokens { | |
| if len(tokens) < 5 { | |
| // Can't possibly be a "${ ... }" sequence without at least enough | |
| // tokens for the delimiters and one token inside them. | |
| return tokens | |
| } | |
| oQuote := tokens[0] | |
| oBrace := tokens[1] | |
| cBrace := tokens[len(tokens)-2] | |
| cQuote := tokens[len(tokens)-1] | |
| if oQuote.Type != hclsyntax.TokenOQuote || oBrace.Type != hclsyntax.TokenTemplateInterp || cBrace.Type != hclsyntax.TokenTemplateSeqEnd || cQuote.Type != hclsyntax.TokenCQuote { | |
| // Not an interpolation sequence at all, then. | |
| return tokens | |
| } | |
| inside := tokens[2 : len(tokens)-2] | |
| // We're only interested in sequences that are provable to be single | |
| // interpolation sequences, which we'll determine by hunting inside | |
| // the interior tokens for any other interpolation sequences. This is | |
| // likely to produce false negatives sometimes, but that's better than | |
| // false positives and we're mainly interested in catching the easy cases | |
| // here. | |
| quotes := 0 | |
| for _, token := range inside { | |
| if token.Type == hclsyntax.TokenOQuote { | |
| quotes++ | |
| continue | |
| } | |
| if token.Type == hclsyntax.TokenCQuote { | |
| quotes-- | |
| continue | |
| } | |
| if quotes > 0 { | |
| // Interpolation sequences inside nested quotes are okay, because | |
| // they are part of a nested expression. | |
| // "${foo("${bar}")}" | |
| continue | |
| } | |
| if token.Type == hclsyntax.TokenTemplateInterp || token.Type == hclsyntax.TokenTemplateSeqEnd { | |
| // We've found another template delimiter within our interior | |
| // tokens, which suggests that we've found something like this: | |
| // "${foo}${bar}" | |
| // That isn't unwrappable, so we'll leave the whole expression alone. | |
| return tokens | |
| } | |
| if token.Type == hclsyntax.TokenQuotedLit { | |
| // If there's any literal characters in the outermost | |
| // quoted sequence then it is not unwrappable. | |
| return tokens | |
| } | |
| } | |
| // If we got down here without an early return then this looks like | |
| // an unwrappable sequence, but we'll trim any leading and trailing | |
| // newlines that might result in an invalid result if we were to | |
| // naively trim something like this: | |
| // "${ | |
| // foo | |
| // }" | |
| trimmed := c.trimNewlines(inside) | |
| // Finally, we check if the unwrapped expression is on multiple lines. If | |
| // so, we ensure that it is surrounded by parenthesis to make sure that it | |
| // parses correctly after unwrapping. This may be redundant in some cases, | |
| // but is required for at least multi-line ternary expressions. | |
| isMultiLine := false | |
| hasLeadingParen := false | |
| hasTrailingParen := false | |
| for i, token := range trimmed { | |
| switch { | |
| case i == 0 && token.Type == hclsyntax.TokenOParen: | |
| hasLeadingParen = true | |
| case token.Type == hclsyntax.TokenNewline: | |
| isMultiLine = true | |
| case i == len(trimmed)-1 && token.Type == hclsyntax.TokenCParen: | |
| hasTrailingParen = true | |
| } | |
| } | |
| if isMultiLine && (!hasLeadingParen || !hasTrailingParen) { | |
| wrapped := make(hclwrite.Tokens, 0, len(trimmed)+2) | |
| wrapped = append(wrapped, &hclwrite.Token{ | |
| Type: hclsyntax.TokenOParen, | |
| Bytes: []byte("("), | |
| }) | |
| wrapped = append(wrapped, trimmed...) | |
| wrapped = append(wrapped, &hclwrite.Token{ | |
| Type: hclsyntax.TokenCParen, | |
| Bytes: []byte(")"), | |
| }) | |
| return wrapped | |
| } | |
| return trimmed | |
| } | |
| func (c *FmtCommand) formatTypeExpr(tokens hclwrite.Tokens) hclwrite.Tokens { | |
| switch len(tokens) { | |
| case 1: | |
| kwTok := tokens[0] | |
| if kwTok.Type != hclsyntax.TokenIdent { | |
| // Not a single type keyword, then. | |
| return tokens | |
| } | |
| // Collection types without an explicit element type mean | |
| // the element type is "any", so we'll normalize that. | |
| switch string(kwTok.Bytes) { | |
| case "list", "map", "set": | |
| return hclwrite.Tokens{ | |
| kwTok, | |
| { | |
| Type: hclsyntax.TokenOParen, | |
| Bytes: []byte("("), | |
| }, | |
| { | |
| Type: hclsyntax.TokenIdent, | |
| Bytes: []byte("any"), | |
| }, | |
| { | |
| Type: hclsyntax.TokenCParen, | |
| Bytes: []byte(")"), | |
| }, | |
| } | |
| default: | |
| return tokens | |
| } | |
| case 3: | |
| // A pre-0.12 legacy quoted string type, like "string". | |
| oQuote := tokens[0] | |
| strTok := tokens[1] | |
| cQuote := tokens[2] | |
| if oQuote.Type != hclsyntax.TokenOQuote || strTok.Type != hclsyntax.TokenQuotedLit || cQuote.Type != hclsyntax.TokenCQuote { | |
| // Not a quoted string sequence, then. | |
| return tokens | |
| } | |
| // Because this quoted syntax is from Terraform 0.11 and | |
| // earlier, which didn't have the idea of "any" as an, | |
| // element type, we use string as the default element | |
| // type. That will avoid oddities if somehow the configuration | |
| // was relying on numeric values being auto-converted to | |
| // string, as 0.11 would do. This mimics what terraform | |
| // 0.12upgrade used to do, because we'd found real-world | |
| // modules that were depending on the auto-stringing.) | |
| switch string(strTok.Bytes) { | |
| case "string": | |
| return hclwrite.Tokens{ | |
| { | |
| Type: hclsyntax.TokenIdent, | |
| Bytes: []byte("string"), | |
| }, | |
| } | |
| case "list": | |
| return hclwrite.Tokens{ | |
| { | |
| Type: hclsyntax.TokenIdent, | |
| Bytes: []byte("list"), | |
| }, | |
| { | |
| Type: hclsyntax.TokenOParen, | |
| Bytes: []byte("("), | |
| }, | |
| { | |
| Type: hclsyntax.TokenIdent, | |
| Bytes: []byte("string"), | |
| }, | |
| { | |
| Type: hclsyntax.TokenCParen, | |
| Bytes: []byte(")"), | |
| }, | |
| } | |
| case "map": | |
| return hclwrite.Tokens{ | |
| { | |
| Type: hclsyntax.TokenIdent, | |
| Bytes: []byte("map"), | |
| }, | |
| { | |
| Type: hclsyntax.TokenOParen, | |
| Bytes: []byte("("), | |
| }, | |
| { | |
| Type: hclsyntax.TokenIdent, | |
| Bytes: []byte("string"), | |
| }, | |
| { | |
| Type: hclsyntax.TokenCParen, | |
| Bytes: []byte(")"), | |
| }, | |
| } | |
| default: | |
| // Something else we're not expecting, then. | |
| return tokens | |
| } | |
| default: | |
| return tokens | |
| } | |
| } | |
| func (c *FmtCommand) trimNewlines(tokens hclwrite.Tokens) hclwrite.Tokens { | |
| if len(tokens) == 0 { | |
| return nil | |
| } | |
| var start, end int | |
| for start = 0; start < len(tokens); start++ { | |
| if tokens[start].Type != hclsyntax.TokenNewline { | |
| break | |
| } | |
| } | |
| for end = len(tokens); end > 0; end-- { | |
| if tokens[end-1].Type != hclsyntax.TokenNewline { | |
| break | |
| } | |
| } | |
| return tokens[start:end] | |
| } | |
| func (c *FmtCommand) Help() string { | |
| helpText := ` | |
| Usage: tofu [global options] fmt [options] [target...] | |
| Rewrites all OpenTofu configuration files to a canonical format. All | |
| configuration files (.tf), variables files (.tfvars), and testing files | |
| (.tftest.hcl) are updated. JSON files (.tf.json, .tfvars.json, or | |
| .tftest.json) are not modified. | |
| By default, fmt scans the current directory for configuration files. If you | |
| provide a directory for the target argument, then fmt will scan that | |
| directory instead. If you provide a file, then fmt will process just that | |
| file. If you provide a single dash ("-"), then fmt will read from standard | |
| input (STDIN). | |
| The content must be in the OpenTofu language native syntax; JSON is not | |
| supported. | |
| Options: | |
| -list=false Don't list files whose formatting differs | |
| (always disabled if using STDIN) | |
| -write=false Don't write to source files | |
| (always disabled if using STDIN or -check) | |
| -diff Display diffs of formatting changes | |
| -check Check if the input is formatted. Exit status will be 0 if all | |
| input is properly formatted and non-zero otherwise. | |
| -no-color If specified, output won't contain any color. | |
| -recursive Also process files in subdirectories. By default, only the | |
| given directory (or current directory) is processed. | |
| ` | |
| return strings.TrimSpace(helpText) | |
| } | |
| func (c *FmtCommand) Synopsis() string { | |
| return "Reformat your configuration in the standard style" | |
| } | |
| func withTempFile(b []byte, fn func(*os.File) error) error { | |
| f, err := os.CreateTemp("", "") | |
| if err != nil { | |
| return err | |
| } | |
| _, err = f.Write(b) | |
| if err == nil { | |
| err = fn(f) | |
| } | |
| err = errors.Join(err, f.Close()) | |
| err = errors.Join(err, os.Remove(f.Name())) | |
| return err | |
| } | |
| func bytesDiff(b1, b2 []byte, path string) (data []byte, err error) { | |
| err = withTempFile(b1, func(f1 *os.File) error { | |
| return withTempFile(b2, func(f2 *os.File) error { | |
| data, err = exec.Command("diff", "--label=old/"+path, "--label=new/"+path, "-u", f1.Name(), f2.Name()).CombinedOutput() | |
| if len(data) > 0 { | |
| // diff exits with a non-zero status when the files don't match. | |
| // Ignore that failure as long as we get output. | |
| err = nil | |
| } | |
| return err | |
| }) | |
| }) | |
| return | |
| } |