Skip to content

Commit

Permalink
decoder: Implement completion for Object
Browse files Browse the repository at this point in the history
  • Loading branch information
radeksimko committed Feb 16, 2023
1 parent bb2205f commit 9f2f36b
Show file tree
Hide file tree
Showing 4 changed files with 2,050 additions and 33 deletions.
56 changes: 26 additions & 30 deletions decoder/expr_object.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"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 Object struct {
Expand All @@ -15,11 +17,6 @@ type Object struct {
pathCtx *PathContext
}

func (obj Object) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.Candidate {
// TODO
return nil
}

func (obj Object) HoverAtPos(ctx context.Context, pos hcl.Pos) *lang.HoverData {
// TODO
return nil
Expand All @@ -40,32 +37,31 @@ func (obj Object) ReferenceTargets(ctx context.Context, addr lang.Address, addrC
return nil
}

type ObjectAttributes struct {
expr hcl.Expression
cons schema.ObjectAttributes
}
func getRawObjectAttributeName(keyExpr hcl.Expression) (string, *hcl.Range, bool) {
switch eType := keyExpr.(type) {
// regardless of what expression it is always wrapped
case *hclsyntax.ObjectConsKeyExpr:
return getRawObjectAttributeName(eType.Wrapped)

func (oa ObjectAttributes) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.Candidate {
// TODO
return nil
}
// most common "naked" keys
case *hclsyntax.ScopeTraversalExpr:
if len(eType.Traversal) != 1 {
return "", nil, false
}
return eType.Traversal.RootName(), eType.Range().Ptr(), true

func (oa ObjectAttributes) HoverAtPos(ctx context.Context, pos hcl.Pos) *lang.HoverData {
// TODO
return nil
}
// less common quoted keys
case *hclsyntax.TemplateExpr:
if !eType.IsStringLiteral() {
return "", nil, false
}
return getRawObjectAttributeName(eType.Parts[0])
case *hclsyntax.LiteralValueExpr:
if eType.Val.Type() != cty.String {
return "", nil, false
}
return eType.Val.AsString(), eType.Range().Ptr(), true
}

func (oa ObjectAttributes) SemanticTokens(ctx context.Context) []lang.SemanticToken {
// TODO
return nil
}

func (oa ObjectAttributes) ReferenceOrigins(ctx context.Context, allowSelfRefs bool) reference.Origins {
// TODO
return nil
}

func (oa ObjectAttributes) ReferenceTargets(ctx context.Context, addr lang.Address, addrCtx AddressContext) reference.Targets {
// TODO
return nil
return "", nil, false
}
280 changes: 280 additions & 0 deletions decoder/expr_object_completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
package decoder

import (
"bytes"
"context"
"sort"
"strings"
"unicode"

"github.com/hashicorp/hcl-lang/lang"
"github.com/hashicorp/hcl-lang/schema"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
)

type declaredAttributes map[string]hcl.Range

func (obj Object) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.Candidate {
if isEmptyExpression(obj.expr) {
return []lang.Candidate{
{ // TODO: Consider rendering first N elements in Label?
Label: "{…}",
Detail: "object",
Kind: lang.ObjectCandidateKind,
Description: obj.cons.Description,
TextEdit: lang.TextEdit{
NewText: "{\n \n}",
Snippet: "{\n ${0}\n}",
Range: hcl.Range{
Filename: obj.expr.Range().Filename,
Start: pos,
End: pos,
},
},
TriggerSuggest: true,
},
}
}

eType, ok := obj.expr.(*hclsyntax.ObjectConsExpr)
if !ok {
return []lang.Candidate{}
}

betweenBraces := hcl.Range{
Filename: eType.Range().Filename,
Start: eType.OpenRange.End,
End: eType.Range().End,
}

if !betweenBraces.ContainsPos(pos) {
return []lang.Candidate{}
}

if len(obj.cons.Attributes) == 0 {
return []lang.Candidate{}
}

editRange := hcl.Range{
Filename: eType.Range().Filename,
Start: pos,
End: pos,
}

declared := make(declaredAttributes, 0)
recoveryPos := eType.OpenRange.Start
var lastItemRange, nextItemRange *hcl.Range

for _, item := range eType.Items {
emptyRange := hcl.Range{
Filename: eType.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{}
}

attrName, attrRange, ok := getRawObjectAttributeName(item.KeyExpr)
if !ok {
continue
}

// collect all declared attributes
declared[attrName] = hcl.RangeBetween(item.KeyExpr.Range(), item.ValueExpr.Range())

if nextItemRange != nil {
continue
}
// check if we've just missed the position
if pos.Byte < item.KeyExpr.Range().Start.Byte {
// record current (next) item so we can avoid completion
// on the same line in multi-line mode (without comma)
nextItemRange = hcl.RangeBetween(item.KeyExpr.Range(), item.ValueExpr.Range()).Ptr()

// enable recovery of incomplete configuration
// between last item's end and position
continue
}
lastItemRange = hcl.RangeBetween(item.KeyExpr.Range(), item.ValueExpr.Range()).Ptr()
recoveryPos = item.ValueExpr.Range().End

if item.KeyExpr.Range().ContainsPos(pos) {
prefix := ""

// if we're before start of the attribute
// it means the attribute is likely quoted
if pos.Byte >= attrRange.Start.Byte {
prefixLen := pos.Byte - attrRange.Start.Byte
prefix = attrName[0:prefixLen]
}

editRange := hcl.RangeBetween(item.KeyExpr.Range(), item.ValueExpr.Range())

return objectAttributesToCandidates(prefix, obj.cons.Attributes, declared, editRange)
}
if item.ValueExpr.Range().ContainsPos(pos) || item.ValueExpr.Range().End.Byte == pos.Byte {
aSchema, ok := obj.cons.Attributes[attrName]
if !ok {
// unknown attribute
return []lang.Candidate{}
}

cons := newExpression(obj.pathCtx, item.ValueExpr, aSchema.Constraint)

return cons.CompletionAtPos(ctx, pos)
}
}

// check any incomplete configuration up to a terminating character
fileBytes := obj.pathCtx.Files[eType.Range().Filename].Bytes
leftBytes := recoverMatchingLeftBytes(fileBytes, pos, func(offset int, r rune) bool {
return !isObjectItemLeftTerminator(r) && offset > recoveryPos.Byte
})
trimmedBytes := bytes.TrimRight(leftBytes, " \t")

if len(trimmedBytes) == 0 {
// no terminating character was found which indicates
// we're on the same line as an existing item
// and we're missing preceding comma
return []lang.Candidate{}
}

if len(trimmedBytes) == 1 && isObjectItemLeftTerminator(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 objectAttributesToCandidates("", obj.cons.Attributes, declared, editRange)
}

// trime left side as well now
// to make prefix/attribute extraction easier below
trimmedBytes = bytes.TrimLeftFunc(trimmedBytes, func(r rune) bool {
return isObjectItemLeftTerminator(r) || unicode.IsSpace(r)
})

// if last byte is =, then it's incomplete attribute
if trimmedBytes[len(trimmedBytes)-1] == '=' {
emptyExpr := newEmptyExpressionAtPos(eType.Range().Filename, pos)

attrName := string(bytes.TrimFunc(trimmedBytes[:len(trimmedBytes)-1], func(r rune) bool {
return unicode.IsSpace(r) || r == '"'
}))
aSchema, ok := obj.cons.Attributes[attrName]
if !ok {
// unknown attribute
return []lang.Candidate{}
}

cons := newExpression(obj.pathCtx, emptyExpr, aSchema.Constraint)

return cons.CompletionAtPos(ctx, pos)
}

prefix := string(bytes.TrimFunc(trimmedBytes, func(r rune) bool {
return unicode.IsSpace(r) || r == '"'
}))

// calculate appropriate edit range in case there
// are also characters on the right from position
// which are worth replacing
remainingRange := hcl.Range{
Filename: eType.Range().Filename,
Start: pos,
End: eType.SrcRange.End,
}
editRange = objectItemPrefixBasedEditRange(remainingRange, fileBytes, trimmedBytes)

return objectAttributesToCandidates(prefix, obj.cons.Attributes, declared, editRange)
}

func isObjectItemLeftTerminator(r rune) bool {
return r == '\n' || r == ',' || r == '{'
}

func recoverMatchingLeftBytes(b []byte, pos hcl.Pos, f func(byteOffset int, r rune) bool) []byte {
for offset := pos.Byte - 1; offset >= 0; offset-- {
nextRune := rune(b[offset])
if !f(offset, nextRune) {
return b[offset:pos.Byte]
}
}
return []byte{}
}

func objectItemPrefixBasedEditRange(remainingRange hcl.Range, fileBytes []byte, rawPrefixBytes []byte) hcl.Range {
remainingBytes := remainingRange.SliceBytes(fileBytes)
roughEndByteOffset := bytes.IndexFunc(remainingBytes, func(r rune) bool {
return r == '\n' || r == '}'
})
// avoid editing over whitespace
trimmedRightBytes := bytes.TrimRightFunc(remainingBytes[:roughEndByteOffset], func(r rune) bool {
return unicode.IsSpace(r)
})
trimmedOffset := len(trimmedRightBytes)

return hcl.Range{
Filename: remainingRange.Filename,
Start: hcl.Pos{
// TODO: Calculate Line+Column for multi-line keys?
Line: remainingRange.Start.Line,
Column: remainingRange.Start.Column - len(rawPrefixBytes),
Byte: remainingRange.Start.Byte - len(rawPrefixBytes),
},
End: hcl.Pos{
// TODO: Calculate Line+Column for multi-line values?
Line: remainingRange.Start.Line,
Column: remainingRange.Start.Column + trimmedOffset,
Byte: remainingRange.Start.Byte + trimmedOffset,
},
}
}

func objectAttributesToCandidates(prefix string, attrs schema.ObjectAttributes, declared declaredAttributes, editRange hcl.Range) []lang.Candidate {
if len(attrs) == 0 {
return []lang.Candidate{}
}

candidates := make([]lang.Candidate, 0)

attrNames := sortedObjectAttributeNames(attrs)

for _, name := range attrNames {
if !strings.HasPrefix(name, prefix) {
continue
}
// avoid suggesting already declared attribute
// unless we're overriding it
if declaredRng, ok := declared[name]; ok && !declaredRng.Overlaps(editRange) {
continue
}

candidates = append(candidates, attributeSchemaToCandidate(name, attrs[name], editRange))
}

return candidates
}

func sortedObjectAttributeNames(objAttributes schema.ObjectAttributes) []string {
names := make([]string, len(objAttributes))
i := 0
for name := range objAttributes {
names[i] = name
i++
}

sort.Strings(names)
return names
}
Loading

0 comments on commit 9f2f36b

Please sign in to comment.