// Copyright IBM Corp. 2014, 2026 // 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, } 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{}) } } // MatchQueryFiles adds a matcher for Terraform query files (.tfquery.hcl and .tfquery.json) func MatchQueryFiles() Option { return func(o *parserConfig) { o.matchers = append(o.matchers, &queryFiles{}) } } // 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 }