blob: cb282a1a7d353bfed51cc7c3894f2642d32cc55c [file] [log] [blame]
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package configs
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/spf13/afero"
)
// ConfigFileSet holds the different types of configuration files found in a directory.
type ConfigFileSet struct {
Primary []string // Regular .tf and .tf.json files
Override []string // Override files (override.tf or *_override.tf)
Tests []string // Test files (.tftest.hcl or .tftest.json)
Queries []string // Query files (.tfquery.hcl)
}
// FileMatcher is an interface for components that can match and process specific file types
// in a Terraform module directory.
type FileMatcher interface {
// Matches returns true if the given filename should be processed by this matcher
Matches(name string) bool
// DirFiles allows the matcher to process files in a directory
// only relevant to its type.
DirFiles(dir string, cfg *parserConfig, fileSet *ConfigFileSet) hcl.Diagnostics
}
// Option is a functional option type for configuring the parser
type Option func(*parserConfig)
type parserConfig struct {
matchers []FileMatcher
testDirectory string
fs afero.Afero
}
// dirFileSet finds Terraform configuration files within directory dir
// and returns a ConfigFileSet containing the found files.
// It uses the given options to determine which types of files to look for
// and how to process them. The returned ConfigFileSet contains the paths
// to the found files, categorized by their type (primary, override, test, query).
func (p *Parser) dirFileSet(dir string, opts ...Option) (ConfigFileSet, hcl.Diagnostics) {
var diags hcl.Diagnostics
fileSet := ConfigFileSet{
Primary: []string{},
Override: []string{},
Tests: []string{},
Queries: []string{},
}
// Set up the parser configuration
cfg := &parserConfig{
// We always match .tf files
matchers: []FileMatcher{&moduleFiles{}},
testDirectory: DefaultTestDirectory,
fs: p.fs,
}
if p.AllowsLanguageExperiments() {
cfg.matchers = append(cfg.matchers, &queryFiles{})
}
for _, opt := range opts {
opt(cfg)
}
// Scan and categorize main directory files
mainDirDiags := p.rootFiles(dir, cfg.matchers, &fileSet)
diags = append(diags, mainDirDiags...)
if diags.HasErrors() {
return fileSet, diags
}
// Process matcher-specific files
for _, matcher := range cfg.matchers {
matcherDiags := matcher.DirFiles(dir, cfg, &fileSet)
diags = append(diags, matcherDiags...)
}
return fileSet, diags
}
// rootFiles scans the main directory for configuration files
// and categorizes them using the appropriate file matchers.
func (p *Parser) rootFiles(dir string, matchers []FileMatcher, fileSet *ConfigFileSet) hcl.Diagnostics {
var diags hcl.Diagnostics
// Read main directory files
infos, err := p.fs.ReadDir(dir)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to read module directory",
Detail: fmt.Sprintf("Module directory %s does not exist or cannot be read.", dir),
})
return diags
}
for _, info := range infos {
if info.IsDir() || IsIgnoredFile(info.Name()) {
continue
}
name := info.Name()
fullPath := filepath.Join(dir, name)
// Try each matcher to see if it matches
for _, matcher := range matchers {
if matcher.Matches(name) {
switch p := matcher.(type) {
case *moduleFiles:
if p.isOverride(name) {
fileSet.Override = append(fileSet.Override, fullPath)
} else {
fileSet.Primary = append(fileSet.Primary, fullPath)
}
case *testFiles:
fileSet.Tests = append(fileSet.Tests, fullPath)
case *queryFiles:
fileSet.Queries = append(fileSet.Queries, fullPath)
}
break // Stop checking other matchers once a match is found
}
}
}
return diags
}
// MatchTestFiles adds a matcher for Terraform test files (.tftest.hcl and .tftest.json)
func MatchTestFiles(dir string) Option {
return func(o *parserConfig) {
o.testDirectory = dir
o.matchers = append(o.matchers, &testFiles{})
}
}
// moduleFiles matches regular Terraform configuration files (.tf and .tf.json)
type moduleFiles struct{}
func (m *moduleFiles) Matches(name string) bool {
ext := fileExt(name)
if ext != ".tf" && ext != ".tf.json" {
return false
}
return true
}
func (m *moduleFiles) isOverride(name string) bool {
ext := fileExt(name)
if ext != ".tf" && ext != ".tf.json" {
return false
}
baseName := name[:len(name)-len(ext)] // strip extension
isOverride := baseName == "override" || strings.HasSuffix(baseName, "_override")
return isOverride
}
func (m *moduleFiles) DirFiles(dir string, options *parserConfig, fileSet *ConfigFileSet) hcl.Diagnostics {
return nil
}
// testFiles matches Terraform test files (.tftest.hcl and .tftest.json)
type testFiles struct{}
func (t *testFiles) Matches(name string) bool {
return strings.HasSuffix(name, ".tftest.hcl") || strings.HasSuffix(name, ".tftest.json")
}
func (t *testFiles) DirFiles(dir string, opts *parserConfig, fileSet *ConfigFileSet) hcl.Diagnostics {
var diags hcl.Diagnostics
testPath := path.Join(dir, opts.testDirectory)
testInfos, err := opts.fs.ReadDir(testPath)
if err != nil {
// Then we couldn't read from the testing directory for some reason.
if os.IsNotExist(err) {
// Then this means the testing directory did not exist.
// We won't actually stop loading the rest of the configuration
// for this, we will add a warning to explain to the user why
// test files weren't processed but leave it at that.
if opts.testDirectory != DefaultTestDirectory {
// We'll only add the warning if a directory other than the
// default has been requested. If the user is just loading
// the default directory then we have no expectation that
// it should actually exist.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagWarning,
Summary: "Test directory does not exist",
Detail: fmt.Sprintf("Requested test directory %s does not exist.", testPath),
})
}
} else {
// Then there is some other reason we couldn't load. We will
// treat this as a full error.
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to read test directory",
Detail: fmt.Sprintf("Test directory %s could not be read: %v.", testPath, err),
})
// We'll also stop loading the rest of the config for this.
return diags
}
return diags
}
// Process test files
for _, info := range testInfos {
if !t.Matches(info.Name()) {
continue
}
name := info.Name()
fileSet.Tests = append(fileSet.Tests, filepath.Join(testPath, name))
}
return diags
}
// queryFiles matches Terraform query files (.tfquery.hcl and .tfquery.json)
type queryFiles struct{}
func (q *queryFiles) Matches(name string) bool {
return strings.HasSuffix(name, ".tfquery.hcl") || strings.HasSuffix(name, ".tfquery.json")
}
func (q *queryFiles) DirFiles(dir string, options *parserConfig, fileSet *ConfigFileSet) hcl.Diagnostics {
return nil
}