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  }