diff --git a/decoder/expr_object.go b/decoder/expr_object.go index 1085e8b3..9739be0f 100644 --- a/decoder/expr_object.go +++ b/decoder/expr_object.go @@ -17,11 +17,6 @@ type Object struct { pathCtx *PathContext } -func (obj Object) HoverAtPos(ctx context.Context, pos hcl.Pos) *lang.HoverData { - // TODO - return nil -} - func (obj Object) SemanticTokens(ctx context.Context) []lang.SemanticToken { // TODO return nil diff --git a/decoder/expr_object_hover.go b/decoder/expr_object_hover.go new file mode 100644 index 00000000..5548d47e --- /dev/null +++ b/decoder/expr_object_hover.go @@ -0,0 +1,89 @@ +package decoder + +import ( + "context" + "fmt" + + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func (obj Object) HoverAtPos(ctx context.Context, pos hcl.Pos) *lang.HoverData { + eType, ok := obj.expr.(*hclsyntax.ObjectConsExpr) + if !ok { + return nil + } + + for _, item := range eType.Items { + attrName, _, ok := getRawObjectAttributeName(item.KeyExpr) + if !ok { + continue + } + + aSchema, ok := obj.cons.Attributes[attrName] + if !ok { + // unknown attribute + return nil + } + + if item.KeyExpr.Range().ContainsPos(pos) { + itemRng := hcl.RangeBetween(item.KeyExpr.Range(), item.ValueExpr.Range()) + content := hoverContentForAttribute(attrName, aSchema) + + return &lang.HoverData{ + Content: content, + Range: itemRng, + } + } + + if item.ValueExpr.Range().ContainsPos(pos) { + expr := newExpression(obj.pathCtx, item.ValueExpr, aSchema.Constraint) + return expr.HoverAtPos(ctx, pos) + } + } + + content := hoverDataForObjectAttributes(obj.cons.Attributes) + content += fmt.Sprintf("_%s_", obj.cons.FriendlyName()) + if obj.cons.Description.Value != "" { + content += "\n\n" + obj.cons.Description.Value + } + + return &lang.HoverData{ + Content: lang.Markdown(content), + Range: eType.Range(), + } +} + +func hoverContentForAttribute(name string, aSchema *schema.AttributeSchema) lang.MarkupContent { + value := fmt.Sprintf("**%s** _%s_", name, detailForAttribute(aSchema)) + if aSchema.Description.Value != "" { + value += fmt.Sprintf("\n\n%s", aSchema.Description.Value) + } + return lang.MarkupContent{ + Kind: lang.MarkdownKind, + Value: value, + } +} + +func hoverDataForObjectAttributes(attributes schema.ObjectAttributes) string { + if len(attributes) == 0 { + return "" + } + + data := "```\n{\n" + for name, attr := range attributes { + exprData := attr.Constraint.FriendlyName() + // TODO: expand and render any "expandable" constraints + // - Object, Map, List, Set, Tuple + // Maybe introduce schema.Constraint.EmptyHoverData(nestingLevel int) *lang.HoverData + + if attr.IsOptional { + exprData = fmt.Sprintf("optional, %s", exprData) + } + data += fmt.Sprintf("%s%s = %s\n", " ", name, exprData) + } + data += "}\n```\n" + return data +} diff --git a/decoder/expr_object_hover_test.go b/decoder/expr_object_hover_test.go new file mode 100644 index 00000000..fa202a99 --- /dev/null +++ b/decoder/expr_object_hover_test.go @@ -0,0 +1,380 @@ +package decoder + +import ( + "context" + "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" +) + +func TestHoverAtPos_exprObject(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + pos hcl.Pos + expectedHoverData *lang.HoverData + }{ + { + "empty single-line object without attributes", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{}, + }, + }, + `attr = {}`, + hcl.Pos{Line: 1, Column: 9, Byte: 8}, + &lang.HoverData{ + Content: lang.Markdown("_object_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + }, + }, + }, + { + "empty multi-line object without attributes", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{}, + }, + }, + `attr = { + +}`, + hcl.Pos{Line: 2, Column: 2, Byte: 10}, + &lang.HoverData{ + Content: lang.Markdown("_object_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 3, Column: 2, Byte: 13}, + }, + }, + }, + { + "empty single-line object with attributes", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{ + Attributes: schema.ObjectAttributes{ + "foo": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keyword", + }, + }, + }, + }, + }, + }, + `attr = {}`, + hcl.Pos{Line: 1, Column: 9, Byte: 8}, + &lang.HoverData{ + Content: lang.Markdown("```\n{\n foo = optional, keyword\n}\n```\n_object_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + }, + }, + }, + { + "empty single-line object with attributes and overrides", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{ + Name: "custom", + Description: lang.Markdown("custom description"), + Attributes: schema.ObjectAttributes{ + "foo": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keyword", + }, + }, + }, + }, + }, + }, + `attr = {}`, + hcl.Pos{Line: 1, Column: 9, Byte: 8}, + &lang.HoverData{ + Content: lang.Markdown("```\n{\n foo = optional, keyword\n}\n```\n_custom_\n\ncustom description"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + }, + }, + }, + { + "empty multi-line object with attributes", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{ + Attributes: schema.ObjectAttributes{ + "foo": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keyword", + }, + }, + }, + }, + }, + }, + `attr = { + +}`, + hcl.Pos{Line: 2, Column: 2, Byte: 10}, + &lang.HoverData{ + Content: lang.Markdown("```\n{\n foo = optional, keyword\n}\n```\n_object_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 3, Column: 2, Byte: 13}, + }, + }, + }, + { + "single item object on valid attribute name", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{ + Attributes: schema.ObjectAttributes{ + "foo": { + IsOptional: true, + Description: lang.Markdown("kw description"), + Constraint: schema.Keyword{ + Keyword: "keyword", + }, + }, + }, + }, + }, + }, + `attr = { + foo = keyword +}`, + hcl.Pos{Line: 2, Column: 5, Byte: 13}, + &lang.HoverData{ + Content: lang.Markdown("**foo** _optional, keyword_\n\nkw description"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 3, Byte: 11}, + End: hcl.Pos{Line: 2, Column: 16, Byte: 24}, + }, + }, + }, + { + "single item object on valid attribute name with custom data", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{ + Attributes: schema.ObjectAttributes{ + "foo": { + IsOptional: true, + IsSensitive: true, + IsDeprecated: true, + Description: lang.Markdown("custom"), + Constraint: schema.Keyword{ + Keyword: "keyword", + Description: lang.Markdown("kw description"), + }, + }, + }, + }, + }, + }, + `attr = { + foo = keyword +}`, + hcl.Pos{Line: 2, Column: 5, Byte: 13}, + &lang.HoverData{ + Content: lang.Markdown("**foo** _optional, sensitive, keyword_\n\ncustom"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 3, Byte: 11}, + End: hcl.Pos{Line: 2, Column: 16, Byte: 24}, + }, + }, + }, + { + "single item object on invalid attribute name", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{ + Attributes: schema.ObjectAttributes{ + "foo": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keyword", + }, + }, + }, + }, + }, + }, + `attr = { + bar = keyword +}`, + hcl.Pos{Line: 2, Column: 5, Byte: 13}, + nil, + }, + { + "multi item object on valid attribute name", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{ + Attributes: schema.ObjectAttributes{ + "foo": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keywordfoo", + }, + }, + "bar": { + IsRequired: true, + Constraint: schema.Keyword{ + Keyword: "keywordbar", + }, + }, + "baz": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keywordbaz", + }, + }, + }, + }, + }, + }, + `attr = { + foo = keywordfoo + bar = keywordbar + baz = keywordbaz +}`, + hcl.Pos{Line: 3, Column: 5, Byte: 32}, + &lang.HoverData{ + Content: lang.Markdown("**bar** _required, keyword_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 3, Byte: 30}, + End: hcl.Pos{Line: 3, Column: 19, Byte: 46}, + }, + }, + }, + { + "multi item object on matching value", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{ + Attributes: schema.ObjectAttributes{ + "foo": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keywordfoo", + }, + }, + "bar": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keywordbar", + }, + }, + "baz": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keywordbaz", + }, + }, + }, + }, + }, + }, + `attr = { + foo = invalid + bar = keywordbar + baz = keywordbaz +}`, + hcl.Pos{Line: 3, Column: 16, Byte: 40}, + &lang.HoverData{ + Content: lang.Markdown("`keywordbar` _keyword_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 9, Byte: 33}, + End: hcl.Pos{Line: 3, Column: 19, Byte: 43}, + }, + }, + }, + { + "multi item object on mismatching value", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Object{ + Attributes: schema.ObjectAttributes{ + "foo": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keywordfoo", + }, + }, + "bar": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keywordbar", + }, + }, + "baz": { + IsOptional: true, + Constraint: schema.Keyword{ + Keyword: "keywordbaz", + }, + }, + }, + }, + }, + }, + `attr = { + foo = invalid + bar = keywordbar + baz = keywordbaz +}`, + hcl.Pos{Line: 2, Column: 13, Byte: 21}, + nil, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, _ := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + }) + + ctx := context.Background() + hoverData, err := d.HoverAtPos(ctx, "test.tf", tc.pos) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedHoverData, hoverData); diff != "" { + t.Fatalf("unexpected hover data: %s", diff) + } + }) + } +} diff --git a/decoder/hover.go b/decoder/hover.go index 1c816740..f6925ffe 100644 --- a/decoder/hover.go +++ b/decoder/hover.go @@ -201,17 +201,6 @@ func (d *PathDecoder) hoverContentForLabel(i int, block *hclsyntax.Block, bSchem return lang.Markdown(content) } -func hoverContentForAttribute(name string, schema *schema.AttributeSchema) lang.MarkupContent { - value := fmt.Sprintf("**%s** _%s_", name, detailForAttribute(schema)) - if schema.Description.Value != "" { - value += fmt.Sprintf("\n\n%s", schema.Description.Value) - } - return lang.MarkupContent{ - Kind: lang.MarkdownKind, - Value: value, - } -} - func (d *PathDecoder) hoverContentForBlock(bType string, schema *schema.BlockSchema) lang.MarkupContent { value := fmt.Sprintf("**%s** _%s_", bType, detailForBlock(schema)) if schema.Description.Value != "" {