diff --git a/internal/decoder/decoder.go b/internal/decoder/decoder.go index e951e7cf5..74247e4e6 100644 --- a/internal/decoder/decoder.go +++ b/internal/decoder/decoder.go @@ -7,9 +7,11 @@ import ( "context" "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/codelens" + "github.com/hashicorp/terraform-ls/internal/decoder/validations" ilsp "github.com/hashicorp/terraform-ls/internal/lsp" lsp "github.com/hashicorp/terraform-ls/internal/protocol" "github.com/hashicorp/terraform-ls/internal/state" @@ -91,5 +93,10 @@ func DecoderContext(ctx context.Context) decoder.DecoderContext { } } + validations := []lang.ValidationFunc{ + validations.UnreferencedOrigins, + } + dCtx.Validations = append(dCtx.Validations, validations...) + return dCtx } diff --git a/internal/decoder/validations/unreferenced_origin.go b/internal/decoder/validations/unreferenced_origin.go new file mode 100644 index 000000000..e8871153a --- /dev/null +++ b/internal/decoder/validations/unreferenced_origin.go @@ -0,0 +1,60 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validations + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl/v2" +) + +func UnreferencedOrigins(ctx context.Context) lang.DiagnosticsMap { + diagsMap := make(lang.DiagnosticsMap) + + pathCtx, err := decoder.PathCtx(ctx) + if err != nil { + return diagsMap + } + + for _, origin := range pathCtx.ReferenceOrigins { + matchableOrigin, ok := origin.(reference.MatchableOrigin) + if !ok { + // we don't report on other origins to avoid complexity for now + // other origins would need to be matched against other + // modules/directories and we cannot be sure the targets are + // available within the workspace or were parsed/decoded/collected + // at the time this event occurs + continue + } + + // we only initially validate variables + // resources and data sources can have unknown schema + // and will be researched at a later point + firstStep := matchableOrigin.Address()[0] + if firstStep.String() != "var" { + continue + } + + _, ok = pathCtx.ReferenceTargets.Match(matchableOrigin) + if !ok { + // target not found + fileName := origin.OriginRange().Filename + d := &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("No declaration found for %q", matchableOrigin.Address()), + Subject: origin.OriginRange().Ptr(), + } + diagsMap[fileName] = diagsMap[fileName].Append(d) + + continue + } + + } + + return diagsMap +} diff --git a/internal/decoder/validations/unreferenced_origin_test.go b/internal/decoder/validations/unreferenced_origin_test.go new file mode 100644 index 000000000..420d4f243 --- /dev/null +++ b/internal/decoder/validations/unreferenced_origin_test.go @@ -0,0 +1,120 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validations + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl/v2" +) + +func TestUnreferencedOrigins(t *testing.T) { + tests := []struct { + name string + origins reference.Origins + want lang.DiagnosticsMap + }{ + { + name: "undeclared variable", + origins: reference.Origins{ + reference.LocalOrigin{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{}, + End: hcl.Pos{}, + }, + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + }, + }, + want: lang.DiagnosticsMap{ + "test.tf": hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "No declaration found for \"var.foo\"", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{}, + End: hcl.Pos{}, + }, + }, + }, + }, + }, + { + name: "many undeclared variables", + origins: reference.Origins{ + reference.LocalOrigin{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 10, Byte: 10}, + }, + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "foo"}, + }, + }, + reference.LocalOrigin{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 10, Byte: 10}, + }, + Addr: lang.Address{ + lang.RootStep{Name: "var"}, + lang.AttrStep{Name: "wakka"}, + }, + }, + }, + want: lang.DiagnosticsMap{ + "test.tf": hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "No declaration found for \"var.foo\"", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 10, Byte: 10}, + }, + }, + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "No declaration found for \"var.wakka\"", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 2, Column: 10, Byte: 10}, + }, + }, + }, + }, + }, + } + + for i, tt := range tests { + t.Run(fmt.Sprintf("%2d-%s", i, tt.name), func(t *testing.T) { + ctx := context.Background() + + pathCtx := &decoder.PathContext{ + ReferenceOrigins: tt.origins, + } + + ctx = decoder.WithPathContext(ctx, pathCtx) + + diags := UnreferencedOrigins(ctx) + if diff := cmp.Diff(tt.want["test.tf"], diags["test.tf"]); diff != "" { + t.Fatalf("unexpected diagnostics: %s", diff) + } + }) + } +} diff --git a/internal/indexer/document_change.go b/internal/indexer/document_change.go index eb636bbf3..74c68c33f 100644 --- a/internal/indexer/document_change.go +++ b/internal/indexer/document_change.go @@ -135,6 +135,19 @@ func (idx *Indexer) decodeModule(ctx context.Context, modHandle document.DirHand } ids = append(ids, refTargetsId) + _, err = idx.jobStore.EnqueueJob(ctx, job.Job{ + Dir: modHandle, + Func: func(ctx context.Context) error { + return module.EarlyValidation(ctx, idx.modStore, idx.schemaStore, modHandle.Path()) + }, + Type: op.OpTypeEarlyValidation.String(), + DependsOn: job.IDs{metaId, refTargetsId}, + IgnoreState: ignoreState, + }) + if err != nil { + return ids, err + } + // This job may make an HTTP request, and we schedule it in // the low-priority queue, so we don't want to wait for it. _, err = idx.jobStore.EnqueueJob(ctx, job.Job{ diff --git a/internal/langserver/handlers/command/validate.go b/internal/langserver/handlers/command/validate.go index 450e7a2b3..8ad9d4265 100644 --- a/internal/langserver/handlers/command/validate.go +++ b/internal/langserver/handlers/command/validate.go @@ -71,6 +71,7 @@ func (h *CmdHandler) TerraformValidateHandler(ctx context.Context, args cmd.Comm validateDiags := diagnostics.HCLDiagsFromJSON(jsonDiags) diags.EmptyRootDiagnostic() diags.Append("terraform validate", validateDiags) + diags.Append("early validation", mod.ValidationDiagnostics) diags.Append("HCL", mod.ModuleDiagnostics.AutoloadedOnly().AsMap()) diags.Append("HCL", mod.VarsDiagnostics.AutoloadedOnly().AsMap()) diff --git a/internal/langserver/handlers/hooks_module.go b/internal/langserver/handlers/hooks_module.go index f3669312f..76ff0b7ff 100644 --- a/internal/langserver/handlers/hooks_module.go +++ b/internal/langserver/handlers/hooks_module.go @@ -156,6 +156,7 @@ func updateDiagnostics(dNotifier *diagnostics.Notifier) notifier.Hook { defer dNotifier.PublishHCLDiags(ctx, mod.Path, diags) if mod != nil { + diags.Append("early validation", mod.ValidationDiagnostics) diags.Append("HCL", mod.ModuleDiagnostics.AutoloadedOnly().AsMap()) diags.Append("HCL", mod.VarsDiagnostics.AutoloadedOnly().AsMap()) } diff --git a/internal/state/module.go b/internal/state/module.go index 207f2c758..c9f3f986e 100644 --- a/internal/state/module.go +++ b/internal/state/module.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/go-memdb" "github.com/hashicorp/go-version" + "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl-lang/reference" "github.com/hashicorp/hcl/v2" tfaddr "github.com/hashicorp/terraform-registry-address" @@ -135,6 +136,9 @@ type Module struct { ModuleDiagnostics ast.ModDiags VarsDiagnostics ast.VarsDiags + + ValidationDiagnostics lang.DiagnosticsMap + ValidationDiagnosticsState op.OpState } func (m *Module) Copy() *Module { @@ -178,6 +182,8 @@ func (m *Module) Copy() *Module { ModuleParsingState: m.ModuleParsingState, VarsParsingState: m.VarsParsingState, + ValidationDiagnosticsState: m.ValidationDiagnosticsState, + Meta: m.Meta.Copy(), MetaErr: m.MetaErr, MetaState: m.MetaState, @@ -211,10 +217,15 @@ func (m *Module) Copy() *Module { 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 { - // hcl.Diagnostic is practically immutable once it comes out of parser - newMod.ModuleDiagnostics[name][i] = diag - } + copy(newMod.ModuleDiagnostics[name], diags) + } + } + + if m.ValidationDiagnostics != nil { + newMod.ValidationDiagnostics = make(lang.DiagnosticsMap, len(m.ValidationDiagnostics)) + for name, diags := range m.ValidationDiagnostics { + newMod.ValidationDiagnostics[name] = make(hcl.Diagnostics, len(diags)) + copy(newMod.ValidationDiagnostics[name], diags) } } @@ -222,10 +233,7 @@ func (m *Module) Copy() *Module { 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 { - // hcl.Diagnostic is practically immutable once it comes out of parser - newMod.VarsDiagnostics[name][i] = diag - } + copy(newMod.VarsDiagnostics[name], diags) } } @@ -243,6 +251,7 @@ func newModule(modPath string) *Module { RefTargetsState: op.OpStateUnknown, ModuleParsingState: op.OpStateUnknown, MetaState: op.OpStateUnknown, + ValidationDiagnosticsState: op.OpStateUnknown, } } @@ -988,6 +997,54 @@ func (s *ModuleStore) UpdateModuleDiagnostics(path string, diags ast.ModDiags) e return nil } +func (s *ModuleStore) SetValidationDiagnosticsState(path string, state op.OpState) error { + txn := s.db.Txn(true) + defer txn.Abort() + + mod, err := moduleCopyByPath(txn, path) + if err != nil { + return err + } + + mod.ValidationDiagnosticsState = state + err = txn.Insert(s.tableName, mod) + if err != nil { + return err + } + + txn.Commit() + return nil +} + +func (s *ModuleStore) UpdateValidateDiagnostics(path string, diags lang.DiagnosticsMap) error { + txn := s.db.Txn(true) + txn.Defer(func() { + s.SetValidationDiagnosticsState(path, op.OpStateLoaded) + }) + defer txn.Abort() + + oldMod, err := moduleByPath(txn, path) + if err != nil { + return err + } + + mod := oldMod.Copy() + mod.ValidationDiagnostics = diags + + err = txn.Insert(s.tableName, mod) + if err != nil { + return err + } + + err = s.queueModuleChange(txn, oldMod, mod) + if err != nil { + return err + } + + txn.Commit() + return nil +} + func (s *ModuleStore) UpdateVarsDiagnostics(path string, diags ast.VarsDiags) error { txn := s.db.Txn(true) defer txn.Abort() diff --git a/internal/state/module_changes.go b/internal/state/module_changes.go index 7bac6b2ab..c94e07d60 100644 --- a/internal/state/module_changes.go +++ b/internal/state/module_changes.go @@ -142,10 +142,10 @@ func (s *ModuleStore) queueModuleChange(txn *memdb.Txn, oldMod, newMod *Module) oldDiags, newDiags := 0, 0 if oldMod != nil { - oldDiags = oldMod.ModuleDiagnostics.Count() + oldMod.VarsDiagnostics.Count() + oldDiags = oldMod.ModuleDiagnostics.Count() + oldMod.VarsDiagnostics.Count() + oldMod.ValidationDiagnostics.Count() } if newMod != nil { - newDiags = newMod.ModuleDiagnostics.Count() + newMod.VarsDiagnostics.Count() + newDiags = newMod.ModuleDiagnostics.Count() + newMod.VarsDiagnostics.Count() + newMod.ValidationDiagnostics.Count() } // Comparing diagnostics accurately could be expensive // so we just treat any non-empty diags as a change diff --git a/internal/terraform/module/module_ops.go b/internal/terraform/module/module_ops.go index 42d9e1cd9..0b2288bfa 100644 --- a/internal/terraform/module/module_ops.go +++ b/internal/terraform/module/module_ops.go @@ -699,6 +699,45 @@ func DecodeVarsReferences(ctx context.Context, modStore *state.ModuleStore, sche return rErr } +func EarlyValidation(ctx context.Context, modStore *state.ModuleStore, schemaReader state.SchemaReader, modPath string) error { + mod, err := modStore.ModuleByPath(modPath) + if err != nil { + return err + } + + // Avoid validation if it is already in progress or already finished + if mod.ValidationDiagnosticsState != op.OpStateUnknown && !job.IgnoreState(ctx) { + return job.StateNotChangedErr{Dir: document.DirHandleFromPath(modPath)} + } + + err = modStore.SetValidationDiagnosticsState(modPath, op.OpStateLoading) + if err != nil { + return err + } + + d := decoder.NewDecoder(&idecoder.PathReader{ + ModuleReader: modStore, + SchemaReader: schemaReader, + }) + d.SetContext(idecoder.DecoderContext(ctx)) + + moduleDecoder, err := d.Path(lang.Path{ + Path: modPath, + LanguageID: ilsp.Terraform.String(), + }) + if err != nil { + return err + } + + diags, rErr := moduleDecoder.Validate(ctx) + sErr := modStore.UpdateValidateDiagnostics(modPath, diags) + if sErr != nil { + return sErr + } + + return rErr +} + func GetModuleDataFromRegistry(ctx context.Context, regClient registry.Client, modStore *state.ModuleStore, modRegStore *state.RegistryModuleStore, modPath string) error { // loop over module calls calls, err := modStore.ModuleCalls(modPath) diff --git a/internal/terraform/module/operation/op_type_string.go b/internal/terraform/module/operation/op_type_string.go index c7271afb9..a53707460 100644 --- a/internal/terraform/module/operation/op_type_string.go +++ b/internal/terraform/module/operation/op_type_string.go @@ -21,11 +21,12 @@ func _() { _ = x[OpTypeGetModuleDataFromRegistry-10] _ = x[OpTypeParseProviderVersions-11] _ = x[OpTypePreloadEmbeddedSchema-12] + _ = x[OpTypeEarlyValidation-13] } -const _OpType_name = "OpTypeUnknownOpTypeGetTerraformVersionOpTypeObtainSchemaOpTypeParseModuleConfigurationOpTypeParseVariablesOpTypeParseModuleManifestOpTypeLoadModuleMetadataOpTypeDecodeReferenceTargetsOpTypeDecodeReferenceOriginsOpTypeDecodeVarsReferencesOpTypeGetModuleDataFromRegistryOpTypeParseProviderVersionsOpTypePreloadEmbeddedSchema" +const _OpType_name = "OpTypeUnknownOpTypeGetTerraformVersionOpTypeObtainSchemaOpTypeParseModuleConfigurationOpTypeParseVariablesOpTypeParseModuleManifestOpTypeLoadModuleMetadataOpTypeDecodeReferenceTargetsOpTypeDecodeReferenceOriginsOpTypeDecodeVarsReferencesOpTypeGetModuleDataFromRegistryOpTypeParseProviderVersionsOpTypePreloadEmbeddedSchemaOpTypeEarlyValidation" -var _OpType_index = [...]uint16{0, 13, 38, 56, 86, 106, 131, 155, 183, 211, 237, 268, 295, 322} +var _OpType_index = [...]uint16{0, 13, 38, 56, 86, 106, 131, 155, 183, 211, 237, 268, 295, 322, 343} func (i OpType) String() string { if i >= OpType(len(_OpType_index)-1) { diff --git a/internal/terraform/module/operation/operation.go b/internal/terraform/module/operation/operation.go index 5a1d1e3d0..908d04ea0 100644 --- a/internal/terraform/module/operation/operation.go +++ b/internal/terraform/module/operation/operation.go @@ -30,4 +30,5 @@ const ( OpTypeGetModuleDataFromRegistry OpTypeParseProviderVersions OpTypePreloadEmbeddedSchema + OpTypeEarlyValidation )