diff --git a/internal/decoder/decoder.go b/internal/decoder/decoder.go index 31ef24e38..5cb6d3481 100644 --- a/internal/decoder/decoder.go +++ b/internal/decoder/decoder.go @@ -2,12 +2,11 @@ package decoder import ( "context" - "fmt" "github.com/hashicorp/hcl-lang/decoder" "github.com/hashicorp/hcl-lang/lang" - "github.com/hashicorp/hcl/v2" lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" "github.com/hashicorp/terraform-ls/internal/terraform/module" ) @@ -30,36 +29,27 @@ func DecoderForModule(ctx context.Context, mod module.Module) (*decoder.Decoder, d.SetUtmMedium(clientName) } - err := loadFiles(d, mod.ParsedModuleFiles) - if err != nil { - return nil, err + for name, f := range mod.ParsedModuleFiles { + err := d.LoadFile(name.String(), f) + if err != nil { + // skip unreadable files + continue + } } return d, nil } -func DecoderForVariables(mod module.Module) (*decoder.Decoder, error) { +func DecoderForVariables(varsFiles ast.VarsFiles) (*decoder.Decoder, error) { d := decoder.NewDecoder() - err := loadFiles(d, mod.ParsedModuleFiles) - if err != nil { - return nil, err - } - - err = loadFiles(d, mod.ParsedVarsFiles) - if err != nil { - return nil, err - } - - return d, nil -} - -func loadFiles(d *decoder.Decoder, files map[string]*hcl.File) error { - for name, f := range files { - err := d.LoadFile(name, f) + for name, f := range varsFiles { + err := d.LoadFile(name.String(), f) if err != nil { - return fmt.Errorf("failed to load a file: %w", err) + // skip unreadable files + continue } } - return nil + + return d, nil } diff --git a/internal/filesystem/filesystem.go b/internal/filesystem/filesystem.go index 177804cd9..4838df9bd 100644 --- a/internal/filesystem/filesystem.go +++ b/internal/filesystem/filesystem.go @@ -3,6 +3,7 @@ package filesystem import ( "bytes" "fmt" + "io/fs" "io/ioutil" "log" "os" @@ -206,37 +207,37 @@ func (fs *fsystem) ReadFile(name string) ([]byte, error) { return b, err } -func (fs *fsystem) ReadDir(name string) ([]os.FileInfo, error) { - memList, err := afero.ReadDir(fs.memFs, name) +func (fsys *fsystem) ReadDir(name string) ([]fs.DirEntry, error) { + memList, err := afero.NewIOFS(fsys.memFs).ReadDir(name) if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("memory FS: %w", err) } - osList, err := afero.ReadDir(fs.osFs, name) + osList, err := afero.NewIOFS(fsys.osFs).ReadDir(name) if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("OS FS: %w", err) } list := memList - for _, osFi := range osList { - if fileIsInList(list, osFi) { + for _, osEntry := range osList { + if fileIsInList(list, osEntry) { continue } - list = append(list, osFi) + list = append(list, osEntry) } return list, nil } -func fileIsInList(list []os.FileInfo, file os.FileInfo) bool { - for _, fi := range list { - if fi.Name() == file.Name() { +func fileIsInList(list []fs.DirEntry, entry fs.DirEntry) bool { + for _, di := range list { + if di.Name() == entry.Name() { return true } } return false } -func (fs *fsystem) Open(name string) (File, error) { +func (fs *fsystem) Open(name string) (fs.File, error) { f, err := fs.memFs.Open(name) if err != nil && os.IsNotExist(err) { return fs.osFs.Open(name) diff --git a/internal/filesystem/filesystem_test.go b/internal/filesystem/filesystem_test.go index 4e5580474..4ca718b2c 100644 --- a/internal/filesystem/filesystem_test.go +++ b/internal/filesystem/filesystem_test.go @@ -1,6 +1,7 @@ package filesystem import ( + "io/fs" "io/ioutil" "log" "os" @@ -317,10 +318,10 @@ func TestFilesystem_ReadDir_memFsOnly(t *testing.T) { } } -func namesFromFileInfos(fis []os.FileInfo) []string { - names := make([]string, len(fis), len(fis)) - for i, fi := range fis { - names[i] = fi.Name() +func namesFromFileInfos(entries []fs.DirEntry) []string { + names := make([]string, len(entries), len(entries)) + for i, entry := range entries { + names[i] = entry.Name() } return names } diff --git a/internal/filesystem/types.go b/internal/filesystem/types.go index 5d1ffb74e..a36f36ded 100644 --- a/internal/filesystem/types.go +++ b/internal/filesystem/types.go @@ -1,6 +1,7 @@ package filesystem import ( + "io/fs" "log" "os" @@ -51,15 +52,7 @@ type Filesystem interface { // direct FS methods ReadFile(name string) ([]byte, error) - ReadDir(name string) ([]os.FileInfo, error) - Open(name string) (File, error) + ReadDir(name string) ([]fs.DirEntry, error) + Open(name string) (fs.File, error) Stat(name string) (os.FileInfo, error) } - -// File represents an open file in FS -// See io/fs.File in http://golang.org/s/draft-iofs-design -type File interface { - Stat() (os.FileInfo, error) - Read([]byte) (int, error) - Close() error -} diff --git a/internal/langserver/handlers/command/validate.go b/internal/langserver/handlers/command/validate.go index d13c53d62..02469b849 100644 --- a/internal/langserver/handlers/command/validate.go +++ b/internal/langserver/handlers/command/validate.go @@ -69,8 +69,8 @@ func TerraformValidateHandler(ctx context.Context, args cmd.CommandArgs) (interf validateDiags := diagnostics.HCLDiagsFromJSON(jsonDiags) diags.EmptyRootDiagnostic() diags.Append("terraform validate", validateDiags) - diags.Append("HCL", mod.ModuleDiagnostics) - diags.Append("HCL", mod.VarsDiagnostics) + diags.Append("HCL", mod.ModuleDiagnostics.AsMap()) + diags.Append("HCL", mod.VarsDiagnostics.AutoloadedOnly().AsMap()) notifier.PublishHCLDiags(ctx, mod.Path, diags) diff --git a/internal/langserver/handlers/did_change.go b/internal/langserver/handlers/did_change.go index 88088f1b2..da4122c8e 100644 --- a/internal/langserver/handlers/did_change.go +++ b/internal/langserver/handlers/did_change.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/terraform-ls/internal/langserver/diagnostics" ilsp "github.com/hashicorp/terraform-ls/internal/lsp" lsp "github.com/hashicorp/terraform-ls/internal/protocol" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" ) @@ -95,9 +96,11 @@ func TextDocumentDidChange(ctx context.Context, params lsp.DidChangeTextDocument diags := diagnostics.NewDiagnostics() diags.EmptyRootDiagnostic() - diags.Append("HCL", mod.ModuleDiagnostics) - diags.Append("HCL", mod.VarsDiagnostics) - + diags.Append("HCL", mod.ModuleDiagnostics.AsMap()) + diags.Append("HCL", mod.VarsDiagnostics.AutoloadedOnly().AsMap()) + if vf, ok := ast.NewVarsFilename(f.Filename()); ok && !vf.IsAutoloaded() { + diags.Append("HCL", mod.VarsDiagnostics.ForFile(vf).AsMap()) + } notifier.PublishHCLDiags(ctx, mod.Path, diags) return nil diff --git a/internal/langserver/handlers/did_close.go b/internal/langserver/handlers/did_close.go index 8fb1a26a9..019fd35cb 100644 --- a/internal/langserver/handlers/did_close.go +++ b/internal/langserver/handlers/did_close.go @@ -3,9 +3,12 @@ package handlers import ( "context" + "github.com/hashicorp/hcl/v2" lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/langserver/diagnostics" ilsp "github.com/hashicorp/terraform-ls/internal/lsp" lsp "github.com/hashicorp/terraform-ls/internal/protocol" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" ) func TextDocumentDidClose(ctx context.Context, params lsp.DidCloseTextDocumentParams) error { @@ -15,5 +18,24 @@ func TextDocumentDidClose(ctx context.Context, params lsp.DidCloseTextDocumentPa } fh := ilsp.FileHandlerFromDocumentURI(params.TextDocument.URI) - return fs.CloseAndRemoveDocument(fh) + err = fs.CloseAndRemoveDocument(fh) + if err != nil { + return err + } + + if vf, ok := ast.NewVarsFilename(fh.Filename()); ok && !vf.IsAutoloaded() { + notifier, err := lsctx.DiagnosticsNotifier(ctx) + if err != nil { + return err + } + + diags := diagnostics.NewDiagnostics() + diags.EmptyRootDiagnostic() + diags.Append("HCL", map[string]hcl.Diagnostics{ + fh.Filename(): {}, + }) + notifier.PublishHCLDiags(ctx, fh.Dir(), diags) + } + + return nil } diff --git a/internal/langserver/handlers/did_open.go b/internal/langserver/handlers/did_open.go index 7c5597e9e..b51465335 100644 --- a/internal/langserver/handlers/did_open.go +++ b/internal/langserver/handlers/did_open.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/terraform-ls/internal/langserver/diagnostics" ilsp "github.com/hashicorp/terraform-ls/internal/lsp" lsp "github.com/hashicorp/terraform-ls/internal/protocol" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" "github.com/hashicorp/terraform-ls/internal/terraform/module" op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" ) @@ -76,8 +77,11 @@ func (lh *logHandler) TextDocumentDidOpen(ctx context.Context, params lsp.DidOpe diags := diagnostics.NewDiagnostics() diags.EmptyRootDiagnostic() - diags.Append("HCL", mod.ModuleDiagnostics) - diags.Append("HCL", mod.VarsDiagnostics) + diags.Append("HCL", mod.ModuleDiagnostics.AsMap()) + diags.Append("HCL", mod.VarsDiagnostics.AutoloadedOnly().AsMap()) + if vf, ok := ast.NewVarsFilename(f.Filename()); ok && !vf.IsAutoloaded() { + diags.Append("HCL", mod.VarsDiagnostics.ForFile(vf).AsMap()) + } notifier.PublishHCLDiags(ctx, mod.Path, diags) diff --git a/internal/langserver/handlers/service.go b/internal/langserver/handlers/service.go index 1f69d06cc..6f8546597 100644 --- a/internal/langserver/handlers/service.go +++ b/internal/langserver/handlers/service.go @@ -154,6 +154,7 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { if err != nil { return nil, err } + ctx = lsctx.WithDiagnosticsNotifier(ctx, notifier) ctx = lsctx.WithDocumentStorage(ctx, svc.fs) return handle(ctx, req, TextDocumentDidClose) }, @@ -500,7 +501,7 @@ func schemaForDocument(mf module.ModuleFinder, doc filesystem.Document) (*schema func decoderForDocument(ctx context.Context, mod module.Module, languageID string) (*decoder.Decoder, error) { if languageID == ilsp.Tfvars.String() { - return idecoder.DecoderForVariables(mod) + return idecoder.DecoderForVariables(mod.ParsedVarsFiles) } return idecoder.DecoderForModule(ctx, mod) } diff --git a/internal/state/module.go b/internal/state/module.go index 8ac1aa261..284bf9853 100644 --- a/internal/state/module.go +++ b/internal/state/module.go @@ -10,6 +10,7 @@ import ( tfaddr "github.com/hashicorp/terraform-registry-address" tfmod "github.com/hashicorp/terraform-schema/module" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" "github.com/hashicorp/terraform-ls/internal/terraform/datadir" op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" ) @@ -82,8 +83,8 @@ type Module struct { RefOriginsErr error RefOriginsState op.OpState - ParsedModuleFiles map[string]*hcl.File - ParsedVarsFiles map[string]*hcl.File + ParsedModuleFiles ast.ModFiles + ParsedVarsFiles ast.VarsFiles ModuleParsingErr error VarsParsingErr error ModuleParsingState op.OpState @@ -93,8 +94,8 @@ type Module struct { MetaErr error MetaState op.OpState - ModuleDiagnostics map[string]hcl.Diagnostics - VarsDiagnostics map[string]hcl.Diagnostics + ModuleDiagnostics ast.ModDiags + VarsDiagnostics ast.VarsDiags } func (m *Module) Copy() *Module { @@ -135,7 +136,7 @@ func (m *Module) Copy() *Module { } if m.ParsedModuleFiles != nil { - newMod.ParsedModuleFiles = make(map[string]*hcl.File, len(m.ParsedModuleFiles)) + newMod.ParsedModuleFiles = make(ast.ModFiles, len(m.ParsedModuleFiles)) for name, f := range m.ParsedModuleFiles { // hcl.File is practically immutable once it comes out of parser newMod.ParsedModuleFiles[name] = f @@ -143,7 +144,7 @@ func (m *Module) Copy() *Module { } if m.ParsedVarsFiles != nil { - newMod.ParsedVarsFiles = make(map[string]*hcl.File, len(m.ParsedVarsFiles)) + newMod.ParsedVarsFiles = make(ast.VarsFiles, len(m.ParsedVarsFiles)) for name, f := range m.ParsedVarsFiles { // hcl.File is practically immutable once it comes out of parser newMod.ParsedVarsFiles[name] = f @@ -151,7 +152,7 @@ func (m *Module) Copy() *Module { } if m.ModuleDiagnostics != nil { - newMod.ModuleDiagnostics = make(map[string]hcl.Diagnostics, len(m.ModuleDiagnostics)) + newMod.ModuleDiagnostics = make(ast.ModDiags, len(m.ModuleDiagnostics)) for name, diags := range m.ModuleDiagnostics { newMod.ModuleDiagnostics[name] = make(hcl.Diagnostics, len(diags)) for i, diag := range diags { @@ -162,7 +163,7 @@ func (m *Module) Copy() *Module { } if m.VarsDiagnostics != nil { - newMod.VarsDiagnostics = make(map[string]hcl.Diagnostics, len(m.VarsDiagnostics)) + newMod.VarsDiagnostics = make(ast.VarsDiags, len(m.VarsDiagnostics)) for name, diags := range m.VarsDiagnostics { newMod.VarsDiagnostics[name] = make(hcl.Diagnostics, len(diags)) for i, diag := range diags { @@ -503,7 +504,7 @@ func (s *ModuleStore) SetVarsParsingState(path string, state op.OpState) error { return nil } -func (s *ModuleStore) UpdateParsedModuleFiles(path string, pFiles map[string]*hcl.File, pErr error) error { +func (s *ModuleStore) UpdateParsedModuleFiles(path string, pFiles ast.ModFiles, pErr error) error { txn := s.db.Txn(true) txn.Defer(func() { s.SetModuleParsingState(path, op.OpStateLoaded) @@ -528,7 +529,7 @@ func (s *ModuleStore) UpdateParsedModuleFiles(path string, pFiles map[string]*hc return nil } -func (s *ModuleStore) UpdateParsedVarsFiles(path string, pFiles map[string]*hcl.File, pErr error) error { +func (s *ModuleStore) UpdateParsedVarsFiles(path string, vFiles ast.VarsFiles, vErr error) error { txn := s.db.Txn(true) txn.Defer(func() { s.SetVarsParsingState(path, op.OpStateLoaded) @@ -540,9 +541,9 @@ func (s *ModuleStore) UpdateParsedVarsFiles(path string, pFiles map[string]*hcl. return err } - mod.ParsedVarsFiles = pFiles + mod.ParsedVarsFiles = vFiles - mod.VarsParsingErr = pErr + mod.VarsParsingErr = vErr err = txn.Insert(s.tableName, mod) if err != nil { @@ -602,7 +603,7 @@ func (s *ModuleStore) UpdateMetadata(path string, meta *tfmod.Meta, mErr error) return nil } -func (s *ModuleStore) UpdateModuleDiagnostics(path string, diags map[string]hcl.Diagnostics) error { +func (s *ModuleStore) UpdateModuleDiagnostics(path string, diags ast.ModDiags) error { txn := s.db.Txn(true) defer txn.Abort() @@ -622,7 +623,7 @@ func (s *ModuleStore) UpdateModuleDiagnostics(path string, diags map[string]hcl. return nil } -func (s *ModuleStore) UpdateVarsDiagnostics(path string, diags map[string]hcl.Diagnostics) error { +func (s *ModuleStore) UpdateVarsDiagnostics(path string, diags ast.VarsDiags) error { txn := s.db.Txn(true) defer txn.Abort() diff --git a/internal/state/module_test.go b/internal/state/module_test.go index c5a0729cd..2b6ee4ee4 100644 --- a/internal/state/module_test.go +++ b/internal/state/module_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" "github.com/hashicorp/terraform-ls/internal/terraform/datadir" "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" tfaddr "github.com/hashicorp/terraform-registry-address" @@ -337,7 +338,7 @@ provider "blah" { t.Fatal(diags) } - err = s.Modules.UpdateParsedModuleFiles(tmpDir, map[string]*hcl.File{ + err = s.Modules.UpdateParsedModuleFiles(tmpDir, ast.ModFiles{ "test.tf": testFile, }, nil) if err != nil { @@ -349,9 +350,9 @@ provider "blah" { t.Fatal(err) } - expectedParsedModuleFiles := map[string]*hcl.File{ + expectedParsedModuleFiles := ast.ModFilesFromMap(map[string]*hcl.File{ "test.tf": testFile, - } + }) if diff := cmp.Diff(expectedParsedModuleFiles, mod.ParsedModuleFiles, cmpOpts); diff != "" { t.Fatalf("unexpected parsed files: %s", diff) } @@ -379,9 +380,9 @@ dev = { t.Fatal(diags) } - err = s.Modules.UpdateParsedVarsFiles(tmpDir, map[string]*hcl.File{ + err = s.Modules.UpdateParsedVarsFiles(tmpDir, ast.VarsFilesFromMap(map[string]*hcl.File{ "test.tfvars": testFile, - }, nil) + }), nil) if err != nil { t.Fatal(err) } @@ -391,9 +392,9 @@ dev = { t.Fatal(err) } - expectedParsedVarsFiles := map[string]*hcl.File{ + expectedParsedVarsFiles := ast.VarsFilesFromMap(map[string]*hcl.File{ "test.tfvars": testFile, - } + }) if diff := cmp.Diff(expectedParsedVarsFiles, mod.ParsedVarsFiles, cmpOpts); diff != "" { t.Fatalf("unexpected parsed files: %s", diff) } @@ -417,16 +418,16 @@ provider "blah" { region = "london" `), "test.tf") - err = s.Modules.UpdateModuleDiagnostics(tmpDir, map[string]hcl.Diagnostics{ + err = s.Modules.UpdateModuleDiagnostics(tmpDir, ast.ModDiagsFromMap(map[string]hcl.Diagnostics{ "test.tf": diags, - }) + })) mod, err := s.Modules.ModuleByPath(tmpDir) if err != nil { t.Fatal(err) } - expectedDiags := map[string]hcl.Diagnostics{ + expectedDiags := ast.ModDiagsFromMap(map[string]hcl.Diagnostics{ "test.tf": { { Severity: hcl.DiagError, @@ -447,7 +448,7 @@ provider "blah" { }, }, }, - } + }) if diff := cmp.Diff(expectedDiags, mod.ModuleDiagnostics, cmpOpts); diff != "" { t.Fatalf("unexpected diagnostics: %s", diff) } @@ -471,16 +472,16 @@ dev = { region = "london" `), "test.tfvars") - err = s.Modules.UpdateVarsDiagnostics(tmpDir, map[string]hcl.Diagnostics{ + err = s.Modules.UpdateVarsDiagnostics(tmpDir, ast.VarsDiagsFromMap(map[string]hcl.Diagnostics{ "test.tfvars": diags, - }) + })) mod, err := s.Modules.ModuleByPath(tmpDir) if err != nil { t.Fatal(err) } - expectedDiags := map[string]hcl.Diagnostics{ + expectedDiags := ast.VarsDiagsFromMap(map[string]hcl.Diagnostics{ "test.tfvars": { { Severity: hcl.DiagError, @@ -501,7 +502,7 @@ dev = { }, }, }, - } + }) if diff := cmp.Diff(expectedDiags, mod.VarsDiagnostics, cmpOpts); diff != "" { t.Fatalf("unexpected diagnostics: %s", diff) } @@ -540,20 +541,22 @@ func BenchmarkModuleByPath(b *testing.B) { pFiles["second.tf"] = f } - err = s.Modules.UpdateParsedModuleFiles(modPath, pFiles, nil) + mFiles := ast.ModFilesFromMap(pFiles) + err = s.Modules.UpdateParsedModuleFiles(modPath, mFiles, nil) if err != nil { b.Fatal(err) } - err = s.Modules.UpdateModuleDiagnostics(modPath, diags) + mDiags := ast.ModDiagsFromMap(diags) + err = s.Modules.UpdateModuleDiagnostics(modPath, mDiags) if err != nil { b.Fatal(err) } expectedMod := &Module{ Path: modPath, - ParsedModuleFiles: pFiles, + ParsedModuleFiles: mFiles, ModuleParsingState: operation.OpStateLoaded, - ModuleDiagnostics: diags, + ModuleDiagnostics: mDiags, } for n := 0; n < b.N; n++ { diff --git a/internal/terraform/ast/ast.go b/internal/terraform/ast/ast.go new file mode 100644 index 000000000..c8b123b58 --- /dev/null +++ b/internal/terraform/ast/ast.go @@ -0,0 +1,13 @@ +package ast + +import ( + "strings" +) + +// isIgnoredFile returns true if the given filename (which must not have a +// directory path ahead of it) should be ignored as e.g. an editor swap file. +func isIgnoredFile(name string) bool { + return strings.HasPrefix(name, ".") || // Unix-like hidden files + strings.HasSuffix(name, "~") || // vim + strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") // emacs +} diff --git a/internal/terraform/ast/ast_test.go b/internal/terraform/ast/ast_test.go new file mode 100644 index 000000000..9faed3f4a --- /dev/null +++ b/internal/terraform/ast/ast_test.go @@ -0,0 +1,39 @@ +package ast + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty-debug/ctydebug" +) + +func TestVarsDiags_autoloadedOnly(t *testing.T) { + vd := VarsDiagsFromMap(map[string]hcl.Diagnostics{ + "alpha.tfvars": {}, + "terraform.tfvars": { + { + Severity: hcl.DiagError, + Summary: "Test error", + Detail: "Test description", + }, + }, + "beta.tfvars": {}, + "gama.auto.tfvars": {}, + }) + diags := vd.AutoloadedOnly().AsMap() + expectedDiags := map[string]hcl.Diagnostics{ + "terraform.tfvars": { + { + Severity: hcl.DiagError, + Summary: "Test error", + Detail: "Test description", + }, + }, + "gama.auto.tfvars": {}, + } + + if diff := cmp.Diff(expectedDiags, diags, ctydebug.CmpOptions); diff != "" { + t.Fatalf("unexpected diagnostics: %s", diff) + } +} diff --git a/internal/terraform/ast/module.go b/internal/terraform/ast/module.go new file mode 100644 index 000000000..5f49b1284 --- /dev/null +++ b/internal/terraform/ast/module.go @@ -0,0 +1,53 @@ +package ast + +import ( + "strings" + + "github.com/hashicorp/hcl/v2" +) + +type ModFilename string + +func (mf ModFilename) String() string { + return string(mf) +} + +func IsModuleFilename(name string) bool { + return strings.HasSuffix(name, ".tf") && !isIgnoredFile(name) +} + +type ModFiles map[ModFilename]*hcl.File + +func ModFilesFromMap(m map[string]*hcl.File) ModFiles { + mf := make(ModFiles, len(m)) + for name, file := range m { + mf[ModFilename(name)] = file + } + return mf +} + +func (mf ModFiles) AsMap() map[string]*hcl.File { + m := make(map[string]*hcl.File, len(mf)) + for name, file := range mf { + m[string(name)] = file + } + return m +} + +type ModDiags map[ModFilename]hcl.Diagnostics + +func ModDiagsFromMap(m map[string]hcl.Diagnostics) ModDiags { + mf := make(ModDiags, len(m)) + for name, file := range m { + mf[ModFilename(name)] = file + } + return mf +} + +func (md ModDiags) AsMap() map[string]hcl.Diagnostics { + m := make(map[string]hcl.Diagnostics, len(md)) + for name, diags := range md { + m[string(name)] = diags + } + return m +} diff --git a/internal/terraform/ast/variables.go b/internal/terraform/ast/variables.go new file mode 100644 index 000000000..5245ac68f --- /dev/null +++ b/internal/terraform/ast/variables.go @@ -0,0 +1,77 @@ +package ast + +import ( + "strings" + + "github.com/hashicorp/hcl/v2" +) + +type VarsFilename string + +func NewVarsFilename(name string) (VarsFilename, bool) { + if IsVarsFilename(name) { + return VarsFilename(name), true + } + return "", false +} + +func IsVarsFilename(name string) bool { + return strings.HasSuffix(name, ".tfvars") && !isIgnoredFile(name) +} + +func (vf VarsFilename) String() string { + return string(vf) +} + +func (vf VarsFilename) IsAutoloaded() bool { + name := string(vf) + return strings.HasSuffix(name, ".auto.tfvars") || name == "terraform.tfvars" +} + +type VarsFiles map[VarsFilename]*hcl.File + +func VarsFilesFromMap(m map[string]*hcl.File) VarsFiles { + mf := make(VarsFiles, len(m)) + for name, file := range m { + mf[VarsFilename(name)] = file + } + return mf +} + +type VarsDiags map[VarsFilename]hcl.Diagnostics + +func VarsDiagsFromMap(m map[string]hcl.Diagnostics) VarsDiags { + mf := make(VarsDiags, len(m)) + for name, file := range m { + mf[VarsFilename(name)] = file + } + return mf +} + +func (vd VarsDiags) AutoloadedOnly() VarsDiags { + diags := make(VarsDiags) + for name, f := range vd { + if name.IsAutoloaded() { + diags[name] = f + } + } + return diags +} + +func (vd VarsDiags) ForFile(name VarsFilename) VarsDiags { + diags := make(VarsDiags) + for fName, f := range vd { + if fName == name { + diags[fName] = f + } + } + return diags +} + +func (vd VarsDiags) AsMap() map[string]hcl.Diagnostics { + m := make(map[string]hcl.Diagnostics, len(vd)) + for name, diags := range vd { + m[string(name)] = diags + } + return m +} diff --git a/internal/terraform/module/module_ops.go b/internal/terraform/module/module_ops.go index 7b98b2e3a..90a3cc25f 100644 --- a/internal/terraform/module/module_ops.go +++ b/internal/terraform/module/module_ops.go @@ -3,18 +3,15 @@ package module import ( "context" "fmt" - "path/filepath" - "strings" "github.com/hashicorp/go-version" "github.com/hashicorp/hcl-lang/decoder" "github.com/hashicorp/hcl-lang/lang" - "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/terraform-ls/internal/filesystem" "github.com/hashicorp/terraform-ls/internal/state" "github.com/hashicorp/terraform-ls/internal/terraform/datadir" op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" + "github.com/hashicorp/terraform-ls/internal/terraform/parser" tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform-schema/earlydecoder" "github.com/hashicorp/terraform-schema/module" @@ -147,47 +144,7 @@ func ParseModuleConfiguration(fs filesystem.Filesystem, modStore *state.ModuleSt return err } - files := make(map[string]*hcl.File, 0) - diags := make(map[string]hcl.Diagnostics, 0) - - infos, err := fs.ReadDir(modPath) - if err != nil { - sErr := modStore.UpdateParsedModuleFiles(modPath, files, err) - if sErr != nil { - return sErr - } - return err - } - - for _, info := range infos { - if info.IsDir() { - // We only care about files - continue - } - - name := info.Name() - if !strings.HasSuffix(name, ".tf") || IsIgnoredFile(name) { - continue - } - // TODO: overrides - - fullPath := filepath.Join(modPath, name) - - src, err := fs.ReadFile(fullPath) - if err != nil { - sErr := modStore.UpdateParsedModuleFiles(modPath, files, err) - if sErr != nil { - return sErr - } - return err - } - - f, pDiags := hclsyntax.ParseConfig(src, name, hcl.InitialPos) - diags[name] = pDiags - if f != nil { - files[name] = f - } - } + files, diags, err := parser.ParseModuleFiles(fs, modPath) sErr := modStore.UpdateParsedModuleFiles(modPath, files, err) if sErr != nil { @@ -208,47 +165,7 @@ func ParseVariables(fs filesystem.Filesystem, modStore *state.ModuleStore, modPa return err } - files := make(map[string]*hcl.File, 0) - diags := make(map[string]hcl.Diagnostics, 0) - - infos, err := fs.ReadDir(modPath) - if err != nil { - sErr := modStore.UpdateParsedVarsFiles(modPath, files, err) - if sErr != nil { - return sErr - } - return err - } - - for _, info := range infos { - if info.IsDir() { - // We only care about files - continue - } - - name := info.Name() - if !(strings.HasSuffix(name, ".auto.tfvars") || - name == "terraform.tfvars") || IsIgnoredFile(name) { - continue - } - - fullPath := filepath.Join(modPath, name) - - src, err := fs.ReadFile(fullPath) - if err != nil { - sErr := modStore.UpdateParsedVarsFiles(modPath, files, err) - if sErr != nil { - return sErr - } - return err - } - - f, pDiags := hclsyntax.ParseConfig(src, name, hcl.InitialPos) - diags[name] = pDiags - if f != nil { - files[name] = f - } - } + files, diags, err := parser.ParseVariableFiles(fs, modPath) sErr := modStore.UpdateParsedVarsFiles(modPath, files, err) if sErr != nil { @@ -263,14 +180,6 @@ func ParseVariables(fs filesystem.Filesystem, modStore *state.ModuleStore, modPa return err } -// IsIgnoredFile returns true if the given filename (which must not have a -// directory path ahead of it) should be ignored as e.g. an editor swap file. -func IsIgnoredFile(name string) bool { - return strings.HasPrefix(name, ".") || // Unix-like hidden files - strings.HasSuffix(name, "~") || // vim - strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") // emacs -} - func ParseModuleManifest(fs filesystem.Filesystem, modStore *state.ModuleStore, modPath string) error { err := modStore.SetModManifestState(modPath, op.OpStateLoading) if err != nil { @@ -317,7 +226,7 @@ func LoadModuleMetadata(modStore *state.ModuleStore, modPath string) error { } var mErr error - meta, diags := earlydecoder.LoadModule(mod.Path, mod.ParsedModuleFiles) + meta, diags := earlydecoder.LoadModule(mod.Path, mod.ParsedModuleFiles.AsMap()) if len(diags) > 0 { mErr = diags } @@ -355,7 +264,7 @@ func DecodeReferenceTargets(modStore *state.ModuleStore, schemaReader state.Sche } d := decoder.NewDecoder() - for name, f := range mod.ParsedModuleFiles { + for name, f := range mod.ParsedModuleFiles.AsMap() { err := d.LoadFile(name, f) if err != nil { return fmt.Errorf("failed to load a file: %w", err) @@ -397,7 +306,7 @@ func DecodeReferenceOrigins(modStore *state.ModuleStore, schemaReader state.Sche } d := decoder.NewDecoder() - for name, f := range mod.ParsedModuleFiles { + for name, f := range mod.ParsedModuleFiles.AsMap() { err := d.LoadFile(name, f) if err != nil { return fmt.Errorf("failed to load a file: %w", err) diff --git a/internal/terraform/parser/module.go b/internal/terraform/parser/module.go new file mode 100644 index 000000000..4b388ed27 --- /dev/null +++ b/internal/terraform/parser/module.go @@ -0,0 +1,49 @@ +package parser + +import ( + "path/filepath" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" +) + +func ParseModuleFiles(fs FS, modPath string) (ast.ModFiles, ast.ModDiags, error) { + files := make(ast.ModFiles, 0) + diags := make(ast.ModDiags, 0) + + infos, err := fs.ReadDir(modPath) + if err != nil { + return nil, nil, err + } + + for _, info := range infos { + if info.IsDir() { + // We only care about files + continue + } + + name := info.Name() + if !ast.IsModuleFilename(name) { + continue + } + + // TODO: overrides + + fullPath := filepath.Join(modPath, name) + + src, err := fs.ReadFile(fullPath) + if err != nil { + return nil, nil, err + } + + f, pDiags := hclsyntax.ParseConfig(src, name, hcl.InitialPos) + filename := ast.ModFilename(name) + diags[filename] = pDiags + if f != nil { + files[filename] = f + } + } + + return files, diags, nil +} diff --git a/internal/terraform/parser/module_test.go b/internal/terraform/parser/module_test.go new file mode 100644 index 000000000..f1a6bfd00 --- /dev/null +++ b/internal/terraform/parser/module_test.go @@ -0,0 +1,114 @@ +package parser + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" + "github.com/spf13/afero" +) + +func TestParseModuleFiles(t *testing.T) { + testCases := []struct { + dirName string + expectedFileNames map[string]struct{} + expectedDiags map[string]hcl.Diagnostics + }{ + { + "empty-dir", + map[string]struct{}{}, + map[string]hcl.Diagnostics{}, + }, + { + "valid-mod-files", + map[string]struct{}{ + "empty.tf": {}, + "resources.tf": {}, + }, + map[string]hcl.Diagnostics{ + "empty.tf": nil, + "resources.tf": nil, + }, + }, + { + "valid-mod-files-with-extra-items", + map[string]struct{}{ + "main.tf": {}, + }, + map[string]hcl.Diagnostics{ + "main.tf": nil, + }, + }, + { + "invalid-mod-files", + map[string]struct{}{ + "incomplete-block.tf": {}, + "missing-brace.tf": {}, + }, + map[string]hcl.Diagnostics{ + "incomplete-block.tf": { + { + Severity: hcl.DiagError, + Summary: "Invalid block definition", + Detail: `A block definition must have block content delimited by "{" and "}", starting on the same line as the block header.`, + Subject: &hcl.Range{ + Filename: "incomplete-block.tf", + Start: hcl.Pos{Line: 1, Column: 30, Byte: 29}, + End: hcl.Pos{Line: 2, Column: 1, Byte: 30}, + }, + Context: &hcl.Range{ + Filename: "incomplete-block.tf", + Start: hcl.InitialPos, + End: hcl.Pos{Line: 2, Column: 1, Byte: 30}, + }, + }, + }, + "missing-brace.tf": { + { + Severity: hcl.DiagError, + Summary: "Argument or block definition required", + Detail: "An argument or block definition is required here.", + Subject: &hcl.Range{ + Filename: "missing-brace.tf", + Start: hcl.Pos{Line: 10, Column: 1, Byte: 207}, + End: hcl.Pos{Line: 10, Column: 1, Byte: 207}, + }, + }, + }, + }, + }, + } + + fs := afero.NewIOFS(afero.NewOsFs()) + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.dirName), func(t *testing.T) { + modPath := filepath.Join("testdata", tc.dirName) + + files, diags, err := ParseModuleFiles(fs, modPath) + if err != nil { + t.Fatal(err) + } + + fileNames := mapKeys(files) + if diff := cmp.Diff(tc.expectedFileNames, fileNames); diff != "" { + t.Fatalf("unexpected file names: %s", diff) + } + + if diff := cmp.Diff(tc.expectedDiags, diags.AsMap()); diff != "" { + t.Fatalf("unexpected diagnostics: %s", diff) + } + }) + } +} + +func mapKeys(mf ast.ModFiles) map[string]struct{} { + m := make(map[string]struct{}, len(mf)) + for name := range mf { + m[name.String()] = struct{}{} + } + return m +} diff --git a/internal/terraform/parser/parser.go b/internal/terraform/parser/parser.go new file mode 100644 index 000000000..8be1ca4d1 --- /dev/null +++ b/internal/terraform/parser/parser.go @@ -0,0 +1,11 @@ +package parser + +import ( + "io/fs" +) + +type FS interface { + fs.FS + ReadDir(name string) ([]fs.DirEntry, error) + ReadFile(name string) ([]byte, error) +} diff --git a/internal/terraform/parser/testdata/empty-dir/.gitkeep b/internal/terraform/parser/testdata/empty-dir/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/internal/terraform/parser/testdata/invalid-mod-files/incomplete-block.tf b/internal/terraform/parser/testdata/invalid-mod-files/incomplete-block.tf new file mode 100644 index 000000000..254229157 --- /dev/null +++ b/internal/terraform/parser/testdata/invalid-mod-files/incomplete-block.tf @@ -0,0 +1 @@ +resource "aws_security_group" diff --git a/internal/terraform/parser/testdata/invalid-mod-files/missing-brace.tf b/internal/terraform/parser/testdata/invalid-mod-files/missing-brace.tf new file mode 100644 index 000000000..2ae050234 --- /dev/null +++ b/internal/terraform/parser/testdata/invalid-mod-files/missing-brace.tf @@ -0,0 +1,9 @@ +resource "aws_security_group" "web-sg" { + name = "${random_pet.name.id}-sg" + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } +# missing brace diff --git a/internal/terraform/parser/testdata/valid-mod-files-with-extra-items/.hidden.tf b/internal/terraform/parser/testdata/valid-mod-files-with-extra-items/.hidden.tf new file mode 100644 index 000000000..8b535912d --- /dev/null +++ b/internal/terraform/parser/testdata/valid-mod-files-with-extra-items/.hidden.tf @@ -0,0 +1,16 @@ +resource "aws_security_group" "web-sg" { + name = "${random_pet.name.id}-sg" + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} diff --git a/internal/terraform/parser/testdata/valid-mod-files-with-extra-items/main.tf b/internal/terraform/parser/testdata/valid-mod-files-with-extra-items/main.tf new file mode 100644 index 000000000..8b535912d --- /dev/null +++ b/internal/terraform/parser/testdata/valid-mod-files-with-extra-items/main.tf @@ -0,0 +1,16 @@ +resource "aws_security_group" "web-sg" { + name = "${random_pet.name.id}-sg" + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} diff --git a/internal/terraform/parser/testdata/valid-mod-files-with-extra-items/main.tf~ b/internal/terraform/parser/testdata/valid-mod-files-with-extra-items/main.tf~ new file mode 100644 index 000000000..8b535912d --- /dev/null +++ b/internal/terraform/parser/testdata/valid-mod-files-with-extra-items/main.tf~ @@ -0,0 +1,16 @@ +resource "aws_security_group" "web-sg" { + name = "${random_pet.name.id}-sg" + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} diff --git a/internal/terraform/parser/testdata/valid-mod-files/empty.tf b/internal/terraform/parser/testdata/valid-mod-files/empty.tf new file mode 100644 index 000000000..e69de29bb diff --git a/internal/terraform/parser/testdata/valid-mod-files/resources.tf b/internal/terraform/parser/testdata/valid-mod-files/resources.tf new file mode 100644 index 000000000..e4a1260b0 --- /dev/null +++ b/internal/terraform/parser/testdata/valid-mod-files/resources.tf @@ -0,0 +1,9 @@ +resource "aws_instance" "web" { + ami = "ami-a0cfeed8" + instance_type = "t2.micro" + user_data = file("init-script.sh") + + tags = { + Name = random_pet.name.id + } +} diff --git a/internal/terraform/parser/variables.go b/internal/terraform/parser/variables.go new file mode 100644 index 000000000..943da9eef --- /dev/null +++ b/internal/terraform/parser/variables.go @@ -0,0 +1,47 @@ +package parser + +import ( + "path/filepath" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform-ls/internal/terraform/ast" +) + +func ParseVariableFiles(fs FS, modPath string) (ast.VarsFiles, ast.VarsDiags, error) { + files := make(ast.VarsFiles, 0) + diags := make(ast.VarsDiags, 0) + + dirEntries, err := fs.ReadDir(modPath) + if err != nil { + return nil, nil, err + } + + for _, entry := range dirEntries { + if entry.IsDir() { + // We only care about files + continue + } + + name := entry.Name() + if !ast.IsVarsFilename(name) { + continue + } + + fullPath := filepath.Join(modPath, name) + + src, err := fs.ReadFile(fullPath) + if err != nil { + return nil, nil, err + } + + f, pDiags := hclsyntax.ParseConfig(src, name, hcl.InitialPos) + filename := ast.VarsFilename(name) + diags[filename] = pDiags + if f != nil { + files[filename] = f + } + } + + return files, diags, nil +}