Skip to content

Commit

Permalink
WIP Completion
Browse files Browse the repository at this point in the history
  • Loading branch information
radeksimko committed Feb 15, 2023
1 parent c5840d4 commit 9662595
Show file tree
Hide file tree
Showing 2 changed files with 1,451 additions and 185 deletions.
283 changes: 187 additions & 96 deletions decoder/expr_object_completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package decoder
import (
"bytes"
"context"
"log"
"sort"
"strings"
"unicode"
"unicode/utf8"

"github.com/hashicorp/hcl-lang/lang"
Expand All @@ -14,13 +16,13 @@ import (
"github.com/zclconf/go-cty/cty"
)

type declaredAttributes map[string]struct{}
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{
{
Label: "{ }",
{ // TODO: Consider rendering first N elements in Label?
Label: "{}",
Detail: "object",
Kind: lang.ObjectCandidateKind,
Description: obj.cons.Description,
Expand All @@ -38,152 +40,239 @@ func (obj Object) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.Candi
}
}

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

fileBytes := obj.pathCtx.Files[obj.expr.Range().Filename].Bytes

betweenBraces := hcl.Range{
Filename: expr.Range().Filename,
Start: expr.OpenRange.End,
End: hcl.Pos{
// exclude the trailing brace } from range
// to make byte slice comparison easier
Line: expr.Range().End.Line,
Column: expr.Range().End.Column - 1,
Byte: expr.Range().End.Byte - 1,
},
Filename: eType.Range().Filename,
Start: eType.OpenRange.End,
End: eType.Range().End,
}

if betweenBraces.ContainsPos(pos) {
slicedBytes := bytes.TrimSpace(betweenBraces.SliceBytes(fileBytes))
if len(obj.cons.Attributes) == 0 {
return []lang.Candidate{}
}

if len(expr.Items) == 0 && len(slicedBytes) == 0 {
return objectAttributesToCandidates("", obj.cons.Attributes, declaredAttributes{}, hcl.Range{
Filename: expr.Range().Filename,
Start: pos,
End: pos,
editRange := hcl.Range{
Filename: eType.Range().Filename,
Start: pos,
End: pos,
}
declared := make(declaredAttributes, 0)

if len(eType.Items) == 0 {
fileBytes := obj.pathCtx.Files[eType.Range().Filename].Bytes
leftBytes := recoverMatchingLeftBytes(fileBytes, pos, func(offset int, r rune) bool {
return !isObjItemTerminatingRune(r) && offset > eType.OpenRange.Start.Byte
})
trimmedBytes := bytes.TrimFunc(leftBytes, func(r rune) bool {
return isObjItemTerminatingRune(r) || r == ' '
})

if len(trimmedBytes) == 0 {
// multi-line mode:
// we're on an empty newline (trimmed \n)
return objectAttributesToCandidates("", obj.cons.Attributes, declared, editRange)
}
if len(trimmedBytes) == 1 && isObjItemTerminatingRune(rune(trimmedBytes[0])) {
// single-line mode:
// we're right past the beginning of the object body {
// or past the comma after a previous item
return objectAttributesToCandidates("", obj.cons.Attributes, declared, editRange)
}

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

attrName := string(bytes.TrimSpace(trimmedBytes[:len(trimmedBytes)-1]))
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(trimmedBytes)
remainingRange := hcl.Range{
Filename: eType.Range().Filename,
Start: pos,
End: eType.SrcRange.End,
}
editRange = objectItemPrefixBasedEditRange(remainingRange, fileBytes, prefix)

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

var lastItemEndPos *hcl.Pos
declaredAttrs := make(declaredAttributes, 0)
for _, item := range expr.Items {
// only consider items declared before position
// to enable completion in between items
if pos.Byte > item.ValueExpr.Range().End.Byte {
vep := item.ValueExpr.Range().End
lastItemEndPos = &vep
recoveryPos := eType.OpenRange.End
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
}

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

if item.KeyExpr.Range().ContainsPos(pos) || item.KeyExpr.Range().End.Byte == pos.Byte {
if nextItemRange != nil {
continue
}

// 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()
continue
}

lastItemRange = hcl.RangeBetween(item.KeyExpr.Range(), item.ValueExpr.Range()).Ptr()
recoveryPos = item.ValueExpr.Range().End

if item.KeyExpr.Range().ContainsPos(pos) {
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, declaredAttributes{}, editRange)
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 {
// no completion for unknown attribute
// unknown attribute
return []lang.Candidate{}
}

elemCons := newExpression(obj.pathCtx, item.ValueExpr, aSchema.Constraint)
return elemCons.CompletionAtPos(ctx, pos)
cons := newExpression(obj.pathCtx, item.ValueExpr, aSchema.Constraint)

return cons.CompletionAtPos(ctx, pos)
}
}

if lastItemEndPos != nil {
if pos.Line == lastItemEndPos.Line && pos.Column < lastItemEndPos.Column {
// reject completion inside indentation space
// 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 !isObjItemTerminatingRune(r) && offset > recoveryPos.Byte
})
trimmedBytes := bytes.TrimRight(leftBytes, " \t")

log.Printf("trimmed bytes: %q", string(trimmedBytes))

if len(trimmedBytes) == 0 {
return []lang.Candidate{}
}

if len(trimmedBytes) == 1 && isObjItemTerminatingRune(rune(trimmedBytes[0])) {
// avoid completing on the same line as next item
if nextItemRange != nil && nextItemRange.Start.Line == pos.Line {
return []lang.Candidate{}
}
if pos.Line > lastItemEndPos.Line {
// TODO: test with multi-line element expressions
lineBytes := bytes.TrimSpace(sliceBytesOnPosLine(fileBytes, pos))

if len(lineBytes) == 0 {
return objectAttributesToCandidates("", obj.cons.Attributes, declaredAttrs, hcl.Range{
Filename: expr.Range().Filename,
Start: pos,
End: pos,
})
}

if lineBytes[len(lineBytes)-1] == '=' {
attrName := string(bytes.TrimSpace(lineBytes[:len(lineBytes)-1]))
aSchema, ok := obj.cons.Attributes[attrName]
if !ok {
// unknown attribute
return []lang.Candidate{}
}

emptyExpr := newEmptyExpressionAtPos(expr.Range().Filename, pos)
elemCons := newExpression(obj.pathCtx, emptyExpr, aSchema.Constraint)
return elemCons.CompletionAtPos(ctx, pos)
// 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{}
}

prefix := string(lineBytes)
return objectAttributesToCandidates(prefix, obj.cons.Attributes, declaredAttrs, hcl.Range{
Filename: expr.Range().Filename,
Start: hcl.Pos{
Line: pos.Line,
Column: pos.Column - len(prefix),
Byte: pos.Byte - len(prefix),
},
End: pos,
})
}
}

// completing empty object
if len(slicedBytes) == 0 {
return objectAttributesToCandidates("", obj.cons.Attributes, map[string]struct{}{}, hcl.Range{
Filename: expr.Range().Filename,
Start: pos,
End: pos,
})
return objectAttributesToCandidates("", obj.cons.Attributes, declared, editRange)
}

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

attrName := string(bytes.TrimSpace(trimmedBytes[:len(trimmedBytes)-1]))
aSchema, ok := obj.cons.Attributes[attrName]
if !ok {
// unknown attribute
return []lang.Candidate{}
}

// completing value of a new item
emptyExpr := newEmptyExpressionAtPos(expr.Range().Filename, pos)
elemCons := newExpression(obj.pathCtx, emptyExpr, aSchema.Constraint)
return elemCons.CompletionAtPos(ctx, pos)
cons := newExpression(obj.pathCtx, emptyExpr, aSchema.Constraint)

return cons.CompletionAtPos(ctx, pos)
}

prefix := string(slicedBytes)
return objectAttributesToCandidates(prefix, obj.cons.Attributes, declaredAttrs, hcl.Range{
Filename: expr.Range().Filename,
Start: hcl.Pos{
Line: pos.Line,
Column: pos.Column - len(prefix),
Byte: pos.Byte - len(prefix),
},
End: pos,
trimmedBytes = bytes.TrimLeftFunc(trimmedBytes, func(r rune) bool {
return unicode.IsSpace(r)
})
prefix := string(trimmedBytes)
remainingRange := hcl.Range{
Filename: eType.Range().Filename,
Start: pos,
End: eType.SrcRange.End,
}
editRange = objectItemPrefixBasedEditRange(remainingRange, fileBytes, prefix)
return objectAttributesToCandidates(prefix, obj.cons.Attributes, declared, editRange)
}

return []lang.Candidate{}
}

func isObjItemTerminatingRune(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, _ := utf8.DecodeRune(b[offset:])
if !f(offset, nextRune) {
return b[offset:pos.Byte]
}
}
return []byte{}
}

func objectItemPrefixBasedEditRange(remainingRange hcl.Range, fileBytes []byte, prefix string) 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(prefix),
Byte: remainingRange.Start.Byte - len(prefix),
},
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 getRawObjectAttributeName(keyExpr hcl.Expression) (string, *hcl.Range, bool) {
switch eType := keyExpr.(type) {
case *hclsyntax.ScopeTraversalExpr:
Expand Down Expand Up @@ -245,7 +334,9 @@ func objectAttributesToCandidates(prefix string, attrs schema.ObjectAttributes,
if !strings.HasPrefix(name, prefix) {
continue
}
if _, ok := declared[name]; ok {
// avoid suggesting already declared attribute
// unless we're overriding it
if declaredRng, ok := declared[name]; ok && !declaredRng.Overlaps(editRange) {
continue
}

Expand Down
Loading

0 comments on commit 9662595

Please sign in to comment.