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 }