diff --git a/.changes/unreleased/ENHANCEMENTS-20240912-120041.yaml b/.changes/unreleased/ENHANCEMENTS-20240912-120041.yaml new file mode 100644 index 000000000..3018baa9f --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20240912-120041.yaml @@ -0,0 +1,6 @@ +kind: ENHANCEMENTS +body: Static schema support for Terraform Test and Mock files +time: 2024-09-12T12:00:41.4902+02:00 +custom: + Issue: "1782" + Repository: terraform-ls diff --git a/go.mod b/go.mod index 29daf6a83..4dbc082b1 100644 --- a/go.mod +++ b/go.mod @@ -13,12 +13,12 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/hc-install v0.9.0 - github.com/hashicorp/hcl-lang v0.0.0-20240605150436-0e930f47b31b - github.com/hashicorp/hcl/v2 v2.21.0 + github.com/hashicorp/hcl-lang v0.0.0-20240830144831-468c47ee72a9 + github.com/hashicorp/hcl/v2 v2.22.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/hashicorp/terraform-json v0.22.1 github.com/hashicorp/terraform-registry-address v0.2.3 - github.com/hashicorp/terraform-schema v0.0.0-20240715103008-d7b11d826dc8 + github.com/hashicorp/terraform-schema v0.0.0-20240920131432-2bacbf6cd0d0 github.com/mcuadros/go-defaults v1.2.0 github.com/mh-cbon/go-fmt-fail v0.0.0-20160815164508-67765b3fbcb5 github.com/mitchellh/cli v1.1.5 diff --git a/go.sum b/go.sum index f4ea36f96..7bb7179af 100644 --- a/go.sum +++ b/go.sum @@ -223,18 +223,18 @@ github.com/hashicorp/hc-install v0.9.0 h1:2dIk8LcvANwtv3QZLckxcjyF5w8KVtiMxu6G6e github.com/hashicorp/hc-install v0.9.0/go.mod h1:+6vOP+mf3tuGgMApVYtmsnDoKWMDcFXeTxCACYZ8SFg= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/hcl-lang v0.0.0-20240605150436-0e930f47b31b h1:DRIJYSwLKoAfdndwIH1NbjLC3wm/RuyT7eEtm8aKw1U= -github.com/hashicorp/hcl-lang v0.0.0-20240605150436-0e930f47b31b/go.mod h1:/g6sedjVJX99knsqTKU9wSWBVtsyDKWJkseNV9Zx1aU= -github.com/hashicorp/hcl/v2 v2.21.0 h1:lve4q/o/2rqwYOgUg3y3V2YPyD1/zkCLGjIV74Jit14= -github.com/hashicorp/hcl/v2 v2.21.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/hcl-lang v0.0.0-20240830144831-468c47ee72a9 h1:+vOoWN3mmsPs/qdx0DhdgA0JO20uqIoibrti+VT9gb4= +github.com/hashicorp/hcl-lang v0.0.0-20240830144831-468c47ee72a9/go.mod h1:q2ps+/W6LMDEr2Y6Z92s0EX7jhMrftJEuwD6LXOv20A= +github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= +github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVWkd/RG0D2XQ= github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7orfb5Ltvec= github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM= -github.com/hashicorp/terraform-schema v0.0.0-20240715103008-d7b11d826dc8 h1:5y1/KiaPi/Ib/ZAkaKS30EjARaKiK2isnTrpI5pe9lI= -github.com/hashicorp/terraform-schema v0.0.0-20240715103008-d7b11d826dc8/go.mod h1:ar787Bv/qD6tlnjtzH8fQ1Yi6c/B5LsnpFlO8c92Atg= +github.com/hashicorp/terraform-schema v0.0.0-20240920131432-2bacbf6cd0d0 h1:tozkcF+T0q75+fJ1ae5W0bh+o6fn3ySQWJjoIh78QGg= +github.com/hashicorp/terraform-schema v0.0.0-20240920131432-2bacbf6cd0d0/go.mod h1:RnKF3wkBHxu54QMGQV3wzHiqunvNwuf9/JE8jUtqQxk= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hexops/autogold v1.3.1 h1:YgxF9OHWbEIUjhDbpnLhgVsjUDsiHDTyDfy2lrfdlzo= diff --git a/internal/features/modules/modules_feature.go b/internal/features/modules/modules_feature.go index 72e7ec5c2..0390a5c63 100644 --- a/internal/features/modules/modules_feature.go +++ b/internal/features/modules/modules_feature.go @@ -282,3 +282,7 @@ func (f *ModulesFeature) MetadataReady(dir document.DirHandle) (<-chan struct{}, return f.Store.MetadataReady(dir) } + +func (s *ModulesFeature) LocalModuleMeta(modPath string) (*tfmod.Meta, error) { + return s.Store.LocalModuleMeta(modPath) +} diff --git a/internal/features/tests/ast/tests.go b/internal/features/tests/ast/tests.go new file mode 100644 index 000000000..20f2109ad --- /dev/null +++ b/internal/features/tests/ast/tests.go @@ -0,0 +1,143 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ast + +import ( + "strings" + + "github.com/hashicorp/hcl/v2" + globalAst "github.com/hashicorp/terraform-ls/internal/terraform/ast" +) + +type Filename interface { + String() string + IsJSON() bool + IsIgnored() bool +} + +// TestFilename is a custom type for test configuration files +type TestFilename string + +func (mf TestFilename) String() string { + return string(mf) +} + +func (mf TestFilename) IsJSON() bool { + return strings.HasSuffix(string(mf), ".json") +} + +func (mf TestFilename) IsIgnored() bool { + return globalAst.IsIgnoredFile(string(mf)) +} + +func IsTestFilename(name string) bool { + return strings.HasSuffix(name, ".tftest.hcl") || + strings.HasSuffix(name, ".tftest.json") +} + +// MockFilename is a custom type for mock configuration files +type MockFilename string + +func (df MockFilename) String() string { + return string(df) +} + +func (df MockFilename) IsJSON() bool { + return strings.HasSuffix(string(df), ".json") +} + +func (df MockFilename) IsIgnored() bool { + return globalAst.IsIgnoredFile(string(df)) +} + +func IsMockFilename(name string) bool { + return strings.HasSuffix(name, ".tfmock.hcl") || + strings.HasSuffix(name, ".tfmock.json") +} + +// FilenameFromName returns either a TestFilename or MockFilename based +// on the name +func FilenameFromName(name string) Filename { + if IsTestFilename(name) { + return TestFilename(name) + } + if IsMockFilename(name) { + return MockFilename(name) + } + + return nil +} + +type Files map[Filename]*hcl.File + +func (sf Files) Copy() Files { + m := make(Files, len(sf)) + for name, file := range sf { + m[name] = file + } + return m +} + +func (mf Files) AsMap() map[string]*hcl.File { + m := make(map[string]*hcl.File, len(mf)) + for name, file := range mf { + m[name.String()] = file + } + return m +} + +type Diagnostics map[Filename]hcl.Diagnostics + +func DiagnosticsFromMap(m map[string]hcl.Diagnostics) Diagnostics { + mf := make(Diagnostics, len(m)) + for name, file := range m { + mf[FilenameFromName(name)] = file + } + return mf +} + +func (sd Diagnostics) Copy() Diagnostics { + m := make(Diagnostics, len(sd)) + for name, diags := range sd { + m[name] = diags + } + return m +} + +// AutoloadedOnly returns only diagnostics that are not from ignored files +func (sd Diagnostics) AutoloadedOnly() Diagnostics { + diags := make(Diagnostics) + for name, f := range sd { + if !name.IsIgnored() { + diags[name] = f + } + } + return diags +} + +func (sd Diagnostics) AsMap() map[string]hcl.Diagnostics { + m := make(map[string]hcl.Diagnostics, len(sd)) + for name, diags := range sd { + m[name.String()] = diags + } + return m +} + +func (sd Diagnostics) Count() int { + count := 0 + for _, diags := range sd { + count += len(diags) + } + return count +} + +type SourceDiagnostics map[globalAst.DiagnosticSource]Diagnostics + +func (svd SourceDiagnostics) Count() int { + count := 0 + for _, diags := range svd { + count += diags.Count() + } + return count +} diff --git a/internal/features/tests/decoder/path_reader.go b/internal/features/tests/decoder/path_reader.go new file mode 100644 index 000000000..ccbc3d03a --- /dev/null +++ b/internal/features/tests/decoder/path_reader.go @@ -0,0 +1,221 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package decoder + +import ( + "context" + "fmt" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform-ls/internal/features/tests/ast" + "github.com/hashicorp/terraform-ls/internal/features/tests/state" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + tfaddr "github.com/hashicorp/terraform-registry-address" + tfmod "github.com/hashicorp/terraform-schema/module" + tfschema "github.com/hashicorp/terraform-schema/schema" + testschema "github.com/hashicorp/terraform-schema/schema/tests" + tftest "github.com/hashicorp/terraform-schema/test" +) + +type PathReader struct { + StateReader StateReader + ModuleReader ModuleReader + RootReader RootReader +} + +type StateReader interface { + List() ([]*state.TestRecord, error) + TestRecordByPath(modPath string) (*state.TestRecord, error) + ProviderSchema(modPath string, addr tfaddr.Provider, vc version.Constraints) (*tfschema.ProviderSchema, error) +} + +type ModuleReader interface { + LocalModuleMeta(modPath string) (*tfmod.Meta, error) +} + +type RootReader interface { + TerraformVersion(modPath string) *version.Version +} + +type CombinedReader struct { + ModuleReader + StateReader + RootReader +} + +var _ decoder.PathReader = &PathReader{} + +// PathContext returns a PathContext for the given path based on the language ID +func (pr *PathReader) PathContext(path lang.Path) (*decoder.PathContext, error) { + record, err := pr.StateReader.TestRecordByPath(path.Path) + if err != nil { + return nil, err + } + + switch path.LanguageID { + case ilsp.Test.String(): + return testPathContext(record, CombinedReader{ + StateReader: pr.StateReader, + ModuleReader: pr.ModuleReader, + RootReader: pr.RootReader, + }) + case ilsp.Mock.String(): + return mockPathContext(record, CombinedReader{ + StateReader: pr.StateReader, + ModuleReader: pr.ModuleReader, + RootReader: pr.RootReader, + }) + } + + return nil, fmt.Errorf("unknown language ID: %q", path.LanguageID) +} + +func testPathContext(record *state.TestRecord, stateReader CombinedReader) (*decoder.PathContext, error) { + // TODO! this should only work for terraform 1.6 and above + version := stateReader.TerraformVersion(record.Path()) + if version == nil { + version = tfschema.LatestAvailableVersion + } + + schema, err := testschema.CoreTestSchemaForVersion(version) + if err != nil { + return nil, err + } + + sm := testschema.NewTestSchemaMerger(schema) + sm.SetStateReader(stateReader) + + meta := &tftest.Meta{ + Path: record.Path(), + Filenames: record.Meta.Filenames, + } + + mergedSchema, err := sm.SchemaForTest(meta) + if err != nil { + return nil, err + } + + pathCtx := &decoder.PathContext{ + Schema: mergedSchema, + ReferenceOrigins: make(reference.Origins, 0), + ReferenceTargets: make(reference.Targets, 0), + Files: make(map[string]*hcl.File, 0), + Validators: validators, + // TODO? functions TFECO-7480 + } + + for _, origin := range record.RefOrigins { + if ast.IsTestFilename(origin.OriginRange().Filename) { + pathCtx.ReferenceOrigins = append(pathCtx.ReferenceOrigins, origin) + } + } + for _, target := range record.RefTargets { + if target.RangePtr != nil && ast.IsTestFilename(target.RangePtr.Filename) { + pathCtx.ReferenceTargets = append(pathCtx.ReferenceTargets, target) + } + } + + for name, f := range record.ParsedFiles { + if _, ok := name.(ast.TestFilename); ok { + pathCtx.Files[name.String()] = f + } + } + + return pathCtx, nil +} + +func mockPathContext(record *state.TestRecord, stateReader CombinedReader) (*decoder.PathContext, error) { + // TODO! this should only work for terraform 1.7 and above + version := stateReader.TerraformVersion(record.Path()) + if version == nil { + version = tfschema.LatestAvailableVersion + } + + schema, err := testschema.CoreMockSchemaForVersion(version) + if err != nil { + return nil, err + } + + sm := testschema.NewMockSchemaMerger(schema) + sm.SetStateReader(stateReader) + + meta := &tftest.Meta{ + Path: record.Path(), + Filenames: record.Meta.Filenames, + } + + mergedSchema, err := sm.SchemaForMock(meta) + if err != nil { + return nil, err + } + + pathCtx := &decoder.PathContext{ + Schema: mergedSchema, + ReferenceOrigins: make(reference.Origins, 0), + ReferenceTargets: make(reference.Targets, 0), + Files: make(map[string]*hcl.File, 0), + Validators: validators, + // TODO? functions TFECO-7480 + } + + for _, origin := range record.RefOrigins { + if ast.IsMockFilename(origin.OriginRange().Filename) { + pathCtx.ReferenceOrigins = append(pathCtx.ReferenceOrigins, origin) + } + } + for _, target := range record.RefTargets { + if target.RangePtr != nil && ast.IsMockFilename(target.RangePtr.Filename) { + pathCtx.ReferenceTargets = append(pathCtx.ReferenceTargets, target) + } + } + + for name, f := range record.ParsedFiles { + if _, ok := name.(ast.MockFilename); ok { + pathCtx.Files[name.String()] = f + } + } + + return pathCtx, nil +} + +func (pr *PathReader) Paths(ctx context.Context) []lang.Path { + paths := make([]lang.Path, 0) + + testRecords, err := pr.StateReader.List() + if err != nil { + return paths + } + + for _, record := range testRecords { + foundTest := false + foundMock := false + for name := range record.ParsedFiles { + if _, ok := name.(ast.TestFilename); ok { + foundTest = true + } + if _, ok := name.(ast.MockFilename); ok { + foundMock = true + } + } + + if foundTest { + paths = append(paths, lang.Path{ + Path: record.Path(), + LanguageID: ilsp.Test.String(), + }) + } + if foundMock { + paths = append(paths, lang.Path{ + Path: record.Path(), + LanguageID: ilsp.Mock.String(), + }) + } + } + + return paths +} diff --git a/internal/features/tests/decoder/validators.go b/internal/features/tests/decoder/validators.go new file mode 100644 index 000000000..e65a6d9e0 --- /dev/null +++ b/internal/features/tests/decoder/validators.go @@ -0,0 +1,19 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package decoder + +import ( + "github.com/hashicorp/hcl-lang/validator" +) + +var validators = []validator.Validator{ + validator.BlockLabelsLength{}, + validator.DeprecatedAttribute{}, + validator.DeprecatedBlock{}, + validator.MaxBlocks{}, + validator.MinBlocks{}, + validator.MissingRequiredAttribute{}, + validator.UnexpectedAttribute{}, + validator.UnexpectedBlock{}, +} diff --git a/internal/features/tests/events.go b/internal/features/tests/events.go new file mode 100644 index 000000000..a6b506151 --- /dev/null +++ b/internal/features/tests/events.go @@ -0,0 +1,285 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tests + +import ( + "context" + "os" + "path/filepath" + + lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/document" + "github.com/hashicorp/terraform-ls/internal/eventbus" + "github.com/hashicorp/terraform-ls/internal/features/tests/ast" + "github.com/hashicorp/terraform-ls/internal/features/tests/jobs" + "github.com/hashicorp/terraform-ls/internal/features/tests/state" + "github.com/hashicorp/terraform-ls/internal/job" + "github.com/hashicorp/terraform-ls/internal/lsp" + "github.com/hashicorp/terraform-ls/internal/protocol" + globalAst "github.com/hashicorp/terraform-ls/internal/terraform/ast" + op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" +) + +func (f *TestsFeature) discover(path string, files []string) error { + for _, file := range files { + if globalAst.IsIgnoredFile(file) { + continue + } + + if ast.IsTestFilename(file) || ast.IsMockFilename(file) { + f.logger.Printf("discovered test file in %s", path) + + err := f.store.AddIfNotExists(path) + if err != nil { + return err + } + + break + } + } + + return nil +} + +func (f *TestsFeature) didOpen(ctx context.Context, dir document.DirHandle, languageID string) (job.IDs, error) { + ids := make(job.IDs, 0) + path := dir.Path() + f.logger.Printf("did open %q %q", path, languageID) + + // We need to decide if the path is relevant to us + if languageID != lsp.Test.String() && languageID != lsp.Mock.String() { + return ids, nil + } + + // Add to state as path is relevant + err := f.store.AddIfNotExists(path) + if err != nil { + return ids, err + } + + decodeIds, err := f.decodeTest(ctx, dir, false, true) + if err != nil { + return ids, err + } + ids = append(ids, decodeIds...) + + return ids, err +} + +func (f *TestsFeature) didChange(ctx context.Context, dir document.DirHandle) (job.IDs, error) { + hasTestRecord := f.store.Exists(dir.Path()) + if !hasTestRecord { + return job.IDs{}, nil + } + + return f.decodeTest(ctx, dir, true, true) +} + +func (f *TestsFeature) didChangeWatched(ctx context.Context, rawPath string, changeType protocol.FileChangeType, isDir bool) (job.IDs, error) { + ids := make(job.IDs, 0) + + switch changeType { + case protocol.Deleted: + // We don't know whether file or dir is being deleted + // 1st we just blindly try to look it up as a directory + hasTestRecord := f.store.Exists(rawPath) + if hasTestRecord { + f.removeIndexedTest(rawPath) + return ids, nil + } + + // 2nd we try again assuming it is a file + parentDir := filepath.Dir(rawPath) + hasTestRecord = f.store.Exists(parentDir) + if !hasTestRecord { + // Nothing relevant found in the feature state + return ids, nil + } + + // and check the parent directory still exists + fi, err := os.Stat(parentDir) + if err != nil { + if os.IsNotExist(err) { + // if not, we remove the indexed module + f.removeIndexedTest(rawPath) + return ids, nil + } + f.logger.Printf("error checking existence (%q deleted): %s", parentDir, err) + return ids, nil + } + if !fi.IsDir() { + // Should never happen + f.logger.Printf("error: %q (deleted) is not a directory", parentDir) + return ids, nil + } + + // If the parent directory exists, we just need to + // check if the there are open documents for the path and the + // path is a module path. If so, we need to reparse the module. + dir := document.DirHandleFromPath(parentDir) + hasOpenDocs, err := f.stateStore.DocumentStore.HasOpenDocuments(dir) + if err != nil { + f.logger.Printf("error when checking for open documents in path (%q deleted): %s", rawPath, err) + } + if !hasOpenDocs { + return ids, nil + } + + return f.decodeTest(ctx, dir, true, true) + + case protocol.Changed: + fallthrough + case protocol.Created: + var dir document.DirHandle + if isDir { + dir = document.DirHandleFromPath(rawPath) + } else { + docHandle := document.HandleFromPath(rawPath) + dir = docHandle.Dir + } + + // Check if the there are open documents for the path and the + // path is a module path. If so, we need to reparse the module. + hasOpenDocs, err := f.stateStore.DocumentStore.HasOpenDocuments(dir) + if err != nil { + f.logger.Printf("error when checking for open documents in path (%q changed): %s", rawPath, err) + } + if !hasOpenDocs { + return ids, nil + } + + hasModuleRecord := f.store.Exists(dir.Path()) + if !hasModuleRecord { + return ids, nil + } + + return f.decodeTest(ctx, dir, true, true) + } + + return nil, nil +} + +func (f *TestsFeature) decodeTest(ctx context.Context, dir document.DirHandle, ignoreState bool, isFirstLevel bool) (job.IDs, error) { + ids := make(job.IDs, 0) + path := dir.Path() + + parseId, err := f.stateStore.JobStore.EnqueueJob(ctx, job.Job{ + Dir: dir, + Func: func(ctx context.Context) error { + return jobs.ParseTestConfiguration(ctx, f.fs, f.store, path) + }, + Type: op.OpTypeParseTestConfiguration.String(), + IgnoreState: ignoreState, + }) + if err != nil { + return ids, err + } + ids = append(ids, parseId) + + validationOptions, _ := lsctx.ValidationOptions(ctx) + + metaId, err := f.stateStore.JobStore.EnqueueJob(ctx, job.Job{ + Dir: dir, + Func: func(ctx context.Context) error { + return jobs.LoadTestMetadata(ctx, f.store, path) + }, + Type: op.OpTypeLoadTestMetadata.String(), + DependsOn: job.IDs{parseId}, + IgnoreState: ignoreState, + Defer: func(ctx context.Context, jobErr error) (job.IDs, error) { + deferIds := make(job.IDs, 0) + if jobErr != nil { + f.logger.Printf("loading module metadata returned error: %s", jobErr) + } + + spawnedIds, err := loadTestModuleSources(ctx, f.store, f.bus, path) + if err != nil { + return deferIds, err + } + deferIds = append(deferIds, spawnedIds...) + + refTargetsId, err := f.stateStore.JobStore.EnqueueJob(ctx, job.Job{ + Dir: dir, + Func: func(ctx context.Context) error { + return jobs.DecodeReferenceTargets(ctx, f.store, path, f.moduleFeature, f.rootFeature) + }, + Type: op.OpTypeDecodeTestReferenceTargets.String(), + DependsOn: spawnedIds, + IgnoreState: ignoreState, + }) + if err != nil { + return deferIds, err + } + deferIds = append(deferIds, refTargetsId) + + refOriginsId, err := f.stateStore.JobStore.EnqueueJob(ctx, job.Job{ + Dir: dir, + Func: func(ctx context.Context) error { + return jobs.DecodeReferenceOrigins(ctx, f.store, path, f.moduleFeature, f.rootFeature) + }, + Type: op.OpTypeDecodeTestReferenceOrigins.String(), + DependsOn: spawnedIds, + IgnoreState: ignoreState, + }) + if err != nil { + return deferIds, err + } + deferIds = append(deferIds, refOriginsId) + + if validationOptions.EnableEnhancedValidation { + _, err = f.stateStore.JobStore.EnqueueJob(ctx, job.Job{ + Dir: dir, + Func: func(ctx context.Context) error { + return jobs.SchemaTestValidation(ctx, f.store, dir.Path(), f.moduleFeature, f.rootFeature) + }, + Type: op.OpTypeSchemaTestValidation.String(), + DependsOn: spawnedIds, + IgnoreState: ignoreState, + }) + if err != nil { + return ids, err + } + } + + return deferIds, nil + }, + }) + if err != nil { + return ids, err + } + ids = append(ids, metaId) + + return ids, nil +} + +func (f *TestsFeature) removeIndexedTest(rawPath string) { + testHandle := document.DirHandleFromPath(rawPath) + + err := f.stateStore.JobStore.DequeueJobsForDir(testHandle) + if err != nil { + f.logger.Printf("failed to dequeue jobs for test: %s", err) + return + } + + err = f.store.Remove(rawPath) + if err != nil { + f.logger.Printf("failed to remove test from state: %s", err) + return + } +} + +func loadTestModuleSources(ctx context.Context, testStore *state.TestStore, bus *eventbus.EventBus, testPath string) (job.IDs, error) { + ids := make(job.IDs, 0) + + _, err := testStore.TestRecordByPath(testPath) + if err != nil { + return ids, err + } + + // TODO! load the adjacent Terraform module (usually ../) TFECO-7479 + // TODO load the run -> module block sources TFECO-7483 + // TODO load the mock_provider block sources TFECO-7481 + + return ids, nil +} diff --git a/internal/features/tests/jobs/metadata.go b/internal/features/tests/jobs/metadata.go new file mode 100644 index 000000000..da86a1258 --- /dev/null +++ b/internal/features/tests/jobs/metadata.go @@ -0,0 +1,49 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jobs + +import ( + "context" + + "github.com/hashicorp/terraform-ls/internal/document" + "github.com/hashicorp/terraform-ls/internal/features/tests/state" + "github.com/hashicorp/terraform-ls/internal/job" + "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" + earlydecoder "github.com/hashicorp/terraform-schema/earlydecoder/tests" +) + +// LoadTestMetadata loads data about the test in a version-independent +// way that enables us to decode the rest of the configuration, +// e.g. by knowing provider versions, etc. +func LoadTestMetadata(ctx context.Context, testStore *state.TestStore, testPath string) error { + record, err := testStore.TestRecordByPath(testPath) + if err != nil { + return err + } + + // TODO: Avoid parsing if upstream (parsing) job reported no changes + + // Avoid parsing if it is already in progress or already known + if record.MetaState != operation.OpStateUnknown && !job.IgnoreState(ctx) { + return job.StateNotChangedErr{Dir: document.DirHandleFromPath(testPath)} + } + + err = testStore.SetMetaState(testPath, operation.OpStateLoading) + if err != nil { + return err + } + + var mErr error + meta, diags := earlydecoder.LoadTest(record.Path(), record.ParsedFiles.AsMap()) + if len(diags) > 0 { + mErr = diags + } + + sErr := testStore.UpdateMetadata(testPath, meta, mErr) + if sErr != nil { + return sErr + } + + return mErr +} diff --git a/internal/features/tests/jobs/parse.go b/internal/features/tests/jobs/parse.go new file mode 100644 index 000000000..ea16c2898 --- /dev/null +++ b/internal/features/tests/jobs/parse.go @@ -0,0 +1,97 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jobs + +import ( + "context" + "path/filepath" + + lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/document" + "github.com/hashicorp/terraform-ls/internal/features/tests/ast" + "github.com/hashicorp/terraform-ls/internal/features/tests/parser" + "github.com/hashicorp/terraform-ls/internal/features/tests/state" + "github.com/hashicorp/terraform-ls/internal/job" + "github.com/hashicorp/terraform-ls/internal/lsp" + globalAst "github.com/hashicorp/terraform-ls/internal/terraform/ast" + "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" + "github.com/hashicorp/terraform-ls/internal/uri" +) + +// ParseTestConfiguration parses the whole test configuration, +// i.e. turns bytes of `*.tftest.hcl` & `*.tfmock.hcl` files into AST ([*hcl.File]). +func ParseTestConfiguration(ctx context.Context, fs ReadOnlyFS, testStore *state.TestStore, testPath string) error { + record, err := testStore.TestRecordByPath(testPath) + if err != nil { + return err + } + + // TODO: Avoid parsing if the content matches existing AST + + // Avoid parsing if it is already in progress or already known + if record.DiagnosticsState[globalAst.HCLParsingSource] != operation.OpStateUnknown && !job.IgnoreState(ctx) { + return job.StateNotChangedErr{Dir: document.DirHandleFromPath(testPath)} + } + + var files ast.Files + var diags ast.Diagnostics + rpcContext := lsctx.DocumentContext(ctx) + + isMatchingLanguageId := (rpcContext.LanguageID == lsp.Test.String() || rpcContext.LanguageID == lsp.Mock.String()) + + // Only parse the file that's being changed/opened, unless this is 1st-time parsing + if record.DiagnosticsState[globalAst.HCLParsingSource] == operation.OpStateLoaded && + rpcContext.IsDidChangeRequest() && + isMatchingLanguageId { + // the file has already been parsed, so only examine this file and not the whole module + err = testStore.SetDiagnosticsState(testPath, globalAst.HCLParsingSource, operation.OpStateLoading) + if err != nil { + return err + } + + filePath, err := uri.PathFromURI(rpcContext.URI) + if err != nil { + return err + } + fileName := filepath.Base(filePath) + + pFile, fDiags, err := parser.ParseFile(fs, filePath) + if err != nil { + return err + } + existingFiles := record.ParsedFiles.Copy() + existingFiles[ast.FilenameFromName(fileName)] = pFile + files = existingFiles + + existingDiags, ok := record.Diagnostics[globalAst.HCLParsingSource] + if !ok { + existingDiags = make(ast.Diagnostics) + } else { + existingDiags = existingDiags.Copy() + } + existingDiags[ast.FilenameFromName(fileName)] = fDiags + diags = existingDiags + + } else { + // this is the first time file is opened so parse the whole module + err = testStore.SetDiagnosticsState(testPath, globalAst.HCLParsingSource, operation.OpStateLoading) + if err != nil { + return err + } + + files, diags, err = parser.ParseFiles(fs, testPath) + } + + sErr := testStore.UpdateParsedFiles(testPath, files, err) + if sErr != nil { + return sErr + } + + sErr = testStore.UpdateDiagnostics(testPath, globalAst.HCLParsingSource, diags) + if sErr != nil { + return sErr + } + + return err +} diff --git a/internal/features/tests/jobs/references.go b/internal/features/tests/jobs/references.go new file mode 100644 index 000000000..4fad6ed5c --- /dev/null +++ b/internal/features/tests/jobs/references.go @@ -0,0 +1,145 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jobs + +import ( + "context" + + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + idecoder "github.com/hashicorp/terraform-ls/internal/decoder" + "github.com/hashicorp/terraform-ls/internal/document" + fdecoder "github.com/hashicorp/terraform-ls/internal/features/tests/decoder" + "github.com/hashicorp/terraform-ls/internal/features/tests/state" + "github.com/hashicorp/terraform-ls/internal/job" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" +) + +// DecodeReferenceTargets collects reference targets, +// using previously parsed AST (via [ParseModuleConfiguration]), +// core schema of appropriate version (as obtained via [GetTerraformVersion]) +// and provider schemas ([PreloadEmbeddedSchema] or [ObtainSchema]). +// +// For example it tells us that variable block between certain LOC +// can be referred to as var.foobar. This is useful e.g. during completion, +// go-to-definition or go-to-references. +func DecodeReferenceTargets(ctx context.Context, testStore *state.TestStore, testPath string, moduleFeature fdecoder.ModuleReader, rootFeature fdecoder.RootReader) error { + mod, err := testStore.TestRecordByPath(testPath) + if err != nil { + return err + } + + // TODO: Avoid collection if upstream jobs reported no changes + + // Avoid collection if it is already in progress or already done + if mod.RefTargetsState != op.OpStateUnknown && !job.IgnoreState(ctx) { + return job.StateNotChangedErr{Dir: document.DirHandleFromPath(testPath)} + } + + err = testStore.SetReferenceTargetsState(testPath, op.OpStateLoading) + if err != nil { + return err + } + + d := decoder.NewDecoder(&fdecoder.PathReader{ + StateReader: testStore, + ModuleReader: moduleFeature, + RootReader: rootFeature, + }) + d.SetContext(idecoder.DecoderContext(ctx)) + + testDecoder, err := d.Path(lang.Path{ + Path: testPath, + LanguageID: ilsp.Test.String(), + }) + if err != nil { + return err + } + testTargets, rErr := testDecoder.CollectReferenceTargets() + + mockDecoder, err := d.Path(lang.Path{ + Path: testPath, + LanguageID: ilsp.Mock.String(), + }) + if err != nil { + return err + } + mockTargets, rErr := mockDecoder.CollectReferenceTargets() + + targets := make(reference.Targets, 0) + targets = append(targets, testTargets...) + targets = append(targets, mockTargets...) + + sErr := testStore.UpdateReferenceTargets(testPath, targets, rErr) + if sErr != nil { + return sErr + } + + return rErr +} + +// DecodeReferenceOrigins collects reference origins, +// using previously parsed AST (via [ParseModuleConfiguration]), +// core schema of appropriate version (as obtained via [GetTerraformVersion]) +// and provider schemas ([PreloadEmbeddedSchema] or [ObtainSchema]). +// +// For example it tells us that there is a reference address var.foobar +// at a particular LOC. This can be later matched with targets +// (as obtained via [DecodeReferenceTargets]) during hover or go-to-definition. +func DecodeReferenceOrigins(ctx context.Context, testStore *state.TestStore, testPath string, moduleFeature fdecoder.ModuleReader, rootFeature fdecoder.RootReader) error { + mod, err := testStore.TestRecordByPath(testPath) + if err != nil { + return err + } + + // TODO: Avoid collection if upstream jobs reported no changes + + // Avoid collection if it is already in progress or already done + if mod.RefOriginsState != op.OpStateUnknown && !job.IgnoreState(ctx) { + return job.StateNotChangedErr{Dir: document.DirHandleFromPath(testPath)} + } + + err = testStore.SetReferenceOriginsState(testPath, op.OpStateLoading) + if err != nil { + return err + } + + d := decoder.NewDecoder(&fdecoder.PathReader{ + StateReader: testStore, + ModuleReader: moduleFeature, + RootReader: rootFeature, + }) + d.SetContext(idecoder.DecoderContext(ctx)) + + testDecoder, err := d.Path(lang.Path{ + Path: testPath, + LanguageID: ilsp.Test.String(), + }) + if err != nil { + return err + } + testOrigins, _ := testDecoder.CollectReferenceOrigins() + + mockDecoder, err := d.Path(lang.Path{ + Path: testPath, + LanguageID: ilsp.Mock.String(), + }) + if err != nil { + return err + } + mockOrigins, rErr := mockDecoder.CollectReferenceOrigins() + + origins := make(reference.Origins, 0) + origins = append(origins, testOrigins...) + origins = append(origins, mockOrigins...) + + sErr := testStore.UpdateReferenceOrigins(testPath, origins, rErr) + if sErr != nil { + return sErr + } + + return rErr +} diff --git a/internal/features/tests/jobs/types.go b/internal/features/tests/jobs/types.go new file mode 100644 index 000000000..6052cff7b --- /dev/null +++ b/internal/features/tests/jobs/types.go @@ -0,0 +1,13 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jobs + +import "io/fs" + +type ReadOnlyFS interface { + fs.FS + ReadDir(name string) ([]fs.DirEntry, error) + ReadFile(name string) ([]byte, error) + Stat(name string) (fs.FileInfo, error) +} diff --git a/internal/features/tests/jobs/validation.go b/internal/features/tests/jobs/validation.go new file mode 100644 index 000000000..94241ee1b --- /dev/null +++ b/internal/features/tests/jobs/validation.go @@ -0,0 +1,81 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package jobs + +import ( + "context" + + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + idecoder "github.com/hashicorp/terraform-ls/internal/decoder" + "github.com/hashicorp/terraform-ls/internal/document" + "github.com/hashicorp/terraform-ls/internal/features/tests/ast" + fdecoder "github.com/hashicorp/terraform-ls/internal/features/tests/decoder" + "github.com/hashicorp/terraform-ls/internal/features/tests/state" + "github.com/hashicorp/terraform-ls/internal/job" + ilsp "github.com/hashicorp/terraform-ls/internal/lsp" + globalAst "github.com/hashicorp/terraform-ls/internal/terraform/ast" + op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" +) + +// SchemaTestValidation does schema-based validation +// of test files (*.tftest.hcl), mock files (*.tfmock.hcl) +// and produces diagnostics associated with any "invalid" parts of code. +// +// It relies on previously parsed AST (via [ParseModuleConfiguration]), +// core schema of appropriate version (as obtained via [GetTerraformVersion]) +// and provider schemas ([PreloadEmbeddedSchema] or [ObtainSchema]). +func SchemaTestValidation(ctx context.Context, testStore *state.TestStore, testPath string, moduleFeature fdecoder.ModuleReader, rootFeature fdecoder.RootReader) error { + mod, err := testStore.TestRecordByPath(testPath) + if err != nil { + return err + } + + // Avoid validation if it is already in progress or already finished + if mod.DiagnosticsState[globalAst.SchemaValidationSource] != op.OpStateUnknown && !job.IgnoreState(ctx) { + return job.StateNotChangedErr{Dir: document.DirHandleFromPath(testPath)} + } + + err = testStore.SetDiagnosticsState(testPath, globalAst.SchemaValidationSource, op.OpStateLoading) + if err != nil { + return err + } + + d := decoder.NewDecoder(&fdecoder.PathReader{ + StateReader: testStore, + ModuleReader: moduleFeature, + RootReader: rootFeature, + }) + d.SetContext(idecoder.DecoderContext(ctx)) + + diags := make(lang.DiagnosticsMap) + + testDecoder, err := d.Path(lang.Path{ + Path: testPath, + LanguageID: ilsp.Test.String(), + }) + if err != nil { + return err + } + testDiags, err := testDecoder.Validate(ctx) + if err != nil { + return err + } + diags = diags.Extend(testDiags) + + mockDecoder, err := d.Path(lang.Path{ + Path: testPath, + LanguageID: ilsp.Mock.String(), + }) + if err != nil { + return err + } + mockDiags, err := mockDecoder.Validate(ctx) + if err != nil { + return err + } + diags = diags.Extend(mockDiags) + + return testStore.UpdateDiagnostics(testPath, globalAst.SchemaValidationSource, ast.DiagnosticsFromMap(diags)) +} diff --git a/internal/features/tests/parser/tests.go b/internal/features/tests/parser/tests.go new file mode 100644 index 000000000..13e43ef3a --- /dev/null +++ b/internal/features/tests/parser/tests.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package parser + +import ( + "path/filepath" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform-ls/internal/features/tests/ast" + "github.com/hashicorp/terraform-ls/internal/terraform/parser" +) + +func ParseFiles(fs parser.FS, testPath string) (ast.Files, ast.Diagnostics, error) { + files := make(ast.Files, 0) + diags := make(ast.Diagnostics, 0) + + infos, err := fs.ReadDir(testPath) + 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.IsTestFilename(name) && !ast.IsMockFilename(name) { + continue + } + + fullPath := filepath.Join(testPath, name) + + src, err := fs.ReadFile(fullPath) + if err != nil { + // If a file isn't accessible, continue with reading the + // remaining module files + continue + } + + filename := ast.FilenameFromName(name) + f, pDiags := parser.ParseFile(src, filename) + + diags[filename] = pDiags + if f != nil { + files[filename] = f + } + } + + return files, diags, nil +} + +func ParseFile(fs parser.FS, filePath string) (*hcl.File, hcl.Diagnostics, error) { + src, err := fs.ReadFile(filePath) + if err != nil { + // If a file isn't accessible, return + return nil, nil, err + } + + name := filepath.Base(filePath) + filename := ast.FilenameFromName(name) + f, pDiags := parser.ParseFile(src, filename) + + return f, pDiags, nil +} diff --git a/internal/features/tests/state/schema.go b/internal/features/tests/state/schema.go new file mode 100644 index 000000000..ec12d7b7e --- /dev/null +++ b/internal/features/tests/state/schema.go @@ -0,0 +1,48 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package state + +import ( + "io" + "log" + + "github.com/hashicorp/go-memdb" + globalState "github.com/hashicorp/terraform-ls/internal/state" +) + +const ( + testsTableName = "tests" +) + +var dbSchema = &memdb.DBSchema{ + Tables: map[string]*memdb.TableSchema{ + testsTableName: { + Name: testsTableName, + Indexes: map[string]*memdb.IndexSchema{ + "id": { + Name: "id", + Unique: true, + Indexer: &memdb.StringFieldIndex{Field: "path"}, + }, + }, + }, + }, +} + +func NewTestStore(changeStore *globalState.ChangeStore, providerSchemasStore *globalState.ProviderSchemaStore) (*TestStore, error) { + db, err := memdb.NewMemDB(dbSchema) + if err != nil { + return nil, err + } + + discardLogger := log.New(io.Discard, "", 0) + + return &TestStore{ + db: db, + tableName: testsTableName, + logger: discardLogger, + changeStore: changeStore, + providerSchemasStore: providerSchemasStore, + }, nil +} diff --git a/internal/features/tests/state/test_meta.go b/internal/features/tests/state/test_meta.go new file mode 100644 index 000000000..ef25927ae --- /dev/null +++ b/internal/features/tests/state/test_meta.go @@ -0,0 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package state + +// TestMetadata contains the result of the early decoding of a test, +// it will be used obtain the correct provider and related module schemas +type TestMetadata struct { + Filenames []string +} + +func (tm TestMetadata) Copy() TestMetadata { + newTm := TestMetadata{ + Filenames: tm.Filenames, + } + + return newTm +} diff --git a/internal/features/tests/state/test_record.go b/internal/features/tests/state/test_record.go new file mode 100644 index 000000000..844ff093c --- /dev/null +++ b/internal/features/tests/state/test_record.go @@ -0,0 +1,106 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package state + +import ( + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform-ls/internal/features/tests/ast" + globalAst "github.com/hashicorp/terraform-ls/internal/terraform/ast" + op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" +) + +// TestRecord represents a test location in the state +type TestRecord struct { + path string + + PreloadEmbeddedSchemaState op.OpState + + Meta TestMetadata + MetaErr error + MetaState op.OpState + + RefTargets reference.Targets + RefTargetsErr error + RefTargetsState op.OpState + + RefOrigins reference.Origins + RefOriginsErr error + RefOriginsState op.OpState + + ParsedFiles ast.Files + ParsingErr error + Diagnostics ast.SourceDiagnostics + DiagnosticsState globalAst.DiagnosticSourceState +} + +func (m *TestRecord) Path() string { + return m.path +} + +func (m *TestRecord) Copy() *TestRecord { + if m == nil { + return nil + } + + newRecord := &TestRecord{ + path: m.path, + + PreloadEmbeddedSchemaState: m.PreloadEmbeddedSchemaState, + + RefTargets: m.RefTargets.Copy(), + RefTargetsErr: m.RefTargetsErr, + RefTargetsState: m.RefTargetsState, + + RefOrigins: m.RefOrigins.Copy(), + RefOriginsErr: m.RefOriginsErr, + RefOriginsState: m.RefOriginsState, + + Meta: m.Meta.Copy(), + MetaErr: m.MetaErr, + MetaState: m.MetaState, + + ParsingErr: m.ParsingErr, + DiagnosticsState: m.DiagnosticsState.Copy(), + } + + if m.ParsedFiles != nil { + newRecord.ParsedFiles = make(ast.Files, len(m.ParsedFiles)) + for name, f := range m.ParsedFiles { + // hcl.File is practically immutable once it comes out of parser + newRecord.ParsedFiles[name] = f + } + } + + if m.Diagnostics != nil { + newRecord.Diagnostics = make(ast.SourceDiagnostics, len(m.Diagnostics)) + + for source, testDiags := range m.Diagnostics { + newRecord.Diagnostics[source] = make(ast.Diagnostics, len(testDiags)) + + for name, diags := range testDiags { + newRecord.Diagnostics[source][name] = make(hcl.Diagnostics, len(diags)) + copy(newRecord.Diagnostics[source][name], diags) + } + } + } + + return newRecord +} + +func newTest(testPath string) *TestRecord { + return &TestRecord{ + path: testPath, + PreloadEmbeddedSchemaState: op.OpStateUnknown, + RefOriginsState: op.OpStateUnknown, + RefTargetsState: op.OpStateUnknown, + MetaState: op.OpStateUnknown, + DiagnosticsState: globalAst.DiagnosticSourceState{ + globalAst.HCLParsingSource: op.OpStateUnknown, + globalAst.SchemaValidationSource: op.OpStateUnknown, + globalAst.ReferenceValidationSource: op.OpStateUnknown, + globalAst.TerraformValidateSource: op.OpStateUnknown, + }, + } +} diff --git a/internal/features/tests/state/test_store.go b/internal/features/tests/state/test_store.go new file mode 100644 index 000000000..bda723ec4 --- /dev/null +++ b/internal/features/tests/state/test_store.go @@ -0,0 +1,448 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package state + +import ( + "log" + + "github.com/hashicorp/go-memdb" + "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/terraform-ls/internal/document" + "github.com/hashicorp/terraform-ls/internal/features/tests/ast" + globalState "github.com/hashicorp/terraform-ls/internal/state" + globalAst "github.com/hashicorp/terraform-ls/internal/terraform/ast" + op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" + tfaddr "github.com/hashicorp/terraform-registry-address" + tfschema "github.com/hashicorp/terraform-schema/schema" + tftest "github.com/hashicorp/terraform-schema/test" +) + +type TestStore struct { + db *memdb.MemDB + tableName string + logger *log.Logger + + changeStore *globalState.ChangeStore + providerSchemasStore *globalState.ProviderSchemaStore +} + +func (s *TestStore) SetLogger(logger *log.Logger) { + s.logger = logger +} + +func (s *TestStore) Add(testPath string) error { + txn := s.db.Txn(true) + defer txn.Abort() + + err := s.add(txn, testPath) + if err != nil { + return err + } + txn.Commit() + + return nil +} + +func (s *TestStore) Remove(testPath string) error { + txn := s.db.Txn(true) + defer txn.Abort() + + oldObj, err := txn.First(s.tableName, "id", testPath) + if err != nil { + return err + } + + if oldObj == nil { + // already removed + return nil + } + + oldRecord := oldObj.(*TestRecord) + err = s.queueRecordChange(oldRecord, nil) + if err != nil { + return err + } + + _, err = txn.DeleteAll(s.tableName, "id", testPath) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) List() ([]*TestRecord, error) { + txn := s.db.Txn(false) + + it, err := txn.Get(s.tableName, "id") + if err != nil { + return nil, err + } + + records := make([]*TestRecord, 0) + for item := it.Next(); item != nil; item = it.Next() { + record := item.(*TestRecord) + records = append(records, record) + } + + return records, nil +} + +func (s *TestStore) TestRecordByPath(path string) (*TestRecord, error) { + txn := s.db.Txn(false) + + mod, err := testByPath(txn, path) + if err != nil { + return nil, err + } + + return mod, nil +} + +func (s *TestStore) Exists(path string) bool { + txn := s.db.Txn(false) + + obj, err := txn.First(s.tableName, "id", path) + if err != nil { + return false + } + + return obj != nil +} + +func (s *TestStore) AddIfNotExists(path string) error { + txn := s.db.Txn(true) + defer txn.Abort() + + _, err := testByPath(txn, path) + if err == nil { + return nil + } + + if globalState.IsRecordNotFound(err) { + err := s.add(txn, path) + if err != nil { + return err + } + + txn.Commit() + return nil + } + + return err +} + +func (s *TestStore) SetDiagnosticsState(path string, source globalAst.DiagnosticSource, state op.OpState) error { + txn := s.db.Txn(true) + defer txn.Abort() + + record, err := testCopyByPath(txn, path) + if err != nil { + return err + } + record.DiagnosticsState[source] = state + + err = txn.Insert(s.tableName, record) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) UpdateParsedFiles(path string, pFiles ast.Files, pErr error) error { + txn := s.db.Txn(true) + defer txn.Abort() + + mod, err := testCopyByPath(txn, path) + if err != nil { + return err + } + + mod.ParsedFiles = pFiles + + mod.ParsingErr = pErr + + err = txn.Insert(s.tableName, mod) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) UpdateDiagnostics(path string, source globalAst.DiagnosticSource, diags ast.Diagnostics) error { + txn := s.db.Txn(true) + txn.Defer(func() { + s.SetDiagnosticsState(path, source, op.OpStateLoaded) + }) + defer txn.Abort() + + oldMod, err := testByPath(txn, path) + if err != nil { + return err + } + + mod := oldMod.Copy() + if mod.Diagnostics == nil { + mod.Diagnostics = make(ast.SourceDiagnostics) + } + mod.Diagnostics[source] = diags + + err = txn.Insert(s.tableName, mod) + if err != nil { + return err + } + + err = s.queueRecordChange(oldMod, mod) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) SetMetaState(path string, state op.OpState) error { + txn := s.db.Txn(true) + defer txn.Abort() + + record, err := testCopyByPath(txn, path) + if err != nil { + return err + } + + record.MetaState = state + err = txn.Insert(s.tableName, record) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) UpdateMetadata(path string, meta *tftest.Meta, mErr error) error { + txn := s.db.Txn(true) + txn.Defer(func() { + s.SetMetaState(path, op.OpStateLoaded) + }) + defer txn.Abort() + + oldRecord, err := testByPath(txn, path) + if err != nil { + return err + } + + record := oldRecord.Copy() + record.Meta = TestMetadata{ + Filenames: meta.Filenames, + } + record.MetaErr = mErr + + err = txn.Insert(s.tableName, record) + if err != nil { + return err + } + + err = txn.Insert(s.tableName, record) + if err != nil { + return err + } + + err = s.queueRecordChange(oldRecord, record) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) SetPreloadEmbeddedSchemaState(path string, state op.OpState) error { + txn := s.db.Txn(true) + defer txn.Abort() + + record, err := testCopyByPath(txn, path) + if err != nil { + return err + } + + record.PreloadEmbeddedSchemaState = state + err = txn.Insert(s.tableName, record) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) add(txn *memdb.Txn, testPath string) error { + // TODO: Introduce Exists method to Txn? + obj, err := txn.First(s.tableName, "id", testPath) + if err != nil { + return err + } + if obj != nil { + return &globalState.AlreadyExistsError{ + Idx: testPath, + } + } + + record := newTest(testPath) + err = txn.Insert(s.tableName, record) + if err != nil { + return err + } + + err = s.queueRecordChange(nil, record) + if err != nil { + return err + } + + return nil +} + +func (s *TestStore) SetReferenceTargetsState(path string, state op.OpState) error { + txn := s.db.Txn(true) + defer txn.Abort() + + mod, err := testCopyByPath(txn, path) + if err != nil { + return err + } + + mod.RefTargetsState = state + err = txn.Insert(s.tableName, mod) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) UpdateReferenceTargets(path string, refs reference.Targets, rErr error) error { + txn := s.db.Txn(true) + txn.Defer(func() { + s.SetReferenceTargetsState(path, op.OpStateLoaded) + }) + defer txn.Abort() + + mod, err := testCopyByPath(txn, path) + if err != nil { + return err + } + + mod.RefTargets = refs + mod.RefTargetsErr = rErr + + err = txn.Insert(s.tableName, mod) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) SetReferenceOriginsState(path string, state op.OpState) error { + txn := s.db.Txn(true) + defer txn.Abort() + + mod, err := testCopyByPath(txn, path) + if err != nil { + return err + } + + mod.RefOriginsState = state + err = txn.Insert(s.tableName, mod) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *TestStore) UpdateReferenceOrigins(path string, origins reference.Origins, roErr error) error { + txn := s.db.Txn(true) + txn.Defer(func() { + s.SetReferenceOriginsState(path, op.OpStateLoaded) + }) + defer txn.Abort() + + mod, err := testCopyByPath(txn, path) + if err != nil { + return err + } + + mod.RefOrigins = origins + mod.RefOriginsErr = roErr + + err = txn.Insert(s.tableName, mod) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func testByPath(txn *memdb.Txn, path string) (*TestRecord, error) { + obj, err := txn.First(testsTableName, "id", path) + if err != nil { + return nil, err + } + if obj == nil { + return nil, &globalState.RecordNotFoundError{ + Source: path, + } + } + return obj.(*TestRecord), nil +} + +func testCopyByPath(txn *memdb.Txn, path string) (*TestRecord, error) { + record, err := testByPath(txn, path) + if err != nil { + return nil, err + } + + return record.Copy(), nil +} + +func (s *TestStore) queueRecordChange(oldRecord, newRecord *TestRecord) error { + changes := globalState.Changes{} + + oldDiags, newDiags := 0, 0 + if oldRecord != nil { + oldDiags = oldRecord.Diagnostics.Count() + } + if newRecord != nil { + newDiags = newRecord.Diagnostics.Count() + } + // Comparing diagnostics accurately could be expensive + // so we just treat any non-empty diags as a change + if oldDiags > 0 || newDiags > 0 { + changes.Diagnostics = true + } + + var dir document.DirHandle + if oldRecord != nil { + dir = document.DirHandleFromPath(oldRecord.Path()) + } else { + dir = document.DirHandleFromPath(newRecord.Path()) + } + + return s.changeStore.QueueChange(dir, changes) +} + +func (s *TestStore) ProviderSchema(modPath string, addr tfaddr.Provider, vc version.Constraints) (*tfschema.ProviderSchema, error) { + return s.providerSchemasStore.ProviderSchema(modPath, addr, vc) +} diff --git a/internal/features/tests/tests_feature.go b/internal/features/tests/tests_feature.go new file mode 100644 index 000000000..81e5f2b12 --- /dev/null +++ b/internal/features/tests/tests_feature.go @@ -0,0 +1,137 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tests + +import ( + "context" + "io" + "log" + + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/terraform-ls/internal/eventbus" + "github.com/hashicorp/terraform-ls/internal/features/modules/jobs" + testDecoder "github.com/hashicorp/terraform-ls/internal/features/tests/decoder" + "github.com/hashicorp/terraform-ls/internal/features/tests/state" + "github.com/hashicorp/terraform-ls/internal/langserver/diagnostics" + globalState "github.com/hashicorp/terraform-ls/internal/state" +) + +type TestsFeature struct { + store *state.TestStore + stateStore *globalState.StateStore + bus *eventbus.EventBus + fs jobs.ReadOnlyFS + logger *log.Logger + stopFunc context.CancelFunc + moduleFeature testDecoder.ModuleReader + rootFeature testDecoder.RootReader +} + +func NewTestsFeature(bus *eventbus.EventBus, stateStore *globalState.StateStore, fs jobs.ReadOnlyFS, moduleFeature testDecoder.ModuleReader, rootFeature testDecoder.RootReader) (*TestsFeature, error) { + store, err := state.NewTestStore(stateStore.ChangeStore, stateStore.ProviderSchemas) + if err != nil { + return nil, err + } + discardLogger := log.New(io.Discard, "", 0) + + return &TestsFeature{ + store: store, + bus: bus, + fs: fs, + stateStore: stateStore, + moduleFeature: moduleFeature, + rootFeature: rootFeature, + logger: discardLogger, + stopFunc: func() {}, + }, nil +} + +func (f *TestsFeature) SetLogger(logger *log.Logger) { + f.logger = logger + f.store.SetLogger(logger) +} + +// Start starts the features separate goroutine. +// It listens to various events from the EventBus and performs corresponding actions. +func (f *TestsFeature) Start(ctx context.Context) { + ctx, cancelFunc := context.WithCancel(ctx) + f.stopFunc = cancelFunc + + topic := "feature.tests" + + didOpenDone := make(chan struct{}, 10) + didChangeDone := make(chan struct{}, 10) + didChangeWatchedDone := make(chan struct{}, 10) + + discover := f.bus.OnDiscover(topic, nil) + didOpen := f.bus.OnDidOpen(topic, didOpenDone) + didChange := f.bus.OnDidChange(topic, didChangeDone) + didChangeWatched := f.bus.OnDidChangeWatched(topic, didChangeWatchedDone) + + go func() { + for { + select { + case discover := <-discover: + // TODO? collect errors + f.discover(discover.Path, discover.Files) + case didOpen := <-didOpen: + // TODO? collect errors + f.didOpen(didOpen.Context, didOpen.Dir, didOpen.LanguageID) + didOpenDone <- struct{}{} + case didChange := <-didChange: + // TODO? collect errors + f.didChange(didChange.Context, didChange.Dir) + didChangeDone <- struct{}{} + case didChangeWatched := <-didChangeWatched: + // TODO? collect errors + f.didChangeWatched(didChangeWatched.Context, didChangeWatched.RawPath, didChangeWatched.ChangeType, didChangeWatched.IsDir) + didChangeWatchedDone <- struct{}{} + + case <-ctx.Done(): + return + } + } + }() +} + +func (f *TestsFeature) Stop() { + f.stopFunc() + f.logger.Print("stopped tests feature") +} + +func (f *TestsFeature) PathContext(path lang.Path) (*decoder.PathContext, error) { + pathReader := &testDecoder.PathReader{ + StateReader: f.store, + ModuleReader: f.moduleFeature, + RootReader: f.rootFeature, + } + + return pathReader.PathContext(path) +} + +func (f *TestsFeature) Paths(ctx context.Context) []lang.Path { + pathReader := &testDecoder.PathReader{ + StateReader: f.store, + ModuleReader: f.moduleFeature, + RootReader: f.rootFeature, + } + + return pathReader.Paths(ctx) +} + +func (f *TestsFeature) Diagnostics(path string) diagnostics.Diagnostics { + diags := diagnostics.NewDiagnostics() + + mod, err := f.store.TestRecordByPath(path) + if err != nil { + return diags + } + + for source, dm := range mod.Diagnostics { + diags.Append(source, dm.AutoloadedOnly().AsMap()) + } + + return diags +} diff --git a/internal/langserver/handlers/did_change_watched_files_test.go b/internal/langserver/handlers/did_change_watched_files_test.go index aa208920c..74b22f0d8 100644 --- a/internal/langserver/handlers/did_change_watched_files_test.go +++ b/internal/langserver/handlers/did_change_watched_files_test.go @@ -67,6 +67,8 @@ func TestLangServer_DidChangeWatchedFiles_change_file(t *testing.T) { defer features.Variables.Stop() features.Stacks.Start(ctx) defer features.Stacks.Stop() + features.Tests.Start(ctx) + defer features.Tests.Stop() wc := walker.NewWalkerCollector() @@ -247,6 +249,8 @@ func TestLangServer_DidChangeWatchedFiles_create_file(t *testing.T) { defer features.Variables.Stop() features.Stacks.Start(ctx) defer features.Stacks.Stop() + features.Tests.Start(ctx) + defer features.Tests.Stop() wc := walker.NewWalkerCollector() ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ @@ -386,6 +390,8 @@ func TestLangServer_DidChangeWatchedFiles_delete_file(t *testing.T) { defer features.Variables.Stop() features.Stacks.Start(ctx) defer features.Stacks.Stop() + features.Tests.Start(ctx) + defer features.Tests.Stop() wc := walker.NewWalkerCollector() ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ @@ -520,6 +526,8 @@ func TestLangServer_DidChangeWatchedFiles_change_dir(t *testing.T) { defer features.Variables.Stop() features.Stacks.Start(ctx) defer features.Stacks.Stop() + features.Tests.Start(ctx) + defer features.Tests.Stop() wc := walker.NewWalkerCollector() ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ @@ -661,6 +669,8 @@ func TestLangServer_DidChangeWatchedFiles_create_dir(t *testing.T) { defer features.Variables.Stop() features.Stacks.Start(ctx) defer features.Stacks.Stop() + features.Tests.Start(ctx) + defer features.Tests.Stop() wc := walker.NewWalkerCollector() ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ @@ -799,6 +809,8 @@ func TestLangServer_DidChangeWatchedFiles_delete_dir(t *testing.T) { defer features.Variables.Stop() features.Stacks.Start(ctx) defer features.Stacks.Stop() + features.Tests.Start(ctx) + defer features.Tests.Stop() wc := walker.NewWalkerCollector() ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ @@ -968,6 +980,8 @@ func TestLangServer_DidChangeWatchedFiles_pluginChange(t *testing.T) { defer features.Variables.Stop() features.Stacks.Start(ctx) defer features.Stacks.Stop() + features.Tests.Start(ctx) + defer features.Tests.Stop() wc := walker.NewWalkerCollector() ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ @@ -1072,6 +1086,8 @@ func TestLangServer_DidChangeWatchedFiles_moduleInstalled(t *testing.T) { defer features.Variables.Stop() features.Stacks.Start(ctx) defer features.Stacks.Stop() + features.Tests.Start(ctx) + defer features.Tests.Stop() wc := walker.NewWalkerCollector() ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ diff --git a/internal/langserver/handlers/hooks_module.go b/internal/langserver/handlers/hooks_module.go index c616d0e78..fb17e3c3e 100644 --- a/internal/langserver/handlers/hooks_module.go +++ b/internal/langserver/handlers/hooks_module.go @@ -61,6 +61,7 @@ func updateDiagnostics(features *Features, dNotifier *diagnostics.Notifier) noti diags.Extend(features.Modules.Diagnostics(path)) diags.Extend(features.Variables.Diagnostics(path)) diags.Extend(features.Stacks.Diagnostics(path)) + diags.Extend(features.Tests.Diagnostics(path)) dNotifier.PublishHCLDiags(ctx, path, diags) } diff --git a/internal/langserver/handlers/initialize_test.go b/internal/langserver/handlers/initialize_test.go index 9173cc870..faf507365 100644 --- a/internal/langserver/handlers/initialize_test.go +++ b/internal/langserver/handlers/initialize_test.go @@ -526,6 +526,8 @@ func TestInitialize_differentWorkspaceLayouts(t *testing.T) { defer features.Variables.Stop() features.Stacks.Start(ctx) defer features.Stacks.Stop() + features.Tests.Start(ctx) + defer features.Tests.Stop() wc := walker.NewWalkerCollector() diff --git a/internal/langserver/handlers/service.go b/internal/langserver/handlers/service.go index 85be34c55..8a9fd276a 100644 --- a/internal/langserver/handlers/service.go +++ b/internal/langserver/handlers/service.go @@ -22,6 +22,7 @@ import ( fmodules "github.com/hashicorp/terraform-ls/internal/features/modules" frootmodules "github.com/hashicorp/terraform-ls/internal/features/rootmodules" "github.com/hashicorp/terraform-ls/internal/features/stacks" + ftests "github.com/hashicorp/terraform-ls/internal/features/tests" fvariables "github.com/hashicorp/terraform-ls/internal/features/variables" "github.com/hashicorp/terraform-ls/internal/filesystem" "github.com/hashicorp/terraform-ls/internal/job" @@ -50,6 +51,7 @@ type Features struct { RootModules *frootmodules.RootModulesFeature Variables *fvariables.VariablesFeature Stacks *stacks.StacksFeature + Tests *ftests.TestsFeature } type service struct { @@ -543,11 +545,19 @@ func (svc *service) configureSessionDependencies(ctx context.Context, cfgOpts *s stacksFeature.SetLogger(svc.logger) stacksFeature.Start(svc.sessCtx) + testsFeature, err := ftests.NewTestsFeature(svc.eventBus, svc.stateStore, svc.fs, modulesFeature, rootModulesFeature) + if err != nil { + return err + } + testsFeature.SetLogger(svc.logger) + testsFeature.Start(svc.sessCtx) + svc.features = &Features{ Modules: modulesFeature, RootModules: rootModulesFeature, Variables: variablesFeature, Stacks: stacksFeature, + Tests: testsFeature, } } @@ -557,6 +567,8 @@ func (svc *service) configureSessionDependencies(ctx context.Context, cfgOpts *s "terraform-vars": svc.features.Variables, "terraform-stack": svc.features.Stacks, "terraform-deploy": svc.features.Stacks, + "terraform-test": svc.features.Tests, + "terraform-mock": svc.features.Tests, }, }) decoderContext := idecoder.DecoderContext(ctx) @@ -649,6 +661,9 @@ func (svc *service) shutdown() { if svc.features.Stacks != nil { svc.features.Stacks.Stop() } + if svc.features.Tests != nil { + svc.features.Tests.Stop() + } } } diff --git a/internal/langserver/handlers/session_mock_test.go b/internal/langserver/handlers/session_mock_test.go index a00beaa5c..812aca4e6 100644 --- a/internal/langserver/handlers/session_mock_test.go +++ b/internal/langserver/handlers/session_mock_test.go @@ -18,6 +18,7 @@ import ( fmodules "github.com/hashicorp/terraform-ls/internal/features/modules" frootmodules "github.com/hashicorp/terraform-ls/internal/features/rootmodules" fstacks "github.com/hashicorp/terraform-ls/internal/features/stacks" + ftests "github.com/hashicorp/terraform-ls/internal/features/tests" fvariables "github.com/hashicorp/terraform-ls/internal/features/variables" "github.com/hashicorp/terraform-ls/internal/filesystem" "github.com/hashicorp/terraform-ls/internal/langserver/session" @@ -167,10 +168,16 @@ func NewTestFeatures(eventBus *eventbus.EventBus, s *state.StateStore, fs *files return nil, err } + testsFeature, err := ftests.NewTestsFeature(eventBus, s, fs, modulesFeature, rootModulesFeature) + if err != nil { + return nil, err + } + return &Features{ Modules: modulesFeature, RootModules: rootModulesFeature, Variables: variablesFeature, Stacks: stacksFeature, + Tests: testsFeature, }, nil } diff --git a/internal/lsp/language_id.go b/internal/lsp/language_id.go index e3c6013df..2d82deb18 100644 --- a/internal/lsp/language_id.go +++ b/internal/lsp/language_id.go @@ -12,6 +12,8 @@ const ( Tfvars LanguageID = "terraform-vars" Stacks LanguageID = "terraform-stack" Deploy LanguageID = "terraform-deploy" + Test LanguageID = "terraform-test" + Mock LanguageID = "terraform-mock" ) func (l LanguageID) String() string { diff --git a/internal/terraform/module/operation/operation.go b/internal/terraform/module/operation/operation.go index 441e3bbcf..124397496 100644 --- a/internal/terraform/module/operation/operation.go +++ b/internal/terraform/module/operation/operation.go @@ -37,4 +37,9 @@ const ( OpTypeTerraformValidate OpTypeParseStackConfiguration OpTypeLoadStackRequiredTerraformVersion + OpTypeParseTestConfiguration + OpTypeLoadTestMetadata + OpTypeDecodeTestReferenceTargets + OpTypeDecodeTestReferenceOrigins + OpTypeSchemaTestValidation )