diff --git a/cmd/hcldec/spec-format.md b/cmd/hcldec/spec-format.md index 9f4d7a9..e26e10f 100644 --- a/cmd/hcldec/spec-format.md +++ b/cmd/hcldec/spec-format.md @@ -258,6 +258,40 @@ of the given type must have. `block` expects a single nested spec block, which is applied to the body of each matching block to produce the resulting map items. +## `block_attrs` spec blocks + +The `block_attrs` spec type is similar to an `attr` spec block of a map type, +but it produces a map from the attributes of a block rather than from an +attribute's expression. + +```hcl +block_attrs { + block_type = "variables" + element_type = string + required = false +} +``` + +This allows a map with user-defined keys to be produced within block syntax, +but due to the constraints of that syntax it also means that the user will +be unable to dynamically-generate either individual key names using key +expressions or the entire map value using a `for` expression. + +`block_attrs` spec blocks accept the following arguments: + +* `block_type` (required) - The block type name to expect within the HCL + input file. This may be omitted when a default name selector is created + by a parent `object` spec, if the input block type name should match the + output JSON object property name. + +* `element_type` (required) - The value type to require for each of the + attributes within a matched block. The resulting value will be a JSON + object whose property values are of this type. + +* `required` (optional) - If `true`, an error will be produced if a block + of the given type is not present. If `false` -- the default -- an absent + block will be indicated by producing `null`. + ## `literal` spec blocks The `literal` spec type returns a given literal value, and creates no diff --git a/cmd/hcldec/spec.go b/cmd/hcldec/spec.go index 31effd3..b62f05b 100644 --- a/cmd/hcldec/spec.go +++ b/cmd/hcldec/spec.go @@ -135,6 +135,9 @@ func decodeSpecBlock(block *hcl.Block) (hcldec.Spec, hcl.Diagnostics) { case "block_map": return decodeBlockMapSpec(block.Body, impliedName) + case "block_attrs": + return decodeBlockAttrsSpec(block.Body, impliedName) + case "default": return decodeDefaultSpec(block.Body) @@ -429,6 +432,47 @@ func decodeBlockNestedSpec(body hcl.Body) (hcldec.Spec, hcl.Diagnostics) { return spec, diags } +func decodeBlockAttrsSpec(body hcl.Body, impliedName string) (hcldec.Spec, hcl.Diagnostics) { + type content struct { + TypeName *string `hcl:"block_type"` + ElementType hcl.Expression `hcl:"element_type"` + Required *bool `hcl:"required"` + } + + var args content + diags := gohcl.DecodeBody(body, nil, &args) + if diags.HasErrors() { + return errSpec, diags + } + + spec := &hcldec.BlockAttrsSpec{ + TypeName: impliedName, + } + + if args.Required != nil { + spec.Required = *args.Required + } + if args.TypeName != nil { + spec.TypeName = *args.TypeName + } + + var typeDiags hcl.Diagnostics + spec.ElementType, typeDiags = evalTypeExpr(args.ElementType) + diags = append(diags, typeDiags...) + + if spec.TypeName == "" { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Missing block_type in block_attrs spec", + Detail: "The block_type attribute is required, to specify the block type name that is expected in an input HCL file.", + Subject: body.MissingItemRange().Ptr(), + }) + return errSpec, diags + } + + return spec, diags +} + func decodeLiteralSpec(body hcl.Body) (hcldec.Spec, hcl.Diagnostics) { type content struct { Value cty.Value `hcl:"value"` diff --git a/hcldec/public_test.go b/hcldec/public_test.go index 23406dc..05ed17a 100644 --- a/hcldec/public_test.go +++ b/hcldec/public_test.go @@ -243,6 +243,121 @@ b {} }, { ` +b { +} +`, + &BlockAttrsSpec{ + TypeName: "b", + ElementType: cty.String, + }, + nil, + cty.MapValEmpty(cty.String), + 0, + }, + { + ` +b { + hello = "world" +} +`, + &BlockAttrsSpec{ + TypeName: "b", + ElementType: cty.String, + }, + nil, + cty.MapVal(map[string]cty.Value{ + "hello": cty.StringVal("world"), + }), + 0, + }, + { + ` +b { + hello = true +} +`, + &BlockAttrsSpec{ + TypeName: "b", + ElementType: cty.String, + }, + nil, + cty.MapVal(map[string]cty.Value{ + "hello": cty.StringVal("true"), + }), + 0, + }, + { + ` +b { + hello = true + goodbye = 5 +} +`, + &BlockAttrsSpec{ + TypeName: "b", + ElementType: cty.String, + }, + nil, + cty.MapVal(map[string]cty.Value{ + "hello": cty.StringVal("true"), + "goodbye": cty.StringVal("5"), + }), + 0, + }, + { + ``, + &BlockAttrsSpec{ + TypeName: "b", + ElementType: cty.String, + }, + nil, + cty.NullVal(cty.Map(cty.String)), + 0, + }, + { + ``, + &BlockAttrsSpec{ + TypeName: "b", + ElementType: cty.String, + Required: true, + }, + nil, + cty.NullVal(cty.Map(cty.String)), + 1, // missing b block + }, + { + ` +b { +} +b { +} + `, + &BlockAttrsSpec{ + TypeName: "b", + ElementType: cty.String, + }, + nil, + cty.MapValEmpty(cty.String), + 1, // duplicate b block + }, + { + ` +b { +} +b { +} + `, + &BlockAttrsSpec{ + TypeName: "b", + ElementType: cty.String, + Required: true, + }, + nil, + cty.MapValEmpty(cty.String), + 1, // duplicate b block + }, + { + ` b {} b {} `, diff --git a/hcldec/spec.go b/hcldec/spec.go index 0d1288c..0bb3f3e 100644 --- a/hcldec/spec.go +++ b/hcldec/spec.go @@ -3,6 +3,7 @@ package hcldec import ( "bytes" "fmt" + "sort" "github.com/hashicorp/hcl2/hcl" "github.com/zclconf/go-cty/cty" @@ -765,6 +766,163 @@ func (s *BlockMapSpec) sourceRange(content *hcl.BodyContent, blockLabels []block return sourceRange(childBlock.Body, labelsForBlock(childBlock), s.Nested) } +// A BlockAttrsSpec is a Spec that interprets a single block as if it were +// a map of some element type. That is, each attribute within the block +// becomes a key in the resulting map and the attribute's value becomes the +// element value, after conversion to the given element type. The resulting +// value is a cty.Map of the given element type. +// +// This spec imposes a validation constraint that there be exactly one block +// of the given type name and that this block may contain only attributes. The +// block does not accept any labels. +// +// This is an alternative to an AttrSpec of a map type for situations where +// block syntax is desired. Note that block syntax does not permit dynamic +// keys, construction of the result via a "for" expression, etc. In most cases +// an AttrSpec is preferred if the desired result is a map whose keys are +// chosen by the user rather than by schema. +type BlockAttrsSpec struct { + TypeName string + ElementType cty.Type + Required bool +} + +func (s *BlockAttrsSpec) visitSameBodyChildren(cb visitFunc) { + // leaf node +} + +// blockSpec implementation +func (s *BlockAttrsSpec) blockHeaderSchemata() []hcl.BlockHeaderSchema { + return []hcl.BlockHeaderSchema{ + { + Type: s.TypeName, + LabelNames: nil, + }, + } +} + +// blockSpec implementation +func (s *BlockAttrsSpec) nestedSpec() Spec { + // This is an odd case: we aren't actually going to apply a nested spec + // in this case, since we're going to interpret the body directly as + // attributes, but we need to return something non-nil so that the + // decoder will recognize this as a block spec. We won't actually be + // using this for anything at decode time. + return noopSpec{} +} + +// specNeedingVariables implementation +func (s *BlockAttrsSpec) variablesNeeded(content *hcl.BodyContent) []hcl.Traversal { + + block, _ := s.findBlock(content) + if block == nil { + return nil + } + + var vars []hcl.Traversal + + attrs, diags := block.Body.JustAttributes() + if diags.HasErrors() { + return nil + } + + for _, attr := range attrs { + vars = append(vars, attr.Expr.Variables()...) + } + + // We'll return the variables references in source order so that any + // error messages that result are also in source order. + sort.Slice(vars, func(i, j int) bool { + return vars[i].SourceRange().Start.Byte < vars[j].SourceRange().Start.Byte + }) + + return vars +} + +func (s *BlockAttrsSpec) decode(content *hcl.BodyContent, blockLabels []blockLabel, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) { + var diags hcl.Diagnostics + + block, other := s.findBlock(content) + if block == nil { + if s.Required { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Missing %s block", s.TypeName), + Detail: fmt.Sprintf( + "A block of type %q is required here.", s.TypeName, + ), + Subject: &content.MissingItemRange, + }) + } + return cty.NullVal(cty.Map(s.ElementType)), diags + } + if other != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Duplicate %s block", s.TypeName), + Detail: fmt.Sprintf( + "Only one block of type %q is allowed. Previous definition was at %s.", + s.TypeName, block.DefRange.String(), + ), + Subject: &other.DefRange, + }) + } + + attrs, attrDiags := block.Body.JustAttributes() + diags = append(diags, attrDiags...) + + if len(attrs) == 0 { + return cty.MapValEmpty(s.ElementType), diags + } + + vals := make(map[string]cty.Value, len(attrs)) + for name, attr := range attrs { + attrVal, attrDiags := attr.Expr.Value(ctx) + diags = append(diags, attrDiags...) + + attrVal, err := convert.Convert(attrVal, s.ElementType) + if err != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid attribute value", + Detail: fmt.Sprintf("Invalid value for attribute of %q block: %s.", s.TypeName, err), + Subject: attr.Expr.Range().Ptr(), + }) + attrVal = cty.UnknownVal(s.ElementType) + } + + vals[name] = attrVal + } + + return cty.MapVal(vals), diags +} + +func (s *BlockAttrsSpec) impliedType() cty.Type { + return cty.Map(s.ElementType) +} + +func (s *BlockAttrsSpec) sourceRange(content *hcl.BodyContent, blockLabels []blockLabel) hcl.Range { + block, _ := s.findBlock(content) + if block == nil { + return content.MissingItemRange + } + return block.DefRange +} + +func (s *BlockAttrsSpec) findBlock(content *hcl.BodyContent) (block *hcl.Block, other *hcl.Block) { + for _, candidate := range content.Blocks { + if candidate.Type != s.TypeName { + continue + } + if block != nil { + return block, candidate + } + block = candidate + } + + return block, nil +} + // A BlockLabelSpec is a Spec that returns a cty.String representing the // label of the block its given body belongs to, if indeed its given body // belongs to a block. It is a programming error to use this in a non-block @@ -1038,3 +1196,28 @@ func (s *TransformFuncSpec) sourceRange(content *hcl.BodyContent, blockLabels [] // not super-accurate, because there's nothing better to return. return s.Wrapped.sourceRange(content, blockLabels) } + +// noopSpec is a placeholder spec that does nothing, used in situations where +// a non-nil placeholder spec is required. It is not exported because there is +// no reason to use it directly; it is always an implementation detail only. +type noopSpec struct { +} + +func (s noopSpec) decode(content *hcl.BodyContent, blockLabels []blockLabel, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) { + return cty.NullVal(cty.DynamicPseudoType), nil +} + +func (s noopSpec) impliedType() cty.Type { + return cty.DynamicPseudoType +} + +func (s noopSpec) visitSameBodyChildren(cb visitFunc) { + // nothing to do +} + +func (s noopSpec) sourceRange(content *hcl.BodyContent, blockLabels []blockLabel) hcl.Range { + // No useful range for a noopSpec, and nobody should be calling this anyway. + return hcl.Range{ + Filename: "noopSpec", + } +} diff --git a/hcldec/spec_test.go b/hcldec/spec_test.go index 90aa213..6a80a9d 100644 --- a/hcldec/spec_test.go +++ b/hcldec/spec_test.go @@ -21,6 +21,7 @@ var _ Spec = (*BlockSpec)(nil) var _ Spec = (*BlockListSpec)(nil) var _ Spec = (*BlockSetSpec)(nil) var _ Spec = (*BlockMapSpec)(nil) +var _ Spec = (*BlockAttrsSpec)(nil) var _ Spec = (*BlockLabelSpec)(nil) var _ Spec = (*DefaultSpec)(nil) var _ Spec = (*TransformExprSpec)(nil) @@ -33,6 +34,7 @@ var _ blockSpec = (*BlockSpec)(nil) var _ blockSpec = (*BlockListSpec)(nil) var _ blockSpec = (*BlockSetSpec)(nil) var _ blockSpec = (*BlockMapSpec)(nil) +var _ blockSpec = (*BlockAttrsSpec)(nil) var _ blockSpec = (*DefaultSpec)(nil) var _ specNeedingVariables = (*AttrSpec)(nil) @@ -40,6 +42,7 @@ var _ specNeedingVariables = (*BlockSpec)(nil) var _ specNeedingVariables = (*BlockListSpec)(nil) var _ specNeedingVariables = (*BlockSetSpec)(nil) var _ specNeedingVariables = (*BlockMapSpec)(nil) +var _ specNeedingVariables = (*BlockAttrsSpec)(nil) func TestDefaultSpec(t *testing.T) { config := ` diff --git a/hcldec/variables_test.go b/hcldec/variables_test.go index 5869e8d..5258ea4 100644 --- a/hcldec/variables_test.go +++ b/hcldec/variables_test.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/hcl2/hcl" "github.com/hashicorp/hcl2/hcl/hclsyntax" + "github.com/zclconf/go-cty/cty" ) func TestVariables(t *testing.T) { @@ -118,6 +119,38 @@ b { }, { ` +b { + a = foo + b = bar +} +`, + &BlockAttrsSpec{ + TypeName: "b", + ElementType: cty.String, + }, + []hcl.Traversal{ + { + hcl.TraverseRoot{ + Name: "foo", + SrcRange: hcl.Range{ + Start: hcl.Pos{Line: 3, Column: 7, Byte: 11}, + End: hcl.Pos{Line: 3, Column: 10, Byte: 14}, + }, + }, + }, + { + hcl.TraverseRoot{ + Name: "bar", + SrcRange: hcl.Range{ + Start: hcl.Pos{Line: 4, Column: 7, Byte: 21}, + End: hcl.Pos{Line: 4, Column: 10, Byte: 24}, + }, + }, + }, + }, + }, + { + ` b { a = foo }