From 639cb6b4f22aa13b99ae07be11c64b4e7f9bf15b Mon Sep 17 00:00:00 2001 From: Radek Simko Date: Mon, 16 Jan 2023 17:26:19 +0000 Subject: [PATCH] decoder: Implement completion for TypeDeclaration --- decoder/expr_type_declaration.go | 9 +- decoder/expr_type_declaration_completion.go | 434 ++++++++ .../expr_type_declaration_completion_test.go | 978 ++++++++++++++++++ 3 files changed, 1416 insertions(+), 5 deletions(-) create mode 100644 decoder/expr_type_declaration_completion.go create mode 100644 decoder/expr_type_declaration_completion_test.go diff --git a/decoder/expr_type_declaration.go b/decoder/expr_type_declaration.go index 4ad264e5..af9474a9 100644 --- a/decoder/expr_type_declaration.go +++ b/decoder/expr_type_declaration.go @@ -14,11 +14,6 @@ type TypeDeclaration struct { pathCtx *PathContext } -func (td TypeDeclaration) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.Candidate { - // TODO - return nil -} - func (td TypeDeclaration) HoverAtPos(ctx context.Context, pos hcl.Pos) *lang.HoverData { // TODO return nil @@ -28,3 +23,7 @@ func (td TypeDeclaration) SemanticTokens(ctx context.Context) []lang.SemanticTok // TODO return nil } + +func isTypeNameWithElementOnly(name string) bool { + return name == "list" || name == "set" || name == "map" +} diff --git a/decoder/expr_type_declaration_completion.go b/decoder/expr_type_declaration_completion.go new file mode 100644 index 00000000..d4134c12 --- /dev/null +++ b/decoder/expr_type_declaration_completion.go @@ -0,0 +1,434 @@ +package decoder + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +func (td TypeDeclaration) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.Candidate { + if isEmptyExpression(td.expr) { + 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) { + if isTypeNameWithElementOnly(eType.Name) { + 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], + pathCtx: td.pathCtx, + } + return cons.CompletionAtPos(ctx, pos) + } + + return []lang.Candidate{} + } + + if eType.Name == "object" { + return td.objectCompletionAtPos(ctx, eType, pos) + } + + if eType.Name == "tuple" { + return td.tupleCompletionAtPos(ctx, eType, pos) + } + } + } + + return []lang.Candidate{} +} + +func (td TypeDeclaration) objectCompletionAtPos(ctx context.Context, funcExpr *hclsyntax.FunctionCallExpr, pos hcl.Pos) []lang.Candidate { + if len(funcExpr.Args) == 0 { + editRange := hcl.Range{ + Filename: funcExpr.Range().Filename, + Start: funcExpr.OpenParenRange.End, + End: funcExpr.CloseParenRange.Start, + } + + return innerObjectTypeAsCompletionCandidates(editRange) + } + + if len(funcExpr.Args) > 1 { + return []lang.Candidate{} + } + + objExpr, isObject := funcExpr.Args[0].(*hclsyntax.ObjectConsExpr) + if !isObject { + return []lang.Candidate{} + } + if !funcExpr.Args[0].Range().ContainsPos(pos) { + return []lang.Candidate{} + } + + editRange := hcl.Range{ + Filename: objExpr.Range().Filename, + Start: pos, + End: pos, + } + + if len(objExpr.Items) == 0 { + // check for incomplete configuration between {} + betweenBraces := hcl.Range{ + Filename: objExpr.Range().Filename, + Start: objExpr.OpenRange.End, + End: pos, + } + fileBytes := td.pathCtx.Files[objExpr.Range().Filename].Bytes + remainingBytes := bytes.TrimSpace(betweenBraces.SliceBytes(fileBytes)) + + if len(remainingBytes) == 0 { + return []lang.Candidate{ + objectAttributeItemAsCompletionCandidate(editRange), + } + } + + // if last byte is =, then it's incomplete attribute + if remainingBytes[len(remainingBytes)-1] == '=' { + // TODO: object optional+default + return allTypeDeclarationsAsCandidates("", editRange) + } + } + + recoveryPos := objExpr.OpenRange.End + var lastItemRange, nextItemRange *hcl.Range + for _, item := range objExpr.Items { + emptyRange := hcl.Range{ + Filename: objExpr.Range().Filename, + Start: item.KeyExpr.Range().End, + End: item.ValueExpr.Range().Start, + } + if emptyRange.ContainsPos(pos) { + // exit early if we're in empty space between key and value + return []lang.Candidate{} + } + + // check if we've just missed the position + if pos.Byte < item.KeyExpr.Range().Start.Byte { + // enable recovery between last item's end and position + + // record current (next) item so we can avoid + // completion on the same line + nextItemRange = hcl.RangeBetween(item.KeyExpr.Range(), item.ValueExpr.Range()).Ptr() + break + } + + lastItemRange = hcl.RangeBetween(item.KeyExpr.Range(), item.ValueExpr.Range()).Ptr() + recoveryPos = item.ValueExpr.Range().End + + 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, + pathCtx: td.pathCtx, + } + return cons.CompletionAtPos(ctx, pos) + } + } + + // check any incomplete configuration up to a terminating charactor + fileBytes := td.pathCtx.Files[objExpr.Range().Filename].Bytes + recoveredBytes := recoverLeftBytes(fileBytes, pos, func(offset int, r rune) bool { + return isObjectItemTerminatingRune(r) && offset > recoveryPos.Byte + }) + trimmedBytes := bytes.TrimRight(recoveredBytes, " \t") + + if len(trimmedBytes) == 0 { + return []lang.Candidate{} + } + + if len(trimmedBytes) == 1 && isObjectItemTerminatingRune(rune(trimmedBytes[0])) { + // avoid completing on the same line as next item + if nextItemRange != nil && nextItemRange.Start.Line == pos.Line { + return []lang.Candidate{} + } + + // avoid completing on the same line as last item + if lastItemRange != nil && lastItemRange.End.Line == pos.Line { + // if it is not single-line notation + if trimmedBytes[0] != ',' { + return []lang.Candidate{} + } + } + + return []lang.Candidate{ + objectAttributeItemAsCompletionCandidate(editRange), + } + } + + // if last byte is =, then it's incomplete attribute + if trimmedBytes[len(trimmedBytes)-1] == '=' { + // TODO: object optional+default + return allTypeDeclarationsAsCandidates("", editRange) + } + + return []lang.Candidate{} +} + +func (td TypeDeclaration) tupleCompletionAtPos(ctx context.Context, funcExpr *hclsyntax.FunctionCallExpr, pos hcl.Pos) []lang.Candidate { + if len(funcExpr.Args) == 0 { + editRange := hcl.Range{ + Filename: funcExpr.Range().Filename, + Start: funcExpr.OpenParenRange.End, + End: funcExpr.CloseParenRange.Start, + } + + return innerTupleTypeAsCompletionCandidates(editRange) + } + + if len(funcExpr.Args) != 1 { + // tuple types have to be wrapped in [] + return []lang.Candidate{} + } + + tupleExpr, ok := funcExpr.Args[0].(*hclsyntax.TupleConsExpr) + if !ok { + return []lang.Candidate{} + } + + for _, expr := range tupleExpr.Exprs { + if expr.Range().ContainsPos(pos) || expr.Range().End.Byte == pos.Byte { + cons := TypeDeclaration{ + expr: expr, + pathCtx: td.pathCtx, + } + return cons.CompletionAtPos(ctx, pos) + } + } + + betweenParens := hcl.Range{ + Filename: tupleExpr.Range().Filename, + Start: tupleExpr.OpenRange.End, + End: hcl.Pos{ + Line: tupleExpr.SrcRange.End.Line, + // shift left in front of the closing brace } + Column: tupleExpr.SrcRange.End.Column - 1, + Byte: tupleExpr.SrcRange.End.Byte - 1, + }, + } + if betweenParens.ContainsPos(pos) || betweenParens.End.Byte == pos.Byte { + editRange := hcl.Range{ + Filename: tupleExpr.Range().Filename, + Start: pos, + End: pos, + } + return allTypeDeclarationsAsCandidates("", editRange) + } + + return []lang.Candidate{} +} + +func allTypeDeclarationsAsCandidates(prefix string, editRange hcl.Range) []lang.Candidate { + candidates := make([]lang.Candidate, 0) + // TODO: any + 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 objectAttributeItemAsCompletionCandidate(editRange hcl.Range) lang.Candidate { + return lang.Candidate{ + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: editRange, + }, + } +} + +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 innerTupleTypeAsCompletionCandidates(editRange hcl.Range) []lang.Candidate { + return []lang.Candidate{ + { + Label: "[…]", + Detail: "tuple", + Kind: lang.TupleCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "[]", + Snippet: "[ ${0} ]", + Range: editRange, + }, + }, + } +} diff --git a/decoder/expr_type_declaration_completion_test.go b/decoder/expr_type_declaration_completion_test.go new file mode 100644 index 00000000..7868a2bc --- /dev/null +++ b/decoder/expr_type_declaration_completion_test.go @@ -0,0 +1,978 @@ +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([]lang.Candidate{ + { + Label: "[…]", + Detail: "tuple", + Kind: lang.TupleCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "[]", + Snippet: "[ ${0} ]", + Range: 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: 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 - missing brackets 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([]lang.Candidate{}), + }, + { + "inside tuple - second type after space", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = tuple([string, ]) +`, + hcl.Pos{Line: 1, Column: 23, Byte: 22}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 23, Byte: 22}, + End: hcl.Pos{Line: 1, Column: 23, Byte: 22}, + })), + }, + { + "inside tuple - missing brackets 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([]lang.Candidate{}), + }, + { + "inside tuple - second partial type", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = tuple([string, s]) +`, + hcl.Pos{Line: 1, Column: 24, Byte: 23}, + 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: 23, Byte: 22}, + End: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + }, + }, + }, + { + 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: 23, Byte: 22}, + End: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + }, + }, + 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{}), + }, + // object tests + { + "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}, + }, + }, + }, + }), + }, + { + "single-line 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: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 16, Byte: 15}, + End: hcl.Pos{Line: 1, Column: 16, Byte: 15}, + }, + }, + }, + }), + }, + { + "single-line object value", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ name = }) +`, + hcl.Pos{Line: 1, Column: 24, Byte: 23}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + End: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + })), + }, + { + "inside single-line object partial value near end", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ foo = s }) +`, + hcl.Pos{Line: 1, Column: 24, Byte: 23}, + 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: 23, Byte: 22}, + End: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + }, + }, + }, + { + 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: 23, Byte: 22}, + End: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + }, + }, + TriggerSuggest: true, + }, + }), + }, + { + "inside single-line object partial value in the middle", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ foo = string }) +`, + hcl.Pos{Line: 1, Column: 22, Byte: 23}, + 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: 23, Byte: 22}, + End: hcl.Pos{Line: 1, Column: 29, Byte: 28}, + }, + }, + }, + { + 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: 23, Byte: 22}, + End: hcl.Pos{Line: 1, Column: 29, Byte: 28}, + }, + }, + TriggerSuggest: true, + }, + }), + }, + { + "single-line before attribute", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ foo = string }) +`, + hcl.Pos{Line: 1, Column: 15, Byte: 16}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "single-line after attribute and comma", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ foo = string, }) +`, + hcl.Pos{Line: 1, Column: 29, Byte: 30}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 29, Byte: 30}, + End: hcl.Pos{Line: 1, Column: 29, Byte: 30}, + }, + }, + }, + }), + }, + { + "single-line after attribute without comma", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ foo = string }) +`, + hcl.Pos{Line: 1, Column: 28, Byte: 29}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "single-line between attributes with commas", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ foo = string, , bar = string }) +`, + hcl.Pos{Line: 1, Column: 29, Byte: 30}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 29, Byte: 30}, + End: hcl.Pos{Line: 1, Column: 29, Byte: 30}, + }, + }, + }, + }), + }, + { + "single-line between attributes without commas", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ foo = string, bar = string }) +`, + hcl.Pos{Line: 1, Column: 29, Byte: 30}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + + // multi-line object tests + { + "multi-line inside object braces", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + +}) +`, + hcl.Pos{Line: 2, Column: 3, Byte: 18}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 3, Byte: 18}, + End: hcl.Pos{Line: 2, Column: 3, Byte: 18}, + }, + }, + }, + }), + }, + { + "multi-line object value", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + name = +}) +`, + hcl.Pos{Line: 2, Column: 10, Byte: 25}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 10, Byte: 25}, + End: hcl.Pos{Line: 2, Column: 10, Byte: 25}, + })), + }, + { + "inside multi-line object partial value near end", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = s +}) +`, + hcl.Pos{Line: 2, Column: 10, Byte: 25}, + 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: 2, Column: 9, Byte: 24}, + End: hcl.Pos{Line: 2, Column: 10, Byte: 25}, + }, + }, + }, + { + 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: 2, Column: 9, Byte: 24}, + End: hcl.Pos{Line: 2, Column: 10, Byte: 25}, + }, + }, + TriggerSuggest: true, + }, + }), + }, + { + "inside multi-line object partial value in the middle", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = string +}) +`, + hcl.Pos{Line: 2, Column: 10, Byte: 25}, + 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: 2, Column: 9, Byte: 24}, + End: hcl.Pos{Line: 2, Column: 15, Byte: 30}, + }, + }, + }, + { + 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: 2, Column: 9, Byte: 24}, + End: hcl.Pos{Line: 2, Column: 15, Byte: 30}, + }, + }, + TriggerSuggest: true, + }, + }), + }, + { + "multi-line object value after existing attribute", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = string + bar = +}) +`, + hcl.Pos{Line: 3, Column: 9, Byte: 39}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 9, Byte: 39}, + End: hcl.Pos{Line: 3, Column: 9, Byte: 39}, + })), + }, + { + "multi-line object value before existing attribute", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + bar = + foo = string +}) +`, + hcl.Pos{Line: 2, Column: 9, Byte: 24}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 9, Byte: 24}, + End: hcl.Pos{Line: 2, Column: 9, Byte: 24}, + })), + }, + { + "multi-line object value between existing attributes", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + bar = number + baz = + foo = string +}) +`, + hcl.Pos{Line: 3, Column: 9, Byte: 39}, + lang.CompleteCandidates(allTypeDeclarationsAsCandidates("", hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 9, Byte: 39}, + End: hcl.Pos{Line: 3, Column: 9, Byte: 39}, + })), + }, + { + "multi-line before attribute same line", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = string +}) +`, + hcl.Pos{Line: 2, Column: 1, Byte: 16}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "multi-line before attribute separate line", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + + foo = string +}) +`, + hcl.Pos{Line: 2, Column: 3, Byte: 18}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 3, Byte: 18}, + End: hcl.Pos{Line: 2, Column: 3, Byte: 18}, + }, + }, + }, + }), + }, + { + "multi-line after attribute", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = string + +}) +`, + hcl.Pos{Line: 3, Column: 3, Byte: 33}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 3, Byte: 33}, + End: hcl.Pos{Line: 3, Column: 3, Byte: 33}, + }, + }, + }, + }), + }, + { + "multi-line after attribute with comma newline", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = string, + +}) +`, + hcl.Pos{Line: 3, Column: 3, Byte: 34}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 3, Byte: 34}, + End: hcl.Pos{Line: 3, Column: 3, Byte: 34}, + }, + }, + }, + }), + }, + { + "multi-line after attribute with comma same line", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = string, +}) +`, + hcl.Pos{Line: 2, Column: 17, Byte: 32}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 17, Byte: 32}, + End: hcl.Pos{Line: 2, Column: 17, Byte: 32}, + }, + }, + }, + }), + }, + { + "multi-line after attribute without comma same line", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = string +}) +`, + hcl.Pos{Line: 2, Column: 16, Byte: 31}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "multi-line between attributes without commas", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = string + + bar = string +}) +`, + hcl.Pos{Line: 3, Column: 3, Byte: 33}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 3, Byte: 33}, + End: hcl.Pos{Line: 3, Column: 3, Byte: 33}, + }, + }, + }, + }), + }, + { + "multi-line between attributes with comma", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + foo = string, + bar = string +}) +`, + hcl.Pos{Line: 2, Column: 17, Byte: 32}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "name = type", + Detail: "type", + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "name = ", + Snippet: fmt.Sprintf("${%d:name} = ", 1), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 17, Byte: 32}, + End: hcl.Pos{Line: 2, Column: 17, Byte: 32}, + }, + }, + }, + }), + }, + { + "multi-line inside attribute", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.TypeDeclaration{}, + }, + }, + `attr = object({ + s = +})`, + hcl.Pos{Line: 2, Column: 4, Byte: 19}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%2d-%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.Logf("pos: %#v, config: %s\n", tc.pos, tc.cfg) + t.Fatalf("unexpected candidates: %s", diff) + } + }) + } +}