From 512ed6b7f8c8fdc7327c24568e77616cdff3dcef Mon Sep 17 00:00:00 2001 From: Radek Simko Date: Thu, 11 Feb 2021 11:07:52 +0000 Subject: [PATCH] Add expression support for literal values --- decoder/attribute_candidates.go | 65 +- decoder/attribute_candidates_test.go | 61 +- decoder/body_decoder_test.go | 16 +- decoder/candidates.go | 36 +- decoder/candidates_test.go | 90 +-- decoder/decoder.go | 5 + decoder/errors.go | 11 + decoder/expression_candidates.go | 685 ++++++++++++++++++ decoder/expression_candidates_test.go | 702 ++++++++++++++++++ decoder/expression_constraints.go | 199 ++++++ decoder/hover.go | 258 ++++++- decoder/hover_expressions_test.go | 582 +++++++++++++++ decoder/hover_test.go | 18 +- decoder/links_test.go | 4 +- decoder/semantic_tokens.go | 217 ++++++ decoder/semantic_tokens_expr_test.go | 980 ++++++++++++++++++++++++++ decoder/semantic_tokens_test.go | 92 ++- decoder/symbol.go | 50 +- decoder/symbol_test.go | 1 + decoder/symbols.go | 69 +- decoder/symbols_test.go | 245 ++++++- go.mod | 1 + lang/candidate.go | 2 + lang/candidate_kind_string.go | 27 + lang/markup.go | 1 + lang/markup_kind_string.go | 25 + lang/semantic_token.go | 10 + lang/semantic_token_type_string.go | 10 +- lang/symbol_kind.go | 31 + schema/attribute_schema.go | 21 +- schema/attribute_schema_test.go | 25 +- schema/dependent_schema_test.go | 50 +- schema/expressions.go | 97 +++ 33 files changed, 4397 insertions(+), 289 deletions(-) create mode 100644 decoder/expression_candidates.go create mode 100644 decoder/expression_candidates_test.go create mode 100644 decoder/expression_constraints.go create mode 100644 decoder/hover_expressions_test.go create mode 100644 decoder/semantic_tokens_expr_test.go create mode 100644 lang/candidate_kind_string.go create mode 100644 lang/markup_kind_string.go create mode 100644 schema/expressions.go diff --git a/decoder/attribute_candidates.go b/decoder/attribute_candidates.go index 8ccb9264..8d4c8525 100644 --- a/decoder/attribute_candidates.go +++ b/decoder/attribute_candidates.go @@ -34,71 +34,18 @@ func detailForAttribute(attr *schema.AttributeSchema) string { detail = "Optional" } - if len(attr.ValueTypes) > 0 { - detail += fmt.Sprintf(", %s", strings.Join(attr.ValueTypes.FriendlyNames(), " or ")) - } else { - detail += fmt.Sprintf(", %s", attr.ValueType.FriendlyName()) + ec := ExprConstraints(attr.Expr) + names := ec.FriendlyNames() + + if len(names) > 0 { + detail += fmt.Sprintf(", %s", strings.Join(names, " or ")) } return detail } func snippetForAttribute(name string, attr *schema.AttributeSchema) string { - if len(attr.ValueTypes) > 0 { - return fmt.Sprintf("%s = %s", name, snippetForAttrValue(1, attr.ValueTypes[0])) - } - return fmt.Sprintf("%s = %s", name, snippetForAttrValue(1, attr.ValueType)) -} - -func snippetForAttrValue(placeholder uint, attrType cty.Type) string { - switch attrType { - case cty.String: - return fmt.Sprintf(`"${%d:value}"`, placeholder) - case cty.Bool: - return fmt.Sprintf(`${%d:false}`, placeholder) - case cty.Number: - return fmt.Sprintf(`${%d:1}`, placeholder) - case cty.DynamicPseudoType: - return fmt.Sprintf(`${%d}`, placeholder) - } - - if attrType.IsMapType() { - return fmt.Sprintf("{\n"+` "${1:key}" = %s`+"\n}", - snippetForAttrValue(placeholder+1, *attrType.MapElementType())) - } - - if attrType.IsListType() || attrType.IsSetType() { - elType := attrType.ElementType() - return fmt.Sprintf("[ %s ]", snippetForAttrValue(placeholder, elType)) - } - - if attrType.IsObjectType() { - objSnippet := "" - for _, name := range sortedObjectAttrNames(attrType) { - valType := attrType.AttributeType(name) - - objSnippet += fmt.Sprintf(" %s = %s\n", name, - snippetForAttrValue(placeholder, valType)) - placeholder++ - } - return fmt.Sprintf("{\n%s}", objSnippet) - } - - if attrType.IsTupleType() { - elTypes := attrType.TupleElementTypes() - if len(elTypes) == 1 { - return fmt.Sprintf("[ %s ]", snippetForAttrValue(placeholder, elTypes[0])) - } - - tupleSnippet := "" - for _, elType := range elTypes { - placeholder++ - tupleSnippet += snippetForAttrValue(placeholder, elType) - } - return fmt.Sprintf("[\n%s]", tupleSnippet) - } - - return "" + return fmt.Sprintf("%s = %s", name, snippetForExprContraints(1, attr.Expr)) } func sortedObjectAttrNames(obj cty.Type) []string { diff --git a/decoder/attribute_candidates_test.go b/decoder/attribute_candidates_test.go index 649e7ff7..cf4a0829 100644 --- a/decoder/attribute_candidates_test.go +++ b/decoder/attribute_candidates_test.go @@ -20,7 +20,7 @@ func TestSnippetForAttribute(t *testing.T) { "primitive type", "primitive", &schema.AttributeSchema{ - ValueType: cty.String, + Expr: schema.LiteralTypeOnly(cty.String), }, `primitive = "${1:value}"`, }, @@ -28,7 +28,7 @@ func TestSnippetForAttribute(t *testing.T) { "map of strings", "mymap", &schema.AttributeSchema{ - ValueType: cty.Map(cty.String), + Expr: schema.LiteralTypeOnly(cty.Map(cty.String)), }, `mymap = { "${1:key}" = "${2:value}" @@ -38,7 +38,7 @@ func TestSnippetForAttribute(t *testing.T) { "map of numbers", "mymap", &schema.AttributeSchema{ - ValueType: cty.Map(cty.Number), + Expr: schema.LiteralTypeOnly(cty.Map(cty.Number)), }, `mymap = { "${1:key}" = ${2:1} @@ -48,7 +48,7 @@ func TestSnippetForAttribute(t *testing.T) { "list of numbers", "mylist", &schema.AttributeSchema{ - ValueType: cty.List(cty.Number), + Expr: schema.LiteralTypeOnly(cty.List(cty.Number)), }, `mylist = [ ${1:1} ]`, }, @@ -56,10 +56,10 @@ func TestSnippetForAttribute(t *testing.T) { "list of objects", "mylistobj", &schema.AttributeSchema{ - ValueType: cty.List(cty.Object(map[string]cty.Type{ + Expr: schema.LiteralTypeOnly(cty.List(cty.Object(map[string]cty.Type{ "first": cty.String, "second": cty.Number, - })), + }))), }, `mylistobj = [ { first = "${1:value}" @@ -70,7 +70,7 @@ func TestSnippetForAttribute(t *testing.T) { "set of numbers", "myset", &schema.AttributeSchema{ - ValueType: cty.Set(cty.Number), + Expr: schema.LiteralTypeOnly(cty.Set(cty.Number)), }, `myset = [ ${1:1} ]`, }, @@ -78,11 +78,11 @@ func TestSnippetForAttribute(t *testing.T) { "object", "myobj", &schema.AttributeSchema{ - ValueType: cty.Object(map[string]cty.Type{ + Expr: schema.LiteralTypeOnly(cty.Object(map[string]cty.Type{ "keystr": cty.String, "keynum": cty.Number, "keybool": cty.Bool, - }), + })), }, `myobj = { keybool = ${1:false} @@ -94,31 +94,30 @@ func TestSnippetForAttribute(t *testing.T) { "unknown type", "mynil", &schema.AttributeSchema{ - ValueType: cty.DynamicPseudoType, + Expr: schema.LiteralTypeOnly(cty.DynamicPseudoType), }, `mynil = ${1}`, }, - // TODO: Indent nested objects correctly - // { - // "nested object", - // "myobj", - // &schema.AttributeSchema{ - // ValueType: cty.Object(map[string]cty.Type{ - // "keystr": cty.String, - // "another": cty.Object(map[string]cty.Type{ - // "nestedstr": cty.String, - // "nested_number": cty.Number, - // }), - // }), - // }, - // `myobj { - // another { - // nested_number = ${1:1} - // nestedstr = "${2:value}" - // } - // keystr = "${2:value}" - // }`, - // }, + { + "nested object", + "myobj", + &schema.AttributeSchema{ + Expr: schema.LiteralTypeOnly(cty.Object(map[string]cty.Type{ + "keystr": cty.String, + "another": cty.Object(map[string]cty.Type{ + "nestedstr": cty.String, + "nested_number": cty.Number, + }), + })), + }, + `myobj = { + another = { + nested_number = ${1:1} + nestedstr = "${2:value}" + } + keystr = "${3:value}" +}`, + }, } for i, tc := range testCases { diff --git a/decoder/body_decoder_test.go b/decoder/body_decoder_test.go index 835f0233..e01ac442 100644 --- a/decoder/body_decoder_test.go +++ b/decoder/body_decoder_test.go @@ -20,10 +20,10 @@ func TestDecoder_CandidateAtPos_incompleteAttributes(t *testing.T) { }, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "attr1": {ValueType: cty.Number}, - "attr2": {ValueType: cty.Number}, - "some_other_attr": {ValueType: cty.Number}, - "another_attr": {ValueType: cty.Number}, + "attr1": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "attr2": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "some_other_attr": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "another_attr": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, }, @@ -93,10 +93,10 @@ func TestDecoder_CandidateAtPos_computedAttributes(t *testing.T) { }, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "attr1": {ValueType: cty.Number, IsComputed: true}, - "attr2": {ValueType: cty.Number, IsComputed: true, IsOptional: true}, - "some_other_attr": {ValueType: cty.Number}, - "another_attr": {ValueType: cty.Number}, + "attr1": {Expr: schema.LiteralTypeOnly(cty.Number), IsComputed: true}, + "attr2": {Expr: schema.LiteralTypeOnly(cty.Number), IsComputed: true, IsOptional: true}, + "some_other_attr": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "another_attr": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, }, diff --git a/decoder/candidates.go b/decoder/candidates.go index f678dd06..30f3a099 100644 --- a/decoder/candidates.go +++ b/decoder/candidates.go @@ -42,19 +42,24 @@ func (d *Decoder) candidatesAtPos(body *hclsyntax.Body, bodySchema *schema.BodyS filename := body.Range().Filename for _, attr := range body.Attributes { - if isRightHandSidePos(attr, pos) { - // TODO: RHS candidates (requires a form of expression schema) - return lang.ZeroCandidates(), &PositionalError{ - Filename: filename, - Pos: pos, - Msg: fmt.Sprintf("%s: no candidates for attribute value", attr.Name), + if attr.Expr.Range().ContainsPos(pos) || attr.EqualsRange.End.Byte == pos.Byte { + if aSchema, ok := bodySchema.Attributes[attr.Name]; ok { + return d.attrValueCandidatesAtPos(attr, aSchema, pos) } + if bodySchema.AnyAttribute != nil { + return d.attrValueCandidatesAtPos(attr, bodySchema.AnyAttribute, pos) + } + + return lang.ZeroCandidates(), nil } if attr.NameRange.ContainsPos(pos) { prefixRng := attr.NameRange prefixRng.End = pos return d.bodySchemaCandidates(body, bodySchema, prefixRng, attr.Range()), nil } + if attr.EqualsRange.ContainsPos(pos) { + return lang.ZeroCandidates(), nil + } } rng := hcl.Range{ @@ -128,25 +133,6 @@ func (d *Decoder) candidatesAtPos(body *hclsyntax.Body, bodySchema *schema.BodyS return d.bodySchemaCandidates(body, bodySchema, rng, rng), nil } -func isRightHandSidePos(attr *hclsyntax.Attribute, pos hcl.Pos) bool { - // Here we assume 1 attribute per line - // which allows us to also catch position in trailing whitespace - // (which HCL parser doesn't consider attribute's range) - if attr.Range().End.Line != pos.Line { - // entirely different line - return false - } - if pos.Column < attr.Range().Start.Column { - // indentation - return false - } - if attr.NameRange.ContainsPos(pos) { - return false - } - - return true -} - func (d *Decoder) nameTokenRangeAtPos(filename string, pos hcl.Pos) (hcl.Range, error) { rng := hcl.Range{ Filename: filename, diff --git a/decoder/candidates_test.go b/decoder/candidates_test.go index aa246202..b54bdbf8 100644 --- a/decoder/candidates_test.go +++ b/decoder/candidates_test.go @@ -59,7 +59,7 @@ func TestDecoder_CandidatesAtPos_unknownBlock(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "count": {ValueType: cty.Number}, + "count": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, } @@ -105,7 +105,7 @@ func TestDecoder_CandidatesAtPos_prefixNearEOF(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "count": {ValueType: cty.Number}, + "count": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, } @@ -169,7 +169,7 @@ func TestDecoder_CandidatesAtPos_invalidBlockPositions(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "num_attr": {ValueType: cty.Number}, + "num_attr": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, } @@ -244,8 +244,8 @@ func TestDecoder_CandidatesAtPos_rightHandSide(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "num_attr": {ValueType: cty.Number}, - "str_attr": {ValueType: cty.String}, + "num_attr": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "str_attr": {Expr: schema.LiteralTypeOnly(cty.String)}, }, }, } @@ -267,16 +267,17 @@ func TestDecoder_CandidatesAtPos_rightHandSide(t *testing.T) { t.Fatal(err) } - _, err = d.CandidatesAtPos("test.tf", hcl.Pos{ + candidates, err := d.CandidatesAtPos("test.tf", hcl.Pos{ Line: 2, Column: 13, Byte: 28, }) - if err == nil { - t.Fatal("expected error") + if err != nil { + t.Fatal(err) } - if !strings.Contains(err.Error(), "no candidates for attribute value") { - t.Fatalf("unexpected error message: %q", err.Error()) + expectedCandidates := lang.CompleteCandidates([]lang.Candidate{}) + if diff := cmp.Diff(expectedCandidates, candidates); diff != "" { + t.Fatalf("unexpected candidates: %s", diff) } } @@ -288,8 +289,8 @@ func TestDecoder_CandidatesAtPos_rightHandSideInString(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "num_attr": {ValueType: cty.Number}, - "str_attr": {ValueType: cty.String}, + "num_attr": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "str_attr": {Expr: schema.LiteralTypeOnly(cty.String)}, }, }, } @@ -311,16 +312,17 @@ func TestDecoder_CandidatesAtPos_rightHandSideInString(t *testing.T) { t.Fatal(err) } - _, err = d.CandidatesAtPos("test.tf", hcl.Pos{ + candidates, err := d.CandidatesAtPos("test.tf", hcl.Pos{ Line: 2, Column: 15, Byte: 30, }) - if err == nil { - t.Fatal("expected error") + if err != nil { + t.Fatal(err) } - if !strings.Contains(err.Error(), "no candidates for attribute value") { - t.Fatalf("unexpected error message: %q", err.Error()) + expectedCandidates := lang.CompleteCandidates([]lang.Candidate{}) + if diff := cmp.Diff(expectedCandidates, candidates); diff != "" { + t.Fatalf("unexpected candidates: %s", diff) } } @@ -336,9 +338,9 @@ func TestDecoder_CandidatesAtPos_endOfLabel(t *testing.T) { }, }): { Attributes: map[string]*schema.AttributeSchema{ - "one": {ValueType: cty.String}, - "two": {ValueType: cty.Number}, - "three": {ValueType: cty.Bool}, + "one": {Expr: schema.LiteralTypeOnly(cty.String)}, + "two": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "three": {Expr: schema.LiteralTypeOnly(cty.Bool)}, }, }, schema.NewSchemaKey(schema.DependencyKeys{ @@ -347,8 +349,8 @@ func TestDecoder_CandidatesAtPos_endOfLabel(t *testing.T) { }, }): { Attributes: map[string]*schema.AttributeSchema{ - "four": {ValueType: cty.Number}, - "five": {ValueType: cty.DynamicPseudoType}, + "four": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "five": {Expr: schema.LiteralTypeOnly(cty.DynamicPseudoType)}, }, }, }, @@ -436,7 +438,7 @@ func TestDecoder_CandidatesAtPos_zeroByteContent(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "count": {ValueType: cty.Number}, + "count": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, } @@ -492,7 +494,7 @@ func TestDecoder_CandidatesAtPos_endOfFilePos(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "count": {ValueType: cty.Number}, + "count": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, } @@ -556,7 +558,7 @@ func TestDecoder_CandidatesAtPos_emptyLabel(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "count": {ValueType: cty.Number}, + "count": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, DependentBody: map[schema.SchemaKey]*schema.BodySchema{ @@ -566,9 +568,9 @@ func TestDecoder_CandidatesAtPos_emptyLabel(t *testing.T) { }, }): { Attributes: map[string]*schema.AttributeSchema{ - "one": {ValueType: cty.String, IsRequired: true}, - "two": {ValueType: cty.Number}, - "three": {ValueType: cty.Bool}, + "one": {Expr: schema.LiteralTypeOnly(cty.String), IsRequired: true}, + "two": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "three": {Expr: schema.LiteralTypeOnly(cty.Bool)}, }, }, schema.NewSchemaKey(schema.DependencyKeys{ @@ -577,8 +579,8 @@ func TestDecoder_CandidatesAtPos_emptyLabel(t *testing.T) { }, }): { Attributes: map[string]*schema.AttributeSchema{ - "four": {ValueType: cty.Number}, - "five": {ValueType: cty.DynamicPseudoType}, + "four": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "five": {Expr: schema.LiteralTypeOnly(cty.DynamicPseudoType)}, }, }, }, @@ -651,7 +653,7 @@ func TestDecoder_CandidatesAtPos_basic(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "count": {ValueType: cty.Number}, + "count": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, DependentBody: map[schema.SchemaKey]*schema.BodySchema{ @@ -661,9 +663,9 @@ func TestDecoder_CandidatesAtPos_basic(t *testing.T) { }, }): { Attributes: map[string]*schema.AttributeSchema{ - "one": {ValueType: cty.String, IsRequired: true}, - "two": {ValueType: cty.Number}, - "three": {ValueType: cty.Bool}, + "one": {Expr: schema.LiteralTypeOnly(cty.String), IsRequired: true}, + "two": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "three": {Expr: schema.LiteralTypeOnly(cty.Bool)}, }, }, schema.NewSchemaKey(schema.DependencyKeys{ @@ -672,8 +674,8 @@ func TestDecoder_CandidatesAtPos_basic(t *testing.T) { }, }): { Attributes: map[string]*schema.AttributeSchema{ - "four": {ValueType: cty.Number}, - "five": {ValueType: cty.DynamicPseudoType}, + "four": {Expr: schema.LiteralTypeOnly(cty.Number)}, + "five": {Expr: schema.LiteralTypeOnly(cty.DynamicPseudoType)}, }, }, }, @@ -903,10 +905,10 @@ func TestDecoder_CandidatesAtPos_AnyAttribute(t *testing.T) { providersSchema := &schema.BlockSchema{ Body: &schema.BodySchema{ AnyAttribute: &schema.AttributeSchema{ - ValueType: cty.Object(map[string]cty.Type{ + Expr: schema.LiteralTypeOnly(cty.Object(map[string]cty.Type{ "source": cty.String, "version": cty.String, - }), + })), }, }, } @@ -973,9 +975,9 @@ func TestDecoder_CandidatesAtPos_multipleTypes(t *testing.T) { Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ "for_each": { - ValueTypes: schema.ValueTypes{ - cty.Set(cty.DynamicPseudoType), - cty.Map(cty.DynamicPseudoType), + Expr: schema.ExprConstraints{ + schema.LiteralTypeExpr{Type: cty.Set(cty.DynamicPseudoType)}, + schema.LiteralTypeExpr{Type: cty.Map(cty.DynamicPseudoType)}, }, }, }, @@ -1013,7 +1015,7 @@ func TestDecoder_CandidatesAtPos_multipleTypes(t *testing.T) { expectedCandidates := lang.CompleteCandidates([]lang.Candidate{ { Label: "for_each", - Detail: "Optional, set of dynamic or map of dynamic", + Detail: "Optional, set of any single type or map of any single type", TextEdit: lang.TextEdit{ Range: hcl.Range{ Filename: "test.tf", @@ -1043,7 +1045,7 @@ func TestDecoder_CandidatesAtPos_incompleteAttrOrBlock(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "count": {ValueType: cty.Number}, + "count": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, } @@ -1171,7 +1173,7 @@ func TestDecoder_CandidatesAtPos_incompleteLabel(t *testing.T) { Labels: resourceLabelSchema, Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ - "count": {ValueType: cty.Number}, + "count": {Expr: schema.LiteralTypeOnly(cty.Number)}, }, }, DependentBody: map[schema.SchemaKey]*schema.BodySchema{ diff --git a/decoder/decoder.go b/decoder/decoder.go index 5a3f07cf..088f1ab4 100644 --- a/decoder/decoder.go +++ b/decoder/decoder.go @@ -165,6 +165,7 @@ func mergeBlockBodySchemas(block *hclsyntax.Block, blockSchema *schema.BlockSche // cty.Type has private fields, so we have to declare custom copier copystructure.Copiers = map[reflect.Type]copystructure.CopierFunc{ reflect.TypeOf(cty.NilType): ctyTypeCopier, + reflect.TypeOf(cty.Value{}): ctyValueCopier, } schemaCopy, err := copystructure.Copy(blockSchema.Body) @@ -268,6 +269,10 @@ func ctyTypeCopier(v interface{}) (interface{}, error) { return v.(cty.Type), nil } +func ctyValueCopier(v interface{}) (interface{}, error) { + return v.(cty.Value), nil +} + func traversalToReference(traversal hcl.Traversal) (lang.Reference, error) { r := lang.Reference{} for _, tr := range traversal { diff --git a/decoder/errors.go b/decoder/errors.go index dfd46117..b5745df7 100644 --- a/decoder/errors.go +++ b/decoder/errors.go @@ -12,6 +12,17 @@ func (*NoSchemaError) Error() string { return fmt.Sprintf("no schema available") } +type ConstraintMismatch struct { + Expr hcl.Expression +} + +func (cm *ConstraintMismatch) Error() string { + if cm.Expr != nil { + return fmt.Sprintf("%T does not match any constraint", cm.Expr) + } + return fmt.Sprintf("expression does not match any constraint") +} + type FileNotFoundError struct { Filename string } diff --git a/decoder/expression_candidates.go b/decoder/expression_candidates.go new file mode 100644 index 00000000..d2bb88e7 --- /dev/null +++ b/decoder/expression_candidates.go @@ -0,0 +1,685 @@ +package decoder + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +func (d *Decoder) attrValueCandidatesAtPos(attr *hclsyntax.Attribute, schema *schema.AttributeSchema, pos hcl.Pos) (lang.Candidates, error) { + constraints, rng := constraintsAtPos(attr.Expr, ExprConstraints(schema.Expr), pos) + if len(constraints) > 0 { + return d.expressionCandidatesAtPos(constraints, rng) + } + return lang.ZeroCandidates(), nil +} + +func constraintsAtPos(expr hcl.Expression, constraints ExprConstraints, pos hcl.Pos) (ExprConstraints, hcl.Range) { + // TODO: Support middle-of-expression completion + + switch eType := expr.(type) { + case *hclsyntax.LiteralValueExpr: + if !eType.Val.IsWhollyKnown() { + return constraints, hcl.Range{ + Start: eType.Range().Start, + End: eType.Range().Start, + Filename: eType.Range().Filename, + } + } + case *hclsyntax.TupleConsExpr: + tc, ok := constraints.TupleConsExpr() + rng := eType.Range() + insideBracketsRng := hcl.Range{ + Start: hcl.Pos{ + Line: rng.Start.Line, + Column: rng.Start.Column + 1, + Byte: rng.Start.Byte + 1, + }, + End: hcl.Pos{ + Line: rng.End.Line, + Column: rng.End.Column - 1, + Byte: rng.End.Byte - 1, + }, + Filename: rng.Filename, + } + if ok && len(eType.Exprs) == 0 && insideBracketsRng.ContainsPos(pos) { + return ExprConstraints(tc.AnyElem), hcl.Range{ + Start: pos, + End: pos, + Filename: eType.Range().Filename, + } + } + } + + return ExprConstraints{}, expr.Range() +} + +func (d *Decoder) expressionCandidatesAtPos(constraints ExprConstraints, editRng hcl.Range) (lang.Candidates, error) { + candidates := lang.NewCandidates() + + for _, c := range constraints { + candidates.List = append(candidates.List, constraintToCandidates(c, editRng)...) + } + + candidates.IsComplete = true + return candidates, nil +} + +func constraintToCandidates(constraint schema.ExprConstraint, editRng hcl.Range) []lang.Candidate { + candidates := make([]lang.Candidate, 0) + + switch c := constraint.(type) { + case schema.LiteralTypeExpr: + candidates = append(candidates, typeToCandidates(c.Type, editRng)...) + case schema.LiteralValue: + if c, ok := valueToCandidate(c.Val, c.Description, editRng); ok { + candidates = append(candidates, c) + } + case schema.KeywordExpr: + candidates = append(candidates, lang.Candidate{ + Label: c.Keyword, + Detail: c.FriendlyName(), + Description: c.Description, + Kind: lang.LiteralValueCandidateKind, + TextEdit: lang.TextEdit{ + NewText: c.Keyword, + Snippet: c.Keyword, + Range: editRng, + }, + }) + case schema.TupleConsExpr: + hasConstraints := len(c.AnyElem) > 0 + + candidates = append(candidates, lang.Candidate{ + Label: fmt.Sprintf(`[%s]`, labelForConstraints(c.AnyElem)), + Detail: c.Name, + Kind: lang.LiteralValueCandidateKind, + TextEdit: lang.TextEdit{ + NewText: `[ ]`, + Snippet: `[ ${0} ]`, + Range: editRng, + }, + TriggerSuggest: hasConstraints, + }) + case schema.MapExpr: + candidates = append(candidates, lang.Candidate{ + Label: fmt.Sprintf(`{ key =%s}`, labelForConstraints(c.Elem)), + Detail: c.FriendlyName(), + Kind: lang.LiteralValueCandidateKind, + TextEdit: lang.TextEdit{ + NewText: fmt.Sprintf("{\n name = %s\n}", + newTextForConstraints(c.Elem, true)), + Snippet: fmt.Sprintf("{\n ${1:name} = %s\n}", + snippetForConstraints(1, c.Elem, true)), + Range: editRng, + }, + TriggerSuggest: len(c.Elem) > 0, + }) + } + + return candidates +} + +func newTextForConstraints(cons schema.ExprConstraints, isNested bool) string { + for _, constraint := range cons { + switch c := constraint.(type) { + case schema.LiteralTypeExpr: + return newTextForLiteralType(c.Type) + case schema.LiteralValue: + return newTextForLiteralValue(c.Val) + case schema.KeywordExpr: + return c.Keyword + case schema.TupleConsExpr: + if isNested { + return "[ ]" + } + return fmt.Sprintf("[\n %s\n]", newTextForConstraints(c.AnyElem, true)) + case schema.MapExpr: + return fmt.Sprintf("{\n %s\n}", newTextForConstraints(c.Elem, true)) + } + } + return "" +} + +func snippetForConstraints(placeholder uint, cons schema.ExprConstraints, isNested bool) string { + for _, constraint := range cons { + switch c := constraint.(type) { + case schema.LiteralTypeExpr: + return snippetForLiteralType(placeholder, c.Type) + case schema.LiteralValue: + return snippetForLiteralValue(placeholder, c.Val) + case schema.KeywordExpr: + return fmt.Sprintf("${%d:%s}", placeholder, c.Keyword) + case schema.TupleConsExpr: + if isNested { + return fmt.Sprintf("[ ${%d} ]", placeholder+1) + } + return fmt.Sprintf("[\n %s\n]", snippetForConstraints(placeholder+1, c.AnyElem, true)) + case schema.MapExpr: + return fmt.Sprintf("{\n %s\n}", snippetForConstraints(placeholder+1, c.Elem, true)) + } + } + return "" +} + +func labelForConstraints(cons schema.ExprConstraints) string { + labels := " " + labelsAdded := 0 + for _, constraint := range cons { + if len(labels) > 10 { + labels += "…" + break + } + if labelsAdded > 0 { + labels += "| " + } + switch c := constraint.(type) { + case schema.LiteralTypeExpr: + labels += labelForLiteralType(c.Type) + case schema.LiteralValue: + continue + case schema.KeywordExpr: + labels += c.FriendlyName() + case schema.TupleConsExpr: + labels += fmt.Sprintf("[%s]", labelForConstraints(c.AnyElem)) + } + labelsAdded++ + } + labels += " " + + return labels +} + +func typeToCandidates(ofType cty.Type, editRng hcl.Range) []lang.Candidate { + candidates := make([]lang.Candidate, 0) + + // TODO: Ensure TextEdit is always single-line, otherwise use AdditionalTextEdit + // See https://github.com/microsoft/language-server-protocol/issues/92 + + if ofType == cty.Bool { + if c, ok := valueToCandidate(cty.True, lang.MarkupContent{}, editRng); ok { + candidates = append(candidates, c) + } + if c, ok := valueToCandidate(cty.False, lang.MarkupContent{}, editRng); ok { + candidates = append(candidates, c) + } + return candidates + } + + if ofType.IsPrimitiveType() { + // Nothing to complete for other primitive types + return candidates + } + + candidates = append(candidates, lang.Candidate{ + Label: labelForLiteralType(ofType), + Detail: ofType.FriendlyNameForConstraint(), + Kind: lang.LiteralValueCandidateKind, + TextEdit: lang.TextEdit{ + NewText: newTextForLiteralType(ofType), + Snippet: snippetForLiteralType(1, ofType), + Range: editRng, + }, + }) + + return candidates +} + +func valueToCandidate(val cty.Value, desc lang.MarkupContent, editRng hcl.Range) (lang.Candidate, bool) { + if !val.IsWhollyKnown() { + // Avoid unknown values + return lang.Candidate{}, false + } + + detail := val.Type().FriendlyNameForConstraint() + + // shorten types which may have longer friendly names + if val.Type().IsObjectType() { + detail = "object" + } + if val.Type().IsMapType() { + detail = "map" + } + if val.Type().IsListType() { + detail = "list" + } + if val.Type().IsSetType() { + detail = "set" + } + if val.Type().IsTupleType() { + detail = "tuple" + } + + return lang.Candidate{ + Label: labelForLiteralValue(val, false), + Detail: detail, + Description: desc, + Kind: lang.LiteralValueCandidateKind, + TextEdit: lang.TextEdit{ + NewText: newTextForLiteralValue(val), + Snippet: snippetForLiteralValue(1, val), + Range: editRng, + }, + }, true +} + +func snippetForExprContraints(placeholder uint, ec schema.ExprConstraints) string { + if len(ec) > 0 { + expr := ec[0] + + switch et := expr.(type) { + case schema.LiteralTypeExpr: + return snippetForLiteralType(placeholder, et.Type) + case schema.LiteralValue: + if len(ec) == 1 { + return snippetForLiteralValue(placeholder, et.Val) + } + return "" + case schema.TupleConsExpr: + ec := ExprConstraints(et.AnyElem) + if ec.HasKeywordsOnly() { + return "[ ${0} ]" + } + return "[\n ${0}\n]" + case schema.MapExpr: + return fmt.Sprintf("{\n ${%d:name} = %s\n }", + placeholder, + snippetForExprContraints(placeholder+1, et.Elem)) + } + return "" + } + return "" +} + +type snippetGenerator struct { + placeholder uint +} + +func snippetForLiteralType(placeholder uint, attrType cty.Type) string { + sg := &snippetGenerator{placeholder: placeholder} + return sg.forLiteralType(attrType, 0) +} + +func (sg *snippetGenerator) forLiteralType(attrType cty.Type, nestingLvl int) string { + switch attrType { + case cty.String: + sg.placeholder++ + return fmt.Sprintf(`"${%d:value}"`, sg.placeholder-1) + case cty.Bool: + sg.placeholder++ + return fmt.Sprintf(`${%d:false}`, sg.placeholder-1) + case cty.Number: + sg.placeholder++ + return fmt.Sprintf(`${%d:1}`, sg.placeholder-1) + case cty.DynamicPseudoType: + sg.placeholder++ + return fmt.Sprintf(`${%d}`, sg.placeholder-1) + } + + nesting := strings.Repeat(" ", nestingLvl+1) + endBraceNesting := strings.Repeat(" ", nestingLvl) + + if attrType.IsMapType() { + mapSnippet := "{\n" + mapSnippet += fmt.Sprintf(`%s"${%d:key}" = `, nesting, sg.placeholder) + sg.placeholder++ + mapSnippet += sg.forLiteralType(*attrType.MapElementType(), nestingLvl+1) + mapSnippet += fmt.Sprintf("\n%s}", endBraceNesting) + return mapSnippet + } + + if attrType.IsListType() || attrType.IsSetType() { + elType := attrType.ElementType() + return fmt.Sprintf("[ %s ]", sg.forLiteralType(elType, nestingLvl)) + } + + if attrType.IsObjectType() { + objSnippet := "" + for _, name := range sortedObjectAttrNames(attrType) { + valType := attrType.AttributeType(name) + + objSnippet += fmt.Sprintf("%s%s = %s\n", + nesting, name, sg.forLiteralType(valType, nestingLvl+1)) + } + return fmt.Sprintf("{\n%s%s}", objSnippet, endBraceNesting) + } + + if attrType.IsTupleType() { + elTypes := attrType.TupleElementTypes() + if len(elTypes) == 1 { + return fmt.Sprintf("[ %s ]", sg.forLiteralType(elTypes[0], nestingLvl)) + } + + tupleSnippet := "" + for _, elType := range elTypes { + tupleSnippet += sg.forLiteralType(elType, nestingLvl+1) + } + return fmt.Sprintf("[\n%s]", tupleSnippet) + } + + return "" +} + +func labelForLiteralValue(val cty.Value, isNested bool) string { + if !val.IsWhollyKnown() { + return "" + } + + switch val.Type() { + case cty.Bool: + return fmt.Sprintf("%t", val.True()) + case cty.String: + if isNested { + return fmt.Sprintf("%q", val.AsString()) + } + return val.AsString() + case cty.Number: + return formatNumberVal(val) + } + + if val.Type().IsMapType() { + label := `{ ` + valueMap := val.AsValueMap() + mapKeys := sortedKeysOfValueMap(valueMap) + i := 0 + for _, key := range mapKeys { + if i > 0 { + label += ", " + } + if len(label) > 10 { + label += "…" + break + } + + label += fmt.Sprintf("%q = %s", + key, labelForLiteralValue(valueMap[key], true)) + i++ + } + label += ` }` + return label + } + + if val.Type().IsListType() || val.Type().IsSetType() || val.Type().IsTupleType() { + label := `[ ` + for i, elem := range val.AsValueSlice() { + if i > 0 { + label += ", " + } + if len(label) > 10 { + label += "…" + break + } + + label += labelForLiteralValue(elem, true) + + } + label += ` ]` + return label + } + + if val.Type().IsObjectType() { + label := `{ ` + attrNames := sortedObjectAttrNames(val.Type()) + i := 0 + for _, name := range attrNames { + if i > 0 { + label += ", " + } + if len(label) > 10 { + label += "…" + break + } + val := val.GetAttr(name) + + label += fmt.Sprintf("%s = %s", name, labelForLiteralValue(val, true)) + i++ + } + + label += ` }` + return label + } + + return "" +} + +func formatNumberVal(val cty.Value) string { + bf := val.AsBigFloat() + + if bf.IsInt() { + intNum, _ := bf.Int64() + return fmt.Sprintf("%d", intNum) + } + + fNum, _ := bf.Float64() + return strconv.FormatFloat(fNum, 'f', -1, 64) + +} + +func labelForLiteralType(attrType cty.Type) string { + if attrType.IsMapType() { + elType := *attrType.MapElementType() + return fmt.Sprintf(`{ "key" = %s }`, + labelForLiteralType(elType)) + } + + if attrType.IsListType() || attrType.IsSetType() { + elType := attrType.ElementType() + return fmt.Sprintf(`[ %s ]`, + labelForLiteralType(elType)) + } + + if attrType.IsTupleType() { + elTypes := attrType.TupleElementTypes() + if len(elTypes) > 2 { + return fmt.Sprintf("[ %s , %s , … ]", + labelForLiteralType(elTypes[0]), + labelForLiteralType(elTypes[1])) + } + if len(elTypes) == 2 { + return fmt.Sprintf("[ %s , %s ]", + labelForLiteralType(elTypes[0]), + labelForLiteralType(elTypes[1])) + } + if len(elTypes) == 1 { + return fmt.Sprintf("[ %s ]", labelForLiteralType(elTypes[0])) + } + return "[ ]" + } + + if attrType.IsObjectType() { + attrNames := sortedObjectAttrNames(attrType) + label := "{ " + for i, attrName := range attrNames { + if i > 0 { + label += ", " + } + if len(label) > 10 { + label += "…" + break + } + + label += fmt.Sprintf("%s = %s", + attrName, + labelForLiteralType(attrType.AttributeType(attrName))) + } + label += " }" + return label + } + + return attrType.FriendlyNameForConstraint() +} + +func newTextForLiteralValue(val cty.Value) string { + switch val.Type() { + case cty.String: + return fmt.Sprintf("%q", val.AsString()) + case cty.Bool: + return fmt.Sprintf("%t", val.True()) + case cty.Number: + return formatNumberVal(val) + case cty.DynamicPseudoType: + return "" + } + + if val.Type().IsMapType() { + newText := "{\n" + valueMap := val.AsValueMap() + mapKeys := sortedKeysOfValueMap(valueMap) + for _, key := range mapKeys { + newText += fmt.Sprintf(" %q = %s\n", + key, newTextForLiteralValue(valueMap[key])) + } + newText += "}" + return newText + } + + if val.Type().IsListType() || val.Type().IsSetType() || val.Type().IsTupleType() { + newText := "[\n" + for _, elem := range val.AsValueSlice() { + newText += fmt.Sprintf(" %s,\n", newTextForLiteralValue(elem)) + } + newText += "]" + return newText + } + + if val.Type().IsObjectType() { + newText := "{\n" + attrNames := sortedObjectAttrNames(val.Type()) + for _, name := range attrNames { + v := val.GetAttr(name) + newText += fmt.Sprintf(" %s = %s\n", name, newTextForLiteralValue(v)) + } + newText += "}" + return newText + } + + return "" +} + +func snippetForLiteralValue(placeholder uint, val cty.Value) string { + sg := &snippetGenerator{placeholder: placeholder} + return sg.forLiteralValue(val, 0) +} + +func (sg *snippetGenerator) forLiteralValue(val cty.Value, nestingLvl int) string { + switch val.Type() { + case cty.String: + sg.placeholder++ + return fmt.Sprintf(`"${%d:%s}"`, sg.placeholder-1, val.AsString()) + case cty.Bool: + sg.placeholder++ + return fmt.Sprintf(`${%d:%t}`, sg.placeholder-1, val.True()) + case cty.Number: + sg.placeholder++ + return fmt.Sprintf(`${%d:%s}`, sg.placeholder-1, formatNumberVal(val)) + case cty.DynamicPseudoType: + sg.placeholder++ + return fmt.Sprintf(`${%d}`, sg.placeholder-1) + } + + nesting := strings.Repeat(" ", nestingLvl+1) + endBraceNesting := strings.Repeat(" ", nestingLvl) + + if val.Type().IsMapType() { + mapSnippet := "{\n" + valueMap := val.AsValueMap() + mapKeys := sortedKeysOfValueMap(valueMap) + for _, key := range mapKeys { + mapSnippet += fmt.Sprintf(`%s"${%d:%s}" = `, nesting, sg.placeholder, key) + sg.placeholder++ + mapSnippet += sg.forLiteralValue(valueMap[key], nestingLvl+1) + mapSnippet += "\n" + } + mapSnippet += fmt.Sprintf("%s}", endBraceNesting) + return mapSnippet + } + + if val.Type().IsListType() || val.Type().IsSetType() || val.Type().IsTupleType() { + snippet := "[\n" + for _, elem := range val.AsValueSlice() { + snippet += fmt.Sprintf("%s%s,\n", nesting, sg.forLiteralValue(elem, nestingLvl+1)) + } + snippet += fmt.Sprintf("%s]", endBraceNesting) + return snippet + } + + if val.Type().IsObjectType() { + snippet := "{\n" + for _, name := range sortedObjectAttrNames(val.Type()) { + v := val.GetAttr(name) + snippet += fmt.Sprintf("%s%s = %s\n", + nesting, name, sg.forLiteralValue(v, nestingLvl+1)) + } + snippet += fmt.Sprintf("%s}", endBraceNesting) + return snippet + } + + return "" +} + +func sortedKeysOfValueMap(m map[string]cty.Value) []string { + keys := make([]string, 0) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func newTextForLiteralType(attrType cty.Type) string { + switch attrType { + case cty.String: + return `""` + case cty.Bool: + return `false` + case cty.Number: + return `1` + case cty.DynamicPseudoType: + return `` + } + + if attrType.IsMapType() { + elType := *attrType.MapElementType() + return fmt.Sprintf("{\n"+` "key" = %s`+"\n}", + newTextForLiteralType(elType)) + } + + if attrType.IsListType() || attrType.IsSetType() { + elType := attrType.ElementType() + return fmt.Sprintf("[ %s ]", newTextForLiteralType(elType)) + } + + if attrType.IsObjectType() { + objSnippet := "" + attrNames := sortedObjectAttrNames(attrType) + for _, name := range attrNames { + valType := attrType.AttributeType(name) + + objSnippet += fmt.Sprintf(" %s = %s\n", name, + newTextForLiteralType(valType)) + } + return fmt.Sprintf("{\n%s}", objSnippet) + } + + if attrType.IsTupleType() { + elTypes := attrType.TupleElementTypes() + if len(elTypes) == 1 { + return fmt.Sprintf("[ %s ]", newTextForLiteralType(elTypes[0])) + } + + tupleSnippet := "" + for _, elType := range elTypes { + tupleSnippet += newTextForLiteralType(elType) + } + return fmt.Sprintf("[\n%s]", tupleSnippet) + } + + return "" +} diff --git a/decoder/expression_candidates_test.go b/decoder/expression_candidates_test.go new file mode 100644 index 00000000..d8a2a075 --- /dev/null +++ b/decoder/expression_candidates_test.go @@ -0,0 +1,702 @@ +package decoder + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +func TestDecoder_CandidateAtPos_expressions(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + pos hcl.Pos + expectedCandidates lang.Candidates + }{ + { + "string type", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.LiteralTypeOnly(cty.String), + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.ZeroCandidates(), + }, + { + "object as value", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.ExprConstraints{ + schema.LiteralValue{ + Val: cty.ObjectVal(map[string]cty.Value{ + "first": cty.StringVal("blah"), + "second": cty.NumberIntVal(2345), + }), + }, + }, + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "{ first = \"blah\", … }", + Detail: "object", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: `{ + first = "blah" + second = 2345 +}`, + Snippet: `{ + first = "${1:blah}" + second = ${2:2345} +}`, + }, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "object as type", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.LiteralTypeOnly(cty.Object(map[string]cty.Type{ + "first": cty.String, + "second": cty.Number, + })), + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "{ first = string, … }", + Detail: "object", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: `{ + first = "" + second = 1 +}`, + Snippet: `{ + first = "${1:value}" + second = ${2:1} +}`, + }, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "list as value", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.ExprConstraints{ + schema.LiteralValue{ + Val: cty.ListVal([]cty.Value{ + cty.StringVal("foo"), + cty.StringVal("bar"), + }), + }, + }, + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: `[ "foo", "bar" ]`, + Detail: "list", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: "[\n \"foo\",\n \"bar\",\n]", + Snippet: "[\n \"${1:foo}\",\n \"${2:bar}\",\n]", + }, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "map as type", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.LiteralTypeOnly(cty.Map(cty.String)), + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: `{ "key" = string }`, + Detail: "map of string", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: `{ + "key" = "" +}`, + Snippet: `{ + "${1:key}" = "${2:value}" +}`, + }, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "map as value", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.ExprConstraints{ + schema.LiteralValue{ + Val: cty.MapVal(map[string]cty.Value{ + "foo": cty.StringVal("moo"), + "bar": cty.StringVal("boo"), + }), + }, + }, + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: `{ "bar" = "boo", … }`, + Detail: "map", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: `{ + "bar" = "boo" + "foo" = "moo" +}`, + Snippet: `{ + "${1:bar}" = "${2:boo}" + "${3:foo}" = "${4:moo}" +}`, + }, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "bool type", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.LiteralTypeOnly(cty.Bool), + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "true", + Detail: "bool", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: "true", + Snippet: "${1:true}", + }, + Kind: lang.LiteralValueCandidateKind, + }, + { + Label: "false", + Detail: "bool", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: "false", + Snippet: "${1:false}", + }, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "string values", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.ExprConstraints{ + schema.LiteralValue{Val: cty.StringVal("first")}, + schema.LiteralValue{Val: cty.StringVal("second")}, + schema.LiteralValue{Val: cty.StringVal("third")}, + }, + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "first", + Detail: "string", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: `"first"`, + Snippet: `"${1:first}"`}, + Kind: lang.LiteralValueCandidateKind, + }, + { + Label: "second", + Detail: "string", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: `"second"`, + Snippet: `"${1:second}"`}, + Kind: lang.LiteralValueCandidateKind, + }, + { + Label: "third", + Detail: "string", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: `"third"`, + Snippet: `"${1:third}"`}, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "tuple constant expression", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.ExprConstraints{ + schema.TupleConsExpr{ + AnyElem: schema.ExprConstraints{ + schema.LiteralValue{Val: cty.StringVal("one")}, + schema.LiteralValue{Val: cty.StringVal("two")}, + }, + }, + }, + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "[ ]", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: "[ ]", + Snippet: "[ ${0} ]", + }, + Kind: lang.LiteralValueCandidateKind, + TriggerSuggest: true, + }, + }), + }, + { + "tuple constant expression inside", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.ExprConstraints{ + schema.TupleConsExpr{ + AnyElem: schema.ExprConstraints{ + schema.LiteralValue{Val: cty.StringVal("one")}, + schema.LiteralValue{Val: cty.StringVal("two")}, + }, + }, + }, + }, + }, + `attr = [ ] +`, + hcl.Pos{Line: 1, Column: 10, Byte: 9}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "one", + Detail: "string", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 10, + Byte: 9, + }, + End: hcl.Pos{ + Line: 1, + Column: 10, + Byte: 9, + }, + }, + NewText: `"one"`, + Snippet: `"${1:one}"`, + }, + Kind: lang.LiteralValueCandidateKind, + }, + { + Label: "two", + Detail: "string", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 10, + Byte: 9, + }, + End: hcl.Pos{ + Line: 1, + Column: 10, + Byte: 9, + }, + }, + NewText: `"two"`, + Snippet: `"${1:two}"`, + }, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "tuple as list type", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.LiteralTypeOnly(cty.List(cty.String)), + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "[ string ]", + Detail: "list of string", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: `[ "" ]`, + Snippet: `[ "${1:value}" ]`, + }, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "tuple as list type inside", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.LiteralTypeOnly(cty.List(cty.String)), + }, + }, + `attr = [ ] +`, + hcl.Pos{Line: 1, Column: 10, Byte: 9}, + lang.ZeroCandidates(), + }, + { + "keyword", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.ExprConstraints{ + schema.KeywordExpr{ + Keyword: "foobar", + Name: "special kw", + }, + }, + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "foobar", + Detail: "special kw", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: "foobar", + Snippet: "foobar", + }, + Kind: lang.LiteralValueCandidateKind, + }, + }), + }, + { + "map expression", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.ExprConstraints{ + schema.MapExpr{ + Elem: schema.LiteralTypeOnly(cty.String), + Name: "map of something", + }, + }, + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "{ key = string }", + Detail: "map of something", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: "{\n name = \"\"\n}", + Snippet: "{\n ${1:name} = \"${1:value}\"\n}", + }, + Kind: lang.LiteralValueCandidateKind, + TriggerSuggest: true, + }, + }), + }, + { + "map expression of tuple expr", + map[string]*schema.AttributeSchema{ + "attr": { + Expr: schema.ExprConstraints{ + schema.MapExpr{ + Elem: schema.ExprConstraints{ + schema.TupleConsExpr{ + Name: "special tuple", + AnyElem: schema.LiteralTypeOnly(cty.Number), + }, + }, + Name: "special map", + }, + }, + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "{ key = [ number ] }", + Detail: "special map", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + End: hcl.Pos{ + Line: 1, + Column: 8, + Byte: 7, + }, + }, + NewText: `{ + name = [ ] +}`, + Snippet: `{ + ${1:name} = [ ${2} ] +}`, + }, + Kind: lang.LiteralValueCandidateKind, + TriggerSuggest: true, + }, + }), + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + d := NewDecoder() + d.SetSchema(&schema.BodySchema{ + Attributes: tc.attrSchema, + }) + + f, _ := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + err := d.LoadFile("test.tf", f) + if err != nil { + t.Fatal(err) + } + + candidates, err := d.CandidatesAtPos("test.tf", tc.pos) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedCandidates, candidates); diff != "" { + t.Fatalf("unexpected candidates: %s", diff) + } + }) + } +} diff --git a/decoder/expression_constraints.go b/decoder/expression_constraints.go new file mode 100644 index 00000000..1e0f0111 --- /dev/null +++ b/decoder/expression_constraints.go @@ -0,0 +1,199 @@ +package decoder + +import ( + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +type ExprConstraints schema.ExprConstraints + +func (ec ExprConstraints) FriendlyNames() []string { + names := make([]string, 0) + for _, constraint := range ec { + if name := constraint.FriendlyName(); name != "" && + !namesContain(names, name) { + names = append(names, name) + } + } + return names +} + +func namesContain(names []string, name string) bool { + for _, n := range names { + if n == name { + return true + } + } + return false +} + +func (ec ExprConstraints) HasKeywordsOnly() bool { + hasKeywordExpr := false + for _, constraint := range ec { + if _, ok := constraint.(schema.KeywordExpr); ok { + hasKeywordExpr = true + } else { + return false + } + } + return hasKeywordExpr +} + +func (ec ExprConstraints) KeywordExpr() (schema.KeywordExpr, bool) { + for _, c := range ec { + if kw, ok := c.(schema.KeywordExpr); ok { + return kw, ok + } + } + return schema.KeywordExpr{}, false +} + +func (ec ExprConstraints) MapExpr() (schema.MapExpr, bool) { + for _, c := range ec { + if me, ok := c.(schema.MapExpr); ok { + return me, ok + } + } + return schema.MapExpr{}, false +} + +func (ec ExprConstraints) TupleConsExpr() (schema.TupleConsExpr, bool) { + for _, c := range ec { + if tc, ok := c.(schema.TupleConsExpr); ok { + return tc, ok + } + } + return schema.TupleConsExpr{}, false +} + +func (ec ExprConstraints) LiteralTypeOfList() (schema.LiteralTypeExpr, bool) { + for _, c := range ec { + if lve, ok := c.(schema.LiteralTypeExpr); ok && lve.Type.IsListType() { + return lve, true + } + } + return schema.LiteralTypeExpr{}, false +} + +func (ec ExprConstraints) LiteralTypeOfSet() (schema.LiteralTypeExpr, bool) { + for _, c := range ec { + if lve, ok := c.(schema.LiteralTypeExpr); ok && lve.Type.IsSetType() { + return lve, true + } + } + return schema.LiteralTypeExpr{}, false +} + +func (ec ExprConstraints) LiteralTypeOfTuple() (schema.LiteralTypeExpr, bool) { + for _, c := range ec { + if lve, ok := c.(schema.LiteralTypeExpr); ok && lve.Type.IsTupleType() { + return lve, true + } + } + return schema.LiteralTypeExpr{}, false +} + +func (ec ExprConstraints) LiteralTypeOfObject() (schema.LiteralTypeExpr, bool) { + for _, c := range ec { + if lve, ok := c.(schema.LiteralTypeExpr); ok && lve.Type.IsObjectType() { + return lve, true + } + } + return schema.LiteralTypeExpr{}, false +} + +func (ec ExprConstraints) LiteralTypeOfMap() (schema.LiteralTypeExpr, bool) { + for _, c := range ec { + if lt, ok := c.(schema.LiteralTypeExpr); ok && lt.Type.IsMapType() { + return lt, true + } + } + return schema.LiteralTypeExpr{}, false +} + +func (ec ExprConstraints) HasLiteralTypeOf(exprType cty.Type) bool { + for _, c := range ec { + if lt, ok := c.(schema.LiteralTypeExpr); ok && lt.Type.Equals(exprType) { + return true + } + } + return false +} + +func (ec ExprConstraints) HasLiteralValueOf(val cty.Value) bool { + for _, c := range ec { + if lv, ok := c.(schema.LiteralValue); ok && lv.Val.RawEquals(val) { + return true + } + } + return false +} + +func (ec ExprConstraints) LiteralValueOf(val cty.Value) (schema.LiteralValue, bool) { + for _, c := range ec { + if lv, ok := c.(schema.LiteralValue); ok && lv.Val.RawEquals(val) { + return lv, true + } + } + return schema.LiteralValue{}, false +} + +func (ec ExprConstraints) LiteralValueOfTupleExpr(expr *hclsyntax.TupleConsExpr) (schema.LiteralValue, bool) { + exprValues := make([]cty.Value, len(expr.Exprs)) + for i, e := range expr.Exprs { + val, _ := e.Value(nil) + if !val.IsWhollyKnown() || val.IsNull() { + return schema.LiteralValue{}, false + } + exprValues[i] = val + } + + for _, c := range ec { + if lv, ok := c.(schema.LiteralValue); ok { + valType := lv.Val.Type() + if valType.IsListType() && lv.Val.RawEquals(cty.ListVal(exprValues)) { + return lv, true + } + if valType.IsSetType() && lv.Val.RawEquals(cty.SetVal(exprValues)) { + return lv, true + } + if valType.IsTupleType() && lv.Val.RawEquals(cty.TupleVal(exprValues)) { + return lv, true + } + } + } + + return schema.LiteralValue{}, false +} + +func (ec ExprConstraints) LiteralValueOfObjectConsExpr(expr *hclsyntax.ObjectConsExpr) (schema.LiteralValue, bool) { + exprValues := make(map[string]cty.Value) + for _, item := range expr.Items { + key, _ := item.KeyExpr.Value(nil) + if !key.IsWhollyKnown() || key.Type() != cty.String { + return schema.LiteralValue{}, false + } + + val, _ := item.ValueExpr.Value(nil) + if !val.IsWhollyKnown() || val.IsNull() { + return schema.LiteralValue{}, false + } + + exprValues[key.AsString()] = val + } + + for _, c := range ec { + if lv, ok := c.(schema.LiteralValue); ok { + valType := lv.Val.Type() + if valType.IsMapType() && lv.Val.RawEquals(cty.MapVal(exprValues)) { + return lv, true + } + if valType.IsObjectType() && lv.Val.RawEquals(cty.ObjectVal(exprValues)) { + return lv, true + } + } + } + + return schema.LiteralValue{}, false +} diff --git a/decoder/hover.go b/decoder/hover.go index f260e737..4eece810 100644 --- a/decoder/hover.go +++ b/decoder/hover.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/hcl-lang/schema" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" ) func (d *Decoder) HoverAtPos(filename string, pos hcl.Pos) (*lang.HoverData, error) { @@ -47,17 +48,38 @@ func (d *Decoder) hoverAtPos(body *hclsyntax.Body, bodySchema *schema.BodySchema if attr.Range().ContainsPos(pos) { aSchema, ok := bodySchema.Attributes[attr.Name] if !ok { - return nil, &PositionalError{ - Filename: filename, - Pos: pos, - Msg: fmt.Sprintf("unknown attribute %q", attr.Name), + if bodySchema.AnyAttribute == nil { + return nil, &PositionalError{ + Filename: filename, + Pos: pos, + Msg: fmt.Sprintf("unknown attribute %q", attr.Name), + } } + aSchema = bodySchema.AnyAttribute + } + + if attr.NameRange.ContainsPos(pos) { + return &lang.HoverData{ + Content: hoverContentForAttribute(name, aSchema), + Range: attr.Range(), + }, nil } - return &lang.HoverData{ - Content: hoverContentForAttribute(name, aSchema), - Range: attr.Range(), - }, nil + if attr.Expr.Range().ContainsPos(pos) { + exprCons := ExprConstraints(aSchema.Expr) + content, err := hoverContentForExpr(attr.Expr, exprCons) + if err != nil { + return nil, &PositionalError{ + Filename: filename, + Pos: pos, + Msg: err.Error(), + } + } + return &lang.HoverData{ + Content: lang.Markdown(content), + Range: attr.Expr.Range(), + }, nil + } } } @@ -119,7 +141,7 @@ func (d *Decoder) hoverAtPos(body *hclsyntax.Body, bodySchema *schema.BodySchema return nil, &PositionalError{ Filename: filename, Pos: pos, - Msg: "position outside of any attribute or block", + Msg: "position outside of any attribute name, value or block", } } @@ -189,3 +211,221 @@ func hoverContentForBlock(bType string, schema *schema.BlockSchema) lang.MarkupC Value: value, } } + +func hoverContentForExpr(expr hcl.Expression, constraints ExprConstraints) (string, error) { + switch e := expr.(type) { + case *hclsyntax.ScopeTraversalExpr: + kw, ok := constraints.KeywordExpr() + if ok && len(e.Traversal) == 1 { + return fmt.Sprintf("`%s` _%s_", kw.Keyword, kw.FriendlyName()), nil + } + case *hclsyntax.TemplateExpr: + if len(e.Parts) == 1 { + return hoverContentForExpr(e.Parts[0], constraints) + } + case *hclsyntax.TemplateWrapExpr: + return hoverContentForExpr(e.Wrapped, constraints) + case *hclsyntax.TupleConsExpr: + tupleCons, ok := constraints.TupleConsExpr() + if ok { + content := fmt.Sprintf("_%s_", tupleCons.FriendlyName()) + if tupleCons.Description.Value != "" { + content += "\n\n" + tupleCons.Description.Value + } + return content, nil + } + list, ok := constraints.LiteralTypeOfList() + if ok { + return hoverContentForType(list.Type) + } + set, ok := constraints.LiteralTypeOfSet() + if ok { + return hoverContentForType(set.Type) + } + tuple, ok := constraints.LiteralTypeOfTuple() + if ok { + return hoverContentForType(tuple.Type) + } + litVal, ok := constraints.LiteralValueOfTupleExpr(e) + if ok { + return hoverContentForValue(litVal.Val, 0) + } + case *hclsyntax.ObjectConsExpr: + mapExpr, ok := constraints.MapExpr() + if ok { + content := fmt.Sprintf("_%s_", mapExpr.FriendlyName()) + if mapExpr.Description.Value != "" { + content += "\n\n" + mapExpr.Description.Value + } + return content, nil + } + objType, ok := constraints.LiteralTypeOfObject() + if ok { + return hoverContentForType(objType.Type) + } + mapType, ok := constraints.LiteralTypeOfMap() + if ok { + return hoverContentForType(mapType.Type) + } + litVal, ok := constraints.LiteralValueOfObjectConsExpr(e) + if ok { + return hoverContentForValue(litVal.Val, 0) + } + case *hclsyntax.LiteralValueExpr: + if constraints.HasLiteralTypeOf(e.Val.Type()) { + return hoverContentForValue(e.Val, 0) + } + lv, ok := constraints.LiteralValueOf(e.Val) + if ok { + return hoverContentForValue(lv.Val, 0) + } + return "", &ConstraintMismatch{e} + } + + return "", fmt.Errorf("unsupported expression (%T)", expr) +} + +func hoverContentForValue(val cty.Value, nestingLvl int) (string, error) { + if !val.IsWhollyKnown() { + if nestingLvl > 0 { + return "", nil + } + return fmt.Sprintf("_%s_", val.Type().FriendlyName()), nil + } + + attrType := val.Type() + if attrType.IsPrimitiveType() { + var value string + switch attrType { + case cty.Bool: + value = fmt.Sprintf("%t", val.True()) + case cty.String: + if strings.ContainsAny(val.AsString(), "\n") && nestingLvl == 0 { + // avoid double newline + strValue := strings.TrimSuffix(val.AsString(), "\n") + return fmt.Sprintf("```\n%s\n```\n_string_", + strValue), nil + } + value = fmt.Sprintf("%q", val.AsString()) + case cty.Number: + value = formatNumberVal(val) + } + + if nestingLvl > 0 { + return value, nil + } + return fmt.Sprintf("`%s` _%s_", + value, attrType.FriendlyName()), nil + } + + if attrType.IsObjectType() { + attrNames := sortedObjectAttrNames(attrType) + if len(attrNames) == 0 { + return attrType.FriendlyName(), nil + } + value := "" + if nestingLvl == 0 { + value += "```\n" + } + value += "{\n" + for _, name := range attrNames { + whitespace := strings.Repeat(" ", nestingLvl+1) + val, err := hoverContentForValue(val.GetAttr(name), nestingLvl+1) + if err == nil { + value += fmt.Sprintf("%s%s = %s\n", + whitespace, name, val) + } + } + value += fmt.Sprintf("%s}", strings.Repeat(" ", nestingLvl)) + if nestingLvl == 0 { + value += "\n```\n_object_" + } + + return value, nil + } + + if attrType.IsMapType() { + elems := val.AsValueMap() + if len(elems) == 0 { + return attrType.FriendlyName(), nil + } + value := "" + if nestingLvl == 0 { + value += "```\n" + } + value += "{\n" + mapKeys := sortedKeysOfValueMap(elems) + for _, key := range mapKeys { + val := elems[key] + elHover, err := hoverContentForValue(val, nestingLvl+1) + if err == nil { + whitespace := strings.Repeat(" ", nestingLvl+1) + value += fmt.Sprintf("%s%q = %s\n", + whitespace, key, elHover) + } + } + value += fmt.Sprintf("%s}", strings.Repeat(" ", nestingLvl)) + if nestingLvl == 0 { + value += fmt.Sprintf("\n```\n_%s_", attrType.FriendlyName()) + } + + return value, nil + } + + if attrType.IsListType() || attrType.IsSetType() || attrType.IsTupleType() { + elems := val.AsValueSlice() + if len(elems) == 0 { + return fmt.Sprintf(`_%s_`, attrType.FriendlyName()), nil + } + value := "" + if nestingLvl == 0 { + value += "```\n" + } + + value += "[\n" + for _, elem := range elems { + whitespace := strings.Repeat(" ", nestingLvl+1) + elHover, err := hoverContentForValue(elem, nestingLvl+1) + if err == nil { + value += fmt.Sprintf("%s%s,\n", whitespace, elHover) + } + } + value += fmt.Sprintf("%s]", strings.Repeat(" ", nestingLvl)) + if nestingLvl == 0 { + value += fmt.Sprintf("\n```\n_%s_", attrType.FriendlyName()) + } + + return value, nil + } + + return "", fmt.Errorf("unsupported type: %q", attrType.FriendlyName()) +} + +func hoverContentForType(attrType cty.Type) (string, error) { + if attrType.IsPrimitiveType() { + return fmt.Sprintf(`_%s_`, attrType.FriendlyName()), nil + } + + if attrType.IsObjectType() { + attrNames := sortedObjectAttrNames(attrType) + if len(attrNames) == 0 { + return attrType.FriendlyName(), nil + } + value := "```\n{\n" + for _, name := range attrNames { + valType := attrType.AttributeType(name) + value += fmt.Sprintf(" %s = %s\n", name, + valType.FriendlyName()) + } + value += "}\n```\n_object_" + + return value, nil + } + + if attrType.IsMapType() || attrType.IsListType() || attrType.IsSetType() || attrType.IsTupleType() { + value := fmt.Sprintf(`_%s_`, attrType.FriendlyName()) + return value, nil + } + + return "", fmt.Errorf("unsupported type: %q", attrType.FriendlyName()) +} diff --git a/decoder/hover_expressions_test.go b/decoder/hover_expressions_test.go new file mode 100644 index 00000000..9f0485dd --- /dev/null +++ b/decoder/hover_expressions_test.go @@ -0,0 +1,582 @@ +package decoder + +import ( + "errors" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestDecoder_HoverAtPos_expressions(t *testing.T) { + testCases := []struct { + name string + attrSchema map[string]*schema.AttributeSchema + cfg string + pos hcl.Pos + expectedData *lang.HoverData + expectedErr error + }{ + { + "string as type", + map[string]*schema.AttributeSchema{ + "str_attr": {Expr: schema.LiteralTypeOnly(cty.String)}, + }, + `str_attr = "test"`, + hcl.Pos{Line: 1, Column: 15, Byte: 14}, + &lang.HoverData{ + Content: lang.Markdown("`\"test\"` _string_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 12, + Byte: 11, + }, + End: hcl.Pos{ + Line: 1, + Column: 18, + Byte: 17, + }, + }, + }, + nil, + }, + { + "heredoc string as type", + map[string]*schema.AttributeSchema{ + "str_attr": {Expr: schema.LiteralTypeOnly(cty.String)}, + }, + `str_attr = < 0 { + t.Fatal(pDiags) + } + err := d.LoadFile("test.tf", f) + if err != nil { + t.Fatal(err) + } + + tokens, err := d.SemanticTokensInFile("test.tf") + if err != nil { + t.Fatal(err) + } + + diff := cmp.Diff(tc.expectedTokens, tokens) + if diff != "" { + t.Fatalf("unexpected tokens: %s", diff) + } + }) + } +} diff --git a/decoder/semantic_tokens_test.go b/decoder/semantic_tokens_test.go index e5b6e39e..0304584c 100644 --- a/decoder/semantic_tokens_test.go +++ b/decoder/semantic_tokens_test.go @@ -76,10 +76,10 @@ func TestDecoder_SemanticTokensInFile_basic(t *testing.T) { Body: &schema.BodySchema{ Attributes: map[string]*schema.AttributeSchema{ "count": { - ValueType: cty.Number, + Expr: schema.LiteralTypeOnly(cty.Number), }, "source": { - ValueType: cty.String, + Expr: schema.LiteralTypeOnly(cty.String), IsDeprecated: true, }, }, @@ -154,6 +154,23 @@ resource "vault_auth_backend" "blah" { }, }, }, + { // "./sub" + Type: lang.TokenString, + Modifiers: []lang.SemanticTokenModifier{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 2, + Column: 12, + Byte: 26, + }, + End: hcl.Pos{ + Line: 2, + Column: 19, + Byte: 33, + }, + }, + }, { // count Type: lang.TokenAttrName, Modifiers: []lang.SemanticTokenModifier{}, @@ -171,6 +188,23 @@ resource "vault_auth_backend" "blah" { }, }, }, + { // 1 + Type: lang.TokenNumber, + Modifiers: []lang.SemanticTokenModifier{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 3, + Column: 12, + Byte: 45, + }, + End: hcl.Pos{ + Line: 3, + Column: 13, + Byte: 46, + }, + }, + }, { // resource Type: lang.TokenBlockType, Modifiers: []lang.SemanticTokenModifier{}, @@ -252,10 +286,10 @@ func TestDecoder_SemanticTokensInFile_dependentSchema(t *testing.T) { }): { Attributes: map[string]*schema.AttributeSchema{ "instance_type": { - ValueType: cty.String, + Expr: schema.LiteralTypeOnly(cty.String), }, "deprecated": { - ValueType: cty.Bool, + Expr: schema.LiteralTypeOnly(cty.Bool), }, }, }, @@ -288,7 +322,7 @@ resource "aws_instance" "beta" { } expectedTokens := []lang.SemanticToken{ - { + { // resource Type: lang.TokenBlockType, Modifiers: []lang.SemanticTokenModifier{}, Range: hcl.Range{ @@ -305,7 +339,7 @@ resource "aws_instance" "beta" { }, }, }, - { + { // "vault_auth_backend" Type: lang.TokenBlockLabel, Modifiers: []lang.SemanticTokenModifier{ lang.TokenModifierDependent, @@ -324,7 +358,7 @@ resource "aws_instance" "beta" { }, }, }, - { + { // "alpha" Type: lang.TokenBlockLabel, Modifiers: []lang.SemanticTokenModifier{}, Range: hcl.Range{ @@ -341,7 +375,7 @@ resource "aws_instance" "beta" { }, }, }, - { + { // resource Type: lang.TokenBlockType, Modifiers: []lang.SemanticTokenModifier{}, Range: hcl.Range{ @@ -358,7 +392,7 @@ resource "aws_instance" "beta" { }, }, }, - { + { // "aws_instance" Type: lang.TokenBlockLabel, Modifiers: []lang.SemanticTokenModifier{ lang.TokenModifierDependent, @@ -377,7 +411,7 @@ resource "aws_instance" "beta" { }, }, }, - { + { // "beta" Type: lang.TokenBlockLabel, Modifiers: []lang.SemanticTokenModifier{}, Range: hcl.Range{ @@ -394,7 +428,7 @@ resource "aws_instance" "beta" { }, }, }, - { + { // instance_type Type: lang.TokenAttrName, Modifiers: []lang.SemanticTokenModifier{ lang.TokenModifierDependent, @@ -413,7 +447,24 @@ resource "aws_instance" "beta" { }, }, }, - { + { // "t2.micro" + Type: lang.TokenString, + Modifiers: []lang.SemanticTokenModifier{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 5, + Column: 19, + Byte: 125, + }, + End: hcl.Pos{ + Line: 5, + Column: 29, + Byte: 135, + }, + }, + }, + { // deprecated Type: lang.TokenAttrName, Modifiers: []lang.SemanticTokenModifier{ lang.TokenModifierDependent, @@ -432,6 +483,23 @@ resource "aws_instance" "beta" { }, }, }, + { // true + Type: lang.TokenBool, + Modifiers: []lang.SemanticTokenModifier{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 6, + Column: 16, + Byte: 151, + }, + End: hcl.Pos{ + Line: 6, + Column: 20, + Byte: 155, + }, + }, + }, } diff := cmp.Diff(expectedTokens, tokens) diff --git a/decoder/symbol.go b/decoder/symbol.go index 2ae2723a..3bff7c5a 100644 --- a/decoder/symbol.go +++ b/decoder/symbol.go @@ -6,7 +6,6 @@ import ( "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl/v2" - "github.com/zclconf/go-cty/cty" ) type symbolImplSigil struct{} @@ -66,12 +65,13 @@ func (bs *BlockSymbol) Range() hcl.Range { return bs.rng } -// BlockSymbol is Symbol implementation representing an attribute +// AttributeSymbol is Symbol implementation representing an attribute type AttributeSymbol struct { AttrName string - Type cty.Type + ExprKind lang.SymbolExprKind - rng hcl.Range + rng hcl.Range + nestedSymbols []Symbol } func (*AttributeSymbol) isSymbolImpl() symbolImplSigil { @@ -99,9 +99,49 @@ func (*AttributeSymbol) Kind() lang.SymbolKind { } func (as *AttributeSymbol) NestedSymbols() []Symbol { - return []Symbol{} + return as.nestedSymbols } func (as *AttributeSymbol) Range() hcl.Range { return as.rng } + +type ExprSymbol struct { + ExprName string + ExprKind lang.SymbolExprKind + + rng hcl.Range + nestedSymbols []Symbol +} + +func (*ExprSymbol) isSymbolImpl() symbolImplSigil { + return symbolImplSigil{} +} + +func (as *ExprSymbol) Equal(other Symbol) bool { + oas, ok := other.(*ExprSymbol) + if !ok { + return false + } + if as == nil || oas == nil { + return as == oas + } + + return reflect.DeepEqual(*as, *oas) +} + +func (as *ExprSymbol) Name() string { + return as.ExprName +} + +func (*ExprSymbol) Kind() lang.SymbolKind { + return lang.ExprSymbolKind +} + +func (as *ExprSymbol) NestedSymbols() []Symbol { + return as.nestedSymbols +} + +func (as *ExprSymbol) Range() hcl.Range { + return as.rng +} diff --git a/decoder/symbol_test.go b/decoder/symbol_test.go index d61915fc..752145cd 100644 --- a/decoder/symbol_test.go +++ b/decoder/symbol_test.go @@ -3,4 +3,5 @@ package decoder var ( _ Symbol = &AttributeSymbol{} _ Symbol = &BlockSymbol{} + _ Symbol = &ExprSymbol{} ) diff --git a/decoder/symbols.go b/decoder/symbols.go index b93aee8d..0aed6633 100644 --- a/decoder/symbols.go +++ b/decoder/symbols.go @@ -1,9 +1,11 @@ package decoder import ( + "fmt" "sort" "strings" + "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" @@ -63,21 +65,11 @@ func symbolsForBody(body *hclsyntax.Body) []Symbol { } for name, attr := range body.Attributes { - var typ cty.Type - - switch expr := attr.Expr.(type) { - case *hclsyntax.LiteralValueExpr: - typ = expr.Val.Type() - case *hclsyntax.TemplateExpr: - if expr.IsStringLiteral() { - typ = cty.String - } - } - symbols = append(symbols, &AttributeSymbol{ - AttrName: name, - Type: typ, - rng: attr.Range(), + AttrName: name, + ExprKind: symbolExprKind(attr.Expr), + rng: attr.Range(), + nestedSymbols: nestedSymbolsForExpr(attr.Expr), }) } for _, block := range body.Blocks { @@ -95,3 +87,52 @@ func symbolsForBody(body *hclsyntax.Body) []Symbol { return symbols } + +func symbolExprKind(expr hcl.Expression) lang.SymbolExprKind { + switch e := expr.(type) { + case *hclsyntax.LiteralValueExpr: + return lang.LiteralTypeKind{Type: e.Val.Type()} + case *hclsyntax.TemplateExpr: + if e.IsStringLiteral() { + return lang.LiteralTypeKind{Type: cty.String} + } + case *hclsyntax.TupleConsExpr: + return lang.TupleConsExprKind{} + case *hclsyntax.ObjectConsExpr: + return lang.ObjectConsExprKind{} + } + return nil +} + +func nestedSymbolsForExpr(expr hcl.Expression) []Symbol { + symbols := make([]Symbol, 0) + + switch e := expr.(type) { + case *hclsyntax.TupleConsExpr: + for i, item := range e.ExprList() { + symbols = append(symbols, &ExprSymbol{ + ExprName: fmt.Sprintf("%d", i), + ExprKind: symbolExprKind(item), + rng: item.Range(), + nestedSymbols: nestedSymbolsForExpr(item), + }) + } + case *hclsyntax.ObjectConsExpr: + for _, item := range e.Items { + key, _ := item.KeyExpr.Value(nil) + if key.IsNull() || key.Type() != cty.String { + // skip items keys that can't be interpolated + // without further context + continue + } + symbols = append(symbols, &ExprSymbol{ + ExprName: key.AsString(), + ExprKind: symbolExprKind(item.ValueExpr), + rng: hcl.RangeBetween(item.KeyExpr.Range(), item.ValueExpr.Range()), + nestedSymbols: nestedSymbolsForExpr(item.ValueExpr), + }) + } + } + + return symbols +} diff --git a/decoder/symbols_test.go b/decoder/symbols_test.go index b7a5762e..8652026b 100644 --- a/decoder/symbols_test.go +++ b/decoder/symbols_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/zclconf/go-cty/cty" @@ -48,6 +49,21 @@ func TestDecoder_SymbolsInFile_zeroByteContent(t *testing.T) { } } +func TestBlah(t *testing.T) { + f, pDiags := hclsyntax.ParseConfig([]byte(` +attr = null +`), "test.tf", hcl.InitialPos) + if len(pDiags) > 0 { + t.Fatal(pDiags) + } + attrs, diags := f.Body.JustAttributes() + if len(diags) > 0 { + t.Fatal(diags) + } + t.Logf("expr: %#v", attrs["attr"].Expr) + +} + func TestDecoder_SymbolsInFile_fileNotFound(t *testing.T) { d := NewDecoder() f, pDiags := hclsyntax.ParseConfig([]byte{}, "test.tf", hcl.InitialPos) @@ -97,12 +113,13 @@ func TestDecoder_SymbolsInFile_basic(t *testing.T) { nestedSymbols: []Symbol{ &AttributeSymbol{ AttrName: "count", - Type: cty.Number, + ExprKind: lang.LiteralTypeKind{Type: cty.Number}, rng: hcl.Range{ Filename: "test.tf", Start: hcl.Pos{Column: 3, Line: 2, Byte: 40}, End: hcl.Pos{Column: 12, Line: 2, Byte: 49}, }, + nestedSymbols: []Symbol{}, }, }, }, @@ -120,12 +137,13 @@ func TestDecoder_SymbolsInFile_basic(t *testing.T) { nestedSymbols: []Symbol{ &AttributeSymbol{ AttrName: "arg", - Type: cty.String, + ExprKind: lang.LiteralTypeKind{Type: cty.String}, rng: hcl.Range{ Filename: "test.tf", Start: hcl.Pos{Column: 3, Line: 6, Byte: 91}, End: hcl.Pos{Column: 11, Line: 6, Byte: 99}, }, + nestedSymbols: []Symbol{}, }, }, }, @@ -189,12 +207,13 @@ provider "google" { nestedSymbols: []Symbol{ &AttributeSymbol{ AttrName: "cidr_block", - Type: cty.String, + ExprKind: lang.LiteralTypeKind{Type: cty.String}, rng: hcl.Range{ Filename: "first.tf", Start: hcl.Pos{Line: 3, Column: 3, Byte: 31}, End: hcl.Pos{Line: 3, Column: 29, Byte: 57}, }, + nestedSymbols: []Symbol{}, }, }, }, @@ -211,21 +230,23 @@ provider "google" { nestedSymbols: []Symbol{ &AttributeSymbol{ AttrName: "project", - Type: cty.String, + ExprKind: lang.LiteralTypeKind{Type: cty.String}, rng: hcl.Range{ Filename: "second.tf", Start: hcl.Pos{Line: 3, Column: 3, Byte: 23}, End: hcl.Pos{Line: 3, Column: 32, Byte: 52}, }, + nestedSymbols: []Symbol{}, }, &AttributeSymbol{ AttrName: "region", - Type: cty.String, + ExprKind: lang.LiteralTypeKind{Type: cty.String}, rng: hcl.Range{ Filename: "second.tf", Start: hcl.Pos{Line: 4, Column: 3, Byte: 55}, End: hcl.Pos{Line: 4, Column: 30, Byte: 82}, }, + nestedSymbols: []Symbol{}, }, }, }, @@ -237,6 +258,214 @@ provider "google" { } } +func TestDecoder_Symbols_expressions(t *testing.T) { + d := NewDecoder() + + testCfg := []byte(` +resource "aws_instance" "test" { + subnet_ids = [ "one-1", "two-2" ] + configuration = { + name = "blah" + num = 42 + boolattr = true + foo(42) = "bar" + } + random_kw = foo +} +`) + f, pDiags := hclsyntax.ParseConfig(testCfg, "test.tf", hcl.InitialPos) + if len(pDiags) > 0 { + t.Fatal(pDiags) + } + + err := d.LoadFile("test.tf", f) + if err != nil { + t.Fatal(err) + } + + symbols, err := d.SymbolsInFile("test.tf") + if err != nil { + t.Fatal(err) + } + + expectedSymbols := []Symbol{ + &BlockSymbol{ + Type: "resource", + Labels: []string{ + "aws_instance", + "test", + }, + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 11, Column: 2, Byte: 180}, + }, + nestedSymbols: []Symbol{ + &AttributeSymbol{ + AttrName: "subnet_ids", + ExprKind: lang.TupleConsExprKind{}, + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 3, + Column: 3, + Byte: 36, + }, + End: hcl.Pos{ + Line: 3, + Column: 36, + Byte: 69, + }, + }, + nestedSymbols: []Symbol{ + &ExprSymbol{ + ExprName: "0", + ExprKind: lang.LiteralTypeKind{ + Type: cty.String, + }, + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 3, + Column: 18, + Byte: 51, + }, + End: hcl.Pos{ + Line: 3, + Column: 25, + Byte: 58, + }, + }, + nestedSymbols: []Symbol{}, + }, + &ExprSymbol{ + ExprName: "1", + ExprKind: lang.LiteralTypeKind{ + Type: cty.String, + }, + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 3, + Column: 27, + Byte: 60, + }, + End: hcl.Pos{ + Line: 3, + Column: 34, + Byte: 67, + }, + }, + nestedSymbols: []Symbol{}, + }, + }, + }, + &AttributeSymbol{ + AttrName: "configuration", + ExprKind: lang.ObjectConsExprKind{}, + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 4, + Column: 3, + Byte: 72, + }, + End: hcl.Pos{ + Line: 9, + Column: 4, + Byte: 160, + }, + }, + nestedSymbols: []Symbol{ + &ExprSymbol{ + ExprName: "name", + ExprKind: lang.LiteralTypeKind{ + Type: cty.String, + }, + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 5, + Column: 4, + Byte: 93, + }, + End: hcl.Pos{ + Line: 5, + Column: 17, + Byte: 106, + }, + }, + nestedSymbols: []Symbol{}, + }, + &ExprSymbol{ + ExprName: "num", + ExprKind: lang.LiteralTypeKind{ + Type: cty.Number, + }, + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 6, + Column: 4, + Byte: 110, + }, + End: hcl.Pos{ + Line: 6, + Column: 12, + Byte: 118, + }, + }, + nestedSymbols: []Symbol{}, + }, + &ExprSymbol{ + ExprName: "boolattr", + ExprKind: lang.LiteralTypeKind{ + Type: cty.Bool, + }, + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 7, + Column: 4, + Byte: 122, + }, + End: hcl.Pos{ + Line: 7, + Column: 19, + Byte: 137, + }, + }, + nestedSymbols: []Symbol{}, + }, + }, + }, + &AttributeSymbol{ + AttrName: "random_kw", + rng: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 10, + Column: 3, + Byte: 163, + }, + End: hcl.Pos{ + Line: 10, + Column: 18, + Byte: 178, + }, + }, + nestedSymbols: []Symbol{}, + }, + }, + }, + } + + diff := cmp.Diff(expectedSymbols, symbols) + if diff != "" { + t.Fatalf("unexpected symbols:\n%s", diff) + } +} + func TestDecoder_Symbols_query(t *testing.T) { d := NewDecoder() @@ -288,21 +517,23 @@ provider "google" { nestedSymbols: []Symbol{ &AttributeSymbol{ AttrName: "project", - Type: cty.String, + ExprKind: lang.LiteralTypeKind{Type: cty.String}, rng: hcl.Range{ Filename: "second.tf", Start: hcl.Pos{Line: 3, Column: 3, Byte: 23}, End: hcl.Pos{Line: 3, Column: 32, Byte: 52}, }, + nestedSymbols: []Symbol{}, }, &AttributeSymbol{ AttrName: "region", - Type: cty.String, + ExprKind: lang.LiteralTypeKind{Type: cty.String}, rng: hcl.Range{ Filename: "second.tf", Start: hcl.Pos{Line: 4, Column: 3, Byte: 55}, End: hcl.Pos{Line: 4, Column: 30, Byte: 82}, }, + nestedSymbols: []Symbol{}, }, }, }, diff --git a/go.mod b/go.mod index 0d944628..54033ce4 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/google/go-cmp v0.5.4 github.com/hashicorp/go-multierror v1.1.0 github.com/hashicorp/hcl/v2 v2.8.2 + github.com/kr/pretty v0.1.0 github.com/mh-cbon/go-fmt-fail v0.0.0-20160815164508-67765b3fbcb5 github.com/mitchellh/copystructure v1.1.1 github.com/zclconf/go-cty v1.7.1 diff --git a/lang/candidate.go b/lang/candidate.go index 88429b2c..ac66d257 100644 --- a/lang/candidate.go +++ b/lang/candidate.go @@ -9,8 +9,10 @@ const ( AttributeCandidateKind BlockCandidateKind LabelCandidateKind + LiteralValueCandidateKind ) +//go:generate stringer -type=CandidateKind -output=candidate_kind_string.go type CandidateKind uint // Candidate represents a completion candidate in the form of diff --git a/lang/candidate_kind_string.go b/lang/candidate_kind_string.go new file mode 100644 index 00000000..8f37f267 --- /dev/null +++ b/lang/candidate_kind_string.go @@ -0,0 +1,27 @@ +// Code generated by "stringer -type=CandidateKind -output=candidate_kind_string.go"; DO NOT EDIT. + +package lang + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[NilCandidateKind-0] + _ = x[AttributeCandidateKind-1] + _ = x[BlockCandidateKind-2] + _ = x[LabelCandidateKind-3] + _ = x[LiteralValueCandidateKind-4] +} + +const _CandidateKind_name = "NilCandidateKindAttributeCandidateKindBlockCandidateKindLabelCandidateKindLiteralValueCandidateKind" + +var _CandidateKind_index = [...]uint8{0, 16, 38, 56, 74, 99} + +func (i CandidateKind) String() string { + if i >= CandidateKind(len(_CandidateKind_index)-1) { + return "CandidateKind(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _CandidateKind_name[_CandidateKind_index[i]:_CandidateKind_index[i+1]] +} diff --git a/lang/markup.go b/lang/markup.go index 571120b1..21f9de75 100644 --- a/lang/markup.go +++ b/lang/markup.go @@ -6,6 +6,7 @@ const ( MarkdownKind ) +//go:generate stringer -type=MarkupKind -output=markup_kind_string.go type MarkupKind uint // MarkupContent represents human-readable content diff --git a/lang/markup_kind_string.go b/lang/markup_kind_string.go new file mode 100644 index 00000000..0d6f7dac --- /dev/null +++ b/lang/markup_kind_string.go @@ -0,0 +1,25 @@ +// Code generated by "stringer -type=MarkupKind -output=markup_kind_string.go"; DO NOT EDIT. + +package lang + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[NilKind-0] + _ = x[PlainTextKind-1] + _ = x[MarkdownKind-2] +} + +const _MarkupKind_name = "NilKindPlainTextKindMarkdownKind" + +var _MarkupKind_index = [...]uint8{0, 7, 20, 32} + +func (i MarkupKind) String() string { + if i >= MarkupKind(len(_MarkupKind_index)-1) { + return "MarkupKind(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _MarkupKind_name[_MarkupKind_index[i]:_MarkupKind_index[i+1]] +} diff --git a/lang/semantic_token.go b/lang/semantic_token.go index f9552e2d..15d2a14b 100644 --- a/lang/semantic_token.go +++ b/lang/semantic_token.go @@ -17,9 +17,19 @@ type SemanticTokenType uint const ( TokenNil SemanticTokenType = iota + + // structural tokens TokenAttrName TokenBlockType TokenBlockLabel + + // expressions + TokenBool + TokenString + TokenNumber + TokenObjectKey + TokenMapKey + TokenKeyword ) func (t SemanticTokenType) GoString() string { diff --git a/lang/semantic_token_type_string.go b/lang/semantic_token_type_string.go index f83a420b..72dfcf4b 100644 --- a/lang/semantic_token_type_string.go +++ b/lang/semantic_token_type_string.go @@ -12,11 +12,17 @@ func _() { _ = x[TokenAttrName-1] _ = x[TokenBlockType-2] _ = x[TokenBlockLabel-3] + _ = x[TokenBool-4] + _ = x[TokenString-5] + _ = x[TokenNumber-6] + _ = x[TokenObjectKey-7] + _ = x[TokenMapKey-8] + _ = x[TokenKeyword-9] } -const _SemanticTokenType_name = "TokenNilTokenAttrNameTokenBlockTypeTokenBlockLabel" +const _SemanticTokenType_name = "TokenNilTokenAttrNameTokenBlockTypeTokenBlockLabelTokenBoolTokenStringTokenNumberTokenObjectKeyTokenMapKeyTokenKeyword" -var _SemanticTokenType_index = [...]uint8{0, 8, 21, 35, 50} +var _SemanticTokenType_index = [...]uint8{0, 8, 21, 35, 50, 59, 70, 81, 95, 106, 118} func (i SemanticTokenType) String() string { if i >= SemanticTokenType(len(_SemanticTokenType_index)-1) { diff --git a/lang/symbol_kind.go b/lang/symbol_kind.go index f3ed8da2..97df1b5d 100644 --- a/lang/symbol_kind.go +++ b/lang/symbol_kind.go @@ -1,5 +1,9 @@ package lang +import ( + "github.com/zclconf/go-cty/cty" +) + // SymbolKind represents kind of a symbol in configuration type SymbolKind uint @@ -7,4 +11,31 @@ const ( NilSymbolKind SymbolKind = iota AttributeSymbolKind BlockSymbolKind + ExprSymbolKind ) + +type exprKindSigil struct{} + +type SymbolExprKind interface { + isSymbolExprKindSigil() exprKindSigil +} + +type LiteralTypeKind struct { + Type cty.Type +} + +func (LiteralTypeKind) isSymbolExprKindSigil() exprKindSigil { + return exprKindSigil{} +} + +type TupleConsExprKind struct{} + +func (TupleConsExprKind) isSymbolExprKindSigil() exprKindSigil { + return exprKindSigil{} +} + +type ObjectConsExprKind struct{} + +func (ObjectConsExprKind) isSymbolExprKindSigil() exprKindSigil { + return exprKindSigil{} +} diff --git a/schema/attribute_schema.go b/schema/attribute_schema.go index d7ef5b6d..f452e32d 100644 --- a/schema/attribute_schema.go +++ b/schema/attribute_schema.go @@ -4,7 +4,6 @@ import ( "errors" "github.com/hashicorp/hcl-lang/lang" - "github.com/zclconf/go-cty/cty" ) // AttributeSchema describes schema for an attribute @@ -15,36 +14,18 @@ type AttributeSchema struct { IsDeprecated bool IsComputed bool - ValueType cty.Type - ValueTypes ValueTypes + Expr ExprConstraints // IsDepKey describes whether to use this attribute (and its value) // as key when looking up dependent schema IsDepKey bool } -type ValueTypes []cty.Type - -func (vt ValueTypes) FriendlyNames() []string { - names := make([]string, len(vt)) - for i, t := range vt { - names[i] = t.FriendlyName() - } - return names -} - func (*AttributeSchema) isSchemaImpl() schemaImplSigil { return schemaImplSigil{} } func (as *AttributeSchema) Validate() error { - if len(as.ValueTypes) == 0 && as.ValueType == cty.NilType { - return errors.New("one of ValueType or ValueTypes must be specified") - } - if len(as.ValueTypes) > 0 && as.ValueType != cty.NilType { - return errors.New("ValueType or ValueTypes must be specified, not both") - } - if as.IsOptional && as.IsRequired { return errors.New("IsOptional or IsRequired must be set, not both") } diff --git a/schema/attribute_schema_test.go b/schema/attribute_schema_test.go index 444d2196..474fb944 100644 --- a/schema/attribute_schema_test.go +++ b/schema/attribute_schema_test.go @@ -13,34 +13,25 @@ func TestAttributeSchema_Validate(t *testing.T) { schema *AttributeSchema expectedErr error }{ - { - &AttributeSchema{}, - errors.New("one of ValueType or ValueTypes must be specified"), - }, { &AttributeSchema{ - ValueType: cty.String, + Expr: LiteralTypeOnly(cty.String), }, errors.New("one of IsRequired, IsOptional, or IsComputed must be set"), }, { &AttributeSchema{ - ValueTypes: []cty.Type{cty.String, cty.Number}, + Expr: ExprConstraints{ + LiteralTypeExpr{Type: cty.String}, + LiteralTypeExpr{Type: cty.Number}, + }, IsComputed: true, }, nil, }, { &AttributeSchema{ - ValueType: cty.String, - ValueTypes: []cty.Type{cty.String, cty.Number}, - IsComputed: true, - }, - errors.New("ValueType or ValueTypes must be specified, not both"), - }, - { - &AttributeSchema{ - ValueType: cty.String, + Expr: LiteralTypeOnly(cty.String), IsRequired: true, IsOptional: true, }, @@ -48,7 +39,7 @@ func TestAttributeSchema_Validate(t *testing.T) { }, { &AttributeSchema{ - ValueType: cty.String, + Expr: LiteralTypeOnly(cty.String), IsRequired: true, IsComputed: true, }, @@ -56,7 +47,7 @@ func TestAttributeSchema_Validate(t *testing.T) { }, { &AttributeSchema{ - ValueType: cty.String, + Expr: LiteralTypeOnly(cty.String), IsOptional: true, IsComputed: true, }, diff --git a/schema/dependent_schema_test.go b/schema/dependent_schema_test.go index 5eb38839..adca5922 100644 --- a/schema/dependent_schema_test.go +++ b/schema/dependent_schema_test.go @@ -22,7 +22,7 @@ func TestBodySchema_FindSchemaDependingOn_label_basic(t *testing.T) { } expectedSchema := &BodySchema{ Attributes: map[string]*AttributeSchema{ - "bar": {ValueType: cty.Number}, + "bar": {Expr: LiteralTypeOnly(cty.Number)}, }, } if diff := cmp.Diff(expectedSchema, bodySchema, ctydebug.CmpOptions); diff != "" { @@ -48,7 +48,7 @@ func TestBodySchema_FindSchemaDependingOn_attributes(t *testing.T) { }, &BodySchema{ Attributes: map[string]*AttributeSchema{ - "depval_attr": {ValueType: cty.String}, + "depval_attr": {Expr: LiteralTypeOnly(cty.String)}, }, }, }, @@ -64,7 +64,7 @@ func TestBodySchema_FindSchemaDependingOn_attributes(t *testing.T) { }, &BodySchema{ Attributes: map[string]*AttributeSchema{ - "number_found": {ValueType: cty.Number}, + "number_found": {Expr: LiteralTypeOnly(cty.Number)}, }, }, }, @@ -83,7 +83,7 @@ func TestBodySchema_FindSchemaDependingOn_attributes(t *testing.T) { }, &BodySchema{ Attributes: map[string]*AttributeSchema{ - "refbar": {ValueType: cty.Number}, + "refbar": {Expr: LiteralTypeOnly(cty.Number)}, }, }, }, @@ -105,7 +105,7 @@ func TestBodySchema_FindSchemaDependingOn_attributes(t *testing.T) { }, &BodySchema{ Attributes: map[string]*AttributeSchema{ - "sortedattr": {ValueType: cty.String}, + "sortedattr": {Expr: LiteralTypeOnly(cty.String)}, }, }, }, @@ -127,7 +127,7 @@ func TestBodySchema_FindSchemaDependingOn_attributes(t *testing.T) { }, &BodySchema{ Attributes: map[string]*AttributeSchema{ - "unsortedattr": {ValueType: cty.String}, + "unsortedattr": {Expr: LiteralTypeOnly(cty.String)}, }, }, }, @@ -174,7 +174,7 @@ func TestBodySchema_FindSchemaDependingOn_label_storedUnsorted(t *testing.T) { } expectedSchema := &BodySchema{ Attributes: map[string]*AttributeSchema{ - "event": {ValueType: cty.String}, + "event": {Expr: LiteralTypeOnly(cty.String)}, }, } if diff := cmp.Diff(expectedSchema, bodySchema, ctydebug.CmpOptions); diff != "" { @@ -195,7 +195,7 @@ func TestBodySchema_FindSchemaDependingOn_label_lookupUnsorted(t *testing.T) { } expectedSchema := &BodySchema{ Attributes: map[string]*AttributeSchema{ - "another": {ValueType: cty.String}, + "another": {Expr: LiteralTypeOnly(cty.String)}, }, } if diff := cmp.Diff(expectedSchema, bodySchema, ctydebug.CmpOptions); diff != "" { @@ -217,7 +217,7 @@ var testSchemaWithLabels = &BlockSchema{ Body: &BodySchema{ Attributes: map[string]*AttributeSchema{ "alias": { - ValueType: cty.String, + Expr: LiteralTypeOnly(cty.String), }, }, }, @@ -228,7 +228,7 @@ var testSchemaWithLabels = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "special_attr": {ValueType: cty.String}, + "special_attr": {Expr: LiteralTypeOnly(cty.String)}, }, }, NewSchemaKey(DependencyKeys{ @@ -237,7 +237,7 @@ var testSchemaWithLabels = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "foo": {ValueType: cty.Number}, + "foo": {Expr: LiteralTypeOnly(cty.Number)}, }, }, NewSchemaKey(DependencyKeys{ @@ -246,7 +246,7 @@ var testSchemaWithLabels = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "bar": {ValueType: cty.Number}, + "bar": {Expr: LiteralTypeOnly(cty.Number)}, }, }, NewSchemaKey(DependencyKeys{ @@ -256,7 +256,7 @@ var testSchemaWithLabels = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "event": {ValueType: cty.String}, + "event": {Expr: LiteralTypeOnly(cty.String)}, }, }, NewSchemaKey(DependencyKeys{ @@ -266,7 +266,7 @@ var testSchemaWithLabels = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "another": {ValueType: cty.String}, + "another": {Expr: LiteralTypeOnly(cty.String)}, }, }, }, @@ -284,16 +284,16 @@ var testSchemaWithAttributes = &BlockSchema{ Body: &BodySchema{ Attributes: map[string]*AttributeSchema{ "depattr": { - ValueType: cty.String, - IsDepKey: true, + Expr: LiteralTypeOnly(cty.String), + IsDepKey: true, }, "depnum": { - ValueType: cty.Number, - IsDepKey: true, + Expr: LiteralTypeOnly(cty.Number), + IsDepKey: true, }, "depref": { - ValueType: cty.DynamicPseudoType, - IsDepKey: true, + Expr: LiteralTypeOnly(cty.DynamicPseudoType), + IsDepKey: true, }, }, }, @@ -309,7 +309,7 @@ var testSchemaWithAttributes = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "depval_attr": {ValueType: cty.String}, + "depval_attr": {Expr: LiteralTypeOnly(cty.String)}, }, }, NewSchemaKey(DependencyKeys{ @@ -323,7 +323,7 @@ var testSchemaWithAttributes = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "number_found": {ValueType: cty.Number}, + "number_found": {Expr: LiteralTypeOnly(cty.Number)}, }, }, NewSchemaKey(DependencyKeys{ @@ -340,7 +340,7 @@ var testSchemaWithAttributes = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "refbar": {ValueType: cty.Number}, + "refbar": {Expr: LiteralTypeOnly(cty.Number)}, }, }, NewSchemaKey(DependencyKeys{ @@ -360,7 +360,7 @@ var testSchemaWithAttributes = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "sortedattr": {ValueType: cty.String}, + "sortedattr": {Expr: LiteralTypeOnly(cty.String)}, }, }, NewSchemaKey(DependencyKeys{ @@ -380,7 +380,7 @@ var testSchemaWithAttributes = &BlockSchema{ }, }): { Attributes: map[string]*AttributeSchema{ - "unsortedattr": {ValueType: cty.String}, + "unsortedattr": {Expr: LiteralTypeOnly(cty.String)}, }, }, }, diff --git a/schema/expressions.go b/schema/expressions.go new file mode 100644 index 00000000..424f9499 --- /dev/null +++ b/schema/expressions.go @@ -0,0 +1,97 @@ +package schema + +import ( + "github.com/hashicorp/hcl-lang/lang" + "github.com/zclconf/go-cty/cty" +) + +type ExprConstraints []ExprConstraint + +type exprConstrSigil struct{} + +type ExprConstraint interface { + isExprConstraintImpl() exprConstrSigil + FriendlyName() string +} + +type LiteralTypeExpr struct { + Type cty.Type +} + +func (LiteralTypeExpr) isExprConstraintImpl() exprConstrSigil { + return exprConstrSigil{} +} + +func (lt LiteralTypeExpr) FriendlyName() string { + return lt.Type.FriendlyNameForConstraint() +} + +type LiteralValue struct { + Val cty.Value + Description lang.MarkupContent +} + +func (LiteralValue) isExprConstraintImpl() exprConstrSigil { + return exprConstrSigil{} +} + +func (lv LiteralValue) FriendlyName() string { + return lv.Val.Type().FriendlyNameForConstraint() +} + +type TupleConsExpr struct { + AnyElem ExprConstraints + Name string + Description lang.MarkupContent +} + +func (TupleConsExpr) isExprConstraintImpl() exprConstrSigil { + return exprConstrSigil{} +} + +func (tc TupleConsExpr) FriendlyName() string { + if tc.Name == "" { + return "tuple" + } + return tc.Name +} + +type MapExpr struct { + Elem ExprConstraints + Name string + Description lang.MarkupContent +} + +func (MapExpr) isExprConstraintImpl() exprConstrSigil { + return exprConstrSigil{} +} + +func (me MapExpr) FriendlyName() string { + if me.Name == "" { + return "map" + } + return me.Name +} + +type KeywordExpr struct { + Keyword string + Name string + Description lang.MarkupContent +} + +func (KeywordExpr) isExprConstraintImpl() exprConstrSigil { + return exprConstrSigil{} +} + +func (ke KeywordExpr) FriendlyName() string { + if ke.Name == "" { + return "keyword" + } + return ke.Name +} + +func LiteralTypeOnly(t cty.Type) ExprConstraints { + return ExprConstraints{ + LiteralTypeExpr{Type: t}, + } +}