diff --git a/decoder/expr_type_declaration.go b/decoder/expr_type_declaration.go index 18980885..31e3d81c 100644 --- a/decoder/expr_type_declaration.go +++ b/decoder/expr_type_declaration.go @@ -2,21 +2,339 @@ package decoder import ( "context" + "fmt" + "log" + "strings" "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl-lang/reference" "github.com/hashicorp/hcl-lang/schema" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" ) type TypeDeclaration struct { expr hcl.Expression cons schema.TypeDeclaration + + insideObject bool + + // TODO: optional attribute mode } func (td TypeDeclaration) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.Candidate { - // TODO - return nil + log.Printf("expression: %#v", td.expr) + if isEmptyExpression(td.expr) || td.expr == nil { + editRange := hcl.Range{ + Filename: td.expr.Range().Filename, + Start: pos, + End: pos, + } + return allTypeDeclarationsAsCandidates("", editRange) + } + + switch eType := td.expr.(type) { + case *hclsyntax.ScopeTraversalExpr: + if len(eType.Traversal) != 1 { + return []lang.Candidate{} + } + + prefixLen := pos.Byte - eType.Range().Start.Byte + prefix := eType.Traversal.RootName()[0:prefixLen] + + editRange := hcl.Range{ + Filename: eType.Range().Filename, + Start: eType.Range().Start, + End: eType.Range().End, + } + + return allTypeDeclarationsAsCandidates(prefix, editRange) + case *hclsyntax.FunctionCallExpr: + // position in complex type name + if eType.NameRange.ContainsPos(pos) { + prefixLen := pos.Byte - eType.NameRange.Start.Byte + prefix := eType.Name[0:prefixLen] + + editRange := eType.Range() + return allTypeDeclarationsAsCandidates(prefix, editRange) + } + + // position inside paranthesis + if hcl.RangeBetween(eType.OpenParenRange, eType.CloseParenRange).ContainsPos(pos) { + isSingleArgType := eType.Name == "list" || eType.Name == "set" || eType.Name == "map" + + // single argument types + if isSingleArgType { + if len(eType.Args) == 0 { + editRange := hcl.Range{ + Filename: eType.Range().Filename, + Start: eType.OpenParenRange.End, + End: eType.CloseParenRange.Start, + } + + return allTypeDeclarationsAsCandidates("", editRange) + } + + if len(eType.Args) == 1 && eType.Args[0].Range().ContainsPos(pos) { + cons := TypeDeclaration{ + expr: eType.Args[0], + } + return cons.CompletionAtPos(ctx, pos) + } + + return []lang.Candidate{} + } + + // object type + if eType.Name == "object" { + if len(eType.Args) == 0 { + editRange := hcl.Range{ + Filename: eType.Range().Filename, + Start: eType.OpenParenRange.End, + End: eType.CloseParenRange.Start, + } + + return innerObjectTypeAsCompletionCandidates(editRange) + } + + if len(eType.Args) == 1 { + objExpr, isObject := eType.Args[0].(*hclsyntax.ObjectConsExpr) + if !isObject { + return []lang.Candidate{} + } + if !eType.Args[0].Range().ContainsPos(pos) { + return []lang.Candidate{} + } + + cons := TypeDeclaration{ + expr: objExpr, + insideObject: true, + } + return cons.CompletionAtPos(ctx, pos) + } + + return []lang.Candidate{} + } + + // multi argument type (tuple) + if eType.Name == "tuple" { + if len(eType.Args) == 0 { + editRange := hcl.Range{ + Filename: eType.Range().Filename, + Start: eType.OpenParenRange.End, + End: eType.CloseParenRange.Start, + } + + return allTypeDeclarationsAsCandidates("", editRange) + } + + for _, expr := range eType.Args { + if expr.Range().ContainsPos(pos) || expr.Range().End.Byte == pos.Byte { + cons := TypeDeclaration{ + expr: expr, + } + return cons.CompletionAtPos(ctx, pos) + } + } + + betweenParens := hcl.Range{ + Filename: eType.Range().Filename, + Start: eType.OpenParenRange.End, + End: eType.CloseParenRange.Start, + } + if betweenParens.ContainsPos(pos) || betweenParens.End.Byte == pos.Byte { + editRange := hcl.Range{ + Filename: eType.Range().Filename, + Start: pos, + End: pos, + } + return allTypeDeclarationsAsCandidates("", editRange) + } + + return []lang.Candidate{} + } + } + case *hclsyntax.ObjectConsExpr: + if !td.insideObject { + // reject completion in bare object notation w/out object() + return []lang.Candidate{} + } + + if len(eType.Items) == 0 { + editRange := hcl.Range{ + Filename: eType.Range().Filename, + Start: pos, + End: pos, + } + + candidates := make([]lang.Candidate, 0) + candidates = append(candidates, lang.Candidate{ + Label: "name = type", + Detail: "attribute", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ${%d}", 1, 2), + Range: editRange, + }, + }) + return candidates + } + + for _, item := range eType.Items { + if item.KeyExpr.Range().ContainsPos(pos) { + return []lang.Candidate{} + } + if item.ValueExpr.Range().ContainsPos(pos) || item.ValueExpr.Range().End.Byte == pos.Byte { + cons := TypeDeclaration{ + expr: item.ValueExpr, + } + return cons.CompletionAtPos(ctx, pos) + } + } + + return []lang.Candidate{} + } + + return []lang.Candidate{} +} + +func allTypeDeclarationsAsCandidates(prefix string, editRange hcl.Range) []lang.Candidate { + candidates := make([]lang.Candidate, 0) + candidates = append(candidates, primitiveTypeDeclarationsAsCandidates(prefix, editRange)...) + candidates = append(candidates, complexTypeDeclarationsAsCandidates(prefix, editRange)...) + return candidates +} + +func primitiveTypeDeclarationsAsCandidates(prefix string, editRange hcl.Range) []lang.Candidate { + candidates := make([]lang.Candidate, 0) + + if strings.HasPrefix("bool", prefix) { + candidates = append(candidates, lang.Candidate{ + Label: cty.Bool.FriendlyNameForConstraint(), + Detail: cty.Bool.FriendlyNameForConstraint(), + Kind: lang.BoolCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "bool", + Snippet: "bool", + Range: editRange, + }, + }) + } + if strings.HasPrefix("number", prefix) { + candidates = append(candidates, lang.Candidate{ + Label: cty.Number.FriendlyNameForConstraint(), + Detail: cty.Number.FriendlyNameForConstraint(), + Kind: lang.NumberCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "number", + Snippet: "number", + Range: editRange, + }, + }) + } + if strings.HasPrefix("string", prefix) { + candidates = append(candidates, lang.Candidate{ + Label: cty.String.FriendlyNameForConstraint(), + Detail: cty.String.FriendlyNameForConstraint(), + Kind: lang.StringCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "string", + Snippet: "string", + Range: editRange, + }, + }) + } + + return candidates +} + +func complexTypeDeclarationsAsCandidates(prefix string, editRange hcl.Range) []lang.Candidate { + candidates := make([]lang.Candidate, 0) + // TODO: indentation + + if strings.HasPrefix("list", prefix) { + candidates = append(candidates, lang.Candidate{ + Label: "list(...)", + Detail: "list", + Kind: lang.ListCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "list()", + Snippet: fmt.Sprintf("list(${%d})", 0), + Range: editRange, + }, + TriggerSuggest: true, + }) + } + if strings.HasPrefix("set", prefix) { + candidates = append(candidates, lang.Candidate{ + Label: "set(...)", + Detail: "set", + Kind: lang.SetCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "set()", + Snippet: fmt.Sprintf("set(${%d})", 0), + Range: editRange, + }, + TriggerSuggest: true, + }) + } + if strings.HasPrefix("tuple", prefix) { + candidates = append(candidates, lang.Candidate{ + Label: "tuple(...)", + Detail: "tuple", + Kind: lang.TupleCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "tuple()", + Snippet: fmt.Sprintf("tuple(${%d})", 0), + Range: editRange, + }, + TriggerSuggest: true, + }) + } + if strings.HasPrefix("map", prefix) { + candidates = append(candidates, lang.Candidate{ + Label: "map(...)", + Detail: "map", + Kind: lang.MapCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "map()", + Snippet: fmt.Sprintf("map(${%d})", 0), + Range: editRange, + }, + TriggerSuggest: true, + }) + } + if strings.HasPrefix("object", prefix) { + candidates = append(candidates, lang.Candidate{ + Label: "object({...})", + Detail: "object", + Kind: lang.ObjectCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "object({\n\n})", + Snippet: fmt.Sprintf("object({\n ${%d:name} = ${%d}\n})", 1, 2), + Range: editRange, + }, + }) + } + + return candidates +} + +func innerObjectTypeAsCompletionCandidates(editRange hcl.Range) []lang.Candidate { + return []lang.Candidate{ + { + Label: "{...}", + Detail: "object", + Kind: lang.ObjectCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "{\n\n}", + Snippet: fmt.Sprintf("{\n ${%d:name} = ${%d}\n}", 1, 2), + Range: editRange, + }, + }, + } } func (td TypeDeclaration) HoverAtPos(ctx context.Context, pos hcl.Pos) *lang.HoverData { diff --git a/decoder/expr_type_declaration_test.go b/decoder/expr_type_declaration_test.go new file mode 100644 index 00000000..92f4fb97 --- /dev/null +++ b/decoder/expr_type_declaration_test.go @@ -0,0 +1,364 @@ +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 TestCompletionAtPos_exprTypeDeclaration(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + pos hcl.Pos + expectedCandidates lang.Candidates + }{ + { + "all types", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = +`, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + })), + }, + { + "inside list", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = list() +`, + hcl.Pos{Line: 1, Column: 13, Byte: 12}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + })), + }, + { + "inside set name", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = set() +`, + hcl.Pos{Line: 1, Column: 9, Byte: 8}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "string", + Detail: "string", + Kind: lang.StringCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "string", + Snippet: "string", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + }, + }, + }, + { + Label: "set(...)", + Detail: "set", + Kind: lang.SetCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "set()", + Snippet: fmt.Sprintf("set(${%d})", 0), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + }, + }, + TriggerSuggest: true, + }, + }), + }, + { + "partial string name", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = st +`, + hcl.Pos{Line: 1, Column: 10, Byte: 9}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "string", + Detail: "string", + Kind: lang.StringCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "string", + Snippet: "string", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + }, + }, + }, + }), + }, + { + "partial list name", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = li +`, + hcl.Pos{Line: 1, Column: 10, Byte: 9}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "list(...)", + Detail: "list", + Kind: lang.ListCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "list()", + Snippet: fmt.Sprintf("list(${%d})", 0), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + }, + }, + TriggerSuggest: true, + }, + }), + }, + { + "inside tuple", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = tuple() +`, + hcl.Pos{Line: 1, Column: 14, Byte: 13}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + End: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + })), + }, + { + "inside tuple - second type after comma", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = tuple(string,) +`, + hcl.Pos{Line: 1, Column: 21, Byte: 20}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 21, Byte: 20}, + End: hcl.Pos{Line: 1, Column: 21, Byte: 20}, + })), + }, + { + "inside tuple - second type after space", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = tuple(string, ) +`, + hcl.Pos{Line: 1, Column: 22, Byte: 21}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 22, Byte: 21}, + End: hcl.Pos{Line: 1, Column: 22, Byte: 21}, + })), + }, + { + "inside tuple - second partial type", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = tuple(string, s) +`, + hcl.Pos{Line: 1, Column: 23, Byte: 22}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "string", + Detail: "string", + Kind: lang.StringCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "string", + Snippet: "string", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 22, Byte: 21}, + End: hcl.Pos{Line: 1, Column: 23, Byte: 22}, + }, + }, + }, + { + Label: "set(...)", + Detail: "set", + Kind: lang.SetCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "set()", + Snippet: "set(${0})", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 22, Byte: 21}, + End: hcl.Pos{Line: 1, Column: 23, Byte: 22}, + }, + }, + TriggerSuggest: true, + }, + }), + }, + { + "inside set - invalid second argument", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = set(string,) +`, + hcl.Pos{Line: 1, Column: 19, Byte: 18}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "inside object without braces", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object() +`, + hcl.Pos{Line: 1, Column: 15, Byte: 14}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "{...}", + Detail: "object", + Kind: lang.ObjectCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "{\n\n}", + Snippet: fmt.Sprintf("{\n ${%d:name} = ${%d}\n}", 1, 2), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 15, Byte: 14}, + End: hcl.Pos{Line: 1, Column: 15, Byte: 14}, + }, + }, + }, + }), + }, + { + "inside object braces", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({}) +`, + hcl.Pos{Line: 1, Column: 16, Byte: 15}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "attribute", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ${%d}", 1, 2), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 16, Byte: 15}, + End: hcl.Pos{Line: 1, Column: 16, Byte: 15}, + }, + }, + }, + }), + }, + // TODO: HCL parses incomplete object in function w/ Range.End = InitialPos + // which makes position seeking hard + // { + // "object value", + // map[string]*schema.AttributeSchema{ + // "attr": { + // Constraint: schema.TypeDeclaration{}, + // }, + // }, + // `attr = object({ name = }) + // `, + // hcl.Pos{Line: 1, Column: 24, Byte: 23}, + // lang.CompleteCandidates([]lang.Candidate{ + // { + // Label: "type", + // Detail: "type", + // Kind: lang.AttributeCandidateKind, + // TextEdit: lang.TextEdit{ + // NewText: "type", + // Snippet: "type", + // Range: hcl.Range{ + // Filename: "test.tf", + // Start: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + // End: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + // }, + // }, + // }, + // }), + // }, + } + + 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() + candidates, err := d.CandidatesAtPos(ctx, "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/schema/constraint_type_declaration.go b/schema/constraint_type_declaration.go index 1160baa8..0f9281ae 100644 --- a/schema/constraint_type_declaration.go +++ b/schema/constraint_type_declaration.go @@ -18,6 +18,8 @@ func (td TypeDeclaration) Copy() Constraint { } func (td TypeDeclaration) EmptyCompletionData(nextPlaceholder int) CompletionData { - // TODO - return CompletionData{} + return CompletionData{ + TriggerSuggest: true, + LastPlaceholder: nextPlaceholder, + } }