github.com/terraform-linters/tflint@v0.51.2-0.20240520175844-3750771571b6/terraform/parser.go (about)

     1  package terraform
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"path/filepath"
     7  	"sort"
     8  	"strings"
     9  
    10  	"github.com/hashicorp/hcl/v2"
    11  	"github.com/hashicorp/hcl/v2/hclparse"
    12  	"github.com/spf13/afero"
    13  	"github.com/zclconf/go-cty/cty"
    14  )
    15  
    16  // Parser is a fork of configs.Parser. This is the main interface to read
    17  // configuration files and other related files from disk.
    18  //
    19  // It retains a cache of all files that are loaded so that they can be used
    20  // to create source code snippets in diagnostics, etc.
    21  type Parser struct {
    22  	fs afero.Afero
    23  	p  *hclparse.Parser
    24  }
    25  
    26  // NewParser creates and returns a new Parser that reads files from the given
    27  // filesystem. If a nil filesystem is passed then the system's "real" filesystem
    28  // will be used, via afero.OsFs.
    29  func NewParser(fs afero.Fs) *Parser {
    30  	if fs == nil {
    31  		fs = afero.OsFs{}
    32  	}
    33  
    34  	return &Parser{
    35  		fs: afero.Afero{Fs: fs},
    36  		p:  hclparse.NewParser(),
    37  	}
    38  }
    39  
    40  // LoadConfigDir reads the .tf and .tf.json files in the given directory and
    41  // then combines these files into a single Module.
    42  //
    43  // If this method returns nil, that indicates that the given directory does not
    44  // exist at all or could not be opened for some reason. Callers may wish to
    45  // detect this case and ignore the returned diagnostics so that they can
    46  // produce a more context-aware error message in that case.
    47  //
    48  // If this method returns a non-nil module while error diagnostics are returned
    49  // then the module may be incomplete but can be used carefully for static
    50  // analysis.
    51  //
    52  // This file does not consider a directory with no files to be an error, and
    53  // will simply return an empty module in that case.
    54  //
    55  // .tf files are parsed using the HCL native syntax while .tf.json files are
    56  // parsed using the HCL JSON syntax.
    57  //
    58  // If a baseDir is passed, the loaded files are assumed to be loaded from that
    59  // directory. However, SourceDir does not contain baseDir because it affects
    60  // `path.module` and `path.root` values.
    61  func (p *Parser) LoadConfigDir(baseDir, dir string) (*Module, hcl.Diagnostics) {
    62  	primaries, overrides, diags := p.configDirFiles(baseDir, dir)
    63  	if diags.HasErrors() {
    64  		return nil, diags
    65  	}
    66  
    67  	mod := NewEmptyModule()
    68  
    69  	for _, path := range primaries {
    70  		f, loadDiags := p.loadHCLFile(baseDir, path)
    71  		diags = diags.Extend(loadDiags)
    72  		if loadDiags.HasErrors() {
    73  			continue
    74  		}
    75  		realPath := filepath.Join(baseDir, path)
    76  
    77  		mod.primaries[realPath] = f
    78  		mod.Sources[realPath] = f.Bytes
    79  		mod.Files[realPath] = f
    80  	}
    81  	for _, path := range overrides {
    82  		f, loadDiags := p.loadHCLFile(baseDir, path)
    83  		diags = diags.Extend(loadDiags)
    84  		if loadDiags.HasErrors() {
    85  			continue
    86  		}
    87  		realPath := filepath.Join(baseDir, path)
    88  
    89  		mod.overrides[realPath] = f
    90  		mod.Sources[realPath] = f.Bytes
    91  		mod.Files[realPath] = f
    92  	}
    93  	if diags.HasErrors() {
    94  		return mod, diags
    95  	}
    96  
    97  	// Do not contain baseDir because it affects `path.module` and `path.root` values.
    98  	mod.SourceDir = dir
    99  
   100  	buildDiags := mod.build()
   101  	diags = diags.Extend(buildDiags)
   102  
   103  	return mod, diags
   104  }
   105  
   106  // LoadConfigDirFiles reads the .tf and .tf.json files in the given directory and
   107  // then returns these files as a map of file path.
   108  //
   109  // The difference with LoadConfigDir is that it returns hcl.File instead of
   110  // a single module. This is useful when parsing HCL files in a context outside of
   111  // Terraform.
   112  //
   113  // If a baseDir is passed, the loaded files are assumed to be loaded from that
   114  // directory.
   115  func (p *Parser) LoadConfigDirFiles(baseDir, dir string) (map[string]*hcl.File, hcl.Diagnostics) {
   116  	primaries, overrides, diags := p.configDirFiles(baseDir, dir)
   117  	if diags.HasErrors() {
   118  		return map[string]*hcl.File{}, diags
   119  	}
   120  
   121  	files := map[string]*hcl.File{}
   122  
   123  	for _, path := range primaries {
   124  		f, loadDiags := p.loadHCLFile(baseDir, path)
   125  		diags = diags.Extend(loadDiags)
   126  		if loadDiags.HasErrors() {
   127  			continue
   128  		}
   129  		files[filepath.Join(baseDir, path)] = f
   130  	}
   131  	for _, path := range overrides {
   132  		f, loadDiags := p.loadHCLFile(baseDir, path)
   133  		diags = diags.Extend(loadDiags)
   134  		if loadDiags.HasErrors() {
   135  			continue
   136  		}
   137  		files[filepath.Join(baseDir, path)] = f
   138  	}
   139  
   140  	return files, diags
   141  }
   142  
   143  // LoadValuesFile reads the file at the given path and parses it as a "values
   144  // file", which is an HCL config file whose top-level attributes are treated
   145  // as arbitrary key.value pairs.
   146  //
   147  // If the file cannot be read -- for example, if it does not exist -- then
   148  // a nil map will be returned along with error diagnostics. Callers may wish
   149  // to disregard the returned diagnostics in this case and instead generate
   150  // their own error message(s) with additional context.
   151  //
   152  // If the returned diagnostics has errors when a non-nil map is returned
   153  // then the map may be incomplete but should be valid enough for careful
   154  // static analysis.
   155  //
   156  // If a baseDir is passed, the loaded file is assumed to be loaded from that
   157  // directory.
   158  func (p *Parser) LoadValuesFile(baseDir, path string) (map[string]cty.Value, hcl.Diagnostics) {
   159  	f, diags := p.loadHCLFile(baseDir, path)
   160  	if diags.HasErrors() {
   161  		return nil, diags
   162  	}
   163  
   164  	vals := make(map[string]cty.Value)
   165  	if f == nil || f.Body == nil {
   166  		return vals, diags
   167  	}
   168  
   169  	attrs, attrDiags := f.Body.JustAttributes()
   170  	diags = diags.Extend(attrDiags)
   171  	if attrs == nil {
   172  		return vals, diags
   173  	}
   174  
   175  	for name, attr := range attrs {
   176  		val, valDiags := attr.Expr.Value(nil)
   177  		diags = diags.Extend(valDiags)
   178  		vals[name] = val
   179  	}
   180  
   181  	return vals, diags
   182  }
   183  
   184  func (p *Parser) loadHCLFile(baseDir, path string) (*hcl.File, hcl.Diagnostics) {
   185  	src, err := p.fs.ReadFile(path)
   186  
   187  	realPath := filepath.Join(baseDir, path)
   188  
   189  	if err != nil {
   190  		if os.IsNotExist(err) {
   191  			return nil, hcl.Diagnostics{
   192  				{
   193  					Severity: hcl.DiagError,
   194  					Summary:  "Failed to read file",
   195  					Subject:  &hcl.Range{},
   196  					Detail:   fmt.Sprintf("The file %q does not exist.", realPath),
   197  				},
   198  			}
   199  		}
   200  		return nil, hcl.Diagnostics{
   201  			{
   202  				Severity: hcl.DiagError,
   203  				Summary:  "Failed to read file",
   204  				Subject:  &hcl.Range{},
   205  				Detail:   fmt.Sprintf("The file %q could not be read.", realPath),
   206  			},
   207  		}
   208  	}
   209  
   210  	switch {
   211  	case strings.HasSuffix(path, ".json"):
   212  		return p.p.ParseJSON(src, realPath)
   213  	default:
   214  		return p.p.ParseHCL(src, realPath)
   215  	}
   216  }
   217  
   218  // Sources returns a map of the cached source buffers for all files that
   219  // have been loaded through this parser, with source filenames (as requested
   220  // when each file was opened) as the keys.
   221  func (p *Parser) Sources() map[string][]byte {
   222  	return p.p.Sources()
   223  }
   224  
   225  // Files returns a map of the cached HCL file objects for all files that
   226  // have been loaded through this parser, with source filenames (as requested
   227  // when each file was opened) as the keys.
   228  func (p *Parser) Files() map[string]*hcl.File {
   229  	return p.p.Files()
   230  }
   231  
   232  // IsConfigDir determines whether the given path refers to a directory that
   233  // exists and contains at least one Terraform config file (with a .tf or
   234  // .tf.json extension.)
   235  func (p *Parser) IsConfigDir(baseDir, path string) bool {
   236  	primaryPaths, overridePaths, _ := p.configDirFiles(baseDir, path)
   237  	return (len(primaryPaths) + len(overridePaths)) > 0
   238  }
   239  
   240  // Exists returns true if the given path exists in fs.
   241  func (p *Parser) Exists(path string) bool {
   242  	_, err := p.fs.Stat(path)
   243  	return err == nil
   244  }
   245  
   246  func (p *Parser) configDirFiles(baseDir, dir string) (primary, override []string, diags hcl.Diagnostics) {
   247  	infos, err := p.fs.ReadDir(dir)
   248  	if err != nil {
   249  		diags = append(diags, &hcl.Diagnostic{
   250  			Severity: hcl.DiagError,
   251  			Summary:  "Failed to read module directory",
   252  			Subject:  &hcl.Range{},
   253  			Detail:   fmt.Sprintf("Module directory %s does not exist or cannot be read.", filepath.Join(baseDir, dir)),
   254  		})
   255  		return
   256  	}
   257  
   258  	for _, info := range infos {
   259  		if info.IsDir() {
   260  			// We only care about files
   261  			continue
   262  		}
   263  
   264  		name := info.Name()
   265  		ext := configFileExt(name)
   266  		if ext == "" || isIgnoredFile(name) {
   267  			continue
   268  		}
   269  
   270  		baseName := name[:len(name)-len(ext)] // strip extension
   271  		isOverride := baseName == "override" || strings.HasSuffix(baseName, "_override")
   272  
   273  		fullPath := filepath.Join(dir, name)
   274  		if isOverride {
   275  			override = append(override, fullPath)
   276  		} else {
   277  			primary = append(primary, fullPath)
   278  		}
   279  	}
   280  
   281  	return
   282  }
   283  
   284  func (p *Parser) autoLoadValuesDirFiles(baseDir, dir string) (files []string, diags hcl.Diagnostics) {
   285  	infos, err := p.fs.ReadDir(dir)
   286  	if err != nil {
   287  		diags = append(diags, &hcl.Diagnostic{
   288  			Severity: hcl.DiagError,
   289  			Summary:  "Failed to read module directory",
   290  			Subject:  &hcl.Range{},
   291  			Detail:   fmt.Sprintf("Module directory %s does not exist or cannot be read.", filepath.Join(baseDir, dir)),
   292  		})
   293  		return nil, diags
   294  	}
   295  
   296  	for _, info := range infos {
   297  		if info.IsDir() {
   298  			// We only care about files
   299  			continue
   300  		}
   301  
   302  		name := info.Name()
   303  		if !isAutoVarFile(name) {
   304  			continue
   305  		}
   306  
   307  		fullPath := filepath.Join(dir, name)
   308  		files = append(files, fullPath)
   309  	}
   310  	// The files should be sorted alphabetically. This is equivalent to priority.
   311  	sort.Strings(files)
   312  
   313  	return
   314  }
   315  
   316  // configFileExt returns the Terraform configuration extension of the given
   317  // path, or a blank string if it is not a recognized extension.
   318  func configFileExt(path string) string {
   319  	if strings.HasSuffix(path, ".tf") {
   320  		return ".tf"
   321  	} else if strings.HasSuffix(path, ".tf.json") {
   322  		return ".tf.json"
   323  	} else {
   324  		return ""
   325  	}
   326  }
   327  
   328  // isAutoVarFile determines if the file ends with .auto.tfvars or .auto.tfvars.json
   329  func isAutoVarFile(path string) bool {
   330  	return strings.HasSuffix(path, ".auto.tfvars") ||
   331  		strings.HasSuffix(path, ".auto.tfvars.json")
   332  }
   333  
   334  // isIgnoredFile returns true if the given filename (which must not have a
   335  // directory path ahead of it) should be ignored as e.g. an editor swap file.
   336  func isIgnoredFile(name string) bool {
   337  	return strings.HasPrefix(name, ".") || // Unix-like hidden files
   338  		strings.HasSuffix(name, "~") || // vim
   339  		strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") // emacs
   340  }