github.com/hashicorp/packer@v1.14.3/hcl2template/formatter.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: BUSL-1.1 3 4 package hcl2template 5 6 import ( 7 "bytes" 8 "fmt" 9 "io" 10 "os" 11 "os/exec" 12 "path/filepath" 13 "strings" 14 15 "github.com/hashicorp/go-multierror" 16 "github.com/hashicorp/hcl/v2" 17 "github.com/hashicorp/hcl/v2/hclparse" 18 "github.com/hashicorp/hcl/v2/hclwrite" 19 ) 20 21 type HCL2Formatter struct { 22 ShowDiff, Write, Recursive bool 23 Output io.Writer 24 parser *hclparse.Parser 25 } 26 27 // NewHCL2Formatter creates a new formatter, ready to format configuration files. 28 func NewHCL2Formatter() *HCL2Formatter { 29 return &HCL2Formatter{ 30 parser: hclparse.NewParser(), 31 } 32 } 33 34 func isHcl2FileOrVarFile(path string) bool { 35 if strings.HasSuffix(path, hcl2FileExt) || strings.HasSuffix(path, hcl2VarFileExt) { 36 return true 37 } 38 return false 39 } 40 41 func (f *HCL2Formatter) formatFile(path string, diags hcl.Diagnostics, bytesModified int) (int, hcl.Diagnostics) { 42 data, err := f.processFile(path) 43 if err != nil { 44 diags = append(diags, &hcl.Diagnostic{ 45 Severity: hcl.DiagError, 46 Summary: fmt.Sprintf("encountered an error while formatting %s", path), 47 Detail: err.Error(), 48 }) 49 } 50 bytesModified += len(data) 51 return bytesModified, diags 52 } 53 54 // Format all HCL2 files in path and return the total bytes formatted. 55 // If any error is encountered, zero bytes will be returned. 56 // 57 // Path can be a directory or a file. 58 func (f *HCL2Formatter) Format(paths []string) (int, hcl.Diagnostics) { 59 var diags hcl.Diagnostics 60 var bytesModified int 61 62 if f.parser == nil { 63 f.parser = hclparse.NewParser() 64 } 65 66 for _, path := range paths { 67 s, err := os.Stat(path) 68 69 if err != nil || !s.IsDir() { 70 bytesModified, diags = f.formatFile(path, diags, bytesModified) 71 } else { 72 fileInfos, err := os.ReadDir(path) 73 if err != nil { 74 diag := &hcl.Diagnostic{ 75 Severity: hcl.DiagError, 76 Summary: "Cannot read hcl directory", 77 Detail: err.Error(), 78 } 79 diags = append(diags, diag) 80 return bytesModified, diags 81 } 82 83 for _, fileInfo := range fileInfos { 84 name := fileInfo.Name() 85 if f.shouldIgnoreFile(name) { 86 continue 87 } 88 filename := filepath.Join(path, name) 89 if fileInfo.IsDir() { 90 if f.Recursive { 91 var tempDiags hcl.Diagnostics 92 var tempBytesModified int 93 var newPaths []string 94 newPaths = append(newPaths, filename) 95 tempBytesModified, tempDiags = f.Format(newPaths) 96 bytesModified += tempBytesModified 97 diags = diags.Extend(tempDiags) 98 } 99 continue 100 } 101 if isHcl2FileOrVarFile(filename) { 102 bytesModified, diags = f.formatFile(filename, diags, bytesModified) 103 } 104 } 105 } 106 } 107 108 return bytesModified, diags 109 } 110 111 // processFile formats the source contents of filename and return the formatted data. 112 // overwriting the contents of the original when the f.Write is true; a diff of the changes 113 // will be outputted if f.ShowDiff is true. 114 func (f *HCL2Formatter) processFile(filename string) ([]byte, error) { 115 116 if f.Output == nil { 117 f.Output = os.Stdout 118 } 119 120 if !(filename == "-") && !isHcl2FileOrVarFile(filename) { 121 return nil, fmt.Errorf("file %s is not a HCL file", filename) 122 } 123 124 var in io.Reader 125 var err error 126 127 if filename == "-" { 128 in = os.Stdin 129 f.ShowDiff = false 130 } else { 131 in, err = os.Open(filename) 132 if err != nil { 133 return nil, fmt.Errorf("failed to open %s: %s", filename, err) 134 } 135 } 136 137 inSrc, err := io.ReadAll(in) 138 if err != nil { 139 return nil, fmt.Errorf("failed to read %s: %s", filename, err) 140 } 141 142 _, diags := f.parser.ParseHCL(inSrc, filename) 143 if diags.HasErrors() { 144 return nil, multierror.Append(nil, diags.Errs()...) 145 } 146 147 outSrc := hclwrite.Format(inSrc) 148 149 if bytes.Equal(inSrc, outSrc) { 150 if filename == "-" { 151 _, _ = f.Output.Write(outSrc) 152 } 153 154 return nil, nil 155 } 156 157 if filename != "-" { 158 s := []byte(fmt.Sprintf("%s\n", filename)) 159 _, _ = f.Output.Write(s) 160 } 161 162 if f.Write { 163 if filename == "-" { 164 _, _ = f.Output.Write(outSrc) 165 } else { 166 if err := os.WriteFile(filename, outSrc, 0644); err != nil { 167 return nil, err 168 } 169 } 170 } 171 172 if f.ShowDiff { 173 diff, err := bytesDiff(inSrc, outSrc, filename) 174 if err != nil { 175 return outSrc, fmt.Errorf("failed to generate diff for %s: %s", filename, err) 176 } 177 _, _ = f.Output.Write(diff) 178 } 179 180 return outSrc, nil 181 } 182 183 // shouldIgnoreFile returns true if the given filename (which must not have a 184 // directory path ahead of it) should be ignored as e.g. an editor swap file. 185 func (f *HCL2Formatter) shouldIgnoreFile(name string) bool { 186 return strings.HasPrefix(name, ".") || // Unix-like hidden files 187 strings.HasSuffix(name, "~") || // vim 188 strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") // emacs 189 } 190 191 // bytesDiff returns the unified diff of b1 and b2 192 // Shamelessly copied from Terraform's fmt command. 193 func bytesDiff(b1, b2 []byte, path string) (data []byte, err error) { 194 f1, err := os.CreateTemp("", "") 195 if err != nil { 196 return 197 } 198 defer os.Remove(f1.Name()) 199 defer f1.Close() 200 201 f2, err := os.CreateTemp("", "") 202 if err != nil { 203 return 204 } 205 defer os.Remove(f2.Name()) 206 defer f2.Close() 207 208 _, _ = f1.Write(b1) 209 _, _ = f2.Write(b2) 210 211 data, err = exec.Command("diff", "--label=old/"+path, "--label=new/"+path, "-u", f1.Name(), f2.Name()).CombinedOutput() 212 if len(data) > 0 { 213 // diff exits with a non-zero status when the files don't match. 214 // Ignore that failure as long as we get output. 215 err = nil 216 } 217 return 218 }