diff --git a/earlydecoder/decoder.go b/earlydecoder/decoder.go index 4d453fcd..fc45eb6f 100644 --- a/earlydecoder/decoder.go +++ b/earlydecoder/decoder.go @@ -5,7 +5,7 @@ import ( "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" - "github.com/hashicorp/terraform-registry-address" + tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform-schema/module" ) @@ -127,10 +127,15 @@ func LoadModule(path string, files map[string]*hcl.File) (*module.Meta, hcl.Diag } } + variables := make(map[string]module.Variable) + for key, variable := range mod.Variables { + variables[key] = *variable + } return &module.Meta{ Path: path, ProviderReferences: refs, ProviderRequirements: providerRequirements, CoreRequirements: coreRequirements, + Variables: variables, }, diags } diff --git a/earlydecoder/decoder_test.go b/earlydecoder/decoder_test.go index 626f835a..66a2b42b 100644 --- a/earlydecoder/decoder_test.go +++ b/earlydecoder/decoder_test.go @@ -8,18 +8,28 @@ import ( "github.com/hashicorp/go-version" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/hashicorp/terraform-registry-address" + tfaddr "github.com/hashicorp/terraform-registry-address" "github.com/hashicorp/terraform-schema/module" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" ) +type testCase struct { + name string + cfg string + expectedMeta *module.Meta + expectedError hcl.Diagnostics +} + +var customComparer = []cmp.Option{ + cmp.Comparer(compareVersionConstraint), + ctydebug.CmpOptions, +} + func TestLoadModule(t *testing.T) { path := t.TempDir() - testCases := []struct { - name string - cfg string - expectedMeta *module.Meta - }{ + testCases := []testCase{ { "empty config", ``, @@ -27,7 +37,9 @@ func TestLoadModule(t *testing.T) { Path: path, ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{}, }, + nil, }, { "core requirements only", @@ -40,7 +52,9 @@ terraform { CoreRequirements: mustConstraints(t, "~> 0.12"), ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{}, }, + nil, }, { "legacy inferred provider requirements", @@ -76,7 +90,9 @@ provider "grafana" { tfaddr.NewLegacyProvider("google"): {}, tfaddr.NewLegacyProvider("grafana"): {}, }, + Variables: map[string]module.Variable{}, }, + nil, }, { "simplified 0.12 provider requirements", @@ -112,7 +128,9 @@ provider "grafana" { tfaddr.NewLegacyProvider("google"): mustConstraints(t, ">= 3.0.0"), tfaddr.NewLegacyProvider("grafana"): {}, }, + Variables: map[string]module.Variable{}, }, + nil, }, { "version-only 0.12 provider requirements", @@ -152,7 +170,9 @@ provider "grafana" { tfaddr.NewLegacyProvider("google"): mustConstraints(t, ">= 3.0.0"), tfaddr.NewLegacyProvider("grafana"): {}, }, + Variables: map[string]module.Variable{}, }, + nil, }, { "0.13+ provider requirements", @@ -222,7 +242,9 @@ provider "grafana" { Type: "grafana", }: mustConstraints(t, "2.1.0"), }, + Variables: map[string]module.Variable{}, }, + nil, }, { "multiple valid version requirements", @@ -282,7 +304,9 @@ resource "google_storage_bucket" "bucket" { Type: "google", }: mustConstraints(t, "2.0.0"), }, + Variables: map[string]module.Variable{}, }, + nil, }, { "multiple invalid version requirements", @@ -343,7 +367,9 @@ resource "google_storage_bucket" "bucket" { Type: "google", }: mustConstraints(t, "2.0.0"), }, + Variables: map[string]module.Variable{}, }, + nil, }, { "0.13+ provider aliases", @@ -396,7 +422,9 @@ provider "aws" { Type: "google", }: mustConstraints(t, "2.0.0"), }, + Variables: map[string]module.Variable{}, }, + nil, }, { "0.15+ provider aliases", @@ -455,7 +483,9 @@ provider "aws" { Type: "google", }: mustConstraints(t, "2.0.0"), }, + Variables: map[string]module.Variable{}, }, + nil, }, { "explicit provider association", @@ -489,11 +519,222 @@ resource "google_something" "test" { Type: "google-beta", }: mustConstraints(t, "2.0.0"), }, + Variables: map[string]module.Variable{}, + }, + nil, + }, + } + + executeTestCases(testCases, t, path) +} + +func TestLoadModule_Variables(t *testing.T) { + path := t.TempDir() + + testCases := []testCase{ + { + "no name variables", + ` +variable "" { +}`, + &module.Meta{ + Path: path, + ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{}, + }, + nil, + }, + { + "no name variables", + ` +variable { +}`, + &module.Meta{ + Path: path, + ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{}, + }, + hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Missing name for variable", + Detail: "All variable blocks must have 1 labels (name).", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 2, + Column: 10, + Byte: 10, + }, + End: hcl.Pos{ + Line: 2, + Column: 11, + Byte: 11, + }, + }, + Context: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 2, + Column: 1, + Byte: 1, + }, + End: hcl.Pos{ + Line: 2, + Column: 11, + Byte: 11, + }, + }, + }, + }, + }, + { + "double label variables", + ` +variable "one" "two" { +}`, + &module.Meta{ + Path: path, + ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{}, + }, + hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Extraneous label for variable", + Detail: "Only 1 labels (name) are expected for variable blocks.", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 2, + Column: 16, + Byte: 16, + }, + End: hcl.Pos{ + Line: 2, + Column: 21, + Byte: 21, + }, + }, + Context: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 2, + Column: 1, + Byte: 1, + }, + End: hcl.Pos{ + Line: 2, + Column: 23, + Byte: 23, + }, + }, + }, + }, + }, + { + "empty variables", + ` +variable "name" { +}`, + &module.Meta{ + Path: path, + ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{ + "name": { + Type: cty.DynamicPseudoType, + }, + }, + }, + nil, + }, + { + "variables with type", + ` +variable "name" { + type = string +}`, + &module.Meta{ + Path: path, + ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{ + "name": { + Type: cty.String, + }, + }, + }, + nil, + }, + { + "variables with description", + ` +variable "name" { + description = "description" +}`, + &module.Meta{ + Path: path, + ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{ + "name": { + Type: cty.DynamicPseudoType, + Description: "description", + }, + }, }, + nil, + }, + { + "variables with sensitive", + ` +variable "name" { + sensitive = true +}`, + &module.Meta{ + Path: path, + ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{ + "name": { + Type: cty.DynamicPseudoType, + IsSensitive: true, + }, + }, + }, + nil, + }, + { + "variables with type and description and sensitive", + ` +variable "name" { + type = string + description = "description" + sensitive = true +}`, + &module.Meta{ + Path: path, + ProviderReferences: map[module.ProviderRef]tfaddr.Provider{}, + ProviderRequirements: map[tfaddr.Provider]version.Constraints{}, + Variables: map[string]module.Variable{ + "name": { + Type: cty.String, + Description: "description", + IsSensitive: true, + }, + }, + }, + nil, }, } + executeTestCases(testCases, t, path) - opts := cmp.Comparer(compareVersionConstraint) +} +func executeTestCases(testCases []testCase, t *testing.T, path string) { for i, tc := range testCases { t.Run(fmt.Sprintf("%d-%s", i, tc.name), func(t *testing.T) { @@ -506,11 +747,12 @@ resource "google_something" "test" { } meta, diags := LoadModule(path, files) - if len(diags) > 0 { - t.Fatal(diags) + + if diff := cmp.Diff(tc.expectedError, diags, customComparer...); diff != "" { + t.Fatalf("expected errors doesn't match: %s", diff) } - if diff := cmp.Diff(tc.expectedMeta, meta, opts); diff != "" { + if diff := cmp.Diff(tc.expectedMeta, meta, customComparer...); diff != "" { t.Fatalf("module meta doesn't match: %s", diff) } }) diff --git a/earlydecoder/load_module.go b/earlydecoder/load_module.go index 855a3b88..87ee7f53 100644 --- a/earlydecoder/load_module.go +++ b/earlydecoder/load_module.go @@ -6,7 +6,9 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/terraform-schema/internal/typeexpr" "github.com/hashicorp/terraform-schema/module" + "github.com/zclconf/go-cty/cty" ) // decodedModule is the type representing a decoded Terraform module. @@ -16,15 +18,17 @@ type decodedModule struct { ProviderConfigs map[string]*providerConfig Resources map[string]*resource DataSources map[string]*dataSource + Variables map[string]*module.Variable } func newDecodedModule() *decodedModule { return &decodedModule{ RequiredCore: make([]string, 0), - ProviderRequirements: make(map[string]*providerRequirement, 0), - ProviderConfigs: make(map[string]*providerConfig, 0), - Resources: make(map[string]*resource, 0), - DataSources: make(map[string]*dataSource, 0), + ProviderRequirements: make(map[string]*providerRequirement), + ProviderConfigs: make(map[string]*providerConfig), + Resources: make(map[string]*resource), + DataSources: make(map[string]*dataSource), + Variables: make(map[string]*module.Variable), } } @@ -166,7 +170,38 @@ func loadModuleFromFile(file *hcl.File, mod *decodedModule) hcl.Diagnostics { LocalName: inferProviderNameFromType(r.Type), } } + + case "variable": + content, _, contentDiags := block.Body.PartialContent(variableSchema) + diags = append(diags, contentDiags...) + if len(block.Labels) != 1 || block.Labels[0] == "" { + continue + } + name := block.Labels[0] + description := "" + isSensitive := false + var valDiags hcl.Diagnostics + if attr, defined := content.Attributes["description"]; defined { + valDiags = gohcl.DecodeExpression(attr.Expr, nil, &description) + diags = append(diags, valDiags...) + } + varType := cty.DynamicPseudoType + if attr, defined := content.Attributes["type"]; defined { + varType, valDiags = typeexpr.TypeConstraint(attr.Expr) + diags = append(diags, valDiags...) + } + if attr, defined := content.Attributes["sensitive"]; defined { + valDiags = gohcl.DecodeExpression(attr.Expr, nil, &isSensitive) + diags = append(diags, valDiags...) + } + mod.Variables[name] = &module.Variable{ + Type: varType, + Description: description, + IsSensitive: isSensitive, + } + } + } return diags diff --git a/earlydecoder/schema.go b/earlydecoder/schema.go index 00592982..d667edbc 100644 --- a/earlydecoder/schema.go +++ b/earlydecoder/schema.go @@ -21,6 +21,10 @@ var rootSchema = &hcl.BodySchema{ Type: "data", LabelNames: []string{"type", "name"}, }, + { + Type: "variable", + LabelNames: []string{"name"}, + }, }, } @@ -55,3 +59,17 @@ var resourceSchema = &hcl.BodySchema{ }, }, } + +var variableSchema = &hcl.BodySchema{ + Attributes: []hcl.AttributeSchema{ + { + Name: "description", + }, + { + Name: "type", + }, + { + Name: "sensitive", + }, + }, +} diff --git a/internal/typeexpr/doc.go b/internal/typeexpr/doc.go new file mode 100644 index 00000000..12e14085 --- /dev/null +++ b/internal/typeexpr/doc.go @@ -0,0 +1,12 @@ +// Package typeexpr is a fork of github.com/hashicorp/hcl/v2/ext/typeexpr +// which has additional experimental support for optional attributes. +// Link to the teraform pr correspondent to this +// https://github.com/hashicorp/terraform/pull/26540 +// +// This is here as part of the module_variable_optional_attrs experiment. +// If that experiment is successful, the changes here may be upstreamed into +// HCL itself or, if we deem it to be Terraform-specific, we should at least +// update this documentation to reflect that this is now the primary +// Terraform-specific type expression implementation, separate from the +// upstream HCL one. +package typeexpr diff --git a/internal/typeexpr/get_type.go b/internal/typeexpr/get_type.go new file mode 100644 index 00000000..de5465b9 --- /dev/null +++ b/internal/typeexpr/get_type.go @@ -0,0 +1,250 @@ +package typeexpr + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" +) + +const invalidTypeSummary = "Invalid type specification" + +// getType is the internal implementation of both Type and TypeConstraint, +// using the passed flag to distinguish. When constraint is false, the "any" +// keyword will produce an error. +func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { + // First we'll try for one of our keywords + kw := hcl.ExprAsKeyword(expr) + switch kw { + case "bool": + return cty.Bool, nil + case "string": + return cty.String, nil + case "number": + return cty.Number, nil + case "any": + if constraint { + return cty.DynamicPseudoType, nil + } + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: fmt.Sprintf("The keyword %q cannot be used in this type specification: an exact type is required.", kw), + Subject: expr.Range().Ptr(), + }} + case "list", "map", "set": + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: fmt.Sprintf("The %s type constructor requires one argument specifying the element type.", kw), + Subject: expr.Range().Ptr(), + }} + case "object": + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "The object type constructor requires one argument specifying the attribute types and values as a map.", + Subject: expr.Range().Ptr(), + }} + case "tuple": + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "The tuple type constructor requires one argument specifying the element types as a list.", + Subject: expr.Range().Ptr(), + }} + case "": + // okay! we'll fall through and try processing as a call, then. + default: + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: fmt.Sprintf("The keyword %q is not a valid type specification.", kw), + Subject: expr.Range().Ptr(), + }} + } + + // If we get down here then our expression isn't just a keyword, so we'll + // try to process it as a call instead. + call, diags := hcl.ExprCall(expr) + if diags.HasErrors() { + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "A type specification is either a primitive type keyword (bool, number, string) or a complex type constructor call, like list(string).", + Subject: expr.Range().Ptr(), + }} + } + + switch call.Name { + case "bool", "string", "number", "any": + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: fmt.Sprintf("Primitive type keyword %q does not expect arguments.", call.Name), + Subject: &call.ArgsRange, + }} + } + + if len(call.Arguments) != 1 { + contextRange := call.ArgsRange + subjectRange := call.ArgsRange + if len(call.Arguments) > 1 { + // If we have too many arguments (as opposed to too _few_) then + // we'll highlight the extraneous arguments as the diagnostic + // subject. + subjectRange = hcl.RangeBetween(call.Arguments[1].Range(), call.Arguments[len(call.Arguments)-1].Range()) + } + + switch call.Name { + case "list", "set", "map": + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: fmt.Sprintf("The %s type constructor requires one argument specifying the element type.", call.Name), + Subject: &subjectRange, + Context: &contextRange, + }} + case "object": + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "The object type constructor requires one argument specifying the attribute types and values as a map.", + Subject: &subjectRange, + Context: &contextRange, + }} + case "tuple": + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "The tuple type constructor requires one argument specifying the element types as a list.", + Subject: &subjectRange, + Context: &contextRange, + }} + } + } + + switch call.Name { + + case "list": + ety, diags := getType(call.Arguments[0], constraint) + return cty.List(ety), diags + case "set": + ety, diags := getType(call.Arguments[0], constraint) + return cty.Set(ety), diags + case "map": + ety, diags := getType(call.Arguments[0], constraint) + return cty.Map(ety), diags + case "object": + attrDefs, diags := hcl.ExprMap(call.Arguments[0]) + if diags.HasErrors() { + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Object type constructor requires a map whose keys are attribute names and whose values are the corresponding attribute types.", + Subject: call.Arguments[0].Range().Ptr(), + Context: expr.Range().Ptr(), + }} + } + + atys := make(map[string]cty.Type) + var optAttrs []string + for _, attrDef := range attrDefs { + attrName := hcl.ExprAsKeyword(attrDef.Key) + if attrName == "" { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Object constructor map keys must be attribute names.", + Subject: attrDef.Key.Range().Ptr(), + Context: expr.Range().Ptr(), + }) + continue + } + atyExpr := attrDef.Value + + // the attribute type expression might be wrapped in the special + // modifier optional(...) to indicate an optional attribute. If + // so, we'll unwrap that first and make a note about it being + // optional for when we construct the type below. + if call, callDiags := hcl.ExprCall(atyExpr); !callDiags.HasErrors() { + if call.Name == "optional" { + if len(call.Arguments) < 1 { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Optional attribute modifier requires the attribute type as its argument.", + Subject: call.ArgsRange.Ptr(), + Context: atyExpr.Range().Ptr(), + }) + continue + } + if constraint { + if len(call.Arguments) > 1 { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Optional attribute modifier expects only one argument: the attribute type.", + Subject: call.ArgsRange.Ptr(), + Context: atyExpr.Range().Ptr(), + }) + } + optAttrs = append(optAttrs, attrName) + } else { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Optional attribute modifier is only for type constraints, not for exact types.", + Subject: call.NameRange.Ptr(), + Context: atyExpr.Range().Ptr(), + }) + } + atyExpr = call.Arguments[0] + } + } + + aty, attrDiags := getType(atyExpr, constraint) + diags = append(diags, attrDiags...) + atys[attrName] = aty + } + // NOTE: ObjectWithOptionalAttrs is experimental in cty at the + // time of writing, so this interface might change even in future + // minor versions of cty. We're accepting that because Terraform + // itself is considering optional attributes as experimental right now. + return cty.ObjectWithOptionalAttrs(atys, optAttrs), diags + case "tuple": + elemDefs, diags := hcl.ExprList(call.Arguments[0]) + if diags.HasErrors() { + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Tuple type constructor requires a list of element types.", + Subject: call.Arguments[0].Range().Ptr(), + Context: expr.Range().Ptr(), + }} + } + etys := make([]cty.Type, len(elemDefs)) + for i, defExpr := range elemDefs { + ety, elemDiags := getType(defExpr, constraint) + diags = append(diags, elemDiags...) + etys[i] = ety + } + return cty.Tuple(etys), diags + case "optional": + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: fmt.Sprintf("Keyword %q is valid only as a modifier for object type attributes.", call.Name), + Subject: call.NameRange.Ptr(), + }} + default: + // Can't access call.Arguments in this path because we've not validated + // that it contains exactly one expression here. + return cty.DynamicPseudoType, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: fmt.Sprintf("Keyword %q is not a valid type constructor.", call.Name), + Subject: expr.Range().Ptr(), + }} + } +} diff --git a/internal/typeexpr/public.go b/internal/typeexpr/public.go new file mode 100644 index 00000000..3b8f618f --- /dev/null +++ b/internal/typeexpr/public.go @@ -0,0 +1,129 @@ +package typeexpr + +import ( + "bytes" + "fmt" + "sort" + + "github.com/hashicorp/hcl/v2/hclsyntax" + + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" +) + +// Type attempts to process the given expression as a type expression and, if +// successful, returns the resulting type. If unsuccessful, error diagnostics +// are returned. +func Type(expr hcl.Expression) (cty.Type, hcl.Diagnostics) { + return getType(expr, false) +} + +// TypeConstraint attempts to parse the given expression as a type constraint +// and, if successful, returns the resulting type. If unsuccessful, error +// diagnostics are returned. +// +// A type constraint has the same structure as a type, but it additionally +// allows the keyword "any" to represent cty.DynamicPseudoType, which is often +// used as a wildcard in type checking and type conversion operations. +func TypeConstraint(expr hcl.Expression) (cty.Type, hcl.Diagnostics) { + return getType(expr, true) +} + +// TypeString returns a string rendering of the given type as it would be +// expected to appear in the HCL native syntax. +// +// This is primarily intended for showing types to the user in an application +// that uses typexpr, where the user can be assumed to be familiar with the +// type expression syntax. In applications that do not use typeexpr these +// results may be confusing to the user and so type.FriendlyName may be +// preferable, even though it's less precise. +// +// TypeString produces reasonable results only for types like what would be +// produced by the Type and TypeConstraint functions. In particular, it cannot +// support capsule types. +func TypeString(ty cty.Type) string { + // Easy cases first + switch ty { + case cty.String: + return "string" + case cty.Bool: + return "bool" + case cty.Number: + return "number" + case cty.DynamicPseudoType: + return "any" + } + + if ty.IsCapsuleType() { + panic("TypeString does not support capsule types") + } + + if ty.IsCollectionType() { + ety := ty.ElementType() + etyString := TypeString(ety) + switch { + case ty.IsListType(): + return fmt.Sprintf("list(%s)", etyString) + case ty.IsSetType(): + return fmt.Sprintf("set(%s)", etyString) + case ty.IsMapType(): + return fmt.Sprintf("map(%s)", etyString) + default: + // Should never happen because the above is exhaustive + panic("unsupported collection type") + } + } + + if ty.IsObjectType() { + var buf bytes.Buffer + buf.WriteString("object({") + atys := ty.AttributeTypes() + names := make([]string, 0, len(atys)) + for name := range atys { + names = append(names, name) + } + sort.Strings(names) + first := true + for _, name := range names { + aty := atys[name] + if !first { + buf.WriteByte(',') + } + if !hclsyntax.ValidIdentifier(name) { + // Should never happen for any type produced by this package, + // but we'll do something reasonable here just so we don't + // produce garbage if someone gives us a hand-assembled object + // type that has weird attribute names. + // Using Go-style quoting here isn't perfect, since it doesn't + // exactly match HCL syntax, but it's fine for an edge-case. + buf.WriteString(fmt.Sprintf("%q", name)) + } else { + buf.WriteString(name) + } + buf.WriteByte('=') + buf.WriteString(TypeString(aty)) + first = false + } + buf.WriteString("})") + return buf.String() + } + + if ty.IsTupleType() { + var buf bytes.Buffer + buf.WriteString("tuple([") + etys := ty.TupleElementTypes() + first := true + for _, ety := range etys { + if !first { + buf.WriteByte(',') + } + buf.WriteString(TypeString(ety)) + first = false + } + buf.WriteString("])") + return buf.String() + } + + // Should never happen because we covered all cases above. + panic(fmt.Errorf("unsupported type %#v", ty)) +} diff --git a/internal/typeexpr/type_type.go b/internal/typeexpr/type_type.go new file mode 100644 index 00000000..5462d82c --- /dev/null +++ b/internal/typeexpr/type_type.go @@ -0,0 +1,118 @@ +package typeexpr + +import ( + "fmt" + "reflect" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/customdecode" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + "github.com/zclconf/go-cty/cty/function" +) + +// TypeConstraintType is a cty capsule type that allows cty type constraints to +// be used as values. +// +// If TypeConstraintType is used in a context supporting the +// customdecode.CustomExpressionDecoder extension then it will implement +// expression decoding using the TypeConstraint function, thus allowing +// type expressions to be used in contexts where value expressions might +// normally be expected, such as in arguments to function calls. +var TypeConstraintType cty.Type + +// TypeConstraintVal constructs a cty.Value whose type is +// TypeConstraintType. +func TypeConstraintVal(ty cty.Type) cty.Value { + return cty.CapsuleVal(TypeConstraintType, &ty) +} + +// TypeConstraintFromVal extracts the type from a cty.Value of +// TypeConstraintType that was previously constructed using TypeConstraintVal. +// +// If the given value isn't a known, non-null value of TypeConstraintType +// then this function will panic. +func TypeConstraintFromVal(v cty.Value) cty.Type { + if !v.Type().Equals(TypeConstraintType) { + panic("value is not of TypeConstraintType") + } + ptr := v.EncapsulatedValue().(*cty.Type) + return *ptr +} + +// ConvertFunc is a cty function that implements type conversions. +// +// Its signature is as follows: +// convert(value, type_constraint) +// +// ...where type_constraint is a type constraint expression as defined by +// typeexpr.TypeConstraint. +// +// It relies on HCL's customdecode extension and so it's not suitable for use +// in non-HCL contexts or if you are using a HCL syntax implementation that +// does not support customdecode for function arguments. However, it _is_ +// supported for function calls in the HCL native expression syntax. +var ConvertFunc function.Function + +func init() { + TypeConstraintType = cty.CapsuleWithOps("type constraint", reflect.TypeOf(cty.Type{}), &cty.CapsuleOps{ + ExtensionData: func(key interface{}) interface{} { + switch key { + case customdecode.CustomExpressionDecoder: + return customdecode.CustomExpressionDecoderFunc( + func(expr hcl.Expression, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) { + ty, diags := TypeConstraint(expr) + if diags.HasErrors() { + return cty.NilVal, diags + } + return TypeConstraintVal(ty), nil + }, + ) + default: + return nil + } + }, + TypeGoString: func(_ reflect.Type) string { + return "typeexpr.TypeConstraintType" + }, + GoString: func(raw interface{}) string { + tyPtr := raw.(*cty.Type) + return fmt.Sprintf("typeexpr.TypeConstraintVal(%#v)", *tyPtr) + }, + RawEquals: func(a, b interface{}) bool { + aPtr := a.(*cty.Type) + bPtr := b.(*cty.Type) + return (*aPtr).Equals(*bPtr) + }, + }) + + ConvertFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowNull: true, + AllowDynamicType: true, + }, + { + Name: "type", + Type: TypeConstraintType, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + wantTypePtr := args[1].EncapsulatedValue().(*cty.Type) + got, err := convert.Convert(args[0], *wantTypePtr) + if err != nil { + return cty.NilType, function.NewArgError(0, err) + } + return got.Type(), nil + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + v, err := convert.Convert(args[0], retType) + if err != nil { + return cty.NilVal, function.NewArgError(0, err) + } + return v, nil + }, + }) +} diff --git a/module/meta.go b/module/meta.go index 822cff53..e8d34391 100644 --- a/module/meta.go +++ b/module/meta.go @@ -2,7 +2,7 @@ package module import ( "github.com/hashicorp/go-version" - "github.com/hashicorp/terraform-registry-address" + tfaddr "github.com/hashicorp/terraform-registry-address" ) type Meta struct { @@ -11,6 +11,7 @@ type Meta struct { ProviderReferences map[ProviderRef]tfaddr.Provider ProviderRequirements map[tfaddr.Provider]version.Constraints CoreRequirements version.Constraints + Variables map[string]Variable } type ProviderRef struct { diff --git a/module/variable.go b/module/variable.go new file mode 100644 index 00000000..843d933c --- /dev/null +++ b/module/variable.go @@ -0,0 +1,14 @@ +package module + +import ( + "github.com/zclconf/go-cty/cty" +) + +type Variable struct { + Description string + Type cty.Type + + // In case the version it is before 0.14 sensitive will always be false + // that was actually the default value for prior versions + IsSensitive bool +}